Skip to content

feat: let a tool result carry an image - #73

Merged
webmatze merged 1 commit into
mainfrom
feat/tool-result-content-blocks
Aug 26, 2026
Merged

feat: let a tool result carry an image#73
webmatze merged 1 commit into
mainfrom
feat/tool-result-content-blocks

Conversation

@webmatze

Copy link
Copy Markdown
Owner

Seit #31 kann das Modell einen Screenshot ansehen, den der Nutzer angehängt hat — aber sich keinen selbst holen: read_file auf ein PNG antwortete „looks binary, use bash to inspect it", weil ein Tool-Ergebnis ein String war. Die naheliegendste Frage zu einem Bild — „sitzt der Button richtig in build/preview.png?" — brauchte einen Menschen, der die Datei vorher anhängt.

Was sich ändert

Ein Anhang reist als Geschwister-Block der Role::Tool-Nachricht, mit der tool_call_id seines Ergebnisses.

Die Alternative wäre gewesen, ihn in den tool_result zu verschachteln — das ist Anthropics Drahtform. Aber alles in smith, das eine Nachricht durchläuft, liest Top-Level-Blöcke: Session-Store, Token-Schätzung, Kompaktierungsstufen und beide Renderer hätten für ein Feature rekursiv werden müssen. Flach tragen sie den neuen Fall unverändert; der eine Provider, der es verschachtelt haben will, faltet beim Serialisieren zusammen — und ein reines Text-Ergebnis serialisiert weiterhin zum selben String, sodass keine laufende Session ihren Prompt-Cache an ein umgeformtes Präfix verliert.

Tool#run bleibt unangetastet. Ein Tool, das ein Bild liefern kann, sagt das durch Überschreiben von run_with_media; die anderen sechzehn lassen den Default stehen. Zustand am Tool wäre der kleinere Diff und der falsche gewesen — ein ParallelTool wird von mehreren Fibern geteilt.

read_file entscheidet nach der Signatur, wie eine @-Mention: derselbe Test, dieselbe Grenze [media] max_bytes. Der reine Text-Einstieg encodiert die Bytes nie — ein Format zu erkennen kostet zwölf Bytes, Base64 ein Drittel der Datei obendrauf.

Provider, die kein Bild in einem Tool-Ergebnis nehmen können, bekommen eine Zeile, die das sagt — in den Ergebnistext gefaltet, nicht daneben: unter role: "tool" behält jeder Adapter nur tool_result-Blöcke, ein Hinweis daneben wäre so still verschwunden wie das Bild. Aus demselben Grund schreibt die Kompaktierung ihren Marker jetzt in das Ergebnis, und der verzweifelte Durchgang darf verwerfen, was ein Tool geholt hat, nie aber das, was der Nutzer an die gerade beantwortete Frage gehängt hat.

MCP-Ergebnisse bleiben textig — eine eigene Änderung.

Verifiziert

  • Anthropic, echt: read_file auf ein rotes 16×16-PNG → „Solid bright red."
  • OpenAI, echt: dieselbe Frage → „I cannot see the image returned by the tool" — statt zu raten.
  • session.json und transcript.jsonl der Session: null Treffer für das Base64, stattdessen media_ref plus eine Datei unter media/.
  • crystal spec: 936 Beispiele, 0 Fehler (26 neu), crystal tool format --check sauber.

Refs #64.

🤖 Generated with Claude Code

Since @-mentions learned about images, the model could look at a
screenshot the user attached but could not fetch one itself: `read_file`
on a PNG answered "looks binary, use bash to inspect it", because a tool
result was a String. The most obvious question about a picture — "is the
button aligned in build/preview.png?" — needed a human to attach the file
first.

An attachment now travels as a sibling of the result it came back with, a
block of its own in the same tool message carrying the call's id. The
alternative was nesting it inside the tool_result, which is Anthropic's
wire shape — but everything in smith that walks a message reads top-level
blocks, and nesting would have made the session store, the token
estimate, the compaction stages and both renderers recursive for one
feature. Flat, they carry the new case unchanged; the one provider that
wants it nested folds it back at serialization time, and a text-only
result still serializes to the string it always did, so no running
session loses its prompt cache to a reshaped prefix.

`Tool#run` is untouched. A tool that can produce a picture says so by
overriding `run_with_media`, which returns the text and the blocks
together; the other sixteen leave it at its default and take the path
they always took. State on the tool would have been the smaller diff and
the wrong one — a ParallelTool is shared between fibers, so the blocks
would belong to whichever call wrote last.

`read_file` decides from the signature, like an @-mention does: a
screenshot saved as `notes.txt` comes back as the PNG it is, a text file
named `shot.png` is still read as text, and the ceiling is the same
`[media] max_bytes`. How a file entered the context says nothing about
what it costs once it is there. Its text-only entry point never encodes
the bytes — recognising a format is twelve bytes of signature, base64 is
a third of the file on top, and a caller with nowhere to put a block has
no use for it.

Providers that cannot take an image inside a tool result — the OpenAI
shape has nowhere to put one, whatever it can do in a user message — get
a line saying so, appended to the result's own text rather than added
beside it: under role tool every adapter keeps only tool_result blocks,
so a note next to the image would have vanished as quietly as the image
did. That is the same reason compaction now writes its marker into the
result instead of replacing the block with a text one, and why the
desperate pass may drop what a tool returned but never what the user
attached to the question being answered.

MCP results stay text. The protocol has image content and the adapter
flattens it in the protocol layer; opening that is its own change.

Refs #64.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@webmatze
webmatze merged commit a3fe627 into main Aug 26, 2026
2 checks passed
@webmatze
webmatze deleted the feat/tool-result-content-blocks branch August 26, 2026 19:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant