Skip to content

toolnexus.content/attach

Clojure (JVM) + cljgo · package net.clojars.muthuishere/toolnexus · SPEC §1B · clojure/src/toolnexus/content.cljc

;; There is no ContentPart type. A part is a plain map, and the keys ARE the wire
;; keys — :mimeType, not :mime-type, the same rule that puts :isError in
;; toolnexus.tool and :input_schema in toolnexus.adapter.
{:type "text" :text "What is in this image?"}
{:type "image" :mimeType "image/png" :data "iVBORw0KGgo…"} ; base64, standard + padded
{:type "image" :mimeType "image/webp" :url "https://…/diagram.webp"}
{:type "file" :mimeType "application/pdf" :data "JVBERi0x…" :name "report.pdf"}
{:type "audio" :mimeType "audio/wav" :data "UklGRi…"}
;; exactly one of :data / :url — both, or neither, is a construction error
;; the edge constructors — the path and the raw bytes never enter the part
(toolnexus.content/attach source) ; path | data: URL | http(s) URL | bytes
(toolnexus.content/attach source opts) ; opts = {:mimeType "…" :name "…" :max-part-bytes n}
(toolnexus.content/from-file path) ; read now, base64 now; mime from the fixed table
(toolnexus.content/image-file path) ; from-file, pinned to an "image" part
(toolnexus.content/from-bytes bs mime) ; byte array | seq of byte values | string (UTF-8)
(toolnexus.content/from-data-url u) ; data:<mime>;base64,<b64> -> {:mimeType :data}
(toolnexus.content/from-url u) ; http(s) -> {:url …}
(toolnexus.content/text-part s)
;; a failure is a VALUE with a stable :code — never a throw
{:type "error" :code "unsupported-source" :error "unsupported byte source: …"}
;; reading a part back
(toolnexus.content/validate-part part) ; part, or an error part
(toolnexus.content/validate-part part max-bytes)
(toolnexus.content/error-part? x)
(toolnexus.content/part-bytes part) ; DECODED bytes, never the +33% base64
(toolnexus.content/describe-part part) ; {:type :mimeType :bytes} — never :data
(toolnexus.content/summarize-part part) ; "image (image/png, 82 bytes)"

The non-text half of a message: text | image | file | audio, carrying base64 bytes or a URL plus a mimeType — never a path. One union covers all three directions: a part you attach to a prompt, a part a tool hands back under :parts, and a part that arrives from an MCP server.

  • Attaching an image, a PDF or an audio clip to a run. A prompt is a string or a vector of parts: (client/run c [(content/text-part "What changed?") (content/attach "shot.png")] {…}). The vector is the canonical transcript shape; the provider blocks are built on the way out, per style, by toolnexus.content/build-wire.
  • Returning one from a tool. A screenshot tool sets :output to a description and the image goes in :parts via toolnexus.tool/with-parts.
  • Reading one back — part-bytes for size, summarize-part for the one-line, payload-free rendering §1B pins byte-identically across all seven ports.
(require '[toolnexus.content :as content]
'[toolnexus.client :as client]
'[clojure.string :as str]
'[koine.fs :as fs])
;; The shared cross-language fixture lives at the repo root. Walk up to it so the
;; example runs from wherever you happen to be inside the tree.
(defn find-up [rel]
(->> (iterate (fn [p] (str "../" p)) rel) (take 8) (filter fs/exists?) first))
(def png (find-up "examples/media/fixture.png"))
;; the COMMITTED golden — trimmed, because a text file ends in a newline and base64 does not
(def golden (str/trim (str (fs/read-file (find-up "examples/media/fixture.png.base64")))))
(def prompt [(content/text-part "What is in this image?")
(content/attach png)])
(def img (second prompt))
(println "part:" (content/summarize-part img))
(assert (= "image" (:type img)))
(assert (= "image/png" (:mimeType img)))
(assert (= golden (:data img))) ; base64d at construction, byte-for-byte the golden
(assert (nil? (:url img))) ; exactly one of :data / :url
(assert (not (str/includes? (pr-str img) "fixture.png"))) ; the PATH is gone
;; §1B pins this string byte-identically in all seven ports; <bytes> is DECODED
(assert (= "image (image/png, 82 bytes)" (content/summarize-part img)))
(assert (= 82 (content/part-bytes img)))
;; a log line or a §9 event renders a part WITHOUT its payload
(assert (= {:type "image" :mimeType "image/png" :bytes 82} (content/describe-part img)))
;; that vector is a prompt — this is the shape client/run turns it into
(def msg (client/user-message prompt))
(assert (= "user" (:role msg)))
(assert (content/part-array? (:content msg)))
(println "OK")

2. Every source this port accepts folds to the same bytes

Section titled “2. Every source this port accepts folds to the same bytes”
(require '[toolnexus.content :as content]
'[toolnexus.tool :as tool]
'[clojure.string :as str]
'[koine.fs :as fs])
(defn find-up [rel]
(->> (iterate (fn [p] (str "../" p)) rel) (take 8) (filter fs/exists?) first))
(def png (find-up "examples/media/fixture.png"))
(def golden (str/trim (str (fs/read-file (find-up "examples/media/fixture.png.base64")))))
;; 1. a path — read now, base64 now
(def from-path (content/attach png))
;; 2. a native byte array: JVM byte[] on one host, Go []byte on the other, and
;; exactly what koine.fs/read-bytes hands back on both.
(def raw (fs/read-bytes png))
(def from-array (content/from-bytes raw "image/png"))
;; 3. a SEQ of byte values — the shape ordinary Clojure code produces. Signed
;; (straight off a JVM array) and unsigned (0-255) fold to the same base64.
(def signed (vec (seq raw)))
(def unsigned (map (fn [b] (bit-and (long b) 255)) (seq raw)))
(def from-vec (content/from-bytes signed "image/png"))
(def from-lazy (content/from-bytes unsigned "image/png"))
;; 4. a data: URL — parsed into {:mimeType :data}, NEVER kept as a :url
(def from-data-url (content/attach (str "data:image/png;base64," golden)))
(assert (= golden
(:data from-path) (:data from-array)
(:data from-vec) (:data from-lazy)
(:data from-data-url)))
(assert (= "image/png" (:mimeType from-data-url)))
(assert (nil? (:url from-data-url)))
;; An http(s) URL is the one source kept AS a url — nothing is fetched here.
(def remote (content/from-url "https://example.com/diagram.webp"))
(assert (= {:type "image" :mimeType "image/webp" :url "https://example.com/diagram.webp"} remote))
(assert (= 0 (content/part-bytes remote))) ; a url part carries no bytes
;; The table drives the part KIND, not just the mime: pdf is a "file", wav is "audio".
(assert (= "file" (:type (content/from-url "https://example.com/spec.pdf"))))
(assert (= "audio" (:type (content/from-url "https://example.com/clip.wav"))))
;; Returning one from a tool: :output stays the text every transcript sees.
(def shot
(tool/tool {:name "screenshot"
:description "Take a screenshot"
:execute (fn [_]
(let [p (content/image-file png)]
(tool/with-parts (tool/success (content/summarize-part p)) [p])))}))
(def r ((:execute shot) {}))
(println "tool output:" (:output r))
(assert (= "image (image/png, 82 bytes)" (:output r)))
(assert (= [from-path] (:parts r)))
(println "OK")
(require '[toolnexus.content :as content]
'[clojure.string :as str])
;; --- both :data and :url, and neither -------------------------------------
(def conflict (content/validate-part
{:type "image" :mimeType "image/png" :data "AQ==" :url "https://x/y.png"}))
(println "conflict =>" (:code conflict) "-" (:error conflict))
(assert (content/error-part? conflict))
(assert (= "source-conflict" (:code conflict)))
(assert (= "source-missing"
(:code (content/validate-part {:type "image" :mimeType "image/png"}))))
;; --- an unknown extension is refused BY NAME, never sniffed ----------------
(def unknown (content/from-file "notes.xyz"))
(println "unknown =>" (:code unknown) "-" (:error unknown))
(assert (= "unknown-extension" (:code unknown)))
(assert (str/includes? (:error unknown) "xyz"))
;; …and an explicit :mimeType is the way through, exactly as the message says
(assert (nil? (:code (content/from-url "https://x/y.bin" {:mimeType "application/octet-stream"}))))
;; --- a handle-shaped source: this port says so instead of pretending -------
(def handle (content/from-bytes {:a :handle} "image/png"))
(println "handle =>" (:code handle))
(assert (= "unsupported-source" (:code handle)))
(assert (str/includes? (:error handle) "java.io.File")) ; names what you are likely holding
(assert (str/includes? (:error handle) "koine.fs/read-bytes")) ; and names the way out
;; the SOURCE is reported before a missing mime — a mime would not have fixed it
(assert (= "unsupported-source" (:code (content/from-bytes {:a :handle} ""))))
;; attach refuses it identically rather than throwing
(assert (= "unsupported-source" (:code (content/attach {:a :handle} {:mimeType "image/png"}))))
;; --- :max-part-bytes — DECODED bytes, enforced at request assembly ---------
(def big (content/from-bytes (repeat 4096 65) "image/png"))
(assert (= 4096 (content/part-bytes big))) ; the base64 is 5464 chars; the limit is not that
;; A part the CALLER attached that busts the limit stops the run before any HTTP call.
(def stopped (content/encode-parts [big] {:style "openai" :provenance "attached"
:max-part-bytes 1024}))
(println "assembly =>" (:code stopped) "-" (:error stopped))
(assert (= "too-large" (:code stopped)))
;; That :error / :code pair is what client/run reports as
;; {:status "incomplete" :limit "contentPart"} — a stopped RunResult, not an exception.
;; A part DERIVED from a tool or MCP result degrades to a placeholder and warns
;; once instead, so a remote server cannot fail a run you never asked to risk.
(content/reset-unsupported-warnings!)
(def degraded (content/encode-parts [big] {:style "openai" :provenance "derived"
:max-part-bytes 1024}))
(assert (nil? (:error degraded)))
(assert (= [{:type "text" :text "[unsupported image part (image/png, 4096 bytes)]"}]
(:blocks degraded)))
;; Under the limit, it is an ordinary image block.
(def fine (content/encode-parts [big] {:style "openai" :provenance "attached"
:max-part-bytes 8192}))
(assert (= "image_url" (:type (first (:blocks fine)))))
;; Nothing above threw. That is the contract, not a coincidence.
(println "OK")