| 1 | # Tool surface |
| 2 | |
| 3 | This document describes the current model-facing tool contract. The v0.9.1 |
| 4 | cutover that produced it is recorded in `docs/RUNTIME_SIMPLIFICATION_DESIGN.md`; |
| 5 | read the workspace version from `Cargo.toml`, not from this line. The registry |
| 6 | remains larger than the first-turn catalog so |
| 7 | saved transcripts can replay and uncommon capabilities can be loaded on demand. |
| 8 | The model should learn one canonical name for each common operation. |
| 9 | |
| 10 | Implementation sources: |
| 11 | |
| 12 | - `crates/tui/src/core/engine/tool_catalog.rs` owns the eager/deferred catalog. |
| 13 | - `crates/tui/src/tools/registry.rs` registers canonical tools and hidden aliases. |
| 14 | - `crates/tui/src/tools/{file_tool,git_tool,run_tool,web_tool,shell}.rs` own the |
| 15 | canonical action schemas. |
| 16 | - `docs/RUNTIME_SIMPLIFICATION_DESIGN.md` records the v0.9.1 cutover and receipt. |
| 17 | |
| 18 | ## Default-active contract |
| 19 | |
| 20 | The default-active policy contains exactly these nine names: |
| 21 | |
| 22 | 1. `Bash` |
| 23 | 2. `File` |
| 24 | 3. `Git` |
| 25 | 4. `Run` |
| 26 | 5. `agent` |
| 27 | 6. `remember` |
| 28 | 7. `tasks` |
| 29 | 8. `work_update` |
| 30 | 9. `tool_search` |
| 31 | |
| 32 | The first eight are `DEFAULT_ACTIVE_NATIVE_TOOLS` in |
| 33 | `crates/tui/src/core/engine/tool_catalog.rs`. `tool_search` is synthetic rather |
| 34 | than registry-backed and is always active. |
| 35 | |
| 36 | `remember` is registered only when the user enables the built-in memory path; |
| 37 | once present, it stays eager so a model can capture a durable preference without |
| 38 | first discovering the tool. A memory-disabled runtime omits that registration and |
| 39 | therefore exposes eight of the nine policy names. |
| 40 | |
| 41 | `update_plan` is **hidden from the model**. It is registered |
| 42 | (`crates/tui/src/tools/plan.rs:401`) but `model_visible()` returns `false` |
| 43 | (`plan.rs:408-413`), and `build_api_tools` filters on that (`registry.rs:235`), |
| 44 | so it never enters the API tool list — which is what `tool_search` indexes. |
| 45 | `tool_search` cannot surface it either. Its own description calls it a "Legacy |
| 46 | compatibility tool for loading older Plan artifacts", and |
| 47 | `update_plan_is_hidden_replay_compatibility` (`plan.rs:598-605`) pins that. |
| 48 | |
| 49 | Plan mode narrows the active set: `Bash` and `Run` drop out, leaving `File`, |
| 50 | `Git`, `agent`, `tasks`, `work_update`, `tool_search`, and — when memory is |
| 51 | enabled — `remember` (`should_register_remember_tool`, |
| 52 | `crates/tui/src/core/engine/tool_setup.rs:113-118`). |
| 53 | |
| 54 | The surface is action-based. A model calls one stable tool name and selects the |
| 55 | operation through its `action` field instead of choosing among many synonymous |
| 56 | single-purpose tools. |
| 57 | |
| 58 | ### Core action tools |
| 59 | |
| 60 | | Tool | Actions | Purpose | |
| 61 | |---|---|---| |
| 62 | | `Bash` | `run`, `wait`, `interact`, `cancel` | Run bounded commands, continue background work, send input, and cancel processes. | |
| 63 | | `File` | `read`, `list`, `search_name`, `search_content`, `write`, `edit`, `patch` | Read, find, and modify workspace files with structured, workspace-aware results. | |
| 64 | | `Git` | `status`, `diff`, `log`, `show`, `blame` | Inspect repository state and history without parsing shell output. | |
| 65 | | `Run` | `tests`, `verifiers` | Run project tests or independent verifier gates with structured results. | |
| 66 | |
| 67 | `Bash` appears only when the active session/profile permits shell use. Plan |
| 68 | keeps it unavailable. In Act and Operate, the active permission posture, |
| 69 | sandbox, command policy, trusted paths, repository law, and managed policy still |
| 70 | apply. Full Access removes ordinary approval prompts; it does not bypass hard |
| 71 | safety or repository-policy holds. |
| 72 | |
| 73 | `File` is capability-filtered by mode. Plan advertises its read-only actions; |
| 74 | write/edit actions require Act or Operate, and `patch` also requires the |
| 75 | apply-patch feature. The same read-before-edit, workspace, and policy checks used |
| 76 | by the former spellings remain in force. |
| 77 | |
| 78 | ### Coordination tools |
| 79 | |
| 80 | | Tool | Purpose | |
| 81 | |---|---| |
| 82 | | `agent` | Dispatch one focused sub-agent run and return an id, compact receipt, and transcript handle. | |
| 83 | | `remember` | Append one terse durable preference or convention when the user has enabled built-in memory. | |
| 84 | | `tasks` | Create, list, read, cancel, gate, and inspect durable task work through one action family. | |
| 85 | | `update_plan` | Registered but not model-visible; replays older Plan artifacts only. New work uses `work_update` plus a normal Plan-mode response. | |
| 86 | | `work_update` | Replace the concrete To-do / Work progress projection for the active thread or durable task. | |
| 87 | | `tool_search` | Discover and load a deferred tool only when the current turn needs it. | |
| 88 | |
| 89 | `work_update` writes the **sole canonical Work ledger**. `update_plan` is |
| 90 | conversational reasoning — strategy, constraints, and route notes that help a |
| 91 | reader understand the approach. It is not a second Work surface, and plan-only |
| 92 | state never becomes model-facing Work grounding. |
| 93 | |
| 94 | That distinction is enforced at the request boundary (#3983): the current To-do |
| 95 | snapshot is rendered by one bounded renderer |
| 96 | (`crates/tui/src/work_grounding.rs`) and appended to each parent turn-loop and |
| 97 | sub-agent step request as a transient `<codewhale:work_state>` block. Forked |
| 98 | sub-agents and `/relay` handoffs embed the byte-identical body. An empty To-do |
| 99 | emits no block at all. |
| 100 | |
| 101 | ## Deferred and dynamic tools |
| 102 | |
| 103 | `Web` is a conditional, deferred action tool with `search`, `fetch`, and `wait` |
| 104 | actions. It is discoverable through `tool_search` only when the active network |
| 105 | policy and runtime backend permit it; it is not one of the nine default-active |
| 106 | names. |
| 107 | |
| 108 | The durable `github`, `automation`, and `rlm` action families are also deferred |
| 109 | by default. `rlm` owns `open`, `eval`, `configure`, and `close` actions for a |
| 110 | persistent sandboxed Python session. Feature-gated native tools may be added to |
| 111 | the active or deferred catalog only when their implementation and host |
| 112 | dependencies are available. |
| 113 | |
| 114 | MCP tools are dynamic. Successfully connected servers register names such as |
| 115 | `mcp_<server>_<tool>` from `~/.codewhale/mcp.json`; a failed or disabled server |
| 116 | must not be presented as an available model tool. |
| 117 | |
| 118 | ## Inspect the model-client request tool payload |
| 119 | |
| 120 | Run `/tools` after a model turn to inspect a bounded projection of the exact |
| 121 | tool field in the latest prepared model-client request. `/tools json` emits the |
| 122 | same evidence as bounded machine-readable JSON. Both formats open in a pager; |
| 123 | they are not copied into transcript history. `/tool-studio` remains a human- |
| 124 | command compatibility alias; it is not a model tool. |
| 125 | |
| 126 | The snapshot distinguishes an absent tool field from a present empty array. It |
| 127 | reports the exact model-client tool JSON byte count and SHA-256 digest only when |
| 128 | measurement fits the one-MiB inspection bound; larger payloads stay unavailable. |
| 129 | Provider adapters may transform, sanitize, or omit those fields while building |
| 130 | a provider-specific wire body, so `/tools` marks provider delivery and the wire |
| 131 | payload unavailable. Capture and rendering are bounded: retained schemas, |
| 132 | descriptions, caller lists, catalog rows, turn IDs, and payload measurement all |
| 133 | carry explicit truncation, omission, or unavailable receipts. The snapshot stays |
| 134 | in memory only for the current session and is replaced on each prepared request. |
| 135 | |
| 136 | Provider, model, approval, registry provenance, and runtime capability metadata |
| 137 | are not fields in the request tool schema. `/tools` therefore reports them as |
| 138 | unavailable instead of joining against mutable state or inferring values. Use |
| 139 | the separate route and permission receipts for those facts. |
| 140 | |
| 141 | ## Modes and permission postures |
| 142 | |
| 143 | Modes and permission postures are separate controls: |
| 144 | |
| 145 | - **Plan** is read-only. It exposes the read-only `File` projection and other |
| 146 | safe inspection capabilities, but no shell or file mutation. |
| 147 | - **Act** is ordinary interactive execution. |
| 148 | - **Operate** uses the same direct-tool authority as Act while preferring Fleet |
| 149 | workers for independent, parallel, isolated, background, or long-running work. |
| 150 | - **Ask**, **Auto-Review**, and **Full Access** control approval behavior within |
| 151 | an action-capable mode. They never widen a Plan turn into write access. |
| 152 | |
| 153 | See `docs/MODES.md` for the full mode and posture contract. |
| 154 | |
| 155 | ## Removed spellings |
| 156 | |
| 157 | The per-action single-purpose names below are **not registered**. They were |
| 158 | deleted, not hidden: a call to any of them fails with `tool '<name>' is not |
| 159 | registered`, because `resolve` has deliberately no fuzzy step |
| 160 | (`crates/tui/src/tools/registry.rs:313-316` — "a hallucinated name must fail, |
| 161 | never dispatch"). There is no replay path for them; a transcript that calls one |
| 162 | will not re-execute. |
| 163 | |
| 164 | | Removed spelling | Use instead | |
| 165 | |---|---| |
| 166 | | `exec_shell`, `exec_shell_wait`, `exec_wait`, `exec_shell_interact`, `exec_interact`, `exec_shell_cancel` | `Bash`: `run`, `wait`, `interact`, `cancel` | |
| 167 | | `read_file`, `list_dir`, `grep_files`, `file_search`, `write_file`, `edit_file` | `File`: `read`, `list`, `search_content`, `search_name`, `write`, `edit` | |
| 168 | | `git_status`, `git_diff`, `git_log`, `git_show`, `git_blame` | `Git`: matching action | |
| 169 | | `run_tests`, `run_verifiers` | `Run`: `tests`, `verifiers` | |
| 170 | | `web_search`, `fetch_url`, `wait_for_dev_server` | `Web`: `search`, `fetch`, `wait` | |
| 171 | |
| 172 | Enforced by `shell_surface_contains_only_the_canonical_bash_tool` |
| 173 | (registry.rs:2290, `"{alias} must be removed"`) and the retired-name loop at |
| 174 | registry.rs:2066-2088 (`"{retired} must stay removed"` / |
| 175 | `"{retired} must not be advertised"`). |
| 176 | |
| 177 | ## Replay-only aliases |
| 178 | |
| 179 | There is exactly one: `apply_patch`. |
| 180 | |
| 181 | | Replay-only spelling | Canonical action | |
| 182 | |---|---| |
| 183 | | `apply_patch` | `File`: `patch` (also DeepSeek Responses' one custom tool) | |
| 184 | |
| 185 | It is registered as a `FileTool::alias` (registry.rs:831) and hidden from the |
| 186 | advertised catalog — `registry.rs:2092-2093` asserts both halves: `contains` |
| 187 | is true, and no API tool carries the name. |
| 188 | |
| 189 | Every other legacy spelling that used to be listed here is **removed, not |
| 190 | hidden**. Calling one hard-errors as an unknown tool; there is no replay |
| 191 | compatibility for them. Tests pin the removals: |
| 192 | |
| 193 | | Removed spellings | Use instead | Pinned by | |
| 194 | |---|---|---| |
| 195 | | `task_create`, `task_list`, `task_read`, `task_cancel`, `task_gate_run` | `tasks` | `runtime_task_families_expose_only_canonical_tools`, registry.rs:2337-2371 | |
| 196 | | `pr_attempt_*` | `tasks` | same test | |
| 197 | | `github_issue_context`, `github_pr_context`, `github_comment`, `github_close_issue`, `github_close_pr` | `github` | same test | |
| 198 | | `automation_create/list/read/update/pause/resume/delete/run` | `automation` | same test | |
| 199 | | `rlm_session_objects`, `rlm_open`, `rlm_eval`, `rlm_configure`, `rlm_close` | `rlm` | `rlm_is_the_only_registered_session_surface`, registry.rs:1519-1538 | |
| 200 | | `checklist_write/add/update/list`, `todo_write/add/update/list` | `work_update` | registry.rs:1476-1490 | |
| 201 | |
| 202 | This matches the "Removed spellings" section above rather than contradicting |
| 203 | it. Replay compatibility does not make an alias a supported spelling for new |
| 204 | model calls; `apply_patch` execution must stay behaviorally equivalent to |
| 205 | `File`: `patch` and must not be added back to the advertised catalog. |
| 206 | |
| 207 | ## Long-running work |
| 208 | |
| 209 | Use `Bash` with `action: "run"` for bounded commands. Set its background option |
| 210 | for work that may outlive a normal foreground wait, then use `wait`, `interact`, |
| 211 | or `cancel` against the returned process id. Live shell jobs are also visible in |
| 212 | `/jobs`; process-local jobs must be marked stale after restart rather than shown |
| 213 | as reattached processes. |
| 214 | |
| 215 | Use `tasks` when the work itself needs a durable lifecycle, structured gates, |
| 216 | artifacts, replayable timelines, or a stable task id. Large tool results should |
| 217 | remain behind bounded handles or artifacts instead of being copied wholesale |
| 218 | into the parent transcript. |
| 219 | |
| 220 | ## Parallel fan-out |
| 221 | |
| 222 | The sub-agent capacity source of truth is |
| 223 | `crates/tui/src/config/subagent_limits.rs`: |
| 224 | |
| 225 | - default configured concurrency: **64**; |
| 226 | - maximum configured concurrency: **128**; |
| 227 | - maximum admitted running-plus-queued work: **1024**. |
| 228 | |
| 229 | These are capacity ceilings, not advice to dispatch every available slot. A |
| 230 | manager should use the smallest useful fan-out, preserve a single owner for |
| 231 | fan-in, and verify worker receipts before reporting combined completion. |
| 232 | |
| 233 | RLM child-query batching is a different, cheaper cost class. Its |
| 234 | `sub_query_batch` helper accepts 1–16 one-shot children inside a live `rlm` |
| 235 | session; it is not a substitute for tool-carrying `agent` workers. |
| 236 | |
| 237 | ## Human inspection: `/tools` (`/tool-studio`) |
| 238 | |
| 239 | `/tools` renders a **read-only, bounded human projection** of the tool field of |
| 240 | the request that was prepared for one `(turn, step)`. It is not a second |
| 241 | registry and not an execution surface. |
| 242 | |
| 243 | **The seam.** The snapshot is built in `crates/tui/src/core/engine/turn_loop.rs` |
| 244 | immediately after `MessageRequest` is constructed, from `request.tools` — the |
| 245 | same value the model client is handed. The engine resolves the surrounding |
| 246 | per-turn data once in `engine.rs` (`ToolSurfaceContext`: flattened registry |
| 247 | facts, the MCP pool's own server attribution, the engine-injected catalog names, |
| 248 | and the resolved model client's receipt) and passes it as plain data, so the |
| 249 | per-step seam never re-locks the MCP pool or holds a tool object. |
| 250 | |
| 251 | **Turn and step identity.** The tool set can differ between steps of a turn, so |
| 252 | each snapshot is stamped with turn id and step and each seam emits its own. The |
| 253 | TUI keeps only the latest (`SessionState.last_tool_request_snapshot`). Before |
| 254 | the first seam there is no snapshot and `/tools` says so rather than rebuilding |
| 255 | a registry in the UI. |
| 256 | |
| 257 | Two kinds of fact are kept apart: |
| 258 | |
| 259 | - **Wire facts** come from the prepared request: name, description, schema, |
| 260 | `defer_loading` / `strict` / `allowed_callers` / `cache_control`, byte |
| 261 | accounting, and the catalog digest. |
| 262 | - **Surface facts** come from the `ToolSurfaceContext`: provenance |
| 263 | (`builtin` / `plugin` / `mcp` / `synthetic` / `unknown`), MCP server identity, |
| 264 | declared capabilities, declared approval requirement, and model visibility. |
| 265 | |
| 266 | Contract: |
| 267 | |
| 268 | - **One digest.** `active_tool_catalog_sha256` |
| 269 | (`crates/tui/src/core/engine/preview.rs`) is the single definition of the |
| 270 | active-tool-catalog hash. The request manifest publishes it as |
| 271 | `ToolSurfaceFacts::active_tool_catalog_sha256` and `/tools` reports the same |
| 272 | value for the same prepared request; neither surface keeps a hash of its own. |
| 273 | - **Nothing is guessed.** MCP server identity is shown only when the real pool |
| 274 | attributed that exact model tool name. `McpPool::mcp_model_tool_name` is the |
| 275 | single definition shared by the model catalog and the human attribution, and |
| 276 | an ambiguous name (two servers colliding on one model name) resolves to no |
| 277 | server. Synthetic provenance comes from |
| 278 | `default_synthetic_catalog_tool_names`, which is asserted against the engine's |
| 279 | own `is_synthetic_catalog_tool` predicate. A transmitted tool with no registry |
| 280 | entry reports `capabilities: unknown`, never "none". |
| 281 | - **Provider availability follows the resolved client.** It comes from |
| 282 | `Engine::tool_surface_provider_receipt`, never from "a tool registry exists". |
| 283 | With no client the receipt is `unavailable` even when the registry is full. |
| 284 | - **Unknown shrinks, it does not vanish.** `unavailable_for_this_request` always |
| 285 | contains `provider_wire_payload`: nothing on this path observes what the |
| 286 | provider adapter finally transmits. It additionally contains `provider` and |
| 287 | `model` without a resolved client, and `provenance` / `capabilities` / |
| 288 | `approval` when no surface context was captured. |
| 289 | - **Absent stays distinct from empty.** A request with no tools field is not a |
| 290 | request with an empty tools array; an unresolved field is `unknown` with a |
| 291 | reason, not a default. |
| 292 | - **Bounded.** Rendering is capped by tool count (32), name, description, schema |
| 293 | bytes, allowed-caller count, and a payload measurement bound, each with an |
| 294 | explicit truncation or omission receipt. Registered tools that this request |
| 295 | does *not* carry are reported as a bounded name list plus an exact count |
| 296 | rather than expanding the projection. |
| 297 | - **Inert.** The snapshot lives beside the transcript, never in |
| 298 | `session.messages`, so it cannot enter a model request or perturb the |
| 299 | provider's prefix cache. It never executes a tool, never reads credentials, |
| 300 | never reorders the catalog, and is never registered as a model-callable tool. |
| 301 | - **Delivery is never claimed.** The capture happens before connection setup, so |
| 302 | `delivery_status` stays `unknown`. |
| 303 | |
| 304 | ## Release verification |
| 305 | |
| 306 | Do not infer the public surface from handler function names. Verify the model |
| 307 | catalog and alias visibility at the exact candidate SHA: |
| 308 | |
| 309 | ```bash |
| 310 | python3 scripts/measure-runtime-contract.py |
| 311 | cargo test -p codewhale-tui --bin codewhale-tui --locked shell_surface_contains_only_the_canonical_bash_tool |
| 312 | cargo test -p codewhale-tui --bin codewhale-tui --locked runtime_task_families_expose_only_canonical_tools |
| 313 | cargo test --locked -p codewhale-tui --bin codewhale-tui print_mode_tool_catalog_metrics -- --ignored --nocapture |
| 314 | ``` |
| 315 | |
| 316 | Check the test names against the source before trusting a green run: `cargo test` |
| 317 | exits 0 with "0 passed; N filtered out" when a filter matches nothing, so a |
| 318 | misspelled filter is indistinguishable from a pass. (Three filters printed here |
| 319 | before v0.9.4 named tests that did not exist.) |
| 320 | |
| 321 | The provider-free full-policy receipt enables built-in memory and must report the |
| 322 | nine default-active names listed above. A memory-disabled receipt truthfully omits |
| 323 | `remember` and reports eight. A separate repository-wide tool count may include deferred, dynamic, |
| 324 | feature-gated, and replay-only registrations; it is not the number of tools |
| 325 | placed in the first-turn model catalog. |
| 326 |