Skip to content

feat(loop): let a tool hand the model an image, not a description of one - #36

Merged
zhanghanduo merged 5 commits into
mainfrom
feat/tool-result-image-attachments
Sep 8, 2026
Merged

feat(loop): let a tool hand the model an image, not a description of one#36
zhanghanduo merged 5 commits into
mainfrom
feat/tool-result-image-attachments

Conversation

@zhanghanduo

Copy link
Copy Markdown
Collaborator

Why

A tool with an image to return could only send prose about it: the products called a separate vision model and put its transcript into the history. The main model never saw the pixels, could not look again when a later turn raised a new question about the same picture, and the round trip was not even cheap — a transcript runs 2-6K tokens where the image it describes is a few hundred.

Verified against the served endpoint before any of this was written, not assumed. Full matrix, numbers and raw errors: MiroHarness internal-docs/designs/2026-09-08-native-image-in-tool-result-calibration.md.

What

A tool returns tool_content(text, images=[image_attachment(...)]). The envelope is a plain JSON dict, not a dataclass, because sandbox-native tools execute in a child process and their return value crosses json.dumps(default=str) on the way back. ToolResult.images carries the attachments; result stays the full text, so observers, spill/recovery, repeat detection and the trajectory see exactly what they saw before.

Visibility is the loop's decision. ModelProfile.supports_images (default False) and the wire protocol gate it; only chat_completions is implemented, as an OpenAI image_url part carrying a data URI. The Anthropic block spelling is not interchangeable — the served endpoint answers it with HTTP 400 and a pydantic union error, failing the whole turn including the other tool results in it — so an unimplemented protocol withholds rather than guessing. HistoryPolicy.max_images_in_history (default 5) now bounds the history. Both flags existed as unread placeholders.

The constraint the design is built around

An image that leaves the context always leaves a sentence behind — withheld for capability, evicted for room, or flattened by text_of. In the calibration run, deleting the image block from an otherwise working request did not make the model report a missing image: it reported a four-digit code and three shapes, every one invented, having been told to answer NO_IMAGE if it could not see one. A tool result that reads as though an image were delivered gets answered as though one were. test_withheld_image_says_so_in_the_text is the anchor for this.

Also here

  • estimate_message_tokens charges for inline images (~1 token/1024 px, fitted against measured prompt_tokens; 1080p ≈ 2.4K, 4K ≈ 8.5K). It previously returned the caption length alone, so a history of screenshots measured as nearly empty to the context guard and every compaction trigger.
  • TrajectoryFileObserver no longer writes image base64 into the trace (~137 KB per 1080p image, per turn it survived). The block keeps its image_url type and states its size.
  • Unrelated flake fix, kept as its own commit: ExtensionsConfig.has_changed compared st_mtime >, which cannot order edits finer than the clock. Measured here: the filesystem timestamp advances in 1 ms steps and two consecutive writes collide about 92% of the time, so a skill toggled within a millisecond of the load was invisible; the strict > also could not see a timestamp moving backward (restored backup, git checkout, rsync --times) and fired for an identical rewrite. Now compares a content digest. This was reaching CI as an intermittent test_skills_loader_reload.py failure whose rate tracked machine speed.

Verification

1548 passed, 2 skipped · ruff clean · pyright 104 errors — identical to the main baseline, zero added · check_unconsumed_fields exit 0.

Two bugs were found by writing the tests rather than by review: a multi-image result evicted its own newest page first (walking history backward but blocks forward), and stale image_meta kept charging tokens for images a compactor had already dropped.

Also verified end-to-end from the consuming product through the real run_agent_loop against live apodex-1.1-mini: with the flag on the model reads a random code and three shapes off the image and the tool message is a block list; with it off the content is plain text and nothing leaks; a 4K image is still read correctly after the product downscales it.

Note on versioning

Bumped to 0.10.0 (new capability → MINOR per docs/versioning.md), superseding the 0.9.1 this is rebased onto. The 0.9.1 changelog section is preserved above 0.9.0. No tag pushed — releasing is a separate, deliberate step once this is reviewed.

🤖 Generated with Claude Code

zhanghanduo and others added 5 commits September 8, 2026 18:07
A tool that had an image to return could only send prose about it: the
products called a separate vision model and put its transcript into the
history. The main model never saw the pixels, could not look again when a
later turn raised a new question about the same picture, and the round trip
was not even cheap — a transcript runs 2-6K tokens where the image it
describes is a few hundred.

A tool can now return `tool_content(text, images=[image_attachment(...)])`.
The envelope is a plain JSON dict rather than a dataclass because
sandbox-native tools execute in a child process and their return value
crosses `json.dumps(default=str)` on the way back; a dataclass arrives as
its repr. `ToolResult.images` carries the attachments and `result` stays the
full text, so observers, spill/recovery, repeat detection and the trajectory
see exactly what they saw before.

Visibility is the loop's decision, not the tool's. `ModelProfile.supports_images`
(default False) and the wire protocol gate it; only `chat_completions` is
implemented, as an OpenAI `image_url` part carrying a data URI. The Anthropic
block spelling is not interchangeable — a served OpenAI-compatible endpoint
answers it with HTTP 400 and a pydantic union error, failing the whole turn
including the other tool results in it — so an unimplemented protocol
withholds instead of guessing. `HistoryPolicy.max_images_in_history` (default
5) now bounds the history. Both flags existed as unread placeholders.

An image that leaves the context always leaves a sentence behind, whether it
was withheld for capability or evicted for room, and `text_of` renders an
image block as a placeholder rather than as nothing. This is the finding the
whole design is built around: in the calibration run, deleting the image
block from an otherwise working request did not make the model report a
missing image — it reported a four-digit code and three shapes, every one
invented. A tool result that reads as though an image were delivered gets
answered as though one were.

`estimate_message_tokens` now charges for inline images, priced off the
`image_meta` message key written at attach time (~1 token per 1024 px, fitted
against measured prompt_tokens; 1080p ≈ 2.4K, 4K ≈ 8.5K). It previously
returned the caption length alone, so a history of screenshots measured as
nearly empty to the context guard and every compaction trigger. The estimate
is driven by the image blocks actually present, so a compactor that rewrites
content to a string stops the charge with them.

Tests: 33 cases across tests/test_tool_content_images.py (envelope validation,
header sniffing for PNG/JPEG/WEBP/GIF, attachment, withholding, eviction
order, the stale-bookkeeping case) and tests/test_agent_loop_image_results.py
(the same behaviours driven through run_agent_loop). Two bugs were found by
writing them: a multi-image result evicted its own newest page first, and
stale `image_meta` kept charging for images a compactor had already dropped.

Generated with [Claude Code](https://claude.ai/code)
via [Very Happy](https://github.com/Mereithhh/very-happy)

Co-Authored-By: Claude <noreply@anthropic.com>
`ExtensionsConfig.has_changed` compared `st_mtime > loaded_mtime`, which is
asking a coarse clock to order edits far finer than it can see. Measured on
this filesystem: the timestamp advances in 1 ms steps, and two consecutive
writes land on an identical mtime about 92% of the time. A skill toggled
within a millisecond of the load was therefore invisible to
`get_enabled_skills`, and the operator's change simply did not take.

The same strict `>` could not see a timestamp moving BACKWARD either, which
is the ordinary result of restoring a backup, a `git checkout`, or an
`rsync --times` of an older revision: contents changed, config reported no
change. And it fired for an identical rewrite, forcing a reload with nothing
to reload.

Compare a blake2b-128 digest of the bytes instead, taken from the exact bytes
that were parsed so the fingerprint and the loaded state can never describe
different contents. The file is a small JSON document and `get_enabled_skills`
is cached by its callers, so reading it is cheaper than being wrong about it.

This was already reaching CI as an intermittent failure in
test_skills_loader_reload.py, at a rate that tracked machine speed — the test
passed only because the work between its two writes usually spilled into the
next millisecond, and a warm suite run did not. Its own `os.utime(+10)`
defence never helped: it shifts both timestamps by the same constant. The
three regression tests pin both mtimes to one value instead, so they fail
deterministically against the old check rather than a quarter of the time,
and they cover the backward-moving timestamp and the identical rewrite as
well. Verified failing on the previous implementation and passing on this
one; 60+ full-suite runs since, with no recurrence.

Generated with [Claude Code](https://claude.ai/code)
via [Very Happy](https://github.com/Mereithhh/very-happy)

Co-Authored-By: Claude <noreply@anthropic.com>
`_message_to_dict` copied a history message verbatim, which was correct while
content was always text. With inline images it means the base64 of every
attachment is written into the trajectory again for each turn the image
survives in history — a single 1080p screenshot is ~137 KB, so a handful of
them turn a readable trace into tens of megabytes of unreadable one.

`tool_content.redacted_for_trace` replaces the payload with its size and is
used by the observer. It is exposed rather than inlined because the trajectory
is not the only thing that writes a message somewhere other than the provider;
a log line or an event record wants the same treatment.

The block keeps its `image_url` type and states how large the image was
instead of vanishing. Same reason the loop narrates an eviction: a trace that
shows no image where the model was looking at one misrepresents what the model
was answering, and a trace is read precisely when someone is trying to work out
why an answer was what it was.

Non-image messages are returned unchanged, by identity, so the common path
allocates nothing.

Generated with [Claude Code](https://claude.ai/code)
via [Very Happy](https://github.com/Mereithhh/very-happy)

Co-Authored-By: Claude <noreply@anthropic.com>
@zhanghanduo
zhanghanduo merged commit f561350 into main Sep 8, 2026
5 checks passed
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