Skip to content

🧰 Tool reference (the ACI)

The agent–computer interface is the surface a model actually has to succeed at. A frontier model tolerates a sloppy one; a 7B does not. Everything here is designed around one principle: a tool must either do the right thing or explain exactly how to retry.

All tools are defined in pkg/workspace. ws_skill is registered by the orchestrator.

Tool Writes? One-line contract
ws_read β€” Read a windowed slice of a file as numbered lines
ws_write βœ… Create a new file (overwrite needs a prior read)
ws_edit βœ… Replace old_str with new_str, uniquely
ws_patch βœ… Apply a unified diff or SEARCH/REPLACE block
ws_mv βœ… Rename/move (uses git mv when available)
ws_delete βœ… Delete a file
ws_list β€” List a directory
ws_glob β€” Find files by pattern (** supported)
ws_grep β€” Regex search over file contents
ws_shell β€” Run one command (bounded)
ws_todo β€” Write/replace a short checklist, echoed back
ws_skill β€” Pull a skill's full body on demand
git_status, git_diff β€” Read-only git

workspace.ToolNames() returns the coding set; workspace.SpecialistToolNames() adds the meta-tools find_models and mcp_call.


Universal rules

Every result is capped. DefaultMaxToolChars is 8000 characters (~2k tokens), configurable with max_tool_chars. Truncation keeps head and tail and appends steering text naming the total size β€” a single oversized result must never evict the rest of the conversation.

Paths are project-relative and jailed. .. escapes are refused. Symlinks are resolved against the real workspace root, so a symlink inside the tree cannot point out of it.

.slmcode/ is off limits. Tools may not write anywhere under .slmcode/ except .slmcode/scratch/. This holds even when the focus guard is disabled β€” it is a privilege boundary, not a heuristic: an agent that could drop a hooks.json would have arbitrary shell on the next run, and one that could rewrite config.yaml could disable its own guards.

write refused β€” .slmcode/hooks.json is harness control state, not project source.
Files under .slmcode/ (hooks.json, config.yaml, pending/, checkpoints/) configure the
harness itself and are never edited by tools.
If you need scratch space, write under .slmcode/scratch/ instead.

Loop guard. Repeated identical calls are detected and answered with an intervention nudge. The tracker is isolated per task, so one task's repetition history cannot poison another's.


ws_read

{"path": "pkg/foo/bar.go", "offset": 1, "limit": 120}

Returns a 120-line window by default (read_window_lines), formatted as %6d|line. A second hard ceiling caps any single read at roughly 15% of the context window.

When the window does not cover the whole file the result ends with:

[showing lines 1–120 of 480 in pkg/foo/bar.go; use offset= to see more]
Next page: ws_read {"path":"pkg/foo/bar.go","offset":121,"limit":120}. To jump straight to a symbol use ws_grep first.

The 42| gutter is display only. Including it in old_str is the single most common small-model edit failure. When every non-blank line of old_str (or of a ws_patch body) carries the gutter the intent is unambiguous: ws_edit/ws_patch strip it, apply the edit, and append [stripped ws_read line numbers from old_str β€” do not include them] to the result so the drift stays visible to pkg/evolve. A gutter on only some lines is not a paste and is still refused by name.

Failure messages point at the recovery tool: a missing path suggests ws_glob/ws_list, a directory suggests ws_list, an out-of-range offset gives the valid range.

ws_write

{"path": "pkg/foo/new.go", "content": "…", "allow_shrink": false}

Creates new files. Overwriting an existing file is refused unless it was read this session (read_before_edit), and the refusal spells out the ws_edit recipe instead.

A catastrophic-truncation guard refuses rewriting a large file as a tiny one; repeat with "allow_shrink": true if that really is the intent. Windows reserved device names (nul, con, com1…) are refused.

ws_edit

{"path": "calc.go", "old_str": "…", "new_str": "…", "replace_all": false}

The match ladder

Small models drift on trailing whitespace, indentation and blank lines when they re-emit a span they just read. Rather than failing outright, ws_edit walks a fixed ladder and stops at the first strategy producing exactly one match:

# Strategy Note appended on success
1 exact (none)
2 trailing-whitespace-insensitive [matched ignoring trailing whitespace β€” your old_str had different line endings]
3 indentation-normalized [matched after normalizing indentation β€” your old_str was indented differently]
4 blank-line-insensitive [matched ignoring blank lines β€” your old_str had different blank-line spacing]
5 anchored-first-last-line [matched on first+last line anchors β€” the middle of your old_str did not match exactly; verify the result with ws_read]

A strategy producing two or more candidates is never applied β€” an ambiguous edit is a wrong edit. The indentation strategy re-applies the file's own leading whitespace to the replacement.

Reporting which rung matched is deliberate: it is how the model learns its old_str drifted, and how pkg/evolve learns which drift your model has.

Refusals

Situation Response
old_str empty or whitespace-only Refused. Empty search used to pass strings.Contains and silently prepend. The message names the three real intents: create β†’ ws_write; append β†’ anchor on the last 2–3 lines; insert β†’ repeat the anchor in new_str.
old_str carries the 42\| gutter on every line Stripped and applied; the result ends with [stripped ws_read line numbers from old_str β€” do not include them].
old_str carries the gutter on only some lines Refused by name, with a before/after example.
old_str == new_str No-op edit refused β€” old_str and new_str are identical.
Exact match found N>1 times old_str found N times … pass replace_all:true, or include more surrounding context … Do NOT use ws_write.
A ladder strategy matched N>1 times Ambiguous edit refused β€” the search text matches N places … (strategy) match. With replace_all:true every span of that strategy is replaced instead (edited … (N replacement(s)) [matched …]).
Argument spelled old_string/search/old, new_string/replace/new Accepted as aliases of old_str/new_str (canonical key wins when both are present). ws_write likewise reads contents/text/body, ws_patch reads diff/hunk.
ws_write with empty content on a new file Write refused β€” content is empty, so this would create <path> as a 0-byte file. The message names the accepted body keys and any unrecognized key in the call. Emptying an existing file needs allow_shrink:true.
No strategy matched Not-found guidance plus a fuzzy hint at the closest span.
Whole-file-style rewrite through ws_edit Refused by the over-edit guard (over_edit_guard).

Success: edited pkg/foo/bar.go (1 replacement(s)) plus any strategy note and syntax note.

ws_patch

{"path": "pkg/foo/bar.go", "patch": "@@ -10,3 +10,4 @@\n …"}

Accepts a unified diff with @@ hunks, a <<<<<<< SEARCH / ======= / >>>>>>> REPLACE block, the same block without the conflict markers (SEARCH / ======= / REPLACE header lines, or just a bare ======= separator), or a bare -/+ block treated as one anchorless hunk.

A pure-insertion hunk (@@ -N,0 +M,K @@, nothing removed) inserts after line N, as every unified-diff consumer does (-0,0 means the top of the file). Trailing prose after a hunk ("This adds the check.") and Markdown fences around the diff are dropped rather than being read as context lines that can never match; an unprefixed line that is followed by a diff line is still treated as context the model forgot to prefix.

Multi-hunk diffs are applied hunk by hunk, each anchored on its @@ line numbers within a Β±20-line window (AnchorWindowLines), with earlier hunks' line delta carried forward. The same match ladder runs inside that window, so a hunk whose context drifted slightly still lands.

Application is all-or-nothing: if any hunk misses, nothing is written and you get a per-hunk report naming which hunks applied, where they anchored (anchored@120..164 exact) and which failed. Partial patches are how a file ends up half-migrated and compiling wrong.

Post-edit syntax checking

After a successful write, edit or patch the harness runs a file-local parse check:

Extension Checker
.go gofmt -e -l
.py python3 -c 'compile(...)' (falls back to python)
.js, .mjs, .cjs node --check
.json python3 -c 'json.load(...)'

TypeScript is deliberately not checked β€” tsc --noEmit needs the whole program and routinely takes 10s+, far too slow to sit inside a tool call. A missing runtime is skipped, never read as broken, and a timed-out check is skipped too.

Two outcomes:

  1. Was broken, still broken β†’ the error is appended to the result in-band, so the model fixes it on the very next turn:
    ⚠ syntax check failed (gofmt) on pkg/foo/bar.go:
    pkg/foo/bar.go:41:2: expected '}', found 'EOF'
    FIX THIS NOW with ws_edit before doing anything else …
    
  2. Parsed before, does not parse now β†’ the edit is reverted and the model is told exactly what it broke:
    EDIT REVERTED β€” pkg/foo/bar.go parsed correctly before your change and does NOT parse after it (gofmt):
    …
    The file is unchanged on disk. Fix the syntax in your replacement text and retry:
      β€’ check brackets/parens/quotes are balanced in new_str
      β€’ check indentation matches the surrounding block
    Do NOT retry the identical edit β€” it will be reverted again.
    

Disable with disable_syntax_check: true.

ws_grep, ws_glob, ws_list

ws_grep takes a real RE2 regular expression. If the pattern does not compile it is used as a literal substring and the result says so, rather than failing. Narrow with glob= and path=. At most 50 hits; the cap is announced.

ws_glob supports ** for any number of directories (pkg/**/*_test.go), capped at 200 hits.

ws_list returns an explicit message when the directory is empty or missing, rather than an empty string the model has to interpret.

ws_mv / ws_delete

ws_mv prefers git mv when a .git directory is present, otherwise renames. It is the supported way to rename β€” rewriting a file and leaving the old one is a common small-model failure mode.

ws_delete is irreversible except through the checkpoint store (file_checkpoints, on by default).

ws_shell

{"command": "go test ./pkg/foo -short", "timeout_sec": 120}

One command per call. No command substitution, no backgrounding.

  • Timeout: 2 minutes by default (shell_timeout), overridable per call but capped by the harness. On timeout the whole process group is killed, so a test runner cannot leave orphaned children holding the terminal.
  • Bounded output buffer: output is capped while the command runs, not after, so a runaway command cannot exhaust memory.
  • Safety: see Permissions & safety for the whitelist tiers, the substitution ban and the write-redirection guard.
  • Empty output is reported explicitly: (command succeeded with no output: …).

ws_todo

{"todos": ["read pkg/x/y.go", "[x] add nil check", "run go test ./pkg/x"]}

Writes or replaces a short checklist and echoes it back, which is the point: the plan stays in recent context, where a small model's attention actually is, instead of scrolling away.

ws_skill

Progressive skill disclosure means most matched skills are rendered as cards (name + description) rather than full bodies β€” multiple simultaneous behavioural directives measurably degrade small-model instruction following. ws_skill {"name": "atomic-coding"} pulls a body on demand. An unknown name returns the list of skills that are available.

See Skills and Context engineering.


The edit-format contract

Every tool-using specialist inherits the same contract (agents.EditContract):

  • old_str must match the file byte-for-byte, indentation included. ws_read first.
  • Strip ws_read's 42| prefix β€” it is display only and never matches.
  • Make old_str unique; include 2–3 surrounding lines when a short span repeats.
  • ws_write creates new files; change existing files with ws_edit or ws_patch.
  • A failed match is always answered by re-reading and retrying, never by ws_write.

The prompt ships a worked example and a worked repair (the line-number-prefix failure and its fix), because for a small model a demonstration outperforms a rule.

Two further invariants come from pkg/agents:

  • One tool call per turn. The harness truncates an assistant message to its first tool call, so a model that ignores the instruction simply loses the extra calls.
  • Never end on a tool call. An agent must produce its final JSON after tool use.

Which edit format a run uses is one of the arms the bandit in pkg/evolve learns over (search_replace, unified_diff, whole_file), keyed on model family and language β€” see Self-improvement.