Skip to content

feat(tools): give tool results an image channel and add view_image - #843

Open
Vasanthdev2004 wants to merge 3 commits into
mainfrom
fix/824-tool-result-images
Open

feat(tools): give tool results an image channel and add view_image#843
Vasanthdev2004 wants to merge 3 commits into
mainfrom
fix/824-tool-result-images

Conversation

@Vasanthdev2004

@Vasanthdev2004 Vasanthdev2004 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Closes #824.

tools.Result carried only text, so no tool could hand the model a picture. The sharpest edge was our own capture tooling — browser_screenshot, desktop_screenshot and browser_pdf wrote a PNG, set Meta["artifact_path"], and returned "Artifact captured: <path>". Nothing read that path afterwards, so the model asked for a screenshot, was told one existed, and couldn't look at it.

Zero was never image-blind on the way inzeroruntime.ImageBlock, Message.Images and internal/imageinput are all mature. What was missing is the model-initiated leg.

The one design decision worth reviewing

Images ride a following user message, not the tool result. I tried the obvious thing first and checked the providers before building on it. All three drop images on a tool-role message:

  • Anthropic maps a tool result to a tool_result block whose content is a string
  • Gemini maps it to a functionResponse with Response: {"result": content}
  • OpenAI guards its image content-parts to the user role, with a comment that says exactly this: "A non-user message that happens to carry Images keeps the plain string/nil content path (its images are simply not serialized)."

So attaching them to the tool result would have plumbed the entire path and delivered nothing, silently. The separate user message is also the only shape that keeps one tool result per tool call, which the providers validate — I didn't want to solve this by duplicating tool results.

If anyone would rather teach the Anthropic mapper to put an image block inside tool_result (it does support that), that's a fine follow-up — but OpenAI still needs the user-message path, so the fallback has to exist regardless.

The rest

view_image resolves through resolveScopedReadPath before imageinput.LoadFile. That scoping is load-bearing, not decoration: LoadFile joins a relative path to the workspace root and otherwise takes the path as given, so without it the tool reads anywhere on disk. There's a test for the absolute-path and .. cases, and deleting the scoping fails it. Registered with the other scoped readers, declared read-only and thread-safe like read_file, and added to the specialist read-only sets — it's exactly as safe as read_file, which is already in each.

Capture tools attach what they wrote. A PDF or unreadable file yields no image and the text stands alone, so a capture is never failed over its attachment.

Empty or disallowed images are dropped rather than sent. A media type outside the provider allow-list would fail the whole turn; the tool's text is still useful without it.

Verified

TestRunDeliversToolResultImagesToTheModel goes through the real Run loop and asserts the image reaches a user-role message and that the tool-result count is unchanged. Four mutations, all killed: dropping the emit, moving it to the tool role, not carrying images off the result, and removing view_image's path scoping.

internal/tools, internal/agent and internal/specialist green; full suite clean apart from TestBuildServeScopeKeepsLexicalPaths and TestAltScreenTranscriptScrollKeepsFooterFixed, which fail identically on clean main on my Windows box.

One thing I noticed and deliberately did not change: a missing file surfaces the resolved absolute path in the error, because resolveScopedReadPath returns the raw syscall error. read_file does the same, so it's a pre-existing convention rather than something this adds — but it does put the user's home directory into model-visible output, and might be worth tightening repo-wide separately.

This also unblocks #823 — MCP image content blocks now have somewhere to go.

Summary by CodeRabbit

  • New Features

    • Added a view_image tool for loading supported images within the workspace, including media metadata.
    • Tool results and captured artifacts can now include images for model processing.
    • Image viewing is available through read-only tool access.
  • Bug Fixes

    • Invalid, unsupported, oversized, or inaccessible images are safely rejected without interrupting ongoing operations.
    • Images are delivered consistently across multiple tool calls and remain available for processing.
  • Tests

    • Added coverage for image delivery, metadata, workspace boundaries, unsupported files, and missing files.

Closes #824.

tools.Result carried only text, so no tool could hand the model a picture.
The sharpest edge was our own capture tooling: browser_screenshot,
desktop_screenshot and browser_pdf wrote a PNG, set Meta["artifact_path"]
and returned "Artifact captured: <path>". Nothing read that path
afterwards, so the model asked for a screenshot, was told one existed, and
could not look at it.

Zero was never image-blind on the way in — zeroruntime.ImageBlock,
Message.Images and internal/imageinput are mature. What was missing is the
model-initiated leg.

Result.Images is delivered as a following USER message, not on the tool
result itself. Every provider drops images on a tool-role message:
Anthropic maps a tool result to a tool_result block whose content is a
string, Gemini to a functionResponse, and OpenAI guards its image
content-parts to the user role with an explicit comment saying so. Attaching
them to the tool result would have plumbed the whole path and delivered
nothing. A separate message also keeps one tool result per tool call, which
the providers validate.

Blocks with no data, or a media type outside the provider allow-list, are
dropped rather than sent — a rejected image would fail the whole turn, and
the tool's text still stands on its own. The message names the tool, since
the images arrive detached from their result and nothing else says which
call produced them when several ran in one turn.

view_image resolves through resolveScopedReadPath before imageinput.LoadFile.
That scoping is load-bearing: LoadFile joins a relative path to the
workspace root and otherwise takes the path as given, so without it the tool
would read anywhere on disk. It is registered with the other scoped readers
and declared read-only and thread-safe, matching read_file.

The capture tools now attach what they wrote. A PDF or an unreadable file
yields no image and the text stands alone, so a capture is never failed over
its attachment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f6c8f9d9-3bcb-46bf-b542-88eef6f1f579

📥 Commits

Reviewing files that changed from the base of the PR and between e2143ec and 83f3ffa.

📒 Files selected for processing (1)
  • internal/agent/tool_result_images_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/agent/tool_result_images_test.go

Walkthrough

The change adds the view_image tool, extends tool results with image blocks, attaches images from local captures, and delivers valid images to the model through separate user-role messages.

Changes

Tool Image Delivery

Layer / File(s) Summary
Add and register view_image
internal/tools/view_image.go, internal/tools/view_image_test.go, internal/tools/registry.go, internal/tools/registry_test.go, internal/specialist/*
The scoped read-only tool loads supported images, enforces workspace boundaries, reports errors, and is registered in tool and specialist configurations.
Carry images in tool results
internal/tools/types.go, internal/tools/local_capture.go
tools.Result now carries non-serialized image blocks. Successful local captures attach readable image artifacts without failing when attachment loading fails.
Append images to the agent transcript
internal/agent/types.go, internal/agent/loop.go, internal/agent/tool_result_images_test.go
The agent copies tool images, filters invalid data, normalizes media types, and appends valid images as a separate user-role message after contiguous tool results. Integration tests verify image delivery and ordering.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Tool
  participant tools.Result
  participant AgentLoop
  participant Model
  Tool->>tools.Result: return image block
  tools.Result->>AgentLoop: provide tool result and Images
  AgentLoop->>AgentLoop: validate, copy, and normalize images
  AgentLoop->>Model: send tool results, then user-role image message
Loading

Possibly related PRs

  • Gitlawb/zero#838: Both changes modify shared local-capture and tool-result infrastructure.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 47.37% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the two primary changes: adding an image channel to tool results and introducing view_image.
Linked Issues check ✅ Passed The changes satisfy issue #824 by adding image channels, forwarding images, attaching capture artifacts, and registering a safely scoped view_image tool.
Out of Scope Changes check ✅ Passed The implementation and tests remain focused on tool-result images, artifact capture, view_image access, and related tool registration.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/824-tool-result-images

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/agent/loop.go`:
- Around line 712-721: Update the tool-result processing loop in the surrounding
function to collect image messages instead of appending them immediately after
each result. Append the collected images only after every tool result has been
recorded, including aborted-call placeholders on early exits, so all tool
results remain contiguous. Add a regression test covering two tool calls where
the first returns an image.

In `@internal/specialist/manifest.go`:
- Around line 85-86: Update the tool manifest’s execute category to include
view_image alongside the existing inspection tools, then add a ResolveTools
regression test that verifies a specialist configured with tools: [execute]
receives view_image.

In `@internal/tools/registry_test.go`:
- Around line 33-34: Extend the toolset validation test around the existing
tool-count assertion and loop to verify that seen[ViewImageToolName] is
registered after iteration. Keep the count check intact while adding this
regression assertion for the specific view-image tool name.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: db56b8c4-ec8a-4fbb-9a12-828b0ea0f92f

📥 Commits

Reviewing files that changed from the base of the PR and between d37de92 and 6916c1e.

📒 Files selected for processing (12)
  • internal/agent/loop.go
  • internal/agent/tool_result_images_test.go
  • internal/agent/types.go
  • internal/specialist/exec.go
  • internal/specialist/exec_test.go
  • internal/specialist/manifest.go
  • internal/tools/local_capture.go
  • internal/tools/registry.go
  • internal/tools/registry_test.go
  • internal/tools/types.go
  • internal/tools/view_image.go
  • internal/tools/view_image_test.go

Comment thread internal/agent/loop.go
Comment thread internal/specialist/manifest.go
Comment thread internal/tools/registry_test.go
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Tests: go test ./...
  • [pass] Build: go run ./cmd/zero-release build
  • [pass] Smoke build: go run ./cmd/zero-release smoke

Scope

Head: 83f3ffaf020d
Changed files (13): internal/agent/loop.go, internal/agent/tool_result_images_test.go, internal/agent/types.go, internal/specialist/exec.go, internal/specialist/exec_test.go, internal/specialist/manifest.go, internal/specialist/view_image_category_test.go, internal/tools/local_capture.go, internal/tools/registry.go, internal/tools/registry_test.go, internal/tools/types.go, internal/tools/view_image.go, and 1 more

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

The image messages are user-role, and the first version appended each one
immediately after its own tool result. With two tool calls in a turn that
produces tool_result, user, tool_result — and a user message between two
tool results breaks strict provider replay.

Anthropic makes it concrete. appendUserBlocks coalesces consecutive user
content into one block list, and tool_result blocks must come first in that
message, so the interleaved shape maps to:

    [tool_result, text, image, tool_result]

which the API rejects. loop.go already documents this exact rule three lines
below the change, for the self-correction feedback it defers for the same
reason.

Images are now collected during the tool loop and appended once every
tool_result is recorded — including the aborted placeholders on the abort,
stop-reason and repeated-failure paths, so an early exit cannot strand them
mid-sequence either.

The original test used a single tool call, which cannot exhibit the bug. The
new one runs two calls where the first returns an image and asserts the image
message does not fall between the first and last tool result.

Also from review: view_image joins the "execute" tool category, which grants
read_file but not view_image, so a specialist declared with tools: [execute]
could not look at a screenshot it had just captured. Covered by a test that
holds for every category granting read_file rather than naming one. And the
core read-only set is now asserted by tool name, not only by count — a count
still passes when one reader is swapped for another.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

All three fixed on ee4994af. The first one was a real bug and worth spelling out, because the repo already had the answer written down.

Tool-result contiguity. Correct, and my implementation was wrong. Images are user-role messages and I appended each one immediately after its own tool result, so two tool calls produced tool_result, user, tool_result. I traced it through the Anthropic mapper rather than reasoning about it — appendUserBlocks coalesces consecutive user content into one block list, and the interleaved sequence maps to:

[2] role=user blocks=[tool_result, text, image, tool_result]

tool_result blocks have to come first in that message, so that's a 400. And loop.go documents this exact rule three lines below my change, where the self-correction feedback is deferred "so the assistant's tool_results stay contiguous (a user message between tool_results breaks strict provider replay)". I wrote a violation of a rule stated immediately below it.

Images are now collected during the loop and flushed once every tool_result is recorded — including the aborted placeholders on the abort, stop-reason and repeated-failure paths, so an early exit can't strand them mid-sequence either.

The reason this shipped: my test used one tool call, which cannot exhibit the bug. The new test runs two calls with the image on the first and asserts the image message doesn't fall between the first and last tool result. Reverting to the old append fails it.

execute category. Also correct and I'd missed a real consequence — a specialist declared tools: [execute] could not look at a screenshot it had just captured. Rather than adding it to that one list, the test asserts the property for every category that grants read_file, so the next category added can't quietly omit it.

By-name registry assertion. Added. A count assertion passes just as happily when one reader is swapped for another.

Four mutations, all killed: reverting to the immediate append, never flushing, dropping view_image from execute, and unregistering it from the core readers. internal/agent, internal/tools, internal/specialist and all four internal/providers/* packages green.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/agent/tool_result_images_test.go`:
- Around line 142-165: Strengthen the assertions in the result message
validation loop to collect each tool-result index and ID, then require exactly
two results in order matching c1 and c2 with adjacent indexes. Require the user
image message to occur immediately after the second tool result, replacing the
current weaker firstTool/lastTool and relative-position checks, and add a
regression test covering a non-image message between the tool results.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f6245c21-ea95-4cb7-a069-bf12504bd62b

📥 Commits

Reviewing files that changed from the base of the PR and between 6916c1e and ee4994a.

📒 Files selected for processing (5)
  • internal/agent/loop.go
  • internal/agent/tool_result_images_test.go
  • internal/specialist/manifest.go
  • internal/specialist/view_image_category_test.go
  • internal/tools/registry_test.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • internal/specialist/manifest.go
  • internal/tools/registry_test.go
  • internal/agent/loop.go

Comment thread internal/agent/tool_result_images_test.go Outdated
The assertions only tracked the first and last tool-result index, so four
distinct broken orderings passed: a message wedged between the two tool results,
a duplicated image flush, a duplicated tool result, and both results answering
the same tool_call_id. Mutation-testing each of those against the real loop
confirmed the old assertions were green on all four.

Now it collects every tool-result index and id and requires exactly two, in
c1-then-c2 order, at adjacent indexes, with the image following the last one and
delivered exactly once. Adjacency is the assertion that carries the contract:
Anthropic needs every tool_result block ahead of any other block in the user
message they coalesce into, so anything between the two results is the 400 this
change exists to avoid.

The image is deliberately NOT pinned to exactly last+1. appendUserBlocks merges
consecutive user-role content, so [tool_result, tool_result, text, image] maps to
tool_result blocks first and replays fine; pinning would fail that valid shape
while killing nothing the adjacency and count assertions do not already catch.

messageShape reports the actual role ordering on failure, and the loop uses two
ifs rather than a switch so an image left on a tool-role message is reported as
the misplacement it is instead of "the image never reached the model".
coderabbitai[bot]
coderabbitai Bot previously approved these changes Aug 1, 2026
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.

Tool results have no image channel, so the model cannot see screenshots it captures

1 participant