Files
zero/internal/tools/local_capture.go
Vasanth TandClaude Opus 5 cd0eb194e4 feat(tools): give tool results an image channel and add view_image (#843)
* feat(tools): give tool results an image channel and add view_image

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 <[email protected]>

* fix(agent): keep tool results contiguous when a tool returns an image

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 <[email protected]>

* test(agent): pin the tool-result/image ordering contract

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".

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-06 21:35:41 +05:30

448 lines
15 KiB
Go

package tools
import (
"context"
"encoding/json"
"fmt"
"github.com/Gitlawb/zero/internal/imageinput"
"github.com/Gitlawb/zero/internal/zeroruntime"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/Gitlawb/zero/internal/localcontrol"
)
const defaultArtifactBaseName = "capture"
type LocalControlArtifactOptions struct {
Browser localcontrol.BrowserOptions
Desktop localcontrol.DesktopOptions
Terminal localcontrol.TerminalOptions
ArtifactsDir string
}
func NewLocalControlArtifactTools(options LocalControlArtifactOptions) []Tool {
return []Tool{newCaptureArtifactTool(options)}
}
type captureArtifactTool struct {
baseTool
browser localcontrol.Browser
desktop localcontrol.Desktop
terminal localcontrol.Terminal
artifactsDir string
}
func newCaptureArtifactTool(options LocalControlArtifactOptions) Tool {
enabled := strings.TrimSpace(options.ArtifactsDir) != "" && (options.Browser.Enabled || options.Desktop.Enabled || options.Terminal.Enabled)
return captureArtifactTool{
baseTool: baseTool{
name: "capture_artifact",
deferred: true,
description: "Capture local browser, desktop, or terminal state into the configured artifact directory.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"action": {Type: "string", Description: "Capture action to run.", Enum: captureArtifactActionNames()},
"name": {Type: "string", Description: "Optional artifact basename. Path separators are ignored."},
"full": {Type: "boolean", Description: "Capture a full-page browser screenshot for browser_screenshot.", Default: false},
"annotate": {Type: "boolean", Description: "Annotate browser screenshots with interactive element labels.", Default: false},
"pid": {Type: "integer", Description: "Target process id for desktop_window_state.", Minimum: intPtr(1)},
"window_id": {Type: "integer", Description: "Target window id for desktop_window_state.", Minimum: intPtr(1)},
"query": {Type: "string", Description: "Optional desktop snapshot query/filter."},
"session": {Type: "string", Description: "Terminal session id for terminal_snapshot, or desktop session id for desktop_window_state."},
"trim": {Type: "boolean", Description: "Trim trailing blank terminal screen lines for terminal_snapshot.", Default: true},
},
Required: []string{"action"},
AdditionalProperties: false,
},
safety: localControlSafety(enabled, SideEffectLocalControl, "Captures local browser, desktop, or terminal state into the configured artifact directory."),
// Captures write artifacts under the configured directory.
capabilities: ToolCapabilities{Effect: EffectWorkspaceWrite, ThreadSafe: false},
},
browser: localcontrol.NewBrowser(options.Browser),
desktop: localcontrol.NewDesktop(options.Desktop),
terminal: localcontrol.NewTerminal(options.Terminal),
artifactsDir: options.ArtifactsDir,
}
}
func (tool captureArtifactTool) RejectBeforePermission(args map[string]any) (Result, bool) {
request, err := captureArtifactArgs(args)
if err != nil {
return errorResult("Error: Invalid arguments for capture_artifact: " + err.Error()), true
}
if strings.TrimSpace(tool.artifactsDir) == "" {
return errorResult("Error: capture_artifact is disabled because no artifact directory is configured."), true
}
if !tool.actionEnabled(request.action) {
return errorResult("Error: Local control driver for " + request.action + " is disabled."), true
}
return Result{}, false
}
func (tool captureArtifactTool) Run(ctx context.Context, args map[string]any) Result {
request, err := captureArtifactArgs(args)
if err != nil {
return errorResult("Error: Invalid arguments for capture_artifact: " + err.Error())
}
if !tool.actionEnabled(request.action) {
return errorResult("Error: Local control driver for " + request.action + " is disabled.")
}
if strings.TrimSpace(tool.artifactsDir) == "" {
return errorResult("Error: capture_artifact is disabled because no artifact directory is configured.")
}
path, err := artifactOutputPath(tool.artifactsDir, request.name, request.extension())
if err != nil {
return errorResult("Error: Failed to prepare artifact path: " + err.Error())
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return errorResult("Error: Failed to create artifact directory: " + err.Error())
}
switch request.action {
case "browser_screenshot":
return tool.captureBrowserScreenshot(ctx, request, path)
case "browser_pdf":
return tool.captureBrowserPDF(ctx, request, path)
case "desktop_screenshot":
return tool.captureDesktopScreenshot(ctx, request, path)
case "desktop_window_state":
return tool.captureDesktopWindowState(ctx, request, path)
case "terminal_snapshot":
return tool.captureTerminalSnapshot(ctx, request, path)
default:
return errorResult("Error: Unsupported capture action " + request.action)
}
}
func (tool captureArtifactTool) actionEnabled(action string) bool {
switch actionDriver(action) {
case "browser":
return tool.browser.Enabled()
case "desktop":
return tool.desktop.Enabled()
case "terminal":
return tool.terminal.Enabled()
default:
return false
}
}
func (tool captureArtifactTool) captureBrowserScreenshot(ctx context.Context, request captureArtifactRequest, path string) Result {
commandArgs := []string{"screenshot"}
if request.full {
commandArgs = append(commandArgs, "--full")
}
if request.annotate {
commandArgs = append(commandArgs, "--annotate")
}
commandArgs = append(commandArgs, path)
return tool.captureHelperArtifact(ctx, tool.browser, "browser", request, path, commandArgs)
}
func (tool captureArtifactTool) captureBrowserPDF(ctx context.Context, request captureArtifactRequest, path string) Result {
return tool.captureHelperArtifact(ctx, tool.browser, "browser", request, path, []string{"pdf", path})
}
func (tool captureArtifactTool) captureDesktopScreenshot(ctx context.Context, request captureArtifactRequest, path string) Result {
payload, err := json.Marshal(map[string]any{"out_file": path})
if err != nil {
return errorResult("Error: Failed to encode desktop screenshot input: " + err.Error())
}
return tool.captureHelperArtifact(ctx, tool.desktop, "desktop", request, path, []string{"screenshot", string(payload)})
}
func (tool captureArtifactTool) captureDesktopWindowState(ctx context.Context, request captureArtifactRequest, path string) Result {
input := map[string]any{"pid": request.pid, "window_id": request.windowID}
if strings.TrimSpace(request.query) != "" {
input["query"] = request.query
}
if strings.TrimSpace(request.session) != "" {
input["session"] = request.session
}
payload, err := json.Marshal(input)
if err != nil {
return errorResult("Error: Failed to encode desktop window state input: " + err.Error())
}
return tool.captureHelperArtifact(ctx, tool.desktop, "desktop", request, path, []string{"get_window_state", string(payload), "--screenshot-out-file", path})
}
func (tool captureArtifactTool) captureTerminalSnapshot(ctx context.Context, request captureArtifactRequest, path string) Result {
commandArgs := []string{"-s", request.session, "snapshot"}
if request.trim {
commandArgs = append(commandArgs, "--trim")
}
result, err := tool.terminal.Run(ctx, commandArgs...)
output := result.Output()
if err != nil {
return captureErrorResult("terminal", request, result, err)
}
if err := os.WriteFile(path, []byte(output), 0o600); err != nil {
return errorResult("Error: Failed to write terminal artifact: " + err.Error())
}
if err := writeArtifactMetadata(path, artifactMetadata{
Action: request.action,
Driver: "terminal",
Path: path,
Args: commandArgs,
}); err != nil {
return errorResult("Error: Failed to write artifact metadata: " + err.Error())
}
return captureOKResult("terminal", request, path, result)
}
func (tool captureArtifactTool) captureHelperArtifact(ctx context.Context, runner localControlRunner, driver string, request captureArtifactRequest, path string, args []string) Result {
result, err := runner.Run(ctx, args...)
if err != nil {
return captureErrorResult(driver, request, result, err)
}
if _, err := os.Stat(path); err != nil {
return errorResult("Error: Helper completed but artifact was not written: " + err.Error())
}
if err := writeArtifactMetadata(path, artifactMetadata{
Action: request.action,
Driver: driver,
Path: path,
Args: args,
}); err != nil {
return errorResult("Error: Failed to write artifact metadata: " + err.Error())
}
return captureOKResult(driver, request, path, result)
}
type captureArtifactRequest struct {
action string
name string
full bool
annotate bool
pid int
windowID int
query string
session string
trim bool
}
func (request captureArtifactRequest) extension() string {
switch request.action {
case "browser_pdf":
return ".pdf"
case "terminal_snapshot":
return ".txt"
default:
return ".png"
}
}
var captureArtifactActions = map[string]bool{
"browser_screenshot": true,
"browser_pdf": true,
"desktop_screenshot": true,
"desktop_window_state": true,
"terminal_snapshot": true,
}
func captureArtifactActionNames() []string {
names := make([]string, 0, len(captureArtifactActions))
for name := range captureArtifactActions {
names = append(names, name)
}
sortStrings(names)
return names
}
func captureArtifactArgs(args map[string]any) (captureArtifactRequest, error) {
action, err := stringArg(args, "action", "", true)
if err != nil {
return captureArtifactRequest{}, err
}
action = strings.ToLower(strings.TrimSpace(action))
if !captureArtifactActions[action] {
return captureArtifactRequest{}, fmt.Errorf("action must be one of: %s", strings.Join(captureArtifactActionNames(), ", "))
}
name, err := stringArgWithEmpty(args, "name", "", false, false)
if err != nil {
return captureArtifactRequest{}, err
}
request := captureArtifactRequest{action: action, name: name, trim: true}
switch action {
case "browser_screenshot":
request.full, err = boolArg(args, "full", false)
if err != nil {
return captureArtifactRequest{}, err
}
request.annotate, err = boolArg(args, "annotate", false)
if err != nil {
return captureArtifactRequest{}, err
}
case "desktop_window_state":
request.pid, err = requiredIntArg(args, "pid", 1, 0)
if err != nil {
return captureArtifactRequest{}, err
}
request.windowID, err = requiredIntArg(args, "window_id", 1, 0)
if err != nil {
return captureArtifactRequest{}, err
}
request.query, err = stringArgWithEmpty(args, "query", "", false, false)
if err != nil {
return captureArtifactRequest{}, err
}
request.session, err = stringArgWithEmpty(args, "session", "", false, false)
if err != nil {
return captureArtifactRequest{}, err
}
case "terminal_snapshot":
request.session, err = stringArg(args, "session", "", true)
if err != nil {
return captureArtifactRequest{}, err
}
request.trim, err = boolArg(args, "trim", true)
if err != nil {
return captureArtifactRequest{}, err
}
}
return request, nil
}
func actionDriver(action string) string {
switch {
case strings.HasPrefix(action, "browser_"):
return "browser"
case strings.HasPrefix(action, "desktop_"):
return "desktop"
case strings.HasPrefix(action, "terminal_"):
return "terminal"
default:
return ""
}
}
type artifactMetadata struct {
Action string `json:"action"`
Driver string `json:"driver"`
Path string `json:"path"`
Args []string `json:"args"`
CreatedAt time.Time `json:"createdAt"`
}
func writeArtifactMetadata(path string, metadata artifactMetadata) error {
metadata.CreatedAt = time.Now().UTC()
data, err := json.MarshalIndent(metadata, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
return os.WriteFile(path+".json", data, 0o600)
}
func captureOKResult(driver string, request captureArtifactRequest, path string, helperResult localcontrol.CommandResult) Result {
output := "Artifact captured: " + path
helperOutput := strings.TrimSpace(helperResult.Output())
if helperOutput != "" {
output += "\n\n" + helperOutput
}
// Attach the artifact itself. Without this the model is told a screenshot
// exists and cannot look at it, which is the whole point of asking for one.
// A PDF or an unreadable file simply yields no image and the text stands on
// its own, so a capture is never failed over the attachment.
var images []zeroruntime.ImageBlock
if image, err := imageinput.LoadFile(path, ""); err == nil {
images = []zeroruntime.ImageBlock{image}
}
return Result{
Status: StatusOK,
Output: output,
Images: images,
Meta: map[string]string{
"artifact_path": path,
"action": request.action,
"driver": driver,
},
ChangedFiles: []string{path, path + ".json"},
Display: Display{
Summary: request.action + " captured",
Kind: "artifact",
},
}
}
func captureErrorResult(driver string, request captureArtifactRequest, result localcontrol.CommandResult, err error) Result {
output := "Error running " + driver + " helper: " + err.Error()
if helperOutput := strings.TrimSpace(result.Output()); helperOutput != "" {
output += "\n\n" + helperOutput
}
meta := map[string]string{
"action": request.action,
"driver": driver,
}
if result.ExitCode != 0 {
meta["exit_code"] = strconv.Itoa(result.ExitCode)
}
return Result{
Status: StatusError,
Output: output,
Meta: meta,
Display: Display{
Summary: request.action + " failed",
Kind: "artifact",
},
}
}
var artifactNameCleanPattern = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
func artifactOutputPath(dir string, name string, extension string) (string, error) {
dir = strings.TrimSpace(dir)
if dir == "" {
return "", fmt.Errorf("artifact directory is not configured")
}
absDir, err := filepath.Abs(dir)
if err != nil {
return "", err
}
base := sanitizeArtifactName(name)
if base == "" {
base = defaultArtifactBaseName + "-" + time.Now().UTC().Format("20060102T150405.000000000Z")
}
if extension != "" {
if ext := filepath.Ext(base); ext != "" {
base = strings.TrimSuffix(base, ext)
}
if base == "" {
base = defaultArtifactBaseName + "-" + time.Now().UTC().Format("20060102T150405.000000000Z")
}
base += extension
}
path := filepath.Join(absDir, base)
rel, err := filepath.Rel(absDir, path)
if err != nil {
return "", err
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
return "", fmt.Errorf("artifact path escapes artifact directory")
}
return path, nil
}
func sanitizeArtifactName(name string) string {
name = filepath.Base(strings.TrimSpace(name))
name = strings.TrimPrefix(name, ".")
name = artifactNameCleanPattern.ReplaceAllString(name, "-")
name = strings.Trim(name, ".-_")
return name
}
func sortStrings(values []string) {
for i := 1; i < len(values); i++ {
for j := i; j > 0 && values[j] < values[j-1]; j-- {
values[j], values[j-1] = values[j-1], values[j]
}
}
}