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 Unverified
B5690EEEBB952194The 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 | |||
| 264 | 264 | - `write`: create or overwrite files, making parent directories as needed. | |
| 265 | 265 | - `edit`: one or more exact-match replacements in an existing file. | |
| 266 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 281 | ## Modes | |
| 279 | 282 | ||
| @@ -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 | ## Modes | 281 | ## 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). | |||
| 225 | 225 | ``` | |
| 226 | 226 | ||
| 227 | 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 | 229 | `web_open`, `web_find`, `web_click`, `schedule`, `update_goal`, `update_plan`, | |
| 230 | 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 | 232 | `update_goal`, and `update_plan`, is reserved even when it is unavailable, so an | |
| 233 | 233 | extension cannot claim or replace it. | |
| 234 | 234 | ||
| @@ -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 an | 232 | `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 | |||
| 106 | 106 | bounded to a short two-second budget; a successful file write or edit is never | |
| 107 | 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 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. | |
| 113 | 114 | Repeated post-write diagnostics are tracked per file by severity/code/message | |
| 114 | 115 | identity, so a line shift does not replay the same error. Clearing a file's | |
| 115 | 116 | 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 never | 106 | 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 diagnostics | 109 | + | 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 persisted | 110 | + | LSP rename, code-action, and AST rewrite edits also run the same bounded checks |
| 111 | - | configuration keys are `lsp_diagnostics_on_write` and | 111 | + | 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/message | 114 | 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's | 115 | 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) { | |||
| 123 | 123 | // Web capability and other native names remain reserved even when their | |
| 124 | 124 | // policy excludes the current session. An extension must not turn a | |
| 125 | 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 | 127 | continue | |
| 128 | 128 | } | |
| 129 | 129 | if _, exists := r.ToolRegistry[info.Name]; exists { | |
| @@ -1318,6 +1318,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) { | |||
| 1318 | 1318 | v.Sandbox = s | |
| 1319 | 1319 | case *tools.GrepTool: | |
| 1320 | 1320 | v.Sandbox = s | |
| 1321 | + | case *tools.ASTTool: | |
| 1322 | + | v.Sandbox = s | |
| 1321 | 1323 | } | |
| 1322 | 1324 | _ = name | |
| 1323 | 1325 | } | |
| @@ -1354,6 +1356,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled | |||
| 1354 | 1356 | "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox}, | |
| 1355 | 1357 | "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox}, | |
| 1356 | 1358 | "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox}, | |
| 1359 | + | "ast": &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit}, | |
| 1357 | 1360 | "update_goal": &tools.UpdateGoalTool{}, | |
| 1358 | 1361 | "update_plan": &tools.UpdatePlanTool{}, | |
| 1359 | 1362 | } | |
| @@ -1365,6 +1368,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled | |||
| 1365 | 1368 | if manager != nil { | |
| 1366 | 1369 | lspTool := tools.NewLSPTool(cwd, manager) | |
| 1367 | 1370 | lspTool.Sandbox = sandbox | |
| 1371 | + | lspTool.LSPDiagnostics = diagnosticsOnEdit | |
| 1368 | 1372 | all["lsp"] = lspTool | |
| 1369 | 1373 | } | |
| 1370 | 1374 | reg := core.Registry{} | |
| @@ -1390,6 +1394,10 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled | |||
| 1390 | 1394 | if orchestratorExplorationToolsAllowed(args) { | |
| 1391 | 1395 | reg["read"] = all["read"] | |
| 1392 | 1396 | reg["grep"] = all["grep"] | |
| 1397 | + | if astTool, ok := all["ast"].(*tools.ASTTool); ok { | |
| 1398 | + | astTool.ReadOnly = true | |
| 1399 | + | reg["ast"] = astTool | |
| 1400 | + | } | |
| 1393 | 1401 | if args.PermissionSet == nil { | |
| 1394 | 1402 | for name, tool := range tools.NewWebTools() { | |
| 1395 | 1403 | reg[name] = tool | |
| @@ -1440,7 +1448,7 @@ func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) boo | |||
| 1440 | 1448 | if diagnosticsOnWrite { | |
| 1441 | 1449 | return true | |
| 1442 | 1450 | } | |
| 1443 | - | case "edit": | |
| 1451 | + | case "edit", "ast": | |
| 1444 | 1452 | if diagnosticsOnEdit { | |
| 1445 | 1453 | return true | |
| 1446 | 1454 | } | |
| @@ -1496,7 +1504,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool { | |||
| 1496 | 1504 | 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 | 1509 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | |
| 1502 | 1510 | var out []ToolSummary | |
| @@ -123,7 +123,7 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) { | |||||
| 123 | // Web capability and other native names remain reserved even when their | 123 | // Web capability and other native names remain reserved even when their | ||
| 124 | // policy excludes the current session. An extension must not turn a | 124 | // 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 | continue | 127 | 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 = s | 1318 | v.Sandbox = s | ||
| 1319 | case *tools.GrepTool: | 1319 | case *tools.GrepTool: | ||
| 1320 | v.Sandbox = s | 1320 | v.Sandbox = s | ||
| 1321 | + | case *tools.ASTTool: | |||
| 1322 | + | v.Sandbox = s | |||
| 1321 | } | 1323 | } | ||
| 1322 | _ = name | 1324 | _ = 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 = sandbox | 1370 | lspTool.Sandbox = sandbox | ||
| 1371 | + | lspTool.LSPDiagnostics = diagnosticsOnEdit | |||
| 1368 | all["lsp"] = lspTool | 1372 | 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] = tool | 1403 | reg[name] = tool | ||
| @@ -1440,7 +1448,7 @@ func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) boo | |||||
| 1440 | if diagnosticsOnWrite { | 1448 | if diagnosticsOnWrite { | ||
| 1441 | return true | 1449 | return true | ||
| 1442 | } | 1450 | } | ||
| 1443 | - | case "edit": | 1451 | + | case "edit", "ast": |
| 1444 | if diagnosticsOnEdit { | 1452 | if diagnosticsOnEdit { | ||
| 1445 | return true | 1453 | return true | ||
| 1446 | } | 1454 | } | ||
| @@ -1496,7 +1504,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool { | |||||
| 1496 | return false | 1504 | 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 []ToolSummary | 1510 | var out []ToolSummary | ||
M packages/agent/config.go +3 -3
| @@ -125,7 +125,7 @@ type Config struct { | |||
| 125 | 125 | LSPDiagnosticsOnWrite *bool `json:"lsp_diagnostics_on_write,omitempty"` | |
| 126 | 126 | ||
| 127 | 127 | // 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 | 129 | LSPDiagnosticsOnEdit *bool `json:"lsp_diagnostics_on_edit,omitempty"` | |
| 130 | 130 | ||
| 131 | 131 | // AutoCompactThreshold is the percentage of the model context window | |
| @@ -292,10 +292,10 @@ func (c Config) LSPDiagnosticsOnWriteEnabled(subagent bool) bool { | |||
| 292 | 292 | return c.LSPEnabledFor(subagent) && (c.LSPDiagnosticsOnWrite == nil || *c.LSPDiagnosticsOnWrite) | |
| 293 | 293 | } | |
| 294 | 294 | ||
| 295 | - | // LSPDiagnosticsOnEditEnabled reports the default-off edit diagnostics | |
| 295 | + | // LSPDiagnosticsOnEditEnabled reports the default-on edit diagnostics | |
| 296 | 296 | // preference without requiring a generated config entry. | |
| 297 | 297 | 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) | |
| 299 | 299 | } | |
| 300 | 300 | ||
| 301 | 301 | // 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 successful | 127 | // 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 window | 131 | // 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 diagnostics | 295 | + | // 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.LSPDiagnosticsOnEdit | 298 | + | 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) { | |||
| 421 | 421 | // into the agent's registry once the extension's ready frame fires. | |
| 422 | 422 | // | |
| 423 | 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, | |
| 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, | |
| 427 | 427 | // update_goal, and update_plan, is reserved even when unavailable, so an | |
| 428 | 428 | // extension cannot claim or replace it. | |
| 429 | 429 | 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 the | 425 | + | // 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 an | 427 | // 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) { | |||
| 135 | 135 | 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 | 143 | func uriToPath(uri string) (string, error) { | |
| 139 | 144 | u, err := url.Parse(uri) | |
| 140 | 145 | if err != nil { | |
| @@ -135,6 +135,11 @@ func pathToURI(path string) (string, error) { | |||||
| 135 | return u.String(), nil | 135 | 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: | |||
| 306 | 306 | // implementer itself. | |
| 307 | 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 | 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 | 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 | 313 | - Give every worker a self-contained task and synthesize the results.` | |
| 314 | 314 | ||
| @@ -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 | |||
| 393 | 393 | Content []json.RawMessage `json:"Content"` | |
| 394 | 394 | IsError bool `json:"IsError"` | |
| 395 | 395 | Timing *provider.ToolTiming `json:"Timing"` | |
| 396 | + | Context provider.ToolContext `json:"Context"` | |
| 396 | 397 | } | |
| 397 | 398 | if err := json.Unmarshal(item.ToolResult, &result); err != nil || len(result.Content) == 0 { | |
| 398 | 399 | if err == nil { | |
| @@ -408,13 +409,15 @@ func ResidentHistoryMessages(items []ResidentHistoryItem) ([]provider.Message, e | |||
| 408 | 409 | Content []json.RawMessage `json:"content"` | |
| 409 | 410 | IsError bool `json:"is_error"` | |
| 410 | 411 | Timing *provider.ToolTiming `json:"timing,omitempty"` | |
| 412 | + | Context provider.ToolContext `json:"context,omitempty"` | |
| 411 | 413 | } `json:"content"` | |
| 412 | 414 | }{Role: provider.RoleTool, Time: item.Time, Content: []struct { | |
| 413 | 415 | CallID string `json:"call_id"` | |
| 414 | 416 | Content []json.RawMessage `json:"content"` | |
| 415 | 417 | IsError bool `json:"is_error"` | |
| 416 | 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 | 421 | if err != nil { | |
| 419 | 422 | return nil, err | |
| 420 | 423 | } | |
| @@ -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, err | 422 | 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) { | |||
| 170 | 170 | // programmatic consumers and confirmation previews. | |
| 171 | 171 | result := core.ToolResult{ | |
| 172 | 172 | Content: []provider.Content{provider.TextBlock{Text: diff}}, | |
| 173 | + | Context: provider.ToolContext{Mutates: []string{canonicalResourcePath(path)}}, | |
| 173 | 174 | Details: map[string]any{"path": path, "edits": len(a.Edits), "diff": diff}, | |
| 174 | 175 | } | |
| 175 | 176 | 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}, nil | 176 | 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, | |||
| 179 | 179 | return core.ToolResult{ | |
| 180 | 180 | Content: []provider.Content{provider.TextBlock{Text: text}}, | |
| 181 | 181 | IsError: isError, | |
| 182 | + | Context: provider.ToolContext{Discovers: grepResultPaths(text, root, info.IsDir())}, | |
| 182 | 183 | Details: map[string]any{ | |
| 183 | 184 | "engine": engine, | |
| 184 | 185 | "path": t.Sandbox.DisplayPath(root, args.Path), | |
| @@ -192,6 +193,31 @@ func (t *GrepTool) executeWithTimeout(ctx context.Context, raw json.RawMessage, | |||
| 192 | 193 | }, 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 | 221 | func parseGrepArgs(raw json.RawMessage) (grepArgs, error) { | |
| 196 | 222 | if len(raw) == 0 || len(raw) > maxGrepInputBytes { | |
| 197 | 223 | 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 | }, nil | 193 | }, 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 | |||
| 21 | 21 | // the model. Manager owns processes and is normally shared by all tool calls | |
| 22 | 22 | // in one agent session. | |
| 23 | 23 | 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 | |
| 27 | 28 | } | |
| 28 | 29 | ||
| 29 | 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 | 162 | return core.ToolResult{}, actionErr | |
| 162 | 163 | } | |
| 163 | 164 | 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 | |
| 165 | 172 | } | |
| 166 | 173 | ||
| 167 | 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 | 207 | if args.Action == "rename" && args.Apply == nil { | |
| 201 | 208 | 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 | 211 | if applyCount > 0 { | |
| 205 | 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}, 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 | |
| 208 | 221 | } | |
| 209 | 222 | ||
| 210 | 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 | 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 | 247 | var b strings.Builder | |
| 235 | 248 | applied := 0 | |
| 249 | + | var modifiedPaths []string | |
| 236 | 250 | var firstErr error | |
| 237 | 251 | pendingEdits := make([]lsp.WorkspaceEdit, 0) | |
| 238 | 252 | for _, response := range responses { | |
| @@ -249,6 +263,7 @@ func formatResponses(cwd string, responses []lsp.Response, action string, apply | |||
| 249 | 263 | } | |
| 250 | 264 | } | |
| 251 | 265 | if len(pendingEdits) > 0 { | |
| 266 | + | modifiedPaths = workspaceEditPaths(cwd, pendingEdits) | |
| 252 | 267 | if err := manager.ApplyEdits(cwd, pendingEdits); err != nil { | |
| 253 | 268 | if firstErr == nil { | |
| 254 | 269 | firstErr = err | |
| @@ -259,9 +274,44 @@ func formatResponses(cwd string, responses []lsp.Response, action string, apply | |||
| 259 | 274 | } | |
| 260 | 275 | } | |
| 261 | 276 | 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 | + | } | |
| 263 | 313 | } | |
| 264 | - | return b.String(), applied, firstErr | |
| 314 | + | return paths | |
| 265 | 315 | } | |
| 266 | 316 | ||
| 267 | 317 | 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 calls | 21 | // 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 string | 24 | + | CWD string |
| 25 | - | Manager *lsp.Manager | 25 | + | Manager *lsp.Manager |
| 26 | - | Sandbox *Sandbox | 26 | + | 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{}, actionErr | 162 | 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}, 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 | |||
| 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 = true | 208 | 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}, 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 | |||
| 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.Builder | 247 | var b strings.Builder | ||
| 235 | applied := 0 | 248 | applied := 0 | ||
| 249 | + | var modifiedPaths []string | |||
| 236 | var firstErr error | 250 | 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 = err | 269 | 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, 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 | + | } | |||
| 263 | } | 313 | } | ||
| 264 | - | return b.String(), applied, firstErr | 314 | + | 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 { | |||
| 49 | 49 | // mutation into a failed tool call, so callers only attach diagnostics that | |
| 50 | 50 | // were actually returned before the short budget expired. | |
| 51 | 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 | 59 | return | |
| 54 | 60 | } | |
| 55 | 61 | if ctx == nil { | |
| @@ -57,22 +63,22 @@ func attachWriteDiagnostics(ctx context.Context, cwd, path string, manager *lsp. | |||
| 57 | 63 | } | |
| 58 | 64 | diagnosticsCtx, cancel := context.WithTimeout(ctx, writeDiagnosticsTimeout) | |
| 59 | 65 | defer cancel() | |
| 60 | - | diagnostics, err := manager.Diagnostics(diagnosticsCtx, cwd, path) | |
| 61 | - | if err != nil { | |
| 66 | + | if len(paths) > maxMutationDiagnosticFiles { | |
| 67 | + | paths = paths[:maxMutationDiagnosticFiles] | |
| 62 | 68 | 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 | |
| 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 | 82 | if len(diagnostics) == 0 { | |
| 77 | 83 | return | |
| 78 | 84 | } | |
| @@ -94,3 +100,17 @@ func attachWriteDiagnostics(ctx context.Context, cwd, path string, manager *lsp. | |||
| 94 | 100 | 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 | + | } | |
| @@ -49,7 +49,13 @@ func CloseLSPManagers(reg core.Registry) error { | |||||
| 49 | // mutation into a failed tool call, so callers only attach diagnostics that | 49 | // 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 | return | 59 | 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 | return | 83 | return | ||
| 78 | } | 84 | } | ||
| @@ -94,3 +100,17 @@ func attachWriteDiagnostics(ctx context.Context, cwd, path string, manager *lsp. | |||||
| 94 | details["diagnostics_summary"] = summary | 100 | 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 | |||
| 83 | 83 | } | |
| 84 | 84 | return core.ToolResult{ | |
| 85 | 85 | Content: []provider.Content{provider.ImageBlock{MimeType: mime, Data: data}}, | |
| 86 | + | Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: canonicalResourcePath(path)}}}, | |
| 86 | 87 | }, nil | |
| 87 | 88 | } | |
| 88 | 89 | ||
| @@ -156,8 +157,10 @@ func (t *ReadTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||
| 156 | 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 | 161 | return core.ToolResult{ | |
| 160 | 162 | Content: []provider.Content{provider.TextBlock{Text: sb.String()}}, | |
| 163 | + | Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: canonicalResourcePath(path), Variant: variant}}}, | |
| 161 | 164 | Details: map[string]any{ | |
| 162 | 165 | "path": path, | |
| 163 | 166 | "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 | 171 | }, 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 | 182 | func resolvePath(cwd, p string) string { | |
| 172 | 183 | if filepath.IsAbs(p) { | |
| 173 | 184 | 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 | }, nil | 87 | }, 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 gutter | 166 | "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 | }, nil | 171 | }, 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 p | 184 | 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 | |||
| 67 | 67 | } | |
| 68 | 68 | result := core.ToolResult{ | |
| 69 | 69 | Content: []provider.Content{provider.TextBlock{Text: a.Content}}, | |
| 70 | + | Context: provider.ToolContext{Mutates: []string{canonicalResourcePath(path)}}, | |
| 70 | 71 | Details: map[string]any{ | |
| 71 | 72 | "path": path, | |
| 72 | 73 | "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 | |||
| 1340 | 1340 | Content: res.Content, | |
| 1341 | 1341 | IsError: res.IsError, | |
| 1342 | 1342 | Timing: res.Timing, | |
| 1343 | + | Context: res.Context, | |
| 1343 | 1344 | }) | |
| 1344 | 1345 | for _, name := range res.ActivateTools { | |
| 1345 | 1346 | 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 ( | |||
| 11 | 11 | const ( | |
| 12 | 12 | maxToolResultTextBytes = 32 * 1024 | |
| 13 | 13 | toolResultOmissionMarker = "[tool result content omitted]" | |
| 14 | + | staleToolResultMarker = "[stale tool result omitted; a newer workspace view supersedes it]" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 16 | 17 | // 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. | |
| 21 | 23 | func projectToolResultMessages(msgs []provider.Message) []provider.Message { | |
| 22 | 24 | projected := copyToolResultMessages(msgs) | |
| 25 | + | stale := staleToolResultIDs(projected) | |
| 23 | 26 | ||
| 24 | 27 | for i := range projected { | |
| 25 | 28 | for j, content := range projected[i].Content { | |
| @@ -27,6 +30,11 @@ func projectToolResultMessages(msgs []provider.Message) []provider.Message { | |||
| 27 | 30 | if !ok { | |
| 28 | 31 | 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 | 39 | if toolResultTextBytes(result.Content) > 0 { | |
| 32 | 40 | result.Content, _ = projectToolResultContent(result.Content, maxToolResultTextBytes) | |
| @@ -41,6 +49,65 @@ func projectToolResultMessages(msgs []provider.Message) []provider.Message { | |||
| 41 | 49 | 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 | 111 | // projectProviderMessages creates the provider-only transcript view. The | |
| 45 | 112 | // timestamp and timing annotations are deliberately added to this copy so | |
| 46 | 113 | // the visible transcript and its persisted historical content remain intact. | |
| @@ -11,15 +11,18 @@ import ( | |||||
| 11 | const ( | 11 | const ( | ||
| 12 | maxToolResultTextBytes = 32 * 1024 | 12 | 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 only | 18 | + | // The byte cap is independent per result. Provenance pruning is the deliberate |
| 18 | - | // on that result: retroactively shrinking older results when a new one arrives | 19 | + | // exception: a newer workspace view can replace an older result, trading the |
| 19 | - | // mutates the prompt prefix and defeats provider prompt caching. Compaction | 20 | + | // 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 | continue | 31 | 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 projected | 49 | 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. The | 111 | // projectProviderMessages creates the provider-only transcript view. The | ||
| 45 | // timestamp and timing annotations are deliberately added to this copy so | 112 | // 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) { | |||
| 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 | 160 | func TestProjectToolResultMessagesKeepsHistoricalPrefixStable(t *testing.T) { | |
| 98 | 161 | messages := make([]provider.Message, 0, 4) | |
| 99 | 162 | 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) { | |||
| 1779 | 1779 | Content []json.RawMessage `json:"content"` | |
| 1780 | 1780 | IsError bool `json:"is_error"` | |
| 1781 | 1781 | Timing *provider.ToolTiming `json:"timing,omitempty"` | |
| 1782 | + | Context provider.ToolContext `json:"context,omitempty"` | |
| 1782 | 1783 | } | |
| 1783 | 1784 | if err := json.Unmarshal(raw, &tr); err != nil || tr.Content == nil { | |
| 1784 | 1785 | if err == nil { | |
| @@ -1786,7 +1787,7 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) { | |||
| 1786 | 1787 | } | |
| 1787 | 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 | 1791 | for innerIdx, c := range tr.Content { | |
| 1791 | 1792 | var inner struct { | |
| 1792 | 1793 | 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 { | |||
| 53 | 53 | // to the persisted/provider-facing tool-result block, where it remains | |
| 54 | 54 | // optional for compatibility with older sessions. | |
| 55 | 55 | 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 | 61 | // Registry is a name->Tool map. | |
| @@ -53,6 +53,9 @@ type ToolResult struct { | |||||
| 53 | // to the persisted/provider-facing tool-result block, where it remains | 53 | // 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.ToolTiming | 55 | 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 { | |||
| 95 | 95 | Content []Content `json:"content"` | |
| 96 | 96 | IsError bool `json:"is_error"` | |
| 97 | 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 | 118 | 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 | |||
| 1190 | 1190 | if tr.IsError { | |
| 1191 | 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 | 1195 | _, stripped := parseImageFootprint(line) | |
| 1195 | 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 | 1205 | if tr.IsError { | |
| 1205 | 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 | 1210 | // Image-footprint rows (the escape row, the blank | |
| 1209 | 1211 | // reservation rows beneath it, and the gap row | |
| 1210 | 1212 | // before the metadata caption) are tagged with the | |
| @@ -2003,7 +2005,7 @@ func trimLeadingSpaces(s string, n int) string { | |||
| 2003 | 2005 | // like a unified diff gets +/- coloring. Image blocks are rendered | |
| 2004 | 2006 | // inline when the terminal supports a protocol, else as a text | |
| 2005 | 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 | 2009 | var body []string | |
| 2008 | 2010 | hasImage := false | |
| 2009 | 2011 | for _, b := range blocks { | |
| @@ -2021,6 +2023,9 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width int, col | |||
| 2021 | 2023 | bodyWidth = flatToolBodyRenderWidth(width) | |
| 2022 | 2024 | } | |
| 2023 | 2025 | estimateWidth := effectiveToolTextWidth(bb.Text, bodyWidth) | |
| 2026 | + | if forceDiff { | |
| 2027 | + | estimateWidth = max(1, bodyWidth-5) | |
| 2028 | + | } | |
| 2024 | 2029 | estimated := estimateToolTextLines(bb.Text, estimateWidth) | |
| 2025 | 2030 | total += estimated | |
| 2026 | 2031 | text := bb.Text | |
| @@ -2030,7 +2035,11 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width int, col | |||
| 2030 | 2035 | if !v.ExpandAll && !hasImage && estimated > ToolCollapseLines { | |
| 2031 | 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 | 2043 | case provider.ImageBlock: | |
| 2035 | 2044 | body = append(body, v.renderImageBlock(bb, width)...) | |
| 2036 | 2045 | } | |
| @@ -2664,11 +2673,21 @@ func skipStringEscape(s string, i int) int { | |||
| 2664 | 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 | 2687 | // 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. | |
| 2672 | 2691 | func looksLikeUnifiedDiff(text string) bool { | |
| 2673 | 2692 | lines := strings.Split(text, "\n") | |
| 2674 | 2693 | 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 blank | 1210 | // Image-footprint rows (the escape row, the blank | ||
| 1209 | // reservation rows beneath it, and the gap row | 1211 | // reservation rows beneath it, and the gap row | ||
| 1210 | // before the metadata caption) are tagged with the | 1212 | // 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 rendered | 2005 | // like a unified diff gets +/- coloring. Image blocks are rendered | ||
| 2004 | // inline when the terminal supports a protocol, else as a text | 2006 | // 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 []string | 2009 | var body []string | ||
| 2008 | hasImage := false | 2010 | 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 += estimated | 2030 | total += estimated | ||
| 2026 | text := bb.Text | 2031 | 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 as | 2687 | // looksLikeUnifiedDiff reports whether text is a context diff as | ||
| 2668 | - | // emitted by the edit tool: rows start with '+', '-', ' ', or | 2688 | + | // emitted by edit and ast. Every non-empty row must start with a diff |
| 2669 | - | // literal "..." (context-break marker). The presence of at least | 2689 | + | // marker, except for the bounded-output truncation notice. The presence |
| 2670 | - | // one '+' or '-' row distinguishes a real diff from an ordinary | 2690 | + | // 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) { | |||
| 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 | 223 | func TestEditConfirmationPreviewRendersDiffInsteadOfNewText(t *testing.T) { | |
| 183 | 224 | args := json.RawMessage(`{"path":"sample.go","edits":[{"oldText":"old value","newText":"new value"}]}`) | |
| 184 | 225 | 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{ | ||