Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,18 @@ The shape most likely to draw a provider's first 400 is the widened enum — `ty

Pinned by [strict-tool-schema.test.ts](src/llm/provider/openai/strict-tool-schema.test.ts), the strict cases in [openai-tool-call-adapter.test.ts](src/llm/provider/openai/openai-tool-call-adapter.test.ts) — including that the flag off is byte-identical to today's payload — [model-strict-tools.test.ts](src/llm/provider/model-strict-tools.test.ts) for the config leg, the strict block in [qwen-tagged-tool-response-adapter.test.ts](src/llm/provider/openai/qwen-tagged-tool-response-adapter.test.ts), the `parallel_tool_calls` floor in [openai-build-body.test.ts](src/llm/provider/openai/openai-build-body.test.ts), and the end-to-end wiring in [step-executor.test.ts](src/agent/step-executor.test.ts).

### Refused before dispatch: control markers, unknown keys, a replaced user file

Two checks run in `executeBatch` on every non-terminal call, in this order, before the registry is asked for anything. A hit turns the call into an error result naming the argument and what to do, so the model reads it on its next step — one step, not a parse-recovery budget, not a task. Terminals are exempt: a reply is shown, not run.

**Control markers.** Live, Gemma 4 31B under the GBNF grammar opened another thought channel mid-call, and because the grammar admits those bytes only inside a JSON string the markers landed in `path` (`os.fs.list {"path": ".}}]<tool_call|>thought<|channel>…"}`): the tool ran on the garbage, the model read "the folder is empty" and overwrote the user's input file. [control-marker-guard.ts](src/tools/control-marker-guard.ts) (`findControlMarkers`) walks every string argument, nested objects and arrays included, for the Gemma 4, Qwen/ChatML and Gemma 3 markers plus the generic `<|name|>` form — one exported list with a comment per family. Paths, commands, patterns, URLs and ids are flagged on any occurrence; the writing tools' content arguments (`os.fs.write.content`, `os.fs.edit.oldString/newString`, `os.fs.patch.patch`) only when a marker starts a line — the line the write-time check draws — so source that mentions `<think>` in a comment is not corrupted. The result carries `details.corrupted` and the hits, the loop tracker counts it like any error and the trace row carries it. The grammar side is hardened too: the reasoning profiles' string body excludes `<|` and `|>` through a three-state rule over the last character (the naive `"<" [^|] | "|" [^>]` shape lets `<<|` and `||>` through), while the plain profile's grammar stays byte-identical to the file — pinned with a small test-side GBNF interpreter that reads the emitted grammar.

**Unknown argument keys.** A worker sent `os.shell.run {"cmd":"python3","-e":"<script>"}`: `cmd` was valid, the unknown key was dropped, `python3` ran with no arguments (exit 0) and the worker reported the work as done. A key the tool does not know is not a spare argument, it is a call the model did not mean. [unknown-argument-guard.ts](src/tools/unknown-argument-guard.ts) (`findUnknownArguments`) checks the top-level keys against the tool's registered args schema after the key normalisation of `coerce-tool-args.ts` has had its chance (a quoted or fused key that normalises into the schema is renamed at dispatch and is not unknown); a hit refuses the call with the keys received, the keys the tool accepts and the nearest match for each (`-args` → `args`). A tool with no registered schema (an MCP tool, an unregistered name) is exempt. `os.shell.run` also says `(ran with no arguments)` on its command line whenever a bare interpreter ran with nothing after it, because nothing in the output would.

**Replacing a pre-existing user file is announced and reversible.** Live, a local model's first step wrote `projects.json` over the user's data file without listing the folder — the result said "(replace)" and the model did not react — and later a 9-row `sales.csv` over the user's 2,401-row dataset; both were named by the request as inputs, both were unrecoverable. Warn-only throughout: the write still lands, but the previous content is saved first and the result says what happened. Before `os.fs.write` replaces a file that existed and was not created this session, [fs-replace-guard.ts](src/tools/os/fs-replace-guard.ts) copies the previous bytes and prepends a line to the result — loud (`⚠ replaced the user's file sales.csv (2,401 lines → 10, header changed); the previous content is saved — os.fs.restore {"path":"sales.csv"} brings it back`) when the line count dropped by 80 % or more, when the first line of a data or text file changed (`.csv .tsv .json .jsonl .md .txt .yaml .yml .toml .ini`), or when the file was over 5 MB (announced, not read or saved); quiet (`replaced the user's file x (N lines → M); previous content saved`) for any other replacement; silent for an empty file and for a file the agent created. `os.fs.edit` / `os.fs.patch` run the shrink rule only, and a patch that creates a file marks it as the agent's; the "(replace)" wording carries the counts. `os.fs.restore { path }` writes the newest saved copy back, approval-gated like a write on the fs ladder (`approval_gated`, `FS_WRITE_CATEGORIES`), names the bytes and lines that came back, and leaves the copy so a second restore still works. The store ([fs-restore-store.ts](src/tools/os/fs-restore-store.ts)) is keyed by session id — `<stateDir>/restore/<sessionId>/`, the last 20 copies, files up to 5 MB, plus a `manifest.json` with the copy index and the paths this session created — on disk rather than in `SessionState` because the tools consult it before a write and see only their `ToolContext`; it is built by `registerOsTools` from `stateDir` and shared by write / edit / patch / restore, and absent (embedders, tests) the guard is off.

Pinned by [src/tools/control-marker-guard.test.ts](src/tools/control-marker-guard.test.ts), [src/tools/unknown-argument-guard.test.ts](src/tools/unknown-argument-guard.test.ts), the guard cases in [src/agent/batch-executor.test.ts](src/agent/batch-executor.test.ts) and [src/tools/os/shell.test.ts](src/tools/os/shell.test.ts), the string-body rule in [src/llm/grammar/build-grammar.test.ts](src/llm/grammar/build-grammar.test.ts), [src/tools/os/fs-replace-guard.test.ts](src/tools/os/fs-replace-guard.test.ts) and the restore cases in [src/tools/os/os-tools.test.ts](src/tools/os/os-tools.test.ts).

### Locked invariants (pinned by tests)

Pinned by [src/agent/batch-executor.test.ts](src/agent/batch-executor.test.ts), [src/agent/step-executor.test.ts](src/agent/step-executor.test.ts), [src/agent/parallel-tool-calls.integration.test.ts](src/agent/parallel-tool-calls.integration.test.ts), [src/agent/loop-detector.test.ts](src/agent/loop-detector.test.ts), [src/llm/grammar/tool-call-grammar.test.ts](src/llm/grammar/tool-call-grammar.test.ts), [src/llm/grammar/build-grammar.test.ts](src/llm/grammar/build-grammar.test.ts), [src/tracing/trace/trace-recorder.test.ts](src/tracing/trace/trace-recorder.test.ts):
Expand Down Expand Up @@ -1496,7 +1508,7 @@ The ladder (`agent.approvalLevel`, config v37; the binary `agent.approvalRequire
| Level | Name | Stops asking for |
|---|---|---|
| 1 | paranoid (default) | nothing — every gated action asks |
| 2 | workspace | `fs_write_workspace`: `os.fs.{write,edit,patch}` strictly inside the session cwd (realpath containment via [src/tools/os/fs-approval-scope.ts](src/tools/os/fs-approval-scope.ts); symlinks pointing outside are classified by their target); `os.git.{init,add,commit,checkout}` on a repository rooted inside the workspace ride the same category |
| 2 | workspace | `fs_write_workspace`: `os.fs.{write,edit,patch,restore}` strictly inside the session cwd (realpath containment via [src/tools/os/fs-approval-scope.ts](src/tools/os/fs-approval-scope.ts); symlinks pointing outside are classified by their target); `os.git.{init,add,commit,checkout}` on a repository rooted inside the workspace ride the same category |
| 3 | home | + `fs_write_home` (writes anywhere under the home directory, plus `os.fs.archive.extract` even into the workspace), `fs_trash`, `http` (SSRF guard is not part of the gate and stays on) |
| 4 | operator | + `shell` (guard verdict `approval_required` only), `script` (`skill.run_script`), `proc_kill`, `git_remote` (`os.git.{push,pull,fetch,clone}` and `os.git.remote add|set-url|remove` — refused outright before the ladder while `git.remoteSync` is off) |
| 5 | full trust | everything, including `browser_nonweb` (file://, javascript:), `trust_config`, and `other` |
Expand Down
2 changes: 1 addition & 1 deletion grammars/tool-call.gbnf
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ tool-call ::= "{" ws "\"tool\"" ws ":" ws tool-name ws "," ws "\"args\"" ws ":"
tool-call-array ::= "[" ws tool-call ( ws "," ws tool-call ){0,15} ws "]"
tool-name ::= browser-tool | os-tool | discovery-tool | memory-tool | tasks-tool | vision-tool | fusion-tool | verify-tool | mcp-native-tool | mcp-server-tool | "\"reply\"" | "\"finish\""
browser-tool ::= "\"browser." ( "navigate" | "click" | "type" | "read_aria" | "search" | "tabs" | "scroll" ) "\""
os-tool ::= "\"os." ( "shell.run" | "fs.read" | "fs.read_document" | "fs.write" | "fs.trash" | "fs.list" | "fs.grep" | "fs.glob" | "fs.locate_project" | "fs.edit" | "fs.hash" | "fs.diff" | "fs.patch" | "fs.watch" | "fs.archive.list" | "fs.archive.read_entry" | "fs.archive.extract" | "http.request" | "web.search" | "web.fetch" | "git.status" | "git.log" | "git.diff" | "git.show" | "git.blame" | "git.branch" | "git.init" | "git.add" | "git.commit" | "git.checkout" | "git.clone" | "git.remote" | "git.fetch" | "git.pull" | "git.push" | "proc.list" | "proc.kill" | "clipboard.read" | "clipboard.write" | "window.list" | "window.focus" | "notify" | "email.inbox" | "email.send" ) "\""
os-tool ::= "\"os." ( "shell.run" | "fs.read" | "fs.read_document" | "fs.write" | "fs.trash" | "fs.list" | "fs.grep" | "fs.glob" | "fs.locate_project" | "fs.edit" | "fs.hash" | "fs.diff" | "fs.patch" | "fs.restore" | "fs.watch" | "fs.archive.list" | "fs.archive.read_entry" | "fs.archive.extract" | "http.request" | "web.search" | "web.fetch" | "git.status" | "git.log" | "git.diff" | "git.show" | "git.blame" | "git.branch" | "git.init" | "git.add" | "git.commit" | "git.checkout" | "git.clone" | "git.remote" | "git.fetch" | "git.pull" | "git.push" | "proc.list" | "proc.kill" | "clipboard.read" | "clipboard.write" | "window.list" | "window.focus" | "notify" | "email.inbox" | "email.send" ) "\""
discovery-tool ::= "\"skill." ( "view" | "run_script" ) "\"" | "\"tool.view\""
memory-tool ::= "\"memory." ( "profile.set" | "profile.remove" | "profile.list" | "profile.history" | "notes.store" | "notes.recall" | "notes.forget" | "lessons.recall" | "procedures.recall" ) "\""
tasks-tool ::= "\"tasks." ( "schedule" | "cron" | "list" | "cancel" | "show" ) "\""
Expand Down
Loading