detached 413a9a5

feat(tools): add structural search and fresh context feedback (#204)

* feat(tools): add structural search and fresh context feedback

* feat(tools): support safe structural rewrites

* fix(tools): harden structural rewrite execution

* fix(ast): preserve outermost rewrite matches

* fix(ast): improve rewrite feedback

* docs(tui): clarify AST diff detection
commit
413a9a581b127d55a832fd18e025173628d3b139
author
bnema · · 2026-09-12 14:34 UTC
committer
GitHub · · 2026-09-12 14:34 UTC
parent
9897fd7
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
25 files · +961 -56
M README.md +5 -2
@@ -264,16 +264,19 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
264264 - `write`: create or overwrite files, making parent directories as needed.
265265 - `edit`: one or more exact-match replacements in an existing file.
266266 - `grep`: search a file or directory for a regular expression using ripgrep when available, with a portable grep fallback. Search output is bounded and follows the active filesystem read scope; it never invokes a shell. The fallback's regular-expression syntax and ignore/hidden-file behavior can differ from ripgrep.
267+- `ast`: search or rewrite source code structurally with an [ast-grep](https://ast-grep.github.io/) pattern. Prefer it when the operation depends on code syntax; use `grep`/`edit` for plain text. Zut starts an installed `ast-grep` or `sg` executable directly without a shell. Searches return compact `path:line` matches. Rewrites are planned in memory, shown as a diff for confirmation, checked against the active filesystem scope, applied with file permissions preserved, and followed by bounded diagnostics. Install ast-grep separately; the tool returns a clear error when neither executable is available.
267268 - `bash`: run a command in the session cwd with merged stdout/stderr. Every call must include a positive integer `timeout` in seconds; calls without it or with `timeout <= 0` are rejected, and the deadline is always enforced. On Unix, zut uses `/bin/bash -c` when available, then `bash -c` from `PATH`, and falls back to POSIX `/bin/sh -c` when Bash is unavailable. On Windows, it uses `cmd /C`. macOS ships Bash 3.2 by default, so newer Bash features may be unavailable.
268269 - `python`: execute inline Python 3 code with an existing interpreter, never installing one. Every call must include nonblank `code` and a positive integer `timeout` in seconds (shared by interpreter discovery and execution). Each call runs a fresh stateless subprocess directly (`-u -B -`, code on stdin) in the session cwd with merged stdout/stderr; there is no persistent interpreter state, no script file, and no bytecode cache. Interactive input is unsupported (stdin observes EOF). Output is bounded (50 KiB, 2,000 lines) with explicit truncation marks; truncated results persist the byte-capped buffer to a temp file referenced as `full output` with its path in `full_output_path`, mirroring `bash`. Discovery order: `python_interpreter` in `$ZUT_HOME/config.json` (one executable path; relative paths resolve against the session cwd), then the active `VIRTUAL_ENV`, then `./.venv`, then `python3`/`python` on `PATH`. On Windows, Store aliases, `py` launchers, and install-manager entrypoints are rejected; only positively identified installed interpreters run. Missing packages fail with the ordinary import traceback. Tool arguments and output are recorded in the session like other tools; files the code deliberately writes persist. Save reusable scripts with `write`/`edit` instead of using `python` as an opaque file-edit path. Unavailable to packaged agents, and rejected while jailed (see below).
269270 - `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path.
270-- `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md).
271+- `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. Successful native edits, writes, renames, and applied code actions automatically attach bounded diagnostics when available. See [docs/lsp.md](docs/lsp.md).
271272 - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open` opens either a complete public URL or a referenced page, while `web_find` and `web_click` respectively find literal text in an opened page and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).
272273 - `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, mark the active goal `done` or `blocked`, or atomically supersede an invalid goal with a necessary in-mission replacement. Pausing, resuming, and clearing missions remain user-controlled through `/goal`.
273274 - `update_plan`: maintain a turn-scoped checklist of `pending`, `in_progress`, and `completed` steps. Successful updates appear inline as an Updated Plan checklist and emit `plan_update` events in JSON and RPC modes.
274275 - `schedule`: add, list, or cancel recurring five-field cron prompts for the current interactive session. A task captures the machine's current timezone when it is created. While this zut process remains open, a due task waits for an active target turn to finish, or resumes an inactive target session in the background; its output is recorded in that target transcript. Resumed background tasks also show a completion notification in the current TUI. Scheduling is in-memory only: quitting zut cancels pending tasks, and schedules do not survive restart or reboot. Print, stream, JSON, and RPC modes do not expose this tool.
275276
276-When the sandbox is on (see `/jail`), filesystem tools, `grep`, and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. `python` is rejected outright while jailed: arbitrary Python cannot be confined by the current jail (admission-time check; use `/unjail`). When admitted, `python` has unrestricted host filesystem/network/process access like `bash`. Jail does not sandbox web-search network egress.
277+Tool results carry resource provenance in the durable session. Before each provider request, Zut replaces an older read only when an equivalent newer view supersedes it, and replaces a multi-file search only after every discovered file has been consumed or mutated. This provider-only projection removes known-stale context without rewriting session history. Because an older projected result changes, prompt caching restarts from that point; compact deterministic markers limit the cost.
278+
279+When the sandbox is on (see `/jail`), filesystem tools, `grep`, `ast`, and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. `python` is rejected outright while jailed: arbitrary Python cannot be confined by the current jail (admission-time check; use `/unjail`). When admitted, `python` has unrestricted host filesystem/network/process access like `bash`. Jail does not sandbox web-search network egress.
277280
278281 ## Modes
279282
@@ -264,16 +264,19 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
264 - `write`: create or overwrite files, making parent directories as needed.264 - `write`: create or overwrite files, making parent directories as needed.
265 - `edit`: one or more exact-match replacements in an existing file.265 - `edit`: one or more exact-match replacements in an existing file.
266 - `grep`: search a file or directory for a regular expression using ripgrep when available, with a portable grep fallback. Search output is bounded and follows the active filesystem read scope; it never invokes a shell. The fallback's regular-expression syntax and ignore/hidden-file behavior can differ from ripgrep.266 - `grep`: search a file or directory for a regular expression using ripgrep when available, with a portable grep fallback. Search output is bounded and follows the active filesystem read scope; it never invokes a shell. The fallback's regular-expression syntax and ignore/hidden-file behavior can differ from ripgrep.
267+- `ast`: search or rewrite source code structurally with an [ast-grep](https://ast-grep.github.io/) pattern. Prefer it when the operation depends on code syntax; use `grep`/`edit` for plain text. Zut starts an installed `ast-grep` or `sg` executable directly without a shell. Searches return compact `path:line` matches. Rewrites are planned in memory, shown as a diff for confirmation, checked against the active filesystem scope, applied with file permissions preserved, and followed by bounded diagnostics. Install ast-grep separately; the tool returns a clear error when neither executable is available.
267 - `bash`: run a command in the session cwd with merged stdout/stderr. Every call must include a positive integer `timeout` in seconds; calls without it or with `timeout <= 0` are rejected, and the deadline is always enforced. On Unix, zut uses `/bin/bash -c` when available, then `bash -c` from `PATH`, and falls back to POSIX `/bin/sh -c` when Bash is unavailable. On Windows, it uses `cmd /C`. macOS ships Bash 3.2 by default, so newer Bash features may be unavailable.268 - `bash`: run a command in the session cwd with merged stdout/stderr. Every call must include a positive integer `timeout` in seconds; calls without it or with `timeout <= 0` are rejected, and the deadline is always enforced. On Unix, zut uses `/bin/bash -c` when available, then `bash -c` from `PATH`, and falls back to POSIX `/bin/sh -c` when Bash is unavailable. On Windows, it uses `cmd /C`. macOS ships Bash 3.2 by default, so newer Bash features may be unavailable.
268 - `python`: execute inline Python 3 code with an existing interpreter, never installing one. Every call must include nonblank `code` and a positive integer `timeout` in seconds (shared by interpreter discovery and execution). Each call runs a fresh stateless subprocess directly (`-u -B -`, code on stdin) in the session cwd with merged stdout/stderr; there is no persistent interpreter state, no script file, and no bytecode cache. Interactive input is unsupported (stdin observes EOF). Output is bounded (50 KiB, 2,000 lines) with explicit truncation marks; truncated results persist the byte-capped buffer to a temp file referenced as `full output` with its path in `full_output_path`, mirroring `bash`. Discovery order: `python_interpreter` in `$ZUT_HOME/config.json` (one executable path; relative paths resolve against the session cwd), then the active `VIRTUAL_ENV`, then `./.venv`, then `python3`/`python` on `PATH`. On Windows, Store aliases, `py` launchers, and install-manager entrypoints are rejected; only positively identified installed interpreters run. Missing packages fail with the ordinary import traceback. Tool arguments and output are recorded in the session like other tools; files the code deliberately writes persist. Save reusable scripts with `write`/`edit` instead of using `python` as an opaque file-edit path. Unavailable to packaged agents, and rejected while jailed (see below).269 - `python`: execute inline Python 3 code with an existing interpreter, never installing one. Every call must include nonblank `code` and a positive integer `timeout` in seconds (shared by interpreter discovery and execution). Each call runs a fresh stateless subprocess directly (`-u -B -`, code on stdin) in the session cwd with merged stdout/stderr; there is no persistent interpreter state, no script file, and no bytecode cache. Interactive input is unsupported (stdin observes EOF). Output is bounded (50 KiB, 2,000 lines) with explicit truncation marks; truncated results persist the byte-capped buffer to a temp file referenced as `full output` with its path in `full_output_path`, mirroring `bash`. Discovery order: `python_interpreter` in `$ZUT_HOME/config.json` (one executable path; relative paths resolve against the session cwd), then the active `VIRTUAL_ENV`, then `./.venv`, then `python3`/`python` on `PATH`. On Windows, Store aliases, `py` launchers, and install-manager entrypoints are rejected; only positively identified installed interpreters run. Missing packages fail with the ordinary import traceback. Tool arguments and output are recorded in the session like other tools; files the code deliberately writes persist. Save reusable scripts with `write`/`edit` instead of using `python` as an opaque file-edit path. Unavailable to packaged agents, and rejected while jailed (see below).
269 - `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path.270 - `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path.
270-- `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md).271+- `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. Successful native edits, writes, renames, and applied code actions automatically attach bounded diagnostics when available. See [docs/lsp.md](docs/lsp.md).
271 - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open` opens either a complete public URL or a referenced page, while `web_find` and `web_click` respectively find literal text in an opened page and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).272 - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open` opens either a complete public URL or a referenced page, while `web_find` and `web_click` respectively find literal text in an opened page and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).
272 - `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, mark the active goal `done` or `blocked`, or atomically supersede an invalid goal with a necessary in-mission replacement. Pausing, resuming, and clearing missions remain user-controlled through `/goal`.273 - `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, mark the active goal `done` or `blocked`, or atomically supersede an invalid goal with a necessary in-mission replacement. Pausing, resuming, and clearing missions remain user-controlled through `/goal`.
273 - `update_plan`: maintain a turn-scoped checklist of `pending`, `in_progress`, and `completed` steps. Successful updates appear inline as an Updated Plan checklist and emit `plan_update` events in JSON and RPC modes.274 - `update_plan`: maintain a turn-scoped checklist of `pending`, `in_progress`, and `completed` steps. Successful updates appear inline as an Updated Plan checklist and emit `plan_update` events in JSON and RPC modes.
274 - `schedule`: add, list, or cancel recurring five-field cron prompts for the current interactive session. A task captures the machine's current timezone when it is created. While this zut process remains open, a due task waits for an active target turn to finish, or resumes an inactive target session in the background; its output is recorded in that target transcript. Resumed background tasks also show a completion notification in the current TUI. Scheduling is in-memory only: quitting zut cancels pending tasks, and schedules do not survive restart or reboot. Print, stream, JSON, and RPC modes do not expose this tool.275 - `schedule`: add, list, or cancel recurring five-field cron prompts for the current interactive session. A task captures the machine's current timezone when it is created. While this zut process remains open, a due task waits for an active target turn to finish, or resumes an inactive target session in the background; its output is recorded in that target transcript. Resumed background tasks also show a completion notification in the current TUI. Scheduling is in-memory only: quitting zut cancels pending tasks, and schedules do not survive restart or reboot. Print, stream, JSON, and RPC modes do not expose this tool.
275 276
276-When the sandbox is on (see `/jail`), filesystem tools, `grep`, and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. `python` is rejected outright while jailed: arbitrary Python cannot be confined by the current jail (admission-time check; use `/unjail`). When admitted, `python` has unrestricted host filesystem/network/process access like `bash`. Jail does not sandbox web-search network egress.277+Tool results carry resource provenance in the durable session. Before each provider request, Zut replaces an older read only when an equivalent newer view supersedes it, and replaces a multi-file search only after every discovered file has been consumed or mutated. This provider-only projection removes known-stale context without rewriting session history. Because an older projected result changes, prompt caching restarts from that point; compact deterministic markers limit the cost.
278+
279+When the sandbox is on (see `/jail`), filesystem tools, `grep`, `ast`, and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. `python` is rejected outright while jailed: arbitrary Python cannot be confined by the current jail (admission-time check; use `/unjail`). When admitted, `python` has unrestricted host filesystem/network/process access like `bash`. Jail does not sandbox web-search network egress.
277 280
278 ## Modes281 ## Modes
279 282
M docs/extensions.md +2 -2
@@ -225,10 +225,10 @@ describing the tool's args (the same shape Anthropic and OpenAI accept).
225225 ```
226226
227227 Tool names live in the same namespace as built-in tools (`read`,
228-`write`, `edit`, `bash`, `python`, `create_worktree`, `grep`, `lsp`, `web_search`,
228+`write`, `edit`, `bash`, `python`, `create_worktree`, `grep`, `ast`, `lsp`, `web_search`,
229229 `web_open`, `web_find`, `web_click`, `schedule`, `update_goal`, `update_plan`,
230230 `skill`). Conflicts with active built-ins are silently shadowed by the built-in.
231-Every native public-web capability name, plus `grep`, `python`, `schedule`,
231+Every native public-web capability name, plus `grep`, `ast`, `python`, `schedule`,
232232 `update_goal`, and `update_plan`, is reserved even when it is unavailable, so an
233233 extension cannot claim or replace it.
234234
@@ -225,10 +225,10 @@ describing the tool's args (the same shape Anthropic and OpenAI accept).
225 ```225 ```
226 226
227 Tool names live in the same namespace as built-in tools (`read`,227 Tool names live in the same namespace as built-in tools (`read`,
228-`write`, `edit`, `bash`, `python`, `create_worktree`, `grep`, `lsp`, `web_search`,228+`write`, `edit`, `bash`, `python`, `create_worktree`, `grep`, `ast`, `lsp`, `web_search`,
229 `web_open`, `web_find`, `web_click`, `schedule`, `update_goal`, `update_plan`,229 `web_open`, `web_find`, `web_click`, `schedule`, `update_goal`, `update_plan`,
230 `skill`). Conflicts with active built-ins are silently shadowed by the built-in.230 `skill`). Conflicts with active built-ins are silently shadowed by the built-in.
231-Every native public-web capability name, plus `grep`, `python`, `schedule`,231+Every native public-web capability name, plus `grep`, `ast`, `python`, `schedule`,
232 `update_goal`, and `update_plan`, is reserved even when it is unavailable, so an232 `update_goal`, and `update_plan`, is reserved even when it is unavailable, so an
233 extension cannot claim or replace it.233 extension cannot claim or replace it.
234 234
M docs/lsp.md +5 -4
@@ -106,10 +106,11 @@ whitespace-compacted and clipped before formatting. Slow write-time checks are
106106 bounded to a short two-second budget; a successful file write or edit is never
107107 turned into a failed mutation because an LSP or linter is unavailable.
108108
109-Write diagnostics are enabled by default when LSP is enabled. Edit diagnostics
110-are disabled by default to keep multi-edit sequences compact. The persisted
111-configuration keys are `lsp_diagnostics_on_write` and
112-`lsp_diagnostics_on_edit` when an embedding needs to override those defaults.
109+Write and edit diagnostics are enabled by default when LSP is enabled. Applied
110+LSP rename, code-action, and AST rewrite edits also run the same bounded checks
111+for up to three modified files within one shared two-second budget. The persisted configuration keys are
112+`lsp_diagnostics_on_write` and `lsp_diagnostics_on_edit` when an embedding
113+needs to override the native file-tool defaults.
113114 Repeated post-write diagnostics are tracked per file by severity/code/message
114115 identity, so a line shift does not replay the same error. Clearing a file's
115116 diagnostics allows a later recurrence to be surfaced again.
@@ -106,10 +106,11 @@ whitespace-compacted and clipped before formatting. Slow write-time checks are
106 bounded to a short two-second budget; a successful file write or edit is never106 bounded to a short two-second budget; a successful file write or edit is never
107 turned into a failed mutation because an LSP or linter is unavailable.107 turned into a failed mutation because an LSP or linter is unavailable.
108 108
109-Write diagnostics are enabled by default when LSP is enabled. Edit diagnostics109+Write and edit diagnostics are enabled by default when LSP is enabled. Applied
110-are disabled by default to keep multi-edit sequences compact. The persisted110+LSP rename, code-action, and AST rewrite edits also run the same bounded checks
111-configuration keys are `lsp_diagnostics_on_write` and111+for up to three modified files within one shared two-second budget. The persisted configuration keys are
112-`lsp_diagnostics_on_edit` when an embedding needs to override those defaults.112+`lsp_diagnostics_on_write` and `lsp_diagnostics_on_edit` when an embedding
113+needs to override the native file-tool defaults.
113 Repeated post-write diagnostics are tracked per file by severity/code/message114 Repeated post-write diagnostics are tracked per file by severity/code/message
114 identity, so a line shift does not replay the same error. Clearing a file's115 identity, so a line shift does not replay the same error. Clearing a file's
115 diagnostics allows a later recurrence to be surfaced again.116 diagnostics allows a later recurrence to be surfaced again.
M packages/agent/build.go +11 -3
@@ -123,7 +123,7 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
123123 // Web capability and other native names remain reserved even when their
124124 // policy excludes the current session. An extension must not turn a
125125 // normal CLI opt-out into a differently implemented capability.
126- if tools.IsWebCapabilityName(info.Name) || info.Name == "grep" || info.Name == "python" || info.Name == "schedule" || info.Name == tools.UpdateGoalToolName || info.Name == tools.UpdatePlanToolName {
126+ if tools.IsWebCapabilityName(info.Name) || info.Name == "grep" || info.Name == "ast" || info.Name == "python" || info.Name == "schedule" || info.Name == tools.UpdateGoalToolName || info.Name == tools.UpdatePlanToolName {
127127 continue
128128 }
129129 if _, exists := r.ToolRegistry[info.Name]; exists {
@@ -1318,6 +1318,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) {
13181318 v.Sandbox = s
13191319 case *tools.GrepTool:
13201320 v.Sandbox = s
1321+ case *tools.ASTTool:
1322+ v.Sandbox = s
13211323 }
13221324 _ = name
13231325 }
@@ -1354,6 +1356,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
13541356 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
13551357 "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox},
13561358 "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox},
1359+ "ast": &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
13571360 "update_goal": &tools.UpdateGoalTool{},
13581361 "update_plan": &tools.UpdatePlanTool{},
13591362 }
@@ -1365,6 +1368,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
13651368 if manager != nil {
13661369 lspTool := tools.NewLSPTool(cwd, manager)
13671370 lspTool.Sandbox = sandbox
1371+ lspTool.LSPDiagnostics = diagnosticsOnEdit
13681372 all["lsp"] = lspTool
13691373 }
13701374 reg := core.Registry{}
@@ -1390,6 +1394,10 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
13901394 if orchestratorExplorationToolsAllowed(args) {
13911395 reg["read"] = all["read"]
13921396 reg["grep"] = all["grep"]
1397+ if astTool, ok := all["ast"].(*tools.ASTTool); ok {
1398+ astTool.ReadOnly = true
1399+ reg["ast"] = astTool
1400+ }
13931401 if args.PermissionSet == nil {
13941402 for name, tool := range tools.NewWebTools() {
13951403 reg[name] = tool
@@ -1440,7 +1448,7 @@ func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) boo
14401448 if diagnosticsOnWrite {
14411449 return true
14421450 }
1443- case "edit":
1451+ case "edit", "ast":
14441452 if diagnosticsOnEdit {
14451453 return true
14461454 }
@@ -1496,7 +1504,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
14961504 return false
14971505 }
14981506
1499-var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "bash", "python", "create_worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "update_plan"}
1507+var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "ast", "bash", "python", "create_worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "update_plan"}
15001508
15011509 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
15021510 var out []ToolSummary
@@ -123,7 +123,7 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
123 // Web capability and other native names remain reserved even when their123 // Web capability and other native names remain reserved even when their
124 // policy excludes the current session. An extension must not turn a124 // policy excludes the current session. An extension must not turn a
125 // normal CLI opt-out into a differently implemented capability.125 // normal CLI opt-out into a differently implemented capability.
126- if tools.IsWebCapabilityName(info.Name) || info.Name == "grep" || info.Name == "python" || info.Name == "schedule" || info.Name == tools.UpdateGoalToolName || info.Name == tools.UpdatePlanToolName {126+ if tools.IsWebCapabilityName(info.Name) || info.Name == "grep" || info.Name == "ast" || info.Name == "python" || info.Name == "schedule" || info.Name == tools.UpdateGoalToolName || info.Name == tools.UpdatePlanToolName {
127 continue127 continue
128 }128 }
129 if _, exists := r.ToolRegistry[info.Name]; exists {129 if _, exists := r.ToolRegistry[info.Name]; exists {
@@ -1318,6 +1318,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) {
1318 v.Sandbox = s1318 v.Sandbox = s
1319 case *tools.GrepTool:1319 case *tools.GrepTool:
1320 v.Sandbox = s1320 v.Sandbox = s
1321+ case *tools.ASTTool:
1322+ v.Sandbox = s
1321 }1323 }
1322 _ = name1324 _ = name
1323 }1325 }
@@ -1354,6 +1356,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
1354 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},1356 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1355 "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox},1357 "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox},
1356 "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox},1358 "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox},
1359+ "ast": &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1357 "update_goal": &tools.UpdateGoalTool{},1360 "update_goal": &tools.UpdateGoalTool{},
1358 "update_plan": &tools.UpdatePlanTool{},1361 "update_plan": &tools.UpdatePlanTool{},
1359 }1362 }
@@ -1365,6 +1368,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
1365 if manager != nil {1368 if manager != nil {
1366 lspTool := tools.NewLSPTool(cwd, manager)1369 lspTool := tools.NewLSPTool(cwd, manager)
1367 lspTool.Sandbox = sandbox1370 lspTool.Sandbox = sandbox
1371+ lspTool.LSPDiagnostics = diagnosticsOnEdit
1368 all["lsp"] = lspTool1372 all["lsp"] = lspTool
1369 }1373 }
1370 reg := core.Registry{}1374 reg := core.Registry{}
@@ -1390,6 +1394,10 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
1390 if orchestratorExplorationToolsAllowed(args) {1394 if orchestratorExplorationToolsAllowed(args) {
1391 reg["read"] = all["read"]1395 reg["read"] = all["read"]
1392 reg["grep"] = all["grep"]1396 reg["grep"] = all["grep"]
1397+ if astTool, ok := all["ast"].(*tools.ASTTool); ok {
1398+ astTool.ReadOnly = true
1399+ reg["ast"] = astTool
1400+ }
1393 if args.PermissionSet == nil {1401 if args.PermissionSet == nil {
1394 for name, tool := range tools.NewWebTools() {1402 for name, tool := range tools.NewWebTools() {
1395 reg[name] = tool1403 reg[name] = tool
@@ -1440,7 +1448,7 @@ func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) boo
1440 if diagnosticsOnWrite {1448 if diagnosticsOnWrite {
1441 return true1449 return true
1442 }1450 }
1443- case "edit":1451+ case "edit", "ast":
1444 if diagnosticsOnEdit {1452 if diagnosticsOnEdit {
1445 return true1453 return true
1446 }1454 }
@@ -1496,7 +1504,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
1496 return false1504 return false
1497 }1505 }
1498 1506
1499-var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "bash", "python", "create_worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "update_plan"}1507+var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "ast", "bash", "python", "create_worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "update_plan"}
1500 1508
1501 func toolSummaries(reg core.Registry, args Args) []ToolSummary {1509 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1502 var out []ToolSummary1510 var out []ToolSummary
M packages/agent/config.go +3 -3
@@ -125,7 +125,7 @@ type Config struct {
125125 LSPDiagnosticsOnWrite *bool `json:"lsp_diagnostics_on_write,omitempty"`
126126
127127 // LSPDiagnosticsOnEdit enables bounded diagnostics after a successful
128- // edit. nil/missing means disabled to keep multi-edit sequences quiet.
128+ // edit. nil/missing means enabled when LSP itself is enabled.
129129 LSPDiagnosticsOnEdit *bool `json:"lsp_diagnostics_on_edit,omitempty"`
130130
131131 // AutoCompactThreshold is the percentage of the model context window
@@ -292,10 +292,10 @@ func (c Config) LSPDiagnosticsOnWriteEnabled(subagent bool) bool {
292292 return c.LSPEnabledFor(subagent) && (c.LSPDiagnosticsOnWrite == nil || *c.LSPDiagnosticsOnWrite)
293293 }
294294
295-// LSPDiagnosticsOnEditEnabled reports the default-off edit diagnostics
295+// LSPDiagnosticsOnEditEnabled reports the default-on edit diagnostics
296296 // preference without requiring a generated config entry.
297297 func (c Config) LSPDiagnosticsOnEditEnabled(subagent bool) bool {
298- return c.LSPEnabledFor(subagent) && c.LSPDiagnosticsOnEdit != nil && *c.LSPDiagnosticsOnEdit
298+ return c.LSPEnabledFor(subagent) && (c.LSPDiagnosticsOnEdit == nil || *c.LSPDiagnosticsOnEdit)
299299 }
300300
301301 // AuthPath returns the path to auth.json.
@@ -125,7 +125,7 @@ type Config struct {
125 LSPDiagnosticsOnWrite *bool `json:"lsp_diagnostics_on_write,omitempty"`125 LSPDiagnosticsOnWrite *bool `json:"lsp_diagnostics_on_write,omitempty"`
126 126
127 // LSPDiagnosticsOnEdit enables bounded diagnostics after a successful127 // LSPDiagnosticsOnEdit enables bounded diagnostics after a successful
128- // edit. nil/missing means disabled to keep multi-edit sequences quiet.128+ // edit. nil/missing means enabled when LSP itself is enabled.
129 LSPDiagnosticsOnEdit *bool `json:"lsp_diagnostics_on_edit,omitempty"`129 LSPDiagnosticsOnEdit *bool `json:"lsp_diagnostics_on_edit,omitempty"`
130 130
131 // AutoCompactThreshold is the percentage of the model context window131 // AutoCompactThreshold is the percentage of the model context window
@@ -292,10 +292,10 @@ func (c Config) LSPDiagnosticsOnWriteEnabled(subagent bool) bool {
292 return c.LSPEnabledFor(subagent) && (c.LSPDiagnosticsOnWrite == nil || *c.LSPDiagnosticsOnWrite)292 return c.LSPEnabledFor(subagent) && (c.LSPDiagnosticsOnWrite == nil || *c.LSPDiagnosticsOnWrite)
293 }293 }
294 294
295-// LSPDiagnosticsOnEditEnabled reports the default-off edit diagnostics295+// LSPDiagnosticsOnEditEnabled reports the default-on edit diagnostics
296 // preference without requiring a generated config entry.296 // preference without requiring a generated config entry.
297 func (c Config) LSPDiagnosticsOnEditEnabled(subagent bool) bool {297 func (c Config) LSPDiagnosticsOnEditEnabled(subagent bool) bool {
298- return c.LSPEnabledFor(subagent) && c.LSPDiagnosticsOnEdit != nil && *c.LSPDiagnosticsOnEdit298+ return c.LSPEnabledFor(subagent) && (c.LSPDiagnosticsOnEdit == nil || *c.LSPDiagnosticsOnEdit)
299 }299 }
300 300
301 // AuthPath returns the path to auth.json.301 // AuthPath returns the path to auth.json.
M packages/agent/ext/ext.go +3 -3
@@ -421,9 +421,9 @@ func (e *Extension) Command(name, description string, fn CommandHandler) {
421421 // into the agent's registry once the extension's ready frame fires.
422422 //
423423 // Conflicts with active built-in tool names (read, write, edit, bash,
424-// python, create_worktree, grep, lsp, web_search, web_open, web_find, web_click,
425-// schedule, update_goal, update_plan, and skill) are silently shadowed by the
426-// built-in. Every native public-web name, plus grep, python, schedule,
424+// python, create_worktree, grep, ast, lsp, web_search, web_open, web_find,
425+// web_click, schedule, update_goal, update_plan, and skill) are silently
426+// shadowed by the built-in. Every native public-web name, plus grep, ast, python, schedule,
427427 // update_goal, and update_plan, is reserved even when unavailable, so an
428428 // extension cannot claim or replace it.
429429 func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {
@@ -421,9 +421,9 @@ func (e *Extension) Command(name, description string, fn CommandHandler) {
421 // into the agent's registry once the extension's ready frame fires.421 // into the agent's registry once the extension's ready frame fires.
422 //422 //
423 // Conflicts with active built-in tool names (read, write, edit, bash,423 // Conflicts with active built-in tool names (read, write, edit, bash,
424-// python, create_worktree, grep, lsp, web_search, web_open, web_find, web_click,424+// python, create_worktree, grep, ast, lsp, web_search, web_open, web_find,
425-// schedule, update_goal, update_plan, and skill) are silently shadowed by the425+// web_click, schedule, update_goal, update_plan, and skill) are silently
426-// built-in. Every native public-web name, plus grep, python, schedule,426+// shadowed by the built-in. Every native public-web name, plus grep, ast, python, schedule,
427 // update_goal, and update_plan, is reserved even when unavailable, so an427 // update_goal, and update_plan, is reserved even when unavailable, so an
428 // extension cannot claim or replace it.428 // extension cannot claim or replace it.
429 func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {429 func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {
M packages/agent/lsp/protocol.go +5 -0
@@ -135,6 +135,11 @@ func pathToURI(path string) (string, error) {
135135 return u.String(), nil
136136 }
137137
138+// URIToPath converts an LSP document URI to a platform-native path.
139+func URIToPath(uri string) (string, error) {
140+ return uriToPath(uri)
141+}
142+
138143 func uriToPath(uri string) (string, error) {
139144 u, err := url.Parse(uri)
140145 if err != nil {
@@ -135,6 +135,11 @@ func pathToURI(path string) (string, error) {
135 return u.String(), nil135 return u.String(), nil
136 }136 }
137 137
138+// URIToPath converts an LSP document URI to a platform-native path.
139+func URIToPath(uri string) (string, error) {
140+ return uriToPath(uri)
141+}
142+
138 func uriToPath(uri string) (string, error) {143 func uriToPath(uri string) (string, error) {
139 u, err := url.Parse(uri)144 u, err := url.Parse(uri)
140 if err != nil {145 if err != nil {
M packages/agent/settings_store.go +2 -2
@@ -306,9 +306,9 @@ After delegating:
306306 // implementer itself.
307307 const StrictOrchestratorSystemAddendum = `Strict subagent orchestration is enabled. You are the primary-agent orchestrator, not an implementer.
308308
309-- Use read and grep only for read-only codebase exploration before dispatching work. Use web_search only for public-web research that improves worker task instructions.
309+- Use read, grep, and ast only for read-only codebase exploration before dispatching work. Prefer ast when the question depends on source syntax. Use web_search only for public-web research that improves worker task instructions.
310310 - Divide the request into concrete, bounded, non-overlapping worker scopes before dispatching. Delegate all implementation, debugging/testing, and code-review work to an appropriately named subagent profile or a clearly described general worker.
311-- Do not write or edit code yourself, make direct implementation tool calls, inspect or review code through implementation tools, or apply worker patches. You may use read, grep, and web_search for the read-only research above and review worker reports.
311+- Do not write or edit code yourself, make direct implementation tool calls, inspect or review code through implementation tools, or apply worker patches. You may use read, grep, ast, and web_search for the read-only research above and review worker reports.
312312 - Once a worker is active, do not investigate or perform its delegated scope. Only coordinate workers, dispatch additional disjoint work, or end or yield the turn.
313313 - Give every worker a self-contained task and synthesize the results.`
314314
@@ -306,9 +306,9 @@ After delegating:
306 // implementer itself.306 // implementer itself.
307 const StrictOrchestratorSystemAddendum = `Strict subagent orchestration is enabled. You are the primary-agent orchestrator, not an implementer.307 const StrictOrchestratorSystemAddendum = `Strict subagent orchestration is enabled. You are the primary-agent orchestrator, not an implementer.
308 308
309-- Use read and grep only for read-only codebase exploration before dispatching work. Use web_search only for public-web research that improves worker task instructions.309+- Use read, grep, and ast only for read-only codebase exploration before dispatching work. Prefer ast when the question depends on source syntax. Use web_search only for public-web research that improves worker task instructions.
310 - Divide the request into concrete, bounded, non-overlapping worker scopes before dispatching. Delegate all implementation, debugging/testing, and code-review work to an appropriately named subagent profile or a clearly described general worker.310 - Divide the request into concrete, bounded, non-overlapping worker scopes before dispatching. Delegate all implementation, debugging/testing, and code-review work to an appropriately named subagent profile or a clearly described general worker.
311-- Do not write or edit code yourself, make direct implementation tool calls, inspect or review code through implementation tools, or apply worker patches. You may use read, grep, and web_search for the read-only research above and review worker reports.311+- Do not write or edit code yourself, make direct implementation tool calls, inspect or review code through implementation tools, or apply worker patches. You may use read, grep, ast, and web_search for the read-only research above and review worker reports.
312 - Once a worker is active, do not investigate or perform its delegated scope. Only coordinate workers, dispatch additional disjoint work, or end or yield the turn.312 - Once a worker is active, do not investigate or perform its delegated scope. Only coordinate workers, dispatch additional disjoint work, or end or yield the turn.
313 - Give every worker a self-contained task and synthesize the results.`313 - Give every worker a self-contained task and synthesize the results.`
314 314
M packages/agent/subagents/resident_history.go +4 -1
@@ -393,6 +393,7 @@ func ResidentHistoryMessages(items []ResidentHistoryItem) ([]provider.Message, e
393393 Content []json.RawMessage `json:"Content"`
394394 IsError bool `json:"IsError"`
395395 Timing *provider.ToolTiming `json:"Timing"`
396+ Context provider.ToolContext `json:"Context"`
396397 }
397398 if err := json.Unmarshal(item.ToolResult, &result); err != nil || len(result.Content) == 0 {
398399 if err == nil {
@@ -408,13 +409,15 @@ func ResidentHistoryMessages(items []ResidentHistoryItem) ([]provider.Message, e
408409 Content []json.RawMessage `json:"content"`
409410 IsError bool `json:"is_error"`
410411 Timing *provider.ToolTiming `json:"timing,omitempty"`
412+ Context provider.ToolContext `json:"context,omitempty"`
411413 } `json:"content"`
412414 }{Role: provider.RoleTool, Time: item.Time, Content: []struct {
413415 CallID string `json:"call_id"`
414416 Content []json.RawMessage `json:"content"`
415417 IsError bool `json:"is_error"`
416418 Timing *provider.ToolTiming `json:"timing,omitempty"`
417- }{{CallID: item.ToolID, Content: result.Content, IsError: result.IsError, Timing: result.Timing}}})
419+ Context provider.ToolContext `json:"context,omitempty"`
420+ }{{CallID: item.ToolID, Content: result.Content, IsError: result.IsError, Timing: result.Timing, Context: result.Context}}})
418421 if err != nil {
419422 return nil, err
420423 }
@@ -393,6 +393,7 @@ func ResidentHistoryMessages(items []ResidentHistoryItem) ([]provider.Message, e
393 Content []json.RawMessage `json:"Content"`393 Content []json.RawMessage `json:"Content"`
394 IsError bool `json:"IsError"`394 IsError bool `json:"IsError"`
395 Timing *provider.ToolTiming `json:"Timing"`395 Timing *provider.ToolTiming `json:"Timing"`
396+ Context provider.ToolContext `json:"Context"`
396 }397 }
397 if err := json.Unmarshal(item.ToolResult, &result); err != nil || len(result.Content) == 0 {398 if err := json.Unmarshal(item.ToolResult, &result); err != nil || len(result.Content) == 0 {
398 if err == nil {399 if err == nil {
@@ -408,13 +409,15 @@ func ResidentHistoryMessages(items []ResidentHistoryItem) ([]provider.Message, e
408 Content []json.RawMessage `json:"content"`409 Content []json.RawMessage `json:"content"`
409 IsError bool `json:"is_error"`410 IsError bool `json:"is_error"`
410 Timing *provider.ToolTiming `json:"timing,omitempty"`411 Timing *provider.ToolTiming `json:"timing,omitempty"`
412+ Context provider.ToolContext `json:"context,omitempty"`
411 } `json:"content"`413 } `json:"content"`
412 }{Role: provider.RoleTool, Time: item.Time, Content: []struct {414 }{Role: provider.RoleTool, Time: item.Time, Content: []struct {
413 CallID string `json:"call_id"`415 CallID string `json:"call_id"`
414 Content []json.RawMessage `json:"content"`416 Content []json.RawMessage `json:"content"`
415 IsError bool `json:"is_error"`417 IsError bool `json:"is_error"`
416 Timing *provider.ToolTiming `json:"timing,omitempty"`418 Timing *provider.ToolTiming `json:"timing,omitempty"`
417- }{{CallID: item.ToolID, Content: result.Content, IsError: result.IsError, Timing: result.Timing}}})419+ Context provider.ToolContext `json:"context,omitempty"`
420+ }{{CallID: item.ToolID, Content: result.Content, IsError: result.IsError, Timing: result.Timing, Context: result.Context}}})
418 if err != nil {421 if err != nil {
419 return nil, err422 return nil, err
420 }423 }
A packages/agent/tools/ast.go +398 -0
@@ -0,0 +1,398 @@
1+package tools
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "encoding/json"
8+ "errors"
9+ "fmt"
10+ "go/format"
11+ "os"
12+ "os/exec"
13+ "path/filepath"
14+ "sort"
15+ "strings"
16+ "unicode/utf8"
17+
18+ "github.com/bnema/zut/packages/agent/lsp"
19+ "github.com/bnema/zut/packages/core"
20+ "github.com/bnema/zut/packages/provider"
21+)
22+
23+const (
24+ maxASTOutputBytes = 60 * 1024
25+ maxASTCaptureBytes = 4 * 1024 * 1024
26+)
27+
28+// ASTTool performs structural searches and rewrites through ast-grep.
29+type ASTTool struct {
30+ CWD string
31+ Sandbox *Sandbox
32+ LSP *lsp.Manager
33+ LSPDiagnostics bool
34+ ReadOnly bool
35+ LookPath func(string) (string, error)
36+}
37+
38+var (
39+ _ core.Tool = (*ASTTool)(nil)
40+ _ core.ToolPreviewer = (*ASTTool)(nil)
41+)
42+
43+type astArgs struct {
44+ Pattern string `json:"pattern"`
45+ Language string `json:"language"`
46+ Path string `json:"path"`
47+ Rewrite string `json:"rewrite,omitempty"`
48+}
49+
50+const astSchema = `{"type":"object","properties":{"pattern":{"type":"string","description":"ast-grep structural pattern, such as '$A($$$ARGS)' for a call."},"language":{"type":"string","description":"ast-grep language id, such as go, rust, python, or typescript."},"path":{"type":"string","description":"File or directory to search, relative to the workspace or absolute."},"rewrite":{"type":"string","description":"Optional structural replacement. When supplied, Zut previews and safely applies every match."}},"required":["pattern","language","path"],"additionalProperties":false}`
51+
52+func (t *ASTTool) Name() string { return "ast" }
53+func (t *ASTTool) Description() string {
54+ return "Prefer this for syntax-aware code searches and rewrites. Uses installed ast-grep, previews mutations, enforces filesystem scope, and attaches diagnostics. Use grep/edit for plain text."
55+}
56+func (t *ASTTool) Schema() json.RawMessage { return json.RawMessage(astSchema) }
57+
58+func (t *ASTTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) {
59+ args, err := parseASTArgs(raw)
60+ if err != nil {
61+ return core.ToolResult{}, err
62+ }
63+ plan, err := t.plan(ctx, args)
64+ if err != nil {
65+ return core.ToolResult{}, err
66+ }
67+ return plan.result, nil
68+}
69+
70+func (t *ASTTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
71+ args, err := parseASTArgs(raw)
72+ if err != nil {
73+ return core.ToolResult{}, err
74+ }
75+ plan, err := t.plan(ctx, args)
76+ if err != nil {
77+ return core.ToolResult{}, err
78+ }
79+ if args.Rewrite == "" {
80+ return plan.result, nil
81+ }
82+ for _, change := range plan.changes {
83+ if err := os.WriteFile(change.path, change.content, change.mode); err != nil {
84+ return core.ToolResult{}, fmt.Errorf("ast: write %s: %w", change.path, err)
85+ }
86+ }
87+ if t.LSPDiagnostics {
88+ attachMutationDiagnostics(ctx, t.CWD, plan.result.Context.Mutates, t.LSP, &plan.result)
89+ }
90+ return plan.result, nil
91+}
92+
93+type astChange struct {
94+ path string
95+ content []byte
96+ mode os.FileMode
97+}
98+
99+type astPlan struct {
100+ result core.ToolResult
101+ changes []astChange
102+}
103+
104+type astMatch struct {
105+ Text string `json:"text"`
106+ File string `json:"file"`
107+ Lines string `json:"lines"`
108+ Replacement string `json:"replacement"`
109+ Range struct {
110+ ByteOffset struct {
111+ Start int `json:"start"`
112+ End int `json:"end"`
113+ } `json:"byteOffset"`
114+ Start struct {
115+ Line int `json:"line"`
116+ } `json:"start"`
117+ } `json:"range"`
118+}
119+
120+func parseASTArgs(raw json.RawMessage) (astArgs, error) {
121+ var args astArgs
122+ if err := json.Unmarshal(raw, &args); err != nil {
123+ return args, fmt.Errorf("ast: invalid arguments: %w", err)
124+ }
125+ args.Pattern = strings.TrimSpace(args.Pattern)
126+ args.Language = strings.TrimSpace(args.Language)
127+ if args.Pattern == "" || args.Language == "" || strings.TrimSpace(args.Path) == "" {
128+ return args, fmt.Errorf("ast: pattern, language, and path are required")
129+ }
130+ return args, nil
131+}
132+
133+func (t *ASTTool) plan(ctx context.Context, args astArgs) (astPlan, error) {
134+ if t.ReadOnly && args.Rewrite != "" {
135+ return astPlan{}, fmt.Errorf("ast: rewrites are unavailable in read-only orchestration")
136+ }
137+ root, err := canonicalPath(t.CWD, args.Path)
138+ if err != nil {
139+ return astPlan{}, fmt.Errorf("ast: resolve path: %w", err)
140+ }
141+ if args.Rewrite == "" {
142+ if err := t.Sandbox.CheckReadPath(root); err != nil {
143+ return astPlan{}, err
144+ }
145+ } else if err := t.Sandbox.CheckWritePath(root); err != nil {
146+ return astPlan{}, err
147+ }
148+ info, err := os.Stat(root)
149+ if err != nil {
150+ return astPlan{}, fmt.Errorf("ast: %w", err)
151+ }
152+ if !info.IsDir() && !info.Mode().IsRegular() {
153+ return astPlan{}, fmt.Errorf("ast: %s is not a regular file or directory", args.Path)
154+ }
155+ binary, err := t.executable()
156+ if err != nil {
157+ return astPlan{}, err
158+ }
159+ commandArgs := []string{"run", "--pattern=" + args.Pattern, "--lang=" + args.Language}
160+ if args.Rewrite != "" {
161+ commandArgs = append(commandArgs, "--rewrite="+args.Rewrite)
162+ }
163+ commandArgs = append(commandArgs, "--json=stream", root)
164+ cmd := exec.CommandContext(ctx, binary, commandArgs...)
165+ cmd.Dir = root
166+ if !info.IsDir() {
167+ cmd.Dir = filepath.Dir(root)
168+ }
169+ configureBashProcess(cmd, nil)
170+ var stdout, stderr bytes.Buffer
171+ capture := &limitedASTBuffer{buffer: &stdout, limit: maxASTCaptureBytes}
172+ cmd.Stdout = capture
173+ cmd.Stderr = &stderr
174+ runErr := cmd.Run()
175+ if ctx.Err() != nil {
176+ return astPlan{}, ctx.Err()
177+ }
178+ if capture.overflow {
179+ return astPlan{}, fmt.Errorf("ast: output exceeds %d bytes; use a narrower path or pattern", capture.limit)
180+ }
181+ if runErr != nil {
182+ var exit *exec.ExitError
183+ if !errors.As(runErr, &exit) || exit.ExitCode() != 1 || strings.TrimSpace(stderr.String()) != "" || stdout.Len() != 0 {
184+ message := strings.TrimSpace(stderr.String())
185+ if message == "" {
186+ message = runErr.Error()
187+ }
188+ return astPlan{}, fmt.Errorf("ast: %s", message)
189+ }
190+ }
191+ matches, err := decodeASTMatches(stdout.Bytes())
192+ if err != nil {
193+ return astPlan{}, err
194+ }
195+ for index := range matches {
196+ if !filepath.IsAbs(matches[index].File) {
197+ matches[index].File = filepath.Join(cmd.Dir, matches[index].File)
198+ }
199+ matches[index].File = filepath.Clean(matches[index].File)
200+ }
201+ if args.Rewrite == "" {
202+ text, paths := formatASTMatches(matches)
203+ return astPlan{result: core.ToolResult{
204+ Content: []provider.Content{provider.TextBlock{Text: text}},
205+ Context: provider.ToolContext{Discovers: paths},
206+ Details: map[string]any{"engine": "ast-grep", "path": root, "language": args.Language, "matches": len(matches)},
207+ }}, nil
208+ }
209+ return t.planRewrite(root, args, matches)
210+}
211+
212+func (t *ASTTool) executable() (string, error) {
213+ lookup := t.LookPath
214+ if lookup == nil {
215+ lookup = exec.LookPath
216+ }
217+ for _, name := range []string{"ast-grep", "sg"} {
218+ if path, err := lookup(name); err == nil && path != "" {
219+ return path, nil
220+ }
221+ }
222+ return "", fmt.Errorf("ast: ast-grep executable ('ast-grep' or 'sg') not found in PATH")
223+}
224+
225+type limitedASTBuffer struct {
226+ buffer *bytes.Buffer
227+ limit int
228+ overflow bool
229+}
230+
231+func (w *limitedASTBuffer) Write(p []byte) (int, error) {
232+ remaining := w.limit - w.buffer.Len()
233+ if remaining > len(p) {
234+ remaining = len(p)
235+ }
236+ if remaining > 0 {
237+ _, _ = w.buffer.Write(p[:remaining])
238+ }
239+ if remaining < len(p) {
240+ w.overflow = true
241+ }
242+ // Always consume the complete write so the child can exit normally. Returning
243+ // an error here closes its stdout pipe and masks this limit as "broken pipe".
244+ return len(p), nil
245+}
246+
247+func decodeASTMatches(output []byte) ([]astMatch, error) {
248+ var matches []astMatch
249+ scanner := bufio.NewScanner(bytes.NewReader(output))
250+ scanner.Buffer(make([]byte, 64*1024), 2*1024*1024)
251+ for scanner.Scan() {
252+ var match astMatch
253+ if err := json.Unmarshal(scanner.Bytes(), &match); err != nil {
254+ return nil, fmt.Errorf("ast: decode result: %w", err)
255+ }
256+ if match.File != "" {
257+ matches = append(matches, match)
258+ }
259+ }
260+ if err := scanner.Err(); err != nil {
261+ return nil, fmt.Errorf("ast: decode output: %w", err)
262+ }
263+ return matches, nil
264+}
265+
266+func formatASTMatches(matches []astMatch) (string, []string) {
267+ seen := make(map[string]bool)
268+ var paths []string
269+ var output strings.Builder
270+ for _, match := range matches {
271+ path := filepath.Clean(match.File)
272+ fmt.Fprintf(&output, "%s:%d: %s\n", path, match.Range.Start.Line+1, strings.TrimSpace(match.Lines))
273+ if !seen[path] {
274+ seen[path] = true
275+ paths = append(paths, path)
276+ }
277+ }
278+ if len(matches) == 0 {
279+ return "No structural matches.", nil
280+ }
281+ return boundASTText(strings.TrimSpace(output.String())), paths
282+}
283+
284+func (t *ASTTool) planRewrite(root string, args astArgs, matches []astMatch) (astPlan, error) {
285+ byFile := make(map[string][]astMatch)
286+ for _, match := range matches {
287+ path, err := canonicalPath(t.CWD, match.File)
288+ if err != nil {
289+ return astPlan{}, err
290+ }
291+ if err := t.Sandbox.CheckWritePath(path); err != nil {
292+ return astPlan{}, err
293+ }
294+ byFile[path] = append(byFile[path], match)
295+ }
296+ paths := make([]string, 0, len(byFile))
297+ for path := range byFile {
298+ paths = append(paths, path)
299+ }
300+ sort.Strings(paths)
301+ var changes []astChange
302+ var diffs strings.Builder
303+ for _, path := range paths {
304+ original, err := os.ReadFile(path)
305+ if err != nil {
306+ return astPlan{}, fmt.Errorf("ast: read %s: %w", path, err)
307+ }
308+ info, err := os.Stat(path)
309+ if err != nil {
310+ return astPlan{}, err
311+ }
312+ updated := append([]byte(nil), original...)
313+ fileMatches := byFile[path]
314+ sort.Slice(fileMatches, func(i, j int) bool {
315+ left, right := fileMatches[i].Range.ByteOffset, fileMatches[j].Range.ByteOffset
316+ if left.Start == right.Start {
317+ return left.End > right.End
318+ }
319+ return left.Start < right.Start
320+ })
321+ selected := make([]astMatch, 0, len(fileMatches))
322+ lastEnd := -1
323+ for _, match := range fileMatches {
324+ start, end := match.Range.ByteOffset.Start, match.Range.ByteOffset.End
325+ if start < 0 || end < start || end > len(original) {
326+ return astPlan{}, fmt.Errorf("ast: invalid rewrite range in %s", path)
327+ }
328+ if start < lastEnd {
329+ continue // ast-grep applies the outermost non-overlapping match.
330+ }
331+ if !bytes.Equal(original[start:end], []byte(match.Text)) {
332+ return astPlan{}, fmt.Errorf("ast: %s changed while planning rewrite", path)
333+ }
334+ selected = append(selected, match)
335+ lastEnd = end
336+ }
337+ for index := len(selected) - 1; index >= 0; index-- {
338+ match := selected[index]
339+ start, end := match.Range.ByteOffset.Start, match.Range.ByteOffset.End
340+ updated = append(updated[:start], append([]byte(match.Replacement), updated[end:]...)...)
341+ }
342+ if bytes.Equal(original, updated) {
343+ continue
344+ }
345+ if strings.EqualFold(args.Language, "go") {
346+ formatted, formatErr := format.Source(updated)
347+ if formatErr == nil {
348+ if bytes.Contains(original, []byte("\r\n")) {
349+ formatted = bytes.ReplaceAll(formatted, []byte("\n"), []byte("\r\n"))
350+ }
351+ updated = formatted
352+ }
353+ }
354+ if bytes.Equal(original, updated) {
355+ continue
356+ }
357+ diffs.WriteString(unifiedDiff(path, string(original), string(updated)))
358+ changes = append(changes, astChange{path: path, content: updated, mode: info.Mode().Perm()})
359+ }
360+ text := boundASTText(diffs.String())
361+ if len(changes) == 0 {
362+ if len(matches) == 0 {
363+ text = "No structural matches."
364+ } else {
365+ text = "Structural matches found, but the rewrite produced no changes."
366+ }
367+ }
368+ mutates := make([]string, 0, len(changes))
369+ for _, change := range changes {
370+ mutates = append(mutates, change.path)
371+ }
372+ return astPlan{changes: changes, result: core.ToolResult{
373+ Content: []provider.Content{provider.TextBlock{Text: text}},
374+ Context: provider.ToolContext{Mutates: mutates},
375+ Details: map[string]any{"engine": "ast-grep", "path": root, "language": args.Language, "matches": len(matches), "files": len(changes), "diff": text},
376+ }}, nil
377+}
378+
379+func canonicalPath(cwd, path string) (string, error) {
380+ path = resolvePath(cwd, path)
381+ absolute, err := filepath.Abs(path)
382+ if err != nil {
383+ return "", err
384+ }
385+ return filepath.Clean(absolute), nil
386+}
387+
388+func boundASTText(text string) string {
389+ if len(text) <= maxASTOutputBytes {
390+ return text
391+ }
392+ suffix := fmt.Sprintf("\n... [truncated at %d bytes]", maxASTOutputBytes)
393+ text = text[:maxASTOutputBytes-len(suffix)]
394+ for len(text) > 0 && !utf8.ValidString(text) {
395+ text = text[:len(text)-1]
396+ }
397+ return text + suffix
398+}
@@ -0,0 +1,398 @@
1+package tools
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "encoding/json"
8+ "errors"
9+ "fmt"
10+ "go/format"
11+ "os"
12+ "os/exec"
13+ "path/filepath"
14+ "sort"
15+ "strings"
16+ "unicode/utf8"
17+
18+ "github.com/bnema/zut/packages/agent/lsp"
19+ "github.com/bnema/zut/packages/core"
20+ "github.com/bnema/zut/packages/provider"
21+)
22+
23+const (
24+ maxASTOutputBytes = 60 * 1024
25+ maxASTCaptureBytes = 4 * 1024 * 1024
26+)
27+
28+// ASTTool performs structural searches and rewrites through ast-grep.
29+type ASTTool struct {
30+ CWD string
31+ Sandbox *Sandbox
32+ LSP *lsp.Manager
33+ LSPDiagnostics bool
34+ ReadOnly bool
35+ LookPath func(string) (string, error)
36+}
37+
38+var (
39+ _ core.Tool = (*ASTTool)(nil)
40+ _ core.ToolPreviewer = (*ASTTool)(nil)
41+)
42+
43+type astArgs struct {
44+ Pattern string `json:"pattern"`
45+ Language string `json:"language"`
46+ Path string `json:"path"`
47+ Rewrite string `json:"rewrite,omitempty"`
48+}
49+
50+const astSchema = `{"type":"object","properties":{"pattern":{"type":"string","description":"ast-grep structural pattern, such as '$A($$$ARGS)' for a call."},"language":{"type":"string","description":"ast-grep language id, such as go, rust, python, or typescript."},"path":{"type":"string","description":"File or directory to search, relative to the workspace or absolute."},"rewrite":{"type":"string","description":"Optional structural replacement. When supplied, Zut previews and safely applies every match."}},"required":["pattern","language","path"],"additionalProperties":false}`
51+
52+func (t *ASTTool) Name() string { return "ast" }
53+func (t *ASTTool) Description() string {
54+ return "Prefer this for syntax-aware code searches and rewrites. Uses installed ast-grep, previews mutations, enforces filesystem scope, and attaches diagnostics. Use grep/edit for plain text."
55+}
56+func (t *ASTTool) Schema() json.RawMessage { return json.RawMessage(astSchema) }
57+
58+func (t *ASTTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) {
59+ args, err := parseASTArgs(raw)
60+ if err != nil {
61+ return core.ToolResult{}, err
62+ }
63+ plan, err := t.plan(ctx, args)
64+ if err != nil {
65+ return core.ToolResult{}, err
66+ }
67+ return plan.result, nil
68+}
69+
70+func (t *ASTTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
71+ args, err := parseASTArgs(raw)
72+ if err != nil {
73+ return core.ToolResult{}, err
74+ }
75+ plan, err := t.plan(ctx, args)
76+ if err != nil {
77+ return core.ToolResult{}, err
78+ }
79+ if args.Rewrite == "" {
80+ return plan.result, nil
81+ }
82+ for _, change := range plan.changes {
83+ if err := os.WriteFile(change.path, change.content, change.mode); err != nil {
84+ return core.ToolResult{}, fmt.Errorf("ast: write %s: %w", change.path, err)
85+ }
86+ }
87+ if t.LSPDiagnostics {
88+ attachMutationDiagnostics(ctx, t.CWD, plan.result.Context.Mutates, t.LSP, &plan.result)
89+ }
90+ return plan.result, nil
91+}
92+
93+type astChange struct {
94+ path string
95+ content []byte
96+ mode os.FileMode
97+}
98+
99+type astPlan struct {
100+ result core.ToolResult
101+ changes []astChange
102+}
103+
104+type astMatch struct {
105+ Text string `json:"text"`
106+ File string `json:"file"`
107+ Lines string `json:"lines"`
108+ Replacement string `json:"replacement"`
109+ Range struct {
110+ ByteOffset struct {
111+ Start int `json:"start"`
112+ End int `json:"end"`
113+ } `json:"byteOffset"`
114+ Start struct {
115+ Line int `json:"line"`
116+ } `json:"start"`
117+ } `json:"range"`
118+}
119+
120+func parseASTArgs(raw json.RawMessage) (astArgs, error) {
121+ var args astArgs
122+ if err := json.Unmarshal(raw, &args); err != nil {
123+ return args, fmt.Errorf("ast: invalid arguments: %w", err)
124+ }
125+ args.Pattern = strings.TrimSpace(args.Pattern)
126+ args.Language = strings.TrimSpace(args.Language)
127+ if args.Pattern == "" || args.Language == "" || strings.TrimSpace(args.Path) == "" {
128+ return args, fmt.Errorf("ast: pattern, language, and path are required")
129+ }
130+ return args, nil
131+}
132+
133+func (t *ASTTool) plan(ctx context.Context, args astArgs) (astPlan, error) {
134+ if t.ReadOnly && args.Rewrite != "" {
135+ return astPlan{}, fmt.Errorf("ast: rewrites are unavailable in read-only orchestration")
136+ }
137+ root, err := canonicalPath(t.CWD, args.Path)
138+ if err != nil {
139+ return astPlan{}, fmt.Errorf("ast: resolve path: %w", err)
140+ }
141+ if args.Rewrite == "" {
142+ if err := t.Sandbox.CheckReadPath(root); err != nil {
143+ return astPlan{}, err
144+ }
145+ } else if err := t.Sandbox.CheckWritePath(root); err != nil {
146+ return astPlan{}, err
147+ }
148+ info, err := os.Stat(root)
149+ if err != nil {
150+ return astPlan{}, fmt.Errorf("ast: %w", err)
151+ }
152+ if !info.IsDir() && !info.Mode().IsRegular() {
153+ return astPlan{}, fmt.Errorf("ast: %s is not a regular file or directory", args.Path)
154+ }
155+ binary, err := t.executable()
156+ if err != nil {
157+ return astPlan{}, err
158+ }
159+ commandArgs := []string{"run", "--pattern=" + args.Pattern, "--lang=" + args.Language}
160+ if args.Rewrite != "" {
161+ commandArgs = append(commandArgs, "--rewrite="+args.Rewrite)
162+ }
163+ commandArgs = append(commandArgs, "--json=stream", root)
164+ cmd := exec.CommandContext(ctx, binary, commandArgs...)
165+ cmd.Dir = root
166+ if !info.IsDir() {
167+ cmd.Dir = filepath.Dir(root)
168+ }
169+ configureBashProcess(cmd, nil)
170+ var stdout, stderr bytes.Buffer
171+ capture := &limitedASTBuffer{buffer: &stdout, limit: maxASTCaptureBytes}
172+ cmd.Stdout = capture
173+ cmd.Stderr = &stderr
174+ runErr := cmd.Run()
175+ if ctx.Err() != nil {
176+ return astPlan{}, ctx.Err()
177+ }
178+ if capture.overflow {
179+ return astPlan{}, fmt.Errorf("ast: output exceeds %d bytes; use a narrower path or pattern", capture.limit)
180+ }
181+ if runErr != nil {
182+ var exit *exec.ExitError
183+ if !errors.As(runErr, &exit) || exit.ExitCode() != 1 || strings.TrimSpace(stderr.String()) != "" || stdout.Len() != 0 {
184+ message := strings.TrimSpace(stderr.String())
185+ if message == "" {
186+ message = runErr.Error()
187+ }
188+ return astPlan{}, fmt.Errorf("ast: %s", message)
189+ }
190+ }
191+ matches, err := decodeASTMatches(stdout.Bytes())
192+ if err != nil {
193+ return astPlan{}, err
194+ }
195+ for index := range matches {
196+ if !filepath.IsAbs(matches[index].File) {
197+ matches[index].File = filepath.Join(cmd.Dir, matches[index].File)
198+ }
199+ matches[index].File = filepath.Clean(matches[index].File)
200+ }
201+ if args.Rewrite == "" {
202+ text, paths := formatASTMatches(matches)
203+ return astPlan{result: core.ToolResult{
204+ Content: []provider.Content{provider.TextBlock{Text: text}},
205+ Context: provider.ToolContext{Discovers: paths},
206+ Details: map[string]any{"engine": "ast-grep", "path": root, "language": args.Language, "matches": len(matches)},
207+ }}, nil
208+ }
209+ return t.planRewrite(root, args, matches)
210+}
211+
212+func (t *ASTTool) executable() (string, error) {
213+ lookup := t.LookPath
214+ if lookup == nil {
215+ lookup = exec.LookPath
216+ }
217+ for _, name := range []string{"ast-grep", "sg"} {
218+ if path, err := lookup(name); err == nil && path != "" {
219+ return path, nil
220+ }
221+ }
222+ return "", fmt.Errorf("ast: ast-grep executable ('ast-grep' or 'sg') not found in PATH")
223+}
224+
225+type limitedASTBuffer struct {
226+ buffer *bytes.Buffer
227+ limit int
228+ overflow bool
229+}
230+
231+func (w *limitedASTBuffer) Write(p []byte) (int, error) {
232+ remaining := w.limit - w.buffer.Len()
233+ if remaining > len(p) {
234+ remaining = len(p)
235+ }
236+ if remaining > 0 {
237+ _, _ = w.buffer.Write(p[:remaining])
238+ }
239+ if remaining < len(p) {
240+ w.overflow = true
241+ }
242+ // Always consume the complete write so the child can exit normally. Returning
243+ // an error here closes its stdout pipe and masks this limit as "broken pipe".
244+ return len(p), nil
245+}
246+
247+func decodeASTMatches(output []byte) ([]astMatch, error) {
248+ var matches []astMatch
249+ scanner := bufio.NewScanner(bytes.NewReader(output))
250+ scanner.Buffer(make([]byte, 64*1024), 2*1024*1024)
251+ for scanner.Scan() {
252+ var match astMatch
253+ if err := json.Unmarshal(scanner.Bytes(), &match); err != nil {
254+ return nil, fmt.Errorf("ast: decode result: %w", err)
255+ }
256+ if match.File != "" {
257+ matches = append(matches, match)
258+ }
259+ }
260+ if err := scanner.Err(); err != nil {
261+ return nil, fmt.Errorf("ast: decode output: %w", err)
262+ }
263+ return matches, nil
264+}
265+
266+func formatASTMatches(matches []astMatch) (string, []string) {
267+ seen := make(map[string]bool)
268+ var paths []string
269+ var output strings.Builder
270+ for _, match := range matches {
271+ path := filepath.Clean(match.File)
272+ fmt.Fprintf(&output, "%s:%d: %s\n", path, match.Range.Start.Line+1, strings.TrimSpace(match.Lines))
273+ if !seen[path] {
274+ seen[path] = true
275+ paths = append(paths, path)
276+ }
277+ }
278+ if len(matches) == 0 {
279+ return "No structural matches.", nil
280+ }
281+ return boundASTText(strings.TrimSpace(output.String())), paths
282+}
283+
284+func (t *ASTTool) planRewrite(root string, args astArgs, matches []astMatch) (astPlan, error) {
285+ byFile := make(map[string][]astMatch)
286+ for _, match := range matches {
287+ path, err := canonicalPath(t.CWD, match.File)
288+ if err != nil {
289+ return astPlan{}, err
290+ }
291+ if err := t.Sandbox.CheckWritePath(path); err != nil {
292+ return astPlan{}, err
293+ }
294+ byFile[path] = append(byFile[path], match)
295+ }
296+ paths := make([]string, 0, len(byFile))
297+ for path := range byFile {
298+ paths = append(paths, path)
299+ }
300+ sort.Strings(paths)
301+ var changes []astChange
302+ var diffs strings.Builder
303+ for _, path := range paths {
304+ original, err := os.ReadFile(path)
305+ if err != nil {
306+ return astPlan{}, fmt.Errorf("ast: read %s: %w", path, err)
307+ }
308+ info, err := os.Stat(path)
309+ if err != nil {
310+ return astPlan{}, err
311+ }
312+ updated := append([]byte(nil), original...)
313+ fileMatches := byFile[path]
314+ sort.Slice(fileMatches, func(i, j int) bool {
315+ left, right := fileMatches[i].Range.ByteOffset, fileMatches[j].Range.ByteOffset
316+ if left.Start == right.Start {
317+ return left.End > right.End
318+ }
319+ return left.Start < right.Start
320+ })
321+ selected := make([]astMatch, 0, len(fileMatches))
322+ lastEnd := -1
323+ for _, match := range fileMatches {
324+ start, end := match.Range.ByteOffset.Start, match.Range.ByteOffset.End
325+ if start < 0 || end < start || end > len(original) {
326+ return astPlan{}, fmt.Errorf("ast: invalid rewrite range in %s", path)
327+ }
328+ if start < lastEnd {
329+ continue // ast-grep applies the outermost non-overlapping match.
330+ }
331+ if !bytes.Equal(original[start:end], []byte(match.Text)) {
332+ return astPlan{}, fmt.Errorf("ast: %s changed while planning rewrite", path)
333+ }
334+ selected = append(selected, match)
335+ lastEnd = end
336+ }
337+ for index := len(selected) - 1; index >= 0; index-- {
338+ match := selected[index]
339+ start, end := match.Range.ByteOffset.Start, match.Range.ByteOffset.End
340+ updated = append(updated[:start], append([]byte(match.Replacement), updated[end:]...)...)
341+ }
342+ if bytes.Equal(original, updated) {
343+ continue
344+ }
345+ if strings.EqualFold(args.Language, "go") {
346+ formatted, formatErr := format.Source(updated)
347+ if formatErr == nil {
348+ if bytes.Contains(original, []byte("\r\n")) {
349+ formatted = bytes.ReplaceAll(formatted, []byte("\n"), []byte("\r\n"))
350+ }
351+ updated = formatted
352+ }
353+ }
354+ if bytes.Equal(original, updated) {
355+ continue
356+ }
357+ diffs.WriteString(unifiedDiff(path, string(original), string(updated)))
358+ changes = append(changes, astChange{path: path, content: updated, mode: info.Mode().Perm()})
359+ }
360+ text := boundASTText(diffs.String())
361+ if len(changes) == 0 {
362+ if len(matches) == 0 {
363+ text = "No structural matches."
364+ } else {
365+ text = "Structural matches found, but the rewrite produced no changes."
366+ }
367+ }
368+ mutates := make([]string, 0, len(changes))
369+ for _, change := range changes {
370+ mutates = append(mutates, change.path)
371+ }
372+ return astPlan{changes: changes, result: core.ToolResult{
373+ Content: []provider.Content{provider.TextBlock{Text: text}},
374+ Context: provider.ToolContext{Mutates: mutates},
375+ Details: map[string]any{"engine": "ast-grep", "path": root, "language": args.Language, "matches": len(matches), "files": len(changes), "diff": text},
376+ }}, nil
377+}
378+
379+func canonicalPath(cwd, path string) (string, error) {
380+ path = resolvePath(cwd, path)
381+ absolute, err := filepath.Abs(path)
382+ if err != nil {
383+ return "", err
384+ }
385+ return filepath.Clean(absolute), nil
386+}
387+
388+func boundASTText(text string) string {
389+ if len(text) <= maxASTOutputBytes {
390+ return text
391+ }
392+ suffix := fmt.Sprintf("\n... [truncated at %d bytes]", maxASTOutputBytes)
393+ text = text[:maxASTOutputBytes-len(suffix)]
394+ for len(text) > 0 && !utf8.ValidString(text) {
395+ text = text[:len(text)-1]
396+ }
397+ return text + suffix
398+}
A packages/agent/tools/ast_test.go +165 -0
@@ -0,0 +1,165 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "runtime"
10+ "strings"
11+ "testing"
12+ "unicode/utf8"
13+
14+ "github.com/bnema/zut/packages/provider"
15+)
16+
17+func TestASTToolReportsMissingBackend(t *testing.T) {
18+ tool := &ASTTool{CWD: t.TempDir(), LookPath: func(string) (string, error) { return "", os.ErrNotExist }}
19+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"pattern":"$A","language":"go","path":"."}`), nil)
20+ if err == nil || !strings.Contains(err.Error(), "ast-grep executable ('ast-grep' or 'sg') not found") {
21+ t.Fatalf("Execute error = %v", err)
22+ }
23+}
24+
25+func TestASTToolReportsCaptureLimitWithoutBrokenPipe(t *testing.T) {
26+ if runtime.GOOS == "windows" {
27+ t.Skip("test fixture uses a POSIX script")
28+ }
29+ root := t.TempDir()
30+ script := filepath.Join(root, "sg")
31+ body := "#!/bin/sh\nhead -c 4194305 /dev/zero | tr '\\0' x\n"
32+ if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
33+ t.Fatal(err)
34+ }
35+ tool := &ASTTool{CWD: root, LookPath: func(string) (string, error) { return script, nil }}
36+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"pattern":"$A","language":"go","path":"."}`), nil)
37+ if err == nil || !strings.Contains(err.Error(), "output exceeds") || strings.Contains(strings.ToLower(err.Error()), "broken pipe") {
38+ t.Fatalf("Execute error = %v", err)
39+ }
40+}
41+
42+func TestASTToolExecutesWithoutShellAndTracksDiscoveredFiles(t *testing.T) {
43+ if runtime.GOOS == "windows" {
44+ t.Skip("test fixture uses a POSIX script")
45+ }
46+ root := t.TempDir()
47+ script := filepath.Join(root, "sg")
48+ body := "#!/bin/sh\nprintf '%s\\n' '{\"file\":\"main.go\",\"range\":{}}'\n"
49+ if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
50+ t.Fatal(err)
51+ }
52+ tool := &ASTTool{CWD: root, LookPath: func(string) (string, error) { return script, nil }}
53+ result, err := tool.Execute(context.Background(), json.RawMessage(`{"pattern":"fmt.Errorf($MSG)","language":"go","path":"."}`), nil)
54+ if err != nil {
55+ t.Fatalf("Execute: %v", err)
56+ }
57+ want := filepath.Join(root, "main.go")
58+ if len(result.Context.Discovers) != 1 || result.Context.Discovers[0] != want {
59+ t.Fatalf("discoveries = %v, want %s", result.Context.Discovers, want)
60+ }
61+}
62+
63+func TestASTToolPrefersArchExecutableName(t *testing.T) {
64+ root := t.TempDir()
65+ var lookedUp []string
66+ tool := &ASTTool{CWD: root, LookPath: func(name string) (string, error) {
67+ lookedUp = append(lookedUp, name)
68+ if name == "ast-grep" {
69+ return "/usr/bin/ast-grep", nil
70+ }
71+ return "", os.ErrNotExist
72+ }}
73+ got, err := tool.executable()
74+ if err != nil || got != "/usr/bin/ast-grep" || len(lookedUp) != 1 {
75+ t.Fatalf("executable = %q, %v; lookups = %v", got, err, lookedUp)
76+ }
77+}
78+
79+func TestASTToolRewritePreviewAndExecute(t *testing.T) {
80+ binary, err := exec.LookPath("ast-grep")
81+ if err != nil {
82+ t.Skip("ast-grep not installed")
83+ }
84+ root := t.TempDir()
85+ path := filepath.Join(root, "main.go")
86+ original := "package main\nfunc main() { println(\"x\") }\n"
87+ if err := os.WriteFile(path, []byte(original), 0o640); err != nil {
88+ t.Fatal(err)
89+ }
90+ tool := &ASTTool{CWD: root, LookPath: func(string) (string, error) { return binary, nil }}
91+ args := json.RawMessage(`{"pattern":"println($A)","language":"go","path":".","rewrite":"fmt.Println($A)"}`)
92+ preview, err := tool.Preview(context.Background(), args)
93+ if err != nil {
94+ t.Fatalf("Preview: %v", err)
95+ }
96+ if !strings.Contains(preview.Content[0].(provider.TextBlock).Text, "+func main() { fmt.Println") {
97+ t.Fatalf("preview = %q", preview.Content[0].(provider.TextBlock).Text)
98+ }
99+ if got, _ := os.ReadFile(path); string(got) != original {
100+ t.Fatal("preview modified the file")
101+ }
102+ result, err := tool.Execute(context.Background(), args, nil)
103+ if err != nil {
104+ t.Fatalf("Execute: %v", err)
105+ }
106+ got, _ := os.ReadFile(path)
107+ if !strings.Contains(string(got), "fmt.Println") || len(result.Context.Mutates) != 1 {
108+ t.Fatalf("result = %q, context = %+v", got, result.Context)
109+ }
110+ if info, _ := os.Stat(path); info.Mode().Perm() != 0o640 {
111+ t.Fatalf("mode = %v", info.Mode().Perm())
112+ }
113+}
114+
115+func TestASTRewriteFormatsGoSource(t *testing.T) {
116+ root := t.TempDir()
117+ path := filepath.Join(root, "main.go")
118+ original := []byte("package main\nfunc add(left, right int) int { return left + right\n }\n")
119+ if err := os.WriteFile(path, original, 0o644); err != nil {
120+ t.Fatal(err)
121+ }
122+ match := astTestMatch(path, "return left + right\n ", "\nreturn left + right\n\n", 45, 66)
123+ plan, err := (&ASTTool{CWD: root}).planRewrite(root, astArgs{Language: "go", Rewrite: "{$$$BODY}"}, []astMatch{match})
124+ if err != nil {
125+ t.Fatalf("planRewrite: %v", err)
126+ }
127+ want := "package main\n\nfunc add(left, right int) int {\n\treturn left + right\n\n}\n"
128+ if got := string(plan.changes[0].content); got != want {
129+ t.Fatalf("rewrite = %q, want %q", got, want)
130+ }
131+}
132+
133+func TestASTRewriteUsesOutermostNestedMatch(t *testing.T) {
134+ root := t.TempDir()
135+ path := filepath.Join(root, "main.go")
136+ original := []byte("package main\nfunc main() { f(g(x)) }\n")
137+ if err := os.WriteFile(path, original, 0o644); err != nil {
138+ t.Fatal(err)
139+ }
140+ matches := []astMatch{
141+ astTestMatch(path, "f(g(x))", "WRAP(f)", 27, 34),
142+ astTestMatch(path, "g(x)", "WRAP(g)", 29, 33),
143+ }
144+ plan, err := (&ASTTool{CWD: root}).planRewrite(root, astArgs{Rewrite: "WRAP($A)"}, matches)
145+ if err != nil {
146+ t.Fatalf("planRewrite: %v", err)
147+ }
148+ if got := string(plan.changes[0].content); !strings.Contains(got, "WRAP(f)") || strings.Contains(got, "WRAP(g)") {
149+ t.Fatalf("rewrite = %q", got)
150+ }
151+}
152+
153+func astTestMatch(path, text, replacement string, start, end int) astMatch {
154+ match := astMatch{File: path, Text: text, Replacement: replacement}
155+ match.Range.ByteOffset.Start = start
156+ match.Range.ByteOffset.End = end
157+ return match
158+}
159+
160+func TestBoundASTTextPreservesUTF8(t *testing.T) {
161+ got := boundASTText(strings.Repeat("界", maxASTOutputBytes))
162+ if !strings.Contains(got, "truncated") || !utf8.ValidString(got) {
163+ t.Fatalf("invalid bounded output")
164+ }
165+}
@@ -0,0 +1,165 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "runtime"
10+ "strings"
11+ "testing"
12+ "unicode/utf8"
13+
14+ "github.com/bnema/zut/packages/provider"
15+)
16+
17+func TestASTToolReportsMissingBackend(t *testing.T) {
18+ tool := &ASTTool{CWD: t.TempDir(), LookPath: func(string) (string, error) { return "", os.ErrNotExist }}
19+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"pattern":"$A","language":"go","path":"."}`), nil)
20+ if err == nil || !strings.Contains(err.Error(), "ast-grep executable ('ast-grep' or 'sg') not found") {
21+ t.Fatalf("Execute error = %v", err)
22+ }
23+}
24+
25+func TestASTToolReportsCaptureLimitWithoutBrokenPipe(t *testing.T) {
26+ if runtime.GOOS == "windows" {
27+ t.Skip("test fixture uses a POSIX script")
28+ }
29+ root := t.TempDir()
30+ script := filepath.Join(root, "sg")
31+ body := "#!/bin/sh\nhead -c 4194305 /dev/zero | tr '\\0' x\n"
32+ if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
33+ t.Fatal(err)
34+ }
35+ tool := &ASTTool{CWD: root, LookPath: func(string) (string, error) { return script, nil }}
36+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"pattern":"$A","language":"go","path":"."}`), nil)
37+ if err == nil || !strings.Contains(err.Error(), "output exceeds") || strings.Contains(strings.ToLower(err.Error()), "broken pipe") {
38+ t.Fatalf("Execute error = %v", err)
39+ }
40+}
41+
42+func TestASTToolExecutesWithoutShellAndTracksDiscoveredFiles(t *testing.T) {
43+ if runtime.GOOS == "windows" {
44+ t.Skip("test fixture uses a POSIX script")
45+ }
46+ root := t.TempDir()
47+ script := filepath.Join(root, "sg")
48+ body := "#!/bin/sh\nprintf '%s\\n' '{\"file\":\"main.go\",\"range\":{}}'\n"
49+ if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
50+ t.Fatal(err)
51+ }
52+ tool := &ASTTool{CWD: root, LookPath: func(string) (string, error) { return script, nil }}
53+ result, err := tool.Execute(context.Background(), json.RawMessage(`{"pattern":"fmt.Errorf($MSG)","language":"go","path":"."}`), nil)
54+ if err != nil {
55+ t.Fatalf("Execute: %v", err)
56+ }
57+ want := filepath.Join(root, "main.go")
58+ if len(result.Context.Discovers) != 1 || result.Context.Discovers[0] != want {
59+ t.Fatalf("discoveries = %v, want %s", result.Context.Discovers, want)
60+ }
61+}
62+
63+func TestASTToolPrefersArchExecutableName(t *testing.T) {
64+ root := t.TempDir()
65+ var lookedUp []string
66+ tool := &ASTTool{CWD: root, LookPath: func(name string) (string, error) {
67+ lookedUp = append(lookedUp, name)
68+ if name == "ast-grep" {
69+ return "/usr/bin/ast-grep", nil
70+ }
71+ return "", os.ErrNotExist
72+ }}
73+ got, err := tool.executable()
74+ if err != nil || got != "/usr/bin/ast-grep" || len(lookedUp) != 1 {
75+ t.Fatalf("executable = %q, %v; lookups = %v", got, err, lookedUp)
76+ }
77+}
78+
79+func TestASTToolRewritePreviewAndExecute(t *testing.T) {
80+ binary, err := exec.LookPath("ast-grep")
81+ if err != nil {
82+ t.Skip("ast-grep not installed")
83+ }
84+ root := t.TempDir()
85+ path := filepath.Join(root, "main.go")
86+ original := "package main\nfunc main() { println(\"x\") }\n"
87+ if err := os.WriteFile(path, []byte(original), 0o640); err != nil {
88+ t.Fatal(err)
89+ }
90+ tool := &ASTTool{CWD: root, LookPath: func(string) (string, error) { return binary, nil }}
91+ args := json.RawMessage(`{"pattern":"println($A)","language":"go","path":".","rewrite":"fmt.Println($A)"}`)
92+ preview, err := tool.Preview(context.Background(), args)
93+ if err != nil {
94+ t.Fatalf("Preview: %v", err)
95+ }
96+ if !strings.Contains(preview.Content[0].(provider.TextBlock).Text, "+func main() { fmt.Println") {
97+ t.Fatalf("preview = %q", preview.Content[0].(provider.TextBlock).Text)
98+ }
99+ if got, _ := os.ReadFile(path); string(got) != original {
100+ t.Fatal("preview modified the file")
101+ }
102+ result, err := tool.Execute(context.Background(), args, nil)
103+ if err != nil {
104+ t.Fatalf("Execute: %v", err)
105+ }
106+ got, _ := os.ReadFile(path)
107+ if !strings.Contains(string(got), "fmt.Println") || len(result.Context.Mutates) != 1 {
108+ t.Fatalf("result = %q, context = %+v", got, result.Context)
109+ }
110+ if info, _ := os.Stat(path); info.Mode().Perm() != 0o640 {
111+ t.Fatalf("mode = %v", info.Mode().Perm())
112+ }
113+}
114+
115+func TestASTRewriteFormatsGoSource(t *testing.T) {
116+ root := t.TempDir()
117+ path := filepath.Join(root, "main.go")
118+ original := []byte("package main\nfunc add(left, right int) int { return left + right\n }\n")
119+ if err := os.WriteFile(path, original, 0o644); err != nil {
120+ t.Fatal(err)
121+ }
122+ match := astTestMatch(path, "return left + right\n ", "\nreturn left + right\n\n", 45, 66)
123+ plan, err := (&ASTTool{CWD: root}).planRewrite(root, astArgs{Language: "go", Rewrite: "{$$$BODY}"}, []astMatch{match})
124+ if err != nil {
125+ t.Fatalf("planRewrite: %v", err)
126+ }
127+ want := "package main\n\nfunc add(left, right int) int {\n\treturn left + right\n\n}\n"
128+ if got := string(plan.changes[0].content); got != want {
129+ t.Fatalf("rewrite = %q, want %q", got, want)
130+ }
131+}
132+
133+func TestASTRewriteUsesOutermostNestedMatch(t *testing.T) {
134+ root := t.TempDir()
135+ path := filepath.Join(root, "main.go")
136+ original := []byte("package main\nfunc main() { f(g(x)) }\n")
137+ if err := os.WriteFile(path, original, 0o644); err != nil {
138+ t.Fatal(err)
139+ }
140+ matches := []astMatch{
141+ astTestMatch(path, "f(g(x))", "WRAP(f)", 27, 34),
142+ astTestMatch(path, "g(x)", "WRAP(g)", 29, 33),
143+ }
144+ plan, err := (&ASTTool{CWD: root}).planRewrite(root, astArgs{Rewrite: "WRAP($A)"}, matches)
145+ if err != nil {
146+ t.Fatalf("planRewrite: %v", err)
147+ }
148+ if got := string(plan.changes[0].content); !strings.Contains(got, "WRAP(f)") || strings.Contains(got, "WRAP(g)") {
149+ t.Fatalf("rewrite = %q", got)
150+ }
151+}
152+
153+func astTestMatch(path, text, replacement string, start, end int) astMatch {
154+ match := astMatch{File: path, Text: text, Replacement: replacement}
155+ match.Range.ByteOffset.Start = start
156+ match.Range.ByteOffset.End = end
157+ return match
158+}
159+
160+func TestBoundASTTextPreservesUTF8(t *testing.T) {
161+ got := boundASTText(strings.Repeat("界", maxASTOutputBytes))
162+ if !strings.Contains(got, "truncated") || !utf8.ValidString(got) {
163+ t.Fatalf("invalid bounded output")
164+ }
165+}
M packages/agent/tools/edit.go +1 -0
@@ -170,6 +170,7 @@ func (t *EditTool) plan(raw json.RawMessage) (editPlan, error) {
170170 // programmatic consumers and confirmation previews.
171171 result := core.ToolResult{
172172 Content: []provider.Content{provider.TextBlock{Text: diff}},
173+ Context: provider.ToolContext{Mutates: []string{canonicalResourcePath(path)}},
173174 Details: map[string]any{"path": path, "edits": len(a.Edits), "diff": diff},
174175 }
175176 return editPlan{path: path, final: final, result: result}, nil
@@ -170,6 +170,7 @@ func (t *EditTool) plan(raw json.RawMessage) (editPlan, error) {
170 // programmatic consumers and confirmation previews.170 // programmatic consumers and confirmation previews.
171 result := core.ToolResult{171 result := core.ToolResult{
172 Content: []provider.Content{provider.TextBlock{Text: diff}},172 Content: []provider.Content{provider.TextBlock{Text: diff}},
173+ Context: provider.ToolContext{Mutates: []string{canonicalResourcePath(path)}},
173 Details: map[string]any{"path": path, "edits": len(a.Edits), "diff": diff},174 Details: map[string]any{"path": path, "edits": len(a.Edits), "diff": diff},
174 }175 }
175 return editPlan{path: path, final: final, result: result}, nil176 return editPlan{path: path, final: final, result: result}, nil
M packages/agent/tools/grep.go +26 -0
@@ -179,6 +179,7 @@ func (t *GrepTool) executeWithTimeout(ctx context.Context, raw json.RawMessage,
179179 return core.ToolResult{
180180 Content: []provider.Content{provider.TextBlock{Text: text}},
181181 IsError: isError,
182+ Context: provider.ToolContext{Discovers: grepResultPaths(text, root, info.IsDir())},
182183 Details: map[string]any{
183184 "engine": engine,
184185 "path": t.Sandbox.DisplayPath(root, args.Path),
@@ -192,6 +193,31 @@ func (t *GrepTool) executeWithTimeout(ctx context.Context, raw json.RawMessage,
192193 }, nil
193194 }
194195
196+func grepResultPaths(output, root string, rootIsDir bool) []string {
197+ seen := make(map[string]bool)
198+ var paths []string
199+ for _, line := range strings.Split(output, "\n") {
200+ candidate, _, ok := strings.Cut(line, ":")
201+ if !ok || candidate == "" {
202+ continue
203+ }
204+ path := candidate
205+ if !filepath.IsAbs(path) {
206+ base := root
207+ if !rootIsDir {
208+ base = filepath.Dir(root)
209+ }
210+ path = filepath.Join(base, path)
211+ }
212+ path = canonicalResourcePath(path)
213+ if !seen[path] {
214+ seen[path] = true
215+ paths = append(paths, path)
216+ }
217+ }
218+ return paths
219+}
220+
195221 func parseGrepArgs(raw json.RawMessage) (grepArgs, error) {
196222 if len(raw) == 0 || len(raw) > maxGrepInputBytes {
197223 return grepArgs{}, fmt.Errorf("grep: invalid arguments")
@@ -179,6 +179,7 @@ func (t *GrepTool) executeWithTimeout(ctx context.Context, raw json.RawMessage,
179 return core.ToolResult{179 return core.ToolResult{
180 Content: []provider.Content{provider.TextBlock{Text: text}},180 Content: []provider.Content{provider.TextBlock{Text: text}},
181 IsError: isError,181 IsError: isError,
182+ Context: provider.ToolContext{Discovers: grepResultPaths(text, root, info.IsDir())},
182 Details: map[string]any{183 Details: map[string]any{
183 "engine": engine,184 "engine": engine,
184 "path": t.Sandbox.DisplayPath(root, args.Path),185 "path": t.Sandbox.DisplayPath(root, args.Path),
@@ -192,6 +193,31 @@ func (t *GrepTool) executeWithTimeout(ctx context.Context, raw json.RawMessage,
192 }, nil193 }, nil
193 }194 }
194 195
196+func grepResultPaths(output, root string, rootIsDir bool) []string {
197+ seen := make(map[string]bool)
198+ var paths []string
199+ for _, line := range strings.Split(output, "\n") {
200+ candidate, _, ok := strings.Cut(line, ":")
201+ if !ok || candidate == "" {
202+ continue
203+ }
204+ path := candidate
205+ if !filepath.IsAbs(path) {
206+ base := root
207+ if !rootIsDir {
208+ base = filepath.Dir(root)
209+ }
210+ path = filepath.Join(base, path)
211+ }
212+ path = canonicalResourcePath(path)
213+ if !seen[path] {
214+ seen[path] = true
215+ paths = append(paths, path)
216+ }
217+ }
218+ return paths
219+}
220+
195 func parseGrepArgs(raw json.RawMessage) (grepArgs, error) {221 func parseGrepArgs(raw json.RawMessage) (grepArgs, error) {
196 if len(raw) == 0 || len(raw) > maxGrepInputBytes {222 if len(raw) == 0 || len(raw) > maxGrepInputBytes {
197 return grepArgs{}, fmt.Errorf("grep: invalid arguments")223 return grepArgs{}, fmt.Errorf("grep: invalid arguments")
M packages/agent/tools/lsp.go +59 -9
@@ -21,9 +21,10 @@ const maxLSPToolOutput = 60 * 1024
2121 // the model. Manager owns processes and is normally shared by all tool calls
2222 // in one agent session.
2323 type LSPTool struct {
24- CWD string
25- Manager *lsp.Manager
26- Sandbox *Sandbox
24+ CWD string
25+ Manager *lsp.Manager
26+ Sandbox *Sandbox
27+ LSPDiagnostics bool
2728 }
2829
2930 // NewLSPTool constructs the model-facing LSP tool.
@@ -161,7 +162,13 @@ func (t *LSPTool) Execute(ctx context.Context, raw json.RawMessage, _ func(strin
161162 return core.ToolResult{}, actionErr
162163 }
163164 text = boundLSPText(text)
164- return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: text}}, IsError: actionErr != nil, Details: details}, nil
165+ result := core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: text}}, IsError: actionErr != nil, Details: details}
166+ if values, ok := details.(map[string]any); ok {
167+ if paths, ok := values["modified_paths"].([]string); ok {
168+ result.Context.Mutates = paths
169+ }
170+ }
171+ return result, nil
165172 }
166173
167174 func (t *LSPTool) executeLanguageAction(ctx context.Context, cwd, path string, args lspArgs) (string, any, error) {
@@ -200,11 +207,17 @@ func (t *LSPTool) executeLanguageAction(ctx context.Context, cwd, path string, a
200207 if args.Action == "rename" && args.Apply == nil {
201208 apply = true
202209 }
203- text, applyCount, applyErr := formatResponses(cwd, responses, args.Action, apply, t.Manager)
210+ text, applyCount, modifiedPaths, applyErr := formatResponses(cwd, responses, args.Action, apply, t.Manager)
204211 if applyCount > 0 {
205212 text += fmt.Sprintf("\nApplied %d workspace edit(s).", applyCount)
206213 }
207- return text, map[string]any{"action": args.Action, "responses": responses, "applied": applyCount}, applyErr
214+ details := map[string]any{"action": args.Action, "responses": responses, "applied": applyCount, "modified_paths": modifiedPaths}
215+ if applyCount > 0 && t.LSPDiagnostics {
216+ result := core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: text}}, Details: details}
217+ attachMutationDiagnostics(ctx, cwd, modifiedPaths, t.Manager, &result)
218+ text = result.Content[0].(provider.TextBlock).Text
219+ }
220+ return text, details, applyErr
208221 }
209222
210223 func (t *LSPTool) executeRawRequest(ctx context.Context, cwd, path string, args lspArgs) (string, any, error) {
@@ -230,9 +243,10 @@ func (t *LSPTool) executeRawRequest(ctx context.Context, cwd, path string, args
230243 return formatRawResponses(responses), map[string]any{"method": args.Method, "responses": responses}, firstResponseError(responses)
231244 }
232245
233-func formatResponses(cwd string, responses []lsp.Response, action string, apply bool, manager *lsp.Manager) (string, int, error) {
246+func formatResponses(cwd string, responses []lsp.Response, action string, apply bool, manager *lsp.Manager) (string, int, []string, error) {
234247 var b strings.Builder
235248 applied := 0
249+ var modifiedPaths []string
236250 var firstErr error
237251 pendingEdits := make([]lsp.WorkspaceEdit, 0)
238252 for _, response := range responses {
@@ -249,6 +263,7 @@ func formatResponses(cwd string, responses []lsp.Response, action string, apply
249263 }
250264 }
251265 if len(pendingEdits) > 0 {
266+ modifiedPaths = workspaceEditPaths(cwd, pendingEdits)
252267 if err := manager.ApplyEdits(cwd, pendingEdits); err != nil {
253268 if firstErr == nil {
254269 firstErr = err
@@ -259,9 +274,44 @@ func formatResponses(cwd string, responses []lsp.Response, action string, apply
259274 }
260275 }
261276 if b.Len() == 0 {
262- return "No results.", applied, firstErr
277+ return "No results.", applied, modifiedPaths, firstErr
278+ }
279+ return b.String(), applied, modifiedPaths, firstErr
280+}
281+
282+func workspaceEditPaths(cwd string, edits []lsp.WorkspaceEdit) []string {
283+ seen := make(map[string]bool)
284+ var paths []string
285+ add := func(uri string) {
286+ path, err := lsp.URIToPath(uri)
287+ if err != nil {
288+ return
289+ }
290+ if !filepath.IsAbs(path) {
291+ path = filepath.Join(cwd, path)
292+ }
293+ path = filepath.Clean(path)
294+ if !seen[path] {
295+ seen[path] = true
296+ paths = append(paths, path)
297+ }
298+ }
299+ for _, edit := range edits {
300+ for uri := range edit.Changes {
301+ add(uri)
302+ }
303+ for _, raw := range edit.DocumentChanges {
304+ var change struct {
305+ TextDocument struct {
306+ URI string `json:"uri"`
307+ } `json:"textDocument"`
308+ }
309+ if json.Unmarshal(raw, &change) == nil {
310+ add(change.TextDocument.URI)
311+ }
312+ }
263313 }
264- return b.String(), applied, firstErr
314+ return paths
265315 }
266316
267317 func workspaceEdits(raw json.RawMessage, action string) []lsp.WorkspaceEdit {
@@ -21,9 +21,10 @@ const maxLSPToolOutput = 60 * 1024
21 // the model. Manager owns processes and is normally shared by all tool calls21 // the model. Manager owns processes and is normally shared by all tool calls
22 // in one agent session.22 // in one agent session.
23 type LSPTool struct {23 type LSPTool struct {
24- CWD string24+ CWD string
25- Manager *lsp.Manager25+ Manager *lsp.Manager
26- Sandbox *Sandbox26+ Sandbox *Sandbox
27+ LSPDiagnostics bool
27 }28 }
28 29
29 // NewLSPTool constructs the model-facing LSP tool.30 // NewLSPTool constructs the model-facing LSP tool.
@@ -161,7 +162,13 @@ func (t *LSPTool) Execute(ctx context.Context, raw json.RawMessage, _ func(strin
161 return core.ToolResult{}, actionErr162 return core.ToolResult{}, actionErr
162 }163 }
163 text = boundLSPText(text)164 text = boundLSPText(text)
164- return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: text}}, IsError: actionErr != nil, Details: details}, nil165+ result := core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: text}}, IsError: actionErr != nil, Details: details}
166+ if values, ok := details.(map[string]any); ok {
167+ if paths, ok := values["modified_paths"].([]string); ok {
168+ result.Context.Mutates = paths
169+ }
170+ }
171+ return result, nil
165 }172 }
166 173
167 func (t *LSPTool) executeLanguageAction(ctx context.Context, cwd, path string, args lspArgs) (string, any, error) {174 func (t *LSPTool) executeLanguageAction(ctx context.Context, cwd, path string, args lspArgs) (string, any, error) {
@@ -200,11 +207,17 @@ func (t *LSPTool) executeLanguageAction(ctx context.Context, cwd, path string, a
200 if args.Action == "rename" && args.Apply == nil {207 if args.Action == "rename" && args.Apply == nil {
201 apply = true208 apply = true
202 }209 }
203- text, applyCount, applyErr := formatResponses(cwd, responses, args.Action, apply, t.Manager)210+ text, applyCount, modifiedPaths, applyErr := formatResponses(cwd, responses, args.Action, apply, t.Manager)
204 if applyCount > 0 {211 if applyCount > 0 {
205 text += fmt.Sprintf("\nApplied %d workspace edit(s).", applyCount)212 text += fmt.Sprintf("\nApplied %d workspace edit(s).", applyCount)
206 }213 }
207- return text, map[string]any{"action": args.Action, "responses": responses, "applied": applyCount}, applyErr214+ details := map[string]any{"action": args.Action, "responses": responses, "applied": applyCount, "modified_paths": modifiedPaths}
215+ if applyCount > 0 && t.LSPDiagnostics {
216+ result := core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: text}}, Details: details}
217+ attachMutationDiagnostics(ctx, cwd, modifiedPaths, t.Manager, &result)
218+ text = result.Content[0].(provider.TextBlock).Text
219+ }
220+ return text, details, applyErr
208 }221 }
209 222
210 func (t *LSPTool) executeRawRequest(ctx context.Context, cwd, path string, args lspArgs) (string, any, error) {223 func (t *LSPTool) executeRawRequest(ctx context.Context, cwd, path string, args lspArgs) (string, any, error) {
@@ -230,9 +243,10 @@ func (t *LSPTool) executeRawRequest(ctx context.Context, cwd, path string, args
230 return formatRawResponses(responses), map[string]any{"method": args.Method, "responses": responses}, firstResponseError(responses)243 return formatRawResponses(responses), map[string]any{"method": args.Method, "responses": responses}, firstResponseError(responses)
231 }244 }
232 245
233-func formatResponses(cwd string, responses []lsp.Response, action string, apply bool, manager *lsp.Manager) (string, int, error) {246+func formatResponses(cwd string, responses []lsp.Response, action string, apply bool, manager *lsp.Manager) (string, int, []string, error) {
234 var b strings.Builder247 var b strings.Builder
235 applied := 0248 applied := 0
249+ var modifiedPaths []string
236 var firstErr error250 var firstErr error
237 pendingEdits := make([]lsp.WorkspaceEdit, 0)251 pendingEdits := make([]lsp.WorkspaceEdit, 0)
238 for _, response := range responses {252 for _, response := range responses {
@@ -249,6 +263,7 @@ func formatResponses(cwd string, responses []lsp.Response, action string, apply
249 }263 }
250 }264 }
251 if len(pendingEdits) > 0 {265 if len(pendingEdits) > 0 {
266+ modifiedPaths = workspaceEditPaths(cwd, pendingEdits)
252 if err := manager.ApplyEdits(cwd, pendingEdits); err != nil {267 if err := manager.ApplyEdits(cwd, pendingEdits); err != nil {
253 if firstErr == nil {268 if firstErr == nil {
254 firstErr = err269 firstErr = err
@@ -259,9 +274,44 @@ func formatResponses(cwd string, responses []lsp.Response, action string, apply
259 }274 }
260 }275 }
261 if b.Len() == 0 {276 if b.Len() == 0 {
262- return "No results.", applied, firstErr277+ return "No results.", applied, modifiedPaths, firstErr
278+ }
279+ return b.String(), applied, modifiedPaths, firstErr
280+}
281+
282+func workspaceEditPaths(cwd string, edits []lsp.WorkspaceEdit) []string {
283+ seen := make(map[string]bool)
284+ var paths []string
285+ add := func(uri string) {
286+ path, err := lsp.URIToPath(uri)
287+ if err != nil {
288+ return
289+ }
290+ if !filepath.IsAbs(path) {
291+ path = filepath.Join(cwd, path)
292+ }
293+ path = filepath.Clean(path)
294+ if !seen[path] {
295+ seen[path] = true
296+ paths = append(paths, path)
297+ }
298+ }
299+ for _, edit := range edits {
300+ for uri := range edit.Changes {
301+ add(uri)
302+ }
303+ for _, raw := range edit.DocumentChanges {
304+ var change struct {
305+ TextDocument struct {
306+ URI string `json:"uri"`
307+ } `json:"textDocument"`
308+ }
309+ if json.Unmarshal(raw, &change) == nil {
310+ add(change.TextDocument.URI)
311+ }
312+ }
263 }313 }
264- return b.String(), applied, firstErr314+ return paths
265 }315 }
266 316
267 func workspaceEdits(raw json.RawMessage, action string) []lsp.WorkspaceEdit {317 func workspaceEdits(raw json.RawMessage, action string) []lsp.WorkspaceEdit {
M packages/agent/tools/lsp_diagnostics.go +34 -14
@@ -49,7 +49,13 @@ func CloseLSPManagers(reg core.Registry) error {
4949 // mutation into a failed tool call, so callers only attach diagnostics that
5050 // were actually returned before the short budget expired.
5151 func attachWriteDiagnostics(ctx context.Context, cwd, path string, manager *lsp.Manager, result *core.ToolResult) {
52- if manager == nil || result == nil {
52+ attachMutationDiagnostics(ctx, cwd, []string{path}, manager, result)
53+}
54+
55+const maxMutationDiagnosticFiles = 3
56+
57+func attachMutationDiagnostics(ctx context.Context, cwd string, paths []string, manager *lsp.Manager, result *core.ToolResult) {
58+ if manager == nil || result == nil || len(paths) == 0 {
5359 return
5460 }
5561 if ctx == nil {
@@ -57,22 +63,22 @@ func attachWriteDiagnostics(ctx context.Context, cwd, path string, manager *lsp.
5763 }
5864 diagnosticsCtx, cancel := context.WithTimeout(ctx, writeDiagnosticsTimeout)
5965 defer cancel()
60- diagnostics, err := manager.Diagnostics(diagnosticsCtx, cwd, path)
61- if err != nil {
66+ if len(paths) > maxMutationDiagnosticFiles {
67+ paths = paths[:maxMutationDiagnosticFiles]
6268 if details, ok := result.Details.(map[string]any); ok {
63- status := "unavailable"
64- switch {
65- case errors.Is(err, context.DeadlineExceeded):
66- status = "timeout"
67- case errors.Is(err, context.Canceled):
68- status = "canceled"
69- }
70- details["diagnostics_status"] = status
71- details["diagnostics_error"] = err.Error()
69+ details["diagnostics_files_truncated"] = true
7270 }
73- return
7471 }
75- diagnostics = manager.ReduceDiagnostics(cwd, path, diagnostics)
72+ var diagnostics []lsp.Diagnostic
73+ for _, path := range paths {
74+ found, err := manager.Diagnostics(diagnosticsCtx, cwd, path)
75+ if err != nil {
76+ setDiagnosticsError(result, err)
77+ continue
78+ }
79+ found = manager.ReduceDiagnostics(cwd, path, found)
80+ diagnostics = append(diagnostics, found...)
81+ }
7682 if len(diagnostics) == 0 {
7783 return
7884 }
@@ -94,3 +100,17 @@ func attachWriteDiagnostics(ctx context.Context, cwd, path string, manager *lsp.
94100 details["diagnostics_summary"] = summary
95101 }
96102 }
103+
104+func setDiagnosticsError(result *core.ToolResult, err error) {
105+ if details, ok := result.Details.(map[string]any); ok {
106+ status := "unavailable"
107+ switch {
108+ case errors.Is(err, context.DeadlineExceeded):
109+ status = "timeout"
110+ case errors.Is(err, context.Canceled):
111+ status = "canceled"
112+ }
113+ details["diagnostics_status"] = status
114+ details["diagnostics_error"] = err.Error()
115+ }
116+}
@@ -49,7 +49,13 @@ func CloseLSPManagers(reg core.Registry) error {
49 // mutation into a failed tool call, so callers only attach diagnostics that49 // mutation into a failed tool call, so callers only attach diagnostics that
50 // were actually returned before the short budget expired.50 // were actually returned before the short budget expired.
51 func attachWriteDiagnostics(ctx context.Context, cwd, path string, manager *lsp.Manager, result *core.ToolResult) {51 func attachWriteDiagnostics(ctx context.Context, cwd, path string, manager *lsp.Manager, result *core.ToolResult) {
52- if manager == nil || result == nil {52+ attachMutationDiagnostics(ctx, cwd, []string{path}, manager, result)
53+}
54+
55+const maxMutationDiagnosticFiles = 3
56+
57+func attachMutationDiagnostics(ctx context.Context, cwd string, paths []string, manager *lsp.Manager, result *core.ToolResult) {
58+ if manager == nil || result == nil || len(paths) == 0 {
53 return59 return
54 }60 }
55 if ctx == nil {61 if ctx == nil {
@@ -57,22 +63,22 @@ func attachWriteDiagnostics(ctx context.Context, cwd, path string, manager *lsp.
57 }63 }
58 diagnosticsCtx, cancel := context.WithTimeout(ctx, writeDiagnosticsTimeout)64 diagnosticsCtx, cancel := context.WithTimeout(ctx, writeDiagnosticsTimeout)
59 defer cancel()65 defer cancel()
60- diagnostics, err := manager.Diagnostics(diagnosticsCtx, cwd, path)66+ if len(paths) > maxMutationDiagnosticFiles {
61- if err != nil {67+ paths = paths[:maxMutationDiagnosticFiles]
62 if details, ok := result.Details.(map[string]any); ok {68 if details, ok := result.Details.(map[string]any); ok {
63- status := "unavailable"69+ details["diagnostics_files_truncated"] = true
64- switch {
65- case errors.Is(err, context.DeadlineExceeded):
66- status = "timeout"
67- case errors.Is(err, context.Canceled):
68- status = "canceled"
69- }
70- details["diagnostics_status"] = status
71- details["diagnostics_error"] = err.Error()
72 }70 }
73- return
74 }71 }
75- diagnostics = manager.ReduceDiagnostics(cwd, path, diagnostics)72+ var diagnostics []lsp.Diagnostic
73+ for _, path := range paths {
74+ found, err := manager.Diagnostics(diagnosticsCtx, cwd, path)
75+ if err != nil {
76+ setDiagnosticsError(result, err)
77+ continue
78+ }
79+ found = manager.ReduceDiagnostics(cwd, path, found)
80+ diagnostics = append(diagnostics, found...)
81+ }
76 if len(diagnostics) == 0 {82 if len(diagnostics) == 0 {
77 return83 return
78 }84 }
@@ -94,3 +100,17 @@ func attachWriteDiagnostics(ctx context.Context, cwd, path string, manager *lsp.
94 details["diagnostics_summary"] = summary100 details["diagnostics_summary"] = summary
95 }101 }
96 }102 }
103+
104+func setDiagnosticsError(result *core.ToolResult, err error) {
105+ if details, ok := result.Details.(map[string]any); ok {
106+ status := "unavailable"
107+ switch {
108+ case errors.Is(err, context.DeadlineExceeded):
109+ status = "timeout"
110+ case errors.Is(err, context.Canceled):
111+ status = "canceled"
112+ }
113+ details["diagnostics_status"] = status
114+ details["diagnostics_error"] = err.Error()
115+ }
116+}
M packages/agent/tools/read.go +11 -0
@@ -83,6 +83,7 @@ func (t *ReadTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
8383 }
8484 return core.ToolResult{
8585 Content: []provider.Content{provider.ImageBlock{MimeType: mime, Data: data}},
86+ Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: canonicalResourcePath(path)}}},
8687 }, nil
8788 }
8889
@@ -156,8 +157,10 @@ func (t *ReadTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
156157 fmt.Fprintf(&sb, "... [truncated at %d bytes]\n", maxReadBytes)
157158 }
158159
160+ variant := fmt.Sprintf("%d:%d", a.Offset, a.Limit)
159161 return core.ToolResult{
160162 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
163+ Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: canonicalResourcePath(path), Variant: variant}}},
161164 Details: map[string]any{
162165 "path": path,
163166 "start_line": start + 1, // 1-indexed; TUI draws the gutter
@@ -168,6 +171,14 @@ func (t *ReadTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
168171 }, nil
169172 }
170173
174+func canonicalResourcePath(path string) string {
175+ absolute, err := filepath.Abs(path)
176+ if err == nil {
177+ return filepath.Clean(absolute)
178+ }
179+ return filepath.Clean(path)
180+}
181+
171182 func resolvePath(cwd, p string) string {
172183 if filepath.IsAbs(p) {
173184 return p
@@ -83,6 +83,7 @@ func (t *ReadTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
83 }83 }
84 return core.ToolResult{84 return core.ToolResult{
85 Content: []provider.Content{provider.ImageBlock{MimeType: mime, Data: data}},85 Content: []provider.Content{provider.ImageBlock{MimeType: mime, Data: data}},
86+ Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: canonicalResourcePath(path)}}},
86 }, nil87 }, nil
87 }88 }
88 89
@@ -156,8 +157,10 @@ func (t *ReadTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
156 fmt.Fprintf(&sb, "... [truncated at %d bytes]\n", maxReadBytes)157 fmt.Fprintf(&sb, "... [truncated at %d bytes]\n", maxReadBytes)
157 }158 }
158 159
160+ variant := fmt.Sprintf("%d:%d", a.Offset, a.Limit)
159 return core.ToolResult{161 return core.ToolResult{
160 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},162 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
163+ Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: canonicalResourcePath(path), Variant: variant}}},
161 Details: map[string]any{164 Details: map[string]any{
162 "path": path,165 "path": path,
163 "start_line": start + 1, // 1-indexed; TUI draws the gutter166 "start_line": start + 1, // 1-indexed; TUI draws the gutter
@@ -168,6 +171,14 @@ func (t *ReadTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
168 }, nil171 }, nil
169 }172 }
170 173
174+func canonicalResourcePath(path string) string {
175+ absolute, err := filepath.Abs(path)
176+ if err == nil {
177+ return filepath.Clean(absolute)
178+ }
179+ return filepath.Clean(path)
180+}
181+
171 func resolvePath(cwd, p string) string {182 func resolvePath(cwd, p string) string {
172 if filepath.IsAbs(p) {183 if filepath.IsAbs(p) {
173 return p184 return p
M packages/agent/tools/write.go +1 -0
@@ -67,6 +67,7 @@ func (t *WriteTool) Execute(ctx context.Context, raw json.RawMessage, progress f
6767 }
6868 result := core.ToolResult{
6969 Content: []provider.Content{provider.TextBlock{Text: a.Content}},
70+ Context: provider.ToolContext{Mutates: []string{canonicalResourcePath(path)}},
7071 Details: map[string]any{
7172 "path": path,
7273 "bytes": len(a.Content),
@@ -67,6 +67,7 @@ func (t *WriteTool) Execute(ctx context.Context, raw json.RawMessage, progress f
67 }67 }
68 result := core.ToolResult{68 result := core.ToolResult{
69 Content: []provider.Content{provider.TextBlock{Text: a.Content}},69 Content: []provider.Content{provider.TextBlock{Text: a.Content}},
70+ Context: provider.ToolContext{Mutates: []string{canonicalResourcePath(path)}},
70 Details: map[string]any{71 Details: map[string]any{
71 "path": path,72 "path": path,
72 "bytes": len(a.Content),73 "bytes": len(a.Content),
M packages/core/agent.go +1 -0
@@ -1340,6 +1340,7 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
13401340 Content: res.Content,
13411341 IsError: res.IsError,
13421342 Timing: res.Timing,
1343+ Context: res.Context,
13431344 })
13441345 for _, name := range res.ActivateTools {
13451346 if _, err := tools.Get(name); err == nil && !containsString(addedTools, name) {
@@ -1340,6 +1340,7 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
1340 Content: res.Content,1340 Content: res.Content,
1341 IsError: res.IsError,1341 IsError: res.IsError,
1342 Timing: res.Timing,1342 Timing: res.Timing,
1343+ Context: res.Context,
1343 })1344 })
1344 for _, name := range res.ActivateTools {1345 for _, name := range res.ActivateTools {
1345 if _, err := tools.Get(name); err == nil && !containsString(addedTools, name) {1346 if _, err := tools.Get(name); err == nil && !containsString(addedTools, name) {
M packages/core/context_budget.go +71 -4
@@ -11,15 +11,18 @@ import (
1111 const (
1212 maxToolResultTextBytes = 32 * 1024
1313 toolResultOmissionMarker = "[tool result content omitted]"
14+ staleToolResultMarker = "[stale tool result omitted; a newer workspace view supersedes it]"
1415 )
1516
1617 // projectToolResultMessages returns a copied provider-input view of msgs.
17-// Each tool result is bounded independently. Its projection must depend only
18-// on that result: retroactively shrinking older results when a new one arrives
19-// mutates the prompt prefix and defeats provider prompt caching. Compaction
20-// owns the aggregate transcript budget.
18+// The byte cap is independent per result. Provenance pruning is the deliberate
19+// exception: a newer workspace view can replace an older result, trading the
20+// provider cache prefix from that point for removal of context known to be
21+// stale. The durable transcript is never rewritten. Compaction owns the
22+// aggregate transcript budget.
2123 func projectToolResultMessages(msgs []provider.Message) []provider.Message {
2224 projected := copyToolResultMessages(msgs)
25+ stale := staleToolResultIDs(projected)
2326
2427 for i := range projected {
2528 for j, content := range projected[i].Content {
@@ -27,6 +30,11 @@ func projectToolResultMessages(msgs []provider.Message) []provider.Message {
2730 if !ok {
2831 continue
2932 }
33+ if stale[result.CallID] {
34+ result.Content = []provider.Content{provider.TextBlock{Text: staleToolResultMarker}}
35+ projected[i].Content[j] = result
36+ continue
37+ }
3038
3139 if toolResultTextBytes(result.Content) > 0 {
3240 result.Content, _ = projectToolResultContent(result.Content, maxToolResultTextBytes)
@@ -41,6 +49,65 @@ func projectToolResultMessages(msgs []provider.Message) []provider.Message {
4149 return projected
4250 }
4351
52+// staleToolResultIDs computes a causal projection over successful tool
53+// results. Re-reading an equivalent resource view supersedes the old read;
54+// reading a discovered file supersedes the search result that found it; and a
55+// mutation supersedes every earlier view of the affected resource.
56+func staleToolResultIDs(msgs []provider.Message) map[string]bool {
57+ stale := make(map[string]bool)
58+ reads := make(map[provider.ResourceRef]string)
59+ discoveries := make(map[string]map[string]bool)
60+ remainingDiscoveries := make(map[string]int)
61+ for _, message := range msgs {
62+ for _, content := range message.Content {
63+ result, ok := content.(provider.ToolResultBlock)
64+ if !ok || result.IsError || result.CallID == "" {
65+ continue
66+ }
67+ for _, resource := range result.Context.Reads {
68+ if previous := reads[resource]; previous != "" {
69+ stale[previous] = true
70+ }
71+ reads[resource] = result.CallID
72+ for discovery := range discoveries[resource.Key] {
73+ remainingDiscoveries[discovery]--
74+ if remainingDiscoveries[discovery] == 0 {
75+ stale[discovery] = true
76+ }
77+ }
78+ delete(discoveries, resource.Key)
79+ }
80+ for _, resource := range result.Context.Mutates {
81+ for view, previous := range reads {
82+ if view.Key == resource {
83+ if previous != result.CallID {
84+ stale[previous] = true
85+ }
86+ delete(reads, view)
87+ }
88+ }
89+ for discovery := range discoveries[resource] {
90+ remainingDiscoveries[discovery]--
91+ if remainingDiscoveries[discovery] == 0 {
92+ stale[discovery] = true
93+ }
94+ }
95+ delete(discoveries, resource)
96+ }
97+ for _, resource := range result.Context.Discovers {
98+ if discoveries[resource] == nil {
99+ discoveries[resource] = make(map[string]bool)
100+ }
101+ if !discoveries[resource][result.CallID] {
102+ discoveries[resource][result.CallID] = true
103+ remainingDiscoveries[result.CallID]++
104+ }
105+ }
106+ }
107+ }
108+ return stale
109+}
110+
44111 // projectProviderMessages creates the provider-only transcript view. The
45112 // timestamp and timing annotations are deliberately added to this copy so
46113 // the visible transcript and its persisted historical content remain intact.
@@ -11,15 +11,18 @@ import (
11 const (11 const (
12 maxToolResultTextBytes = 32 * 102412 maxToolResultTextBytes = 32 * 1024
13 toolResultOmissionMarker = "[tool result content omitted]"13 toolResultOmissionMarker = "[tool result content omitted]"
14+ staleToolResultMarker = "[stale tool result omitted; a newer workspace view supersedes it]"
14 )15 )
15 16
16 // projectToolResultMessages returns a copied provider-input view of msgs.17 // projectToolResultMessages returns a copied provider-input view of msgs.
17-// Each tool result is bounded independently. Its projection must depend only18+// The byte cap is independent per result. Provenance pruning is the deliberate
18-// on that result: retroactively shrinking older results when a new one arrives19+// exception: a newer workspace view can replace an older result, trading the
19-// mutates the prompt prefix and defeats provider prompt caching. Compaction20+// provider cache prefix from that point for removal of context known to be
20-// owns the aggregate transcript budget.21+// stale. The durable transcript is never rewritten. Compaction owns the
22+// aggregate transcript budget.
21 func projectToolResultMessages(msgs []provider.Message) []provider.Message {23 func projectToolResultMessages(msgs []provider.Message) []provider.Message {
22 projected := copyToolResultMessages(msgs)24 projected := copyToolResultMessages(msgs)
25+ stale := staleToolResultIDs(projected)
23 26
24 for i := range projected {27 for i := range projected {
25 for j, content := range projected[i].Content {28 for j, content := range projected[i].Content {
@@ -27,6 +30,11 @@ func projectToolResultMessages(msgs []provider.Message) []provider.Message {
27 if !ok {30 if !ok {
28 continue31 continue
29 }32 }
33+ if stale[result.CallID] {
34+ result.Content = []provider.Content{provider.TextBlock{Text: staleToolResultMarker}}
35+ projected[i].Content[j] = result
36+ continue
37+ }
30 38
31 if toolResultTextBytes(result.Content) > 0 {39 if toolResultTextBytes(result.Content) > 0 {
32 result.Content, _ = projectToolResultContent(result.Content, maxToolResultTextBytes)40 result.Content, _ = projectToolResultContent(result.Content, maxToolResultTextBytes)
@@ -41,6 +49,65 @@ func projectToolResultMessages(msgs []provider.Message) []provider.Message {
41 return projected49 return projected
42 }50 }
43 51
52+// staleToolResultIDs computes a causal projection over successful tool
53+// results. Re-reading an equivalent resource view supersedes the old read;
54+// reading a discovered file supersedes the search result that found it; and a
55+// mutation supersedes every earlier view of the affected resource.
56+func staleToolResultIDs(msgs []provider.Message) map[string]bool {
57+ stale := make(map[string]bool)
58+ reads := make(map[provider.ResourceRef]string)
59+ discoveries := make(map[string]map[string]bool)
60+ remainingDiscoveries := make(map[string]int)
61+ for _, message := range msgs {
62+ for _, content := range message.Content {
63+ result, ok := content.(provider.ToolResultBlock)
64+ if !ok || result.IsError || result.CallID == "" {
65+ continue
66+ }
67+ for _, resource := range result.Context.Reads {
68+ if previous := reads[resource]; previous != "" {
69+ stale[previous] = true
70+ }
71+ reads[resource] = result.CallID
72+ for discovery := range discoveries[resource.Key] {
73+ remainingDiscoveries[discovery]--
74+ if remainingDiscoveries[discovery] == 0 {
75+ stale[discovery] = true
76+ }
77+ }
78+ delete(discoveries, resource.Key)
79+ }
80+ for _, resource := range result.Context.Mutates {
81+ for view, previous := range reads {
82+ if view.Key == resource {
83+ if previous != result.CallID {
84+ stale[previous] = true
85+ }
86+ delete(reads, view)
87+ }
88+ }
89+ for discovery := range discoveries[resource] {
90+ remainingDiscoveries[discovery]--
91+ if remainingDiscoveries[discovery] == 0 {
92+ stale[discovery] = true
93+ }
94+ }
95+ delete(discoveries, resource)
96+ }
97+ for _, resource := range result.Context.Discovers {
98+ if discoveries[resource] == nil {
99+ discoveries[resource] = make(map[string]bool)
100+ }
101+ if !discoveries[resource][result.CallID] {
102+ discoveries[resource][result.CallID] = true
103+ remainingDiscoveries[result.CallID]++
104+ }
105+ }
106+ }
107+ }
108+ return stale
109+}
110+
44 // projectProviderMessages creates the provider-only transcript view. The111 // projectProviderMessages creates the provider-only transcript view. The
45 // timestamp and timing annotations are deliberately added to this copy so112 // timestamp and timing annotations are deliberately added to this copy so
46 // the visible transcript and its persisted historical content remain intact.113 // the visible transcript and its persisted historical content remain intact.
M packages/core/context_budget_test.go +63 -0
@@ -94,6 +94,69 @@ func TestProjectToolTimingIsModelOnly(t *testing.T) {
9494 }
9595 }
9696
97+func TestProjectToolResultMessagesOmitsSupersededWorkspaceViews(t *testing.T) {
98+ path := "/workspace/main.go"
99+ messages := []provider.Message{{Role: provider.RoleTool, Content: []provider.Content{
100+ provider.ToolResultBlock{CallID: "search", Content: []provider.Content{provider.TextBlock{Text: "main.go:10"}}, Context: provider.ToolContext{Discovers: []string{path}}},
101+ provider.ToolResultBlock{CallID: "read-old", Content: []provider.Content{provider.TextBlock{Text: "old"}}, Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: path}}}},
102+ provider.ToolResultBlock{CallID: "write", Content: []provider.Content{provider.TextBlock{Text: "changed"}}, Context: provider.ToolContext{Mutates: []string{path}}},
103+ provider.ToolResultBlock{CallID: "read-new", Content: []provider.Content{provider.TextBlock{Text: "new"}}, Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: path}}}},
104+ }}}
105+
106+ projected := projectToolResultMessages(messages)
107+ for _, id := range []string{"search", "read-old"} {
108+ if got := toolResultTextForCall(projected, id); got != staleToolResultMarker {
109+ t.Fatalf("projected %s = %q, want stale marker", id, got)
110+ }
111+ }
112+ if got := toolResultTextForCall(projected, "write"); got != "changed" {
113+ t.Fatalf("mutation result = %q", got)
114+ }
115+ if got := toolResultTextForCall(projected, "read-new"); got != "new" {
116+ t.Fatalf("latest read = %q", got)
117+ }
118+ if got := toolResultTextForCall(messages, "read-old"); got != "old" {
119+ t.Fatalf("durable transcript was mutated: %q", got)
120+ }
121+}
122+
123+func TestProjectToolResultMessagesKeepsDiscoveryUntilAllFilesConsumed(t *testing.T) {
124+ first, second := "/workspace/a.go", "/workspace/b.go"
125+ messages := []provider.Message{{Role: provider.RoleTool, Content: []provider.Content{
126+ provider.ToolResultBlock{CallID: "search", Content: []provider.Content{provider.TextBlock{Text: "a.go\nb.go"}}, Context: provider.ToolContext{Discovers: []string{first, second}}},
127+ provider.ToolResultBlock{CallID: "read-a", Content: []provider.Content{provider.TextBlock{Text: "a"}}, Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: first}}}},
128+ }}}
129+ if got := toolResultTextForCall(projectToolResultMessages(messages), "search"); got != "a.go\nb.go" {
130+ t.Fatalf("partially consumed search = %q", got)
131+ }
132+ messages[0].Content = append(messages[0].Content, provider.ToolResultBlock{CallID: "read-b", Content: []provider.Content{provider.TextBlock{Text: "b"}}, Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: second}}}})
133+ if got := toolResultTextForCall(projectToolResultMessages(messages), "search"); got != staleToolResultMarker {
134+ t.Fatalf("fully consumed search = %q", got)
135+ }
136+}
137+
138+func TestProjectToolResultMessagesDoesNotStaleCurrentReadMutation(t *testing.T) {
139+ path := "/workspace/a.go"
140+ messages := []provider.Message{{Role: provider.RoleTool, Content: []provider.Content{
141+ provider.ToolResultBlock{CallID: "current", Content: []provider.Content{provider.TextBlock{Text: "updated"}}, Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: path}}, Mutates: []string{path}}},
142+ }}}
143+ if got := toolResultTextForCall(projectToolResultMessages(messages), "current"); got != "updated" {
144+ t.Fatalf("current result = %q", got)
145+ }
146+}
147+
148+func TestProjectToolResultMessagesPreservesDistinctReadVariants(t *testing.T) {
149+ path := "/workspace/main.go"
150+ messages := []provider.Message{{Role: provider.RoleTool, Content: []provider.Content{
151+ provider.ToolResultBlock{CallID: "first", Content: []provider.Content{provider.TextBlock{Text: "lines 1-10"}}, Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: path, Variant: "1:10"}}}},
152+ provider.ToolResultBlock{CallID: "second", Content: []provider.Content{provider.TextBlock{Text: "lines 20-30"}}, Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: path, Variant: "20:10"}}}},
153+ }}}
154+ projected := projectToolResultMessages(messages)
155+ if got := toolResultTextForCall(projected, "first"); got != "lines 1-10" {
156+ t.Fatalf("distinct range was omitted: %q", got)
157+ }
158+}
159+
97160 func TestProjectToolResultMessagesKeepsHistoricalPrefixStable(t *testing.T) {
98161 messages := make([]provider.Message, 0, 4)
99162 for i := 0; i < 4; i++ {
@@ -94,6 +94,69 @@ func TestProjectToolTimingIsModelOnly(t *testing.T) {
94 }94 }
95 }95 }
96 96
97+func TestProjectToolResultMessagesOmitsSupersededWorkspaceViews(t *testing.T) {
98+ path := "/workspace/main.go"
99+ messages := []provider.Message{{Role: provider.RoleTool, Content: []provider.Content{
100+ provider.ToolResultBlock{CallID: "search", Content: []provider.Content{provider.TextBlock{Text: "main.go:10"}}, Context: provider.ToolContext{Discovers: []string{path}}},
101+ provider.ToolResultBlock{CallID: "read-old", Content: []provider.Content{provider.TextBlock{Text: "old"}}, Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: path}}}},
102+ provider.ToolResultBlock{CallID: "write", Content: []provider.Content{provider.TextBlock{Text: "changed"}}, Context: provider.ToolContext{Mutates: []string{path}}},
103+ provider.ToolResultBlock{CallID: "read-new", Content: []provider.Content{provider.TextBlock{Text: "new"}}, Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: path}}}},
104+ }}}
105+
106+ projected := projectToolResultMessages(messages)
107+ for _, id := range []string{"search", "read-old"} {
108+ if got := toolResultTextForCall(projected, id); got != staleToolResultMarker {
109+ t.Fatalf("projected %s = %q, want stale marker", id, got)
110+ }
111+ }
112+ if got := toolResultTextForCall(projected, "write"); got != "changed" {
113+ t.Fatalf("mutation result = %q", got)
114+ }
115+ if got := toolResultTextForCall(projected, "read-new"); got != "new" {
116+ t.Fatalf("latest read = %q", got)
117+ }
118+ if got := toolResultTextForCall(messages, "read-old"); got != "old" {
119+ t.Fatalf("durable transcript was mutated: %q", got)
120+ }
121+}
122+
123+func TestProjectToolResultMessagesKeepsDiscoveryUntilAllFilesConsumed(t *testing.T) {
124+ first, second := "/workspace/a.go", "/workspace/b.go"
125+ messages := []provider.Message{{Role: provider.RoleTool, Content: []provider.Content{
126+ provider.ToolResultBlock{CallID: "search", Content: []provider.Content{provider.TextBlock{Text: "a.go\nb.go"}}, Context: provider.ToolContext{Discovers: []string{first, second}}},
127+ provider.ToolResultBlock{CallID: "read-a", Content: []provider.Content{provider.TextBlock{Text: "a"}}, Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: first}}}},
128+ }}}
129+ if got := toolResultTextForCall(projectToolResultMessages(messages), "search"); got != "a.go\nb.go" {
130+ t.Fatalf("partially consumed search = %q", got)
131+ }
132+ messages[0].Content = append(messages[0].Content, provider.ToolResultBlock{CallID: "read-b", Content: []provider.Content{provider.TextBlock{Text: "b"}}, Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: second}}}})
133+ if got := toolResultTextForCall(projectToolResultMessages(messages), "search"); got != staleToolResultMarker {
134+ t.Fatalf("fully consumed search = %q", got)
135+ }
136+}
137+
138+func TestProjectToolResultMessagesDoesNotStaleCurrentReadMutation(t *testing.T) {
139+ path := "/workspace/a.go"
140+ messages := []provider.Message{{Role: provider.RoleTool, Content: []provider.Content{
141+ provider.ToolResultBlock{CallID: "current", Content: []provider.Content{provider.TextBlock{Text: "updated"}}, Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: path}}, Mutates: []string{path}}},
142+ }}}
143+ if got := toolResultTextForCall(projectToolResultMessages(messages), "current"); got != "updated" {
144+ t.Fatalf("current result = %q", got)
145+ }
146+}
147+
148+func TestProjectToolResultMessagesPreservesDistinctReadVariants(t *testing.T) {
149+ path := "/workspace/main.go"
150+ messages := []provider.Message{{Role: provider.RoleTool, Content: []provider.Content{
151+ provider.ToolResultBlock{CallID: "first", Content: []provider.Content{provider.TextBlock{Text: "lines 1-10"}}, Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: path, Variant: "1:10"}}}},
152+ provider.ToolResultBlock{CallID: "second", Content: []provider.Content{provider.TextBlock{Text: "lines 20-30"}}, Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: path, Variant: "20:10"}}}},
153+ }}}
154+ projected := projectToolResultMessages(messages)
155+ if got := toolResultTextForCall(projected, "first"); got != "lines 1-10" {
156+ t.Fatalf("distinct range was omitted: %q", got)
157+ }
158+}
159+
97 func TestProjectToolResultMessagesKeepsHistoricalPrefixStable(t *testing.T) {160 func TestProjectToolResultMessagesKeepsHistoricalPrefixStable(t *testing.T) {
98 messages := make([]provider.Message, 0, 4)161 messages := make([]provider.Message, 0, 4)
99 for i := 0; i < 4; i++ {162 for i := 0; i < 4; i++ {
M packages/core/session.go +2 -1
@@ -1779,6 +1779,7 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
17791779 Content []json.RawMessage `json:"content"`
17801780 IsError bool `json:"is_error"`
17811781 Timing *provider.ToolTiming `json:"timing,omitempty"`
1782+ Context provider.ToolContext `json:"context,omitempty"`
17821783 }
17831784 if err := json.Unmarshal(raw, &tr); err != nil || tr.Content == nil {
17841785 if err == nil {
@@ -1786,7 +1787,7 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
17861787 }
17871788 return provider.Message{}, fmt.Errorf("content block %d: %w", idx, err)
17881789 }
1789- block := provider.ToolResultBlock{CallID: tr.CallID, IsError: tr.IsError, Timing: tr.Timing}
1790+ block := provider.ToolResultBlock{CallID: tr.CallID, IsError: tr.IsError, Timing: tr.Timing, Context: tr.Context}
17901791 for innerIdx, c := range tr.Content {
17911792 var inner struct {
17921793 Text string `json:"text"`
@@ -1779,6 +1779,7 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
1779 Content []json.RawMessage `json:"content"`1779 Content []json.RawMessage `json:"content"`
1780 IsError bool `json:"is_error"`1780 IsError bool `json:"is_error"`
1781 Timing *provider.ToolTiming `json:"timing,omitempty"`1781 Timing *provider.ToolTiming `json:"timing,omitempty"`
1782+ Context provider.ToolContext `json:"context,omitempty"`
1782 }1783 }
1783 if err := json.Unmarshal(raw, &tr); err != nil || tr.Content == nil {1784 if err := json.Unmarshal(raw, &tr); err != nil || tr.Content == nil {
1784 if err == nil {1785 if err == nil {
@@ -1786,7 +1787,7 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
1786 }1787 }
1787 return provider.Message{}, fmt.Errorf("content block %d: %w", idx, err)1788 return provider.Message{}, fmt.Errorf("content block %d: %w", idx, err)
1788 }1789 }
1789- block := provider.ToolResultBlock{CallID: tr.CallID, IsError: tr.IsError, Timing: tr.Timing}1790+ block := provider.ToolResultBlock{CallID: tr.CallID, IsError: tr.IsError, Timing: tr.Timing, Context: tr.Context}
1790 for innerIdx, c := range tr.Content {1791 for innerIdx, c := range tr.Content {
1791 var inner struct {1792 var inner struct {
1792 Text string `json:"text"`1793 Text string `json:"text"`
M packages/core/tool.go +3 -0
@@ -53,6 +53,9 @@ type ToolResult struct {
5353 // to the persisted/provider-facing tool-result block, where it remains
5454 // optional for compatibility with older sessions.
5555 Timing *provider.ToolTiming
56+ // Context describes workspace resources read, discovered, or mutated by
57+ // this result. Core uses it only for provider-visible stale-result pruning.
58+ Context provider.ToolContext
5659 }
5760
5861 // Registry is a name->Tool map.
@@ -53,6 +53,9 @@ type ToolResult struct {
53 // to the persisted/provider-facing tool-result block, where it remains53 // to the persisted/provider-facing tool-result block, where it remains
54 // optional for compatibility with older sessions.54 // optional for compatibility with older sessions.
55 Timing *provider.ToolTiming55 Timing *provider.ToolTiming
56+ // Context describes workspace resources read, discovered, or mutated by
57+ // this result. Core uses it only for provider-visible stale-result pruning.
58+ Context provider.ToolContext
56 }59 }
57 60
58 // Registry is a name->Tool map.61 // Registry is a name->Tool map.
M packages/provider/provider.go +18 -0
@@ -95,6 +95,24 @@ type ToolResultBlock struct {
9595 Content []Content `json:"content"`
9696 IsError bool `json:"is_error"`
9797 Timing *ToolTiming `json:"timing,omitempty"`
98+ // Context records resource provenance used to remove stale tool output
99+ // from later provider requests without rewriting the durable transcript.
100+ Context ToolContext `json:"context,omitzero"`
101+}
102+
103+// ToolContext describes how a tool result relates to workspace resources.
104+// Keys are opaque to core; tool owners normalize them consistently.
105+type ToolContext struct {
106+ Reads []ResourceRef `json:"reads,omitempty"`
107+ Discovers []string `json:"discovers,omitempty"`
108+ Mutates []string `json:"mutates,omitempty"`
109+}
110+
111+// ResourceRef identifies one view of a resource. Variant distinguishes
112+// bounded ranges or query forms whose results remain independently useful.
113+type ResourceRef struct {
114+ Key string `json:"key"`
115+ Variant string `json:"variant,omitempty"`
98116 }
99117
100118 func (ToolResultBlock) isContent() {}
@@ -95,6 +95,24 @@ type ToolResultBlock struct {
95 Content []Content `json:"content"`95 Content []Content `json:"content"`
96 IsError bool `json:"is_error"`96 IsError bool `json:"is_error"`
97 Timing *ToolTiming `json:"timing,omitempty"`97 Timing *ToolTiming `json:"timing,omitempty"`
98+ // Context records resource provenance used to remove stale tool output
99+ // from later provider requests without rewriting the durable transcript.
100+ Context ToolContext `json:"context,omitzero"`
101+}
102+
103+// ToolContext describes how a tool result relates to workspace resources.
104+// Keys are opaque to core; tool owners normalize them consistently.
105+type ToolContext struct {
106+ Reads []ResourceRef `json:"reads,omitempty"`
107+ Discovers []string `json:"discovers,omitempty"`
108+ Mutates []string `json:"mutates,omitempty"`
109+}
110+
111+// ResourceRef identifies one view of a resource. Variant distinguishes
112+// bounded ranges or query forms whose results remain independently useful.
113+type ResourceRef struct {
114+ Key string `json:"key"`
115+ Variant string `json:"variant,omitempty"`
98 }116 }
99 117
100 func (ToolResultBlock) isContent() {}118 func (ToolResultBlock) isContent() {}
M packages/tui/view.go +27 -8
@@ -1190,7 +1190,8 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
11901190 if tr.IsError {
11911191 lines = append(lines, toolBodyLine(v.Theme, v.Theme.FGColor(color, " error"), width, v.CompactMode))
11921192 }
1193- for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine) {
1193+ forceDiff := astRewriteArgs(v.toolCallNames[tr.CallID], v.toolCallArgs[tr.CallID])
1194+ for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine, forceDiff) {
11941195 _, stripped := parseImageFootprint(line)
11951196 lines = append(lines, toolBodyLine(v.Theme, stripped, width, v.CompactMode))
11961197 }
@@ -1204,7 +1205,8 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
12041205 if tr.IsError {
12051206 lines = append(lines, toolBoxSide(v.Theme, v.Theme.FGColor(color, " error"), width))
12061207 }
1207- for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine) {
1208+ forceDiff := astRewriteArgs(v.toolCallNames[tr.CallID], v.toolCallArgs[tr.CallID])
1209+ for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine, forceDiff) {
12081210 // Image-footprint rows (the escape row, the blank
12091211 // reservation rows beneath it, and the gap row
12101212 // before the metadata caption) are tagged with the
@@ -2003,7 +2005,7 @@ func trimLeadingSpaces(s string, n int) string {
20032005 // like a unified diff gets +/- coloring. Image blocks are rendered
20042006 // inline when the terminal supports a protocol, else as a text
20052007 // placeholder with dimensions.
2006-func (v *View) renderToolResultContent(blocks []provider.Content, width int, color TerminalColor, sourcePath string, startLine int) []string {
2008+func (v *View) renderToolResultContent(blocks []provider.Content, width int, color TerminalColor, sourcePath string, startLine int, forceDiff bool) []string {
20072009 var body []string
20082010 hasImage := false
20092011 for _, b := range blocks {
@@ -2021,6 +2023,9 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width int, col
20212023 bodyWidth = flatToolBodyRenderWidth(width)
20222024 }
20232025 estimateWidth := effectiveToolTextWidth(bb.Text, bodyWidth)
2026+ if forceDiff {
2027+ estimateWidth = max(1, bodyWidth-5)
2028+ }
20242029 estimated := estimateToolTextLines(bb.Text, estimateWidth)
20252030 total += estimated
20262031 text := bb.Text
@@ -2030,7 +2035,11 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width int, col
20302035 if !v.ExpandAll && !hasImage && estimated > ToolCollapseLines {
20312036 text = previewToolText(text, estimateWidth)
20322037 }
2033- body = append(body, v.renderToolText(text, bodyWidth, color, sourcePath, startLine)...)
2038+ if forceDiff {
2039+ body = append(body, v.renderUnifiedDiff(text, bodyWidth, sourcePath)...)
2040+ } else {
2041+ body = append(body, v.renderToolText(text, bodyWidth, color, sourcePath, startLine)...)
2042+ }
20342043 case provider.ImageBlock:
20352044 body = append(body, v.renderImageBlock(bb, width)...)
20362045 }
@@ -2664,11 +2673,21 @@ func skipStringEscape(s string, i int) int {
26642673 return len(s)
26652674 }
26662675
2676+// astRewriteArgs reports whether a completed AST call requested mutation.
2677+func astRewriteArgs(name string, raw json.RawMessage) bool {
2678+ if !strings.EqualFold(name, "ast") {
2679+ return false
2680+ }
2681+ var args struct {
2682+ Rewrite string `json:"rewrite"`
2683+ }
2684+ return json.Unmarshal(raw, &args) == nil && args.Rewrite != ""
2685+}
2686+
26672687 // looksLikeUnifiedDiff reports whether text is a context diff as
2668-// emitted by the edit tool: rows start with '+', '-', ' ', or
2669-// literal "..." (context-break marker). The presence of at least
2670-// one '+' or '-' row distinguishes a real diff from an ordinary
2671-// file whose lines happen to begin with a space.
2688+// emitted by edit and ast. Every non-empty row must start with a diff
2689+// marker, except for the bounded-output truncation notice. The presence
2690+// of at least one '+' or '-' row distinguishes a diff from ordinary text.
26722691 func looksLikeUnifiedDiff(text string) bool {
26732692 lines := strings.Split(text, "\n")
26742693 if len(lines) < 2 {
@@ -1190,7 +1190,8 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
1190 if tr.IsError {1190 if tr.IsError {
1191 lines = append(lines, toolBodyLine(v.Theme, v.Theme.FGColor(color, " error"), width, v.CompactMode))1191 lines = append(lines, toolBodyLine(v.Theme, v.Theme.FGColor(color, " error"), width, v.CompactMode))
1192 }1192 }
1193- for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine) {1193+ forceDiff := astRewriteArgs(v.toolCallNames[tr.CallID], v.toolCallArgs[tr.CallID])
1194+ for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine, forceDiff) {
1194 _, stripped := parseImageFootprint(line)1195 _, stripped := parseImageFootprint(line)
1195 lines = append(lines, toolBodyLine(v.Theme, stripped, width, v.CompactMode))1196 lines = append(lines, toolBodyLine(v.Theme, stripped, width, v.CompactMode))
1196 }1197 }
@@ -1204,7 +1205,8 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
1204 if tr.IsError {1205 if tr.IsError {
1205 lines = append(lines, toolBoxSide(v.Theme, v.Theme.FGColor(color, " error"), width))1206 lines = append(lines, toolBoxSide(v.Theme, v.Theme.FGColor(color, " error"), width))
1206 }1207 }
1207- for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine) {1208+ forceDiff := astRewriteArgs(v.toolCallNames[tr.CallID], v.toolCallArgs[tr.CallID])
1209+ for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine, forceDiff) {
1208 // Image-footprint rows (the escape row, the blank1210 // Image-footprint rows (the escape row, the blank
1209 // reservation rows beneath it, and the gap row1211 // reservation rows beneath it, and the gap row
1210 // before the metadata caption) are tagged with the1212 // before the metadata caption) are tagged with the
@@ -2003,7 +2005,7 @@ func trimLeadingSpaces(s string, n int) string {
2003 // like a unified diff gets +/- coloring. Image blocks are rendered2005 // like a unified diff gets +/- coloring. Image blocks are rendered
2004 // inline when the terminal supports a protocol, else as a text2006 // inline when the terminal supports a protocol, else as a text
2005 // placeholder with dimensions.2007 // placeholder with dimensions.
2006-func (v *View) renderToolResultContent(blocks []provider.Content, width int, color TerminalColor, sourcePath string, startLine int) []string {2008+func (v *View) renderToolResultContent(blocks []provider.Content, width int, color TerminalColor, sourcePath string, startLine int, forceDiff bool) []string {
2007 var body []string2009 var body []string
2008 hasImage := false2010 hasImage := false
2009 for _, b := range blocks {2011 for _, b := range blocks {
@@ -2021,6 +2023,9 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width int, col
2021 bodyWidth = flatToolBodyRenderWidth(width)2023 bodyWidth = flatToolBodyRenderWidth(width)
2022 }2024 }
2023 estimateWidth := effectiveToolTextWidth(bb.Text, bodyWidth)2025 estimateWidth := effectiveToolTextWidth(bb.Text, bodyWidth)
2026+ if forceDiff {
2027+ estimateWidth = max(1, bodyWidth-5)
2028+ }
2024 estimated := estimateToolTextLines(bb.Text, estimateWidth)2029 estimated := estimateToolTextLines(bb.Text, estimateWidth)
2025 total += estimated2030 total += estimated
2026 text := bb.Text2031 text := bb.Text
@@ -2030,7 +2035,11 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width int, col
2030 if !v.ExpandAll && !hasImage && estimated > ToolCollapseLines {2035 if !v.ExpandAll && !hasImage && estimated > ToolCollapseLines {
2031 text = previewToolText(text, estimateWidth)2036 text = previewToolText(text, estimateWidth)
2032 }2037 }
2033- body = append(body, v.renderToolText(text, bodyWidth, color, sourcePath, startLine)...)2038+ if forceDiff {
2039+ body = append(body, v.renderUnifiedDiff(text, bodyWidth, sourcePath)...)
2040+ } else {
2041+ body = append(body, v.renderToolText(text, bodyWidth, color, sourcePath, startLine)...)
2042+ }
2034 case provider.ImageBlock:2043 case provider.ImageBlock:
2035 body = append(body, v.renderImageBlock(bb, width)...)2044 body = append(body, v.renderImageBlock(bb, width)...)
2036 }2045 }
@@ -2664,11 +2673,21 @@ func skipStringEscape(s string, i int) int {
2664 return len(s)2673 return len(s)
2665 }2674 }
2666 2675
2676+// astRewriteArgs reports whether a completed AST call requested mutation.
2677+func astRewriteArgs(name string, raw json.RawMessage) bool {
2678+ if !strings.EqualFold(name, "ast") {
2679+ return false
2680+ }
2681+ var args struct {
2682+ Rewrite string `json:"rewrite"`
2683+ }
2684+ return json.Unmarshal(raw, &args) == nil && args.Rewrite != ""
2685+}
2686+
2667 // looksLikeUnifiedDiff reports whether text is a context diff as2687 // looksLikeUnifiedDiff reports whether text is a context diff as
2668-// emitted by the edit tool: rows start with '+', '-', ' ', or2688+// emitted by edit and ast. Every non-empty row must start with a diff
2669-// literal "..." (context-break marker). The presence of at least2689+// marker, except for the bounded-output truncation notice. The presence
2670-// one '+' or '-' row distinguishes a real diff from an ordinary2690+// of at least one '+' or '-' row distinguishes a diff from ordinary text.
2671-// file whose lines happen to begin with a space.
2672 func looksLikeUnifiedDiff(text string) bool {2691 func looksLikeUnifiedDiff(text string) bool {
2673 lines := strings.Split(text, "\n")2692 lines := strings.Split(text, "\n")
2674 if len(lines) < 2 {2693 if len(lines) < 2 {
M packages/tui/view_tool_overlay_test.go +41 -0
@@ -179,6 +179,47 @@ func TestLiveEditOverlayKeepsStableHeightWhileStreaming(t *testing.T) {
179179 }
180180 }
181181
182+func TestCompletedASTRewriteRendersPersistentDiff(t *testing.T) {
183+ args := json.RawMessage(`{"pattern":"old($A)","language":"go","path":"sample.go","rewrite":"new($A)"}`)
184+ v := View{
185+ Theme: Dark,
186+ Messages: []provider.Message{
187+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "toolu_ast", Name: "ast", Arguments: args}}},
188+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
189+ CallID: "toolu_ast",
190+ Content: []provider.Content{provider.TextBlock{Text: " old(value)\n-old(value)\n+new(value)\n"}},
191+ }}},
192+ },
193+ }
194+
195+ rendered := strings.Join(v.Build(80), "\n")
196+ plain := stripANSI(rendered)
197+ for _, want := range []string{"ast sample.go", "- 2 old(value)", "+ 2 new(value)"} {
198+ if !strings.Contains(plain, want) {
199+ t.Fatalf("completed AST rewrite missing %q:\n%s", want, plain)
200+ }
201+ }
202+}
203+
204+func TestASTSearchResultIsNotForcedIntoDiffRendering(t *testing.T) {
205+ args := json.RawMessage(`{"pattern":"old($A)","language":"go","path":"sample.go"}`)
206+ v := View{
207+ Theme: Dark,
208+ Messages: []provider.Message{
209+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "toolu_ast", Name: "ast", Arguments: args}}},
210+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
211+ CallID: "toolu_ast",
212+ Content: []provider.Content{provider.TextBlock{Text: "sample.go:1: old(value)"}},
213+ }}},
214+ },
215+ }
216+
217+ plain := stripANSI(strings.Join(v.Build(80), "\n"))
218+ if !strings.Contains(plain, "sample.go:1: old(value)") {
219+ t.Fatalf("AST search result was hidden or rewritten:\n%s", plain)
220+ }
221+}
222+
182223 func TestEditConfirmationPreviewRendersDiffInsteadOfNewText(t *testing.T) {
183224 args := json.RawMessage(`{"path":"sample.go","edits":[{"oldText":"old value","newText":"new value"}]}`)
184225 v := View{
@@ -179,6 +179,47 @@ func TestLiveEditOverlayKeepsStableHeightWhileStreaming(t *testing.T) {
179 }179 }
180 }180 }
181 181
182+func TestCompletedASTRewriteRendersPersistentDiff(t *testing.T) {
183+ args := json.RawMessage(`{"pattern":"old($A)","language":"go","path":"sample.go","rewrite":"new($A)"}`)
184+ v := View{
185+ Theme: Dark,
186+ Messages: []provider.Message{
187+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "toolu_ast", Name: "ast", Arguments: args}}},
188+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
189+ CallID: "toolu_ast",
190+ Content: []provider.Content{provider.TextBlock{Text: " old(value)\n-old(value)\n+new(value)\n"}},
191+ }}},
192+ },
193+ }
194+
195+ rendered := strings.Join(v.Build(80), "\n")
196+ plain := stripANSI(rendered)
197+ for _, want := range []string{"ast sample.go", "- 2 old(value)", "+ 2 new(value)"} {
198+ if !strings.Contains(plain, want) {
199+ t.Fatalf("completed AST rewrite missing %q:\n%s", want, plain)
200+ }
201+ }
202+}
203+
204+func TestASTSearchResultIsNotForcedIntoDiffRendering(t *testing.T) {
205+ args := json.RawMessage(`{"pattern":"old($A)","language":"go","path":"sample.go"}`)
206+ v := View{
207+ Theme: Dark,
208+ Messages: []provider.Message{
209+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "toolu_ast", Name: "ast", Arguments: args}}},
210+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
211+ CallID: "toolu_ast",
212+ Content: []provider.Content{provider.TextBlock{Text: "sample.go:1: old(value)"}},
213+ }}},
214+ },
215+ }
216+
217+ plain := stripANSI(strings.Join(v.Build(80), "\n"))
218+ if !strings.Contains(plain, "sample.go:1: old(value)") {
219+ t.Fatalf("AST search result was hidden or rewritten:\n%s", plain)
220+ }
221+}
222+
182 func TestEditConfirmationPreviewRendersDiffInsteadOfNewText(t *testing.T) {223 func TestEditConfirmationPreviewRendersDiffInsteadOfNewText(t *testing.T) {
183 args := json.RawMessage(`{"path":"sample.go","edits":[{"oldText":"old value","newText":"new value"}]}`)224 args := json.RawMessage(`{"path":"sample.go","edits":[{"oldText":"old value","newText":"new value"}]}`)
184 v := View{225 v := View{

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close