| 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,file_tool,shell}.rs` own the small foreground |
| 15 | primitive behavior and schemas; the other native tools remain searchable. |
| 16 | - `docs/RUNTIME_SIMPLIFICATION_DESIGN.md` records the v0.9.1 cutover and receipt. |
| 17 | |
| 18 | ## Default-active contract |
| 19 | |
| 20 | New turns start with ten eager native names plus synthetic `tool_search`: |
| 21 | |
| 22 | 1. `read` |
| 23 | 2. `write` |
| 24 | 3. `edit` |
| 25 | 4. `bash` |
| 26 | 5. `agent` |
| 27 | 6. `workflow` |
| 28 | 7. `todo_write` |
| 29 | 8. `create_goal` |
| 30 | 9. `get_goal` |
| 31 | 10. `update_goal` |
| 32 | 11. `tool_search` (synthetic, always active) |
| 33 | |
| 34 | The ten native names are `DEFAULT_ACTIVE_NATIVE_TOOLS` in |
| 35 | `crates/tui/src/core/engine/tool_catalog.rs`, pinned by |
| 36 | `default_active_contract_keeps_discovery_and_core_tools_eager`. An authority |
| 37 | boundary may remove `agent` at the maximum child depth, but route size alone |
| 38 | must not change this core vocabulary. |
| 39 | |
| 40 | The direct schemas deliberately stay small: |
| 41 | |
| 42 | | Tool | Input | Purpose | |
| 43 | |---|---|---| |
| 44 | | `read` | `path`, optional `offset`, optional `limit` | Read a bounded file window with explicit continuation or truncation notices. | |
| 45 | | `write` | `path`, `content` | Create or replace a file. | |
| 46 | | `edit` | `path`, `edits` | Apply one or more unambiguous text replacements against one original snapshot. | |
| 47 | | `bash` | `command`, optional `timeout` | Run one cancellable foreground shell command and return a bounded tail. | |
| 48 | | `agent` | delegated task and optional scope/context controls | Start or inspect focused child work. | |
| 49 | | `workflow` | plan/script/source_path plus run controls | Coordinate multi-agent phases with dependencies and completion checks. | |
| 50 | | `todo_write` | complete replacement list of `{content, status}` items | Keep optional, agent-owned progress notes for genuinely multi-step work. | |
| 51 | | `create_goal` | objective plus optional budget | Start the session goal the turn works toward. | |
| 52 | | `get_goal` | none | Read the active goal and its progress. | |
| 53 | | `update_goal` | terminal status | Mark the goal complete or blocked. | |
| 54 | | `tool_search` | `query`, optional matching controls | Discover policy-allowed deferred tools and add selected schemas to this conversation's toolbox. | |
| 55 | |
| 56 | Mode is an authority decision, not a synonym system. Plan, Work, and Operate |
| 57 | use the same primitive identities. Plan centrally refuses `write`, `edit`, and |
| 58 | `bash`; Work and Operate still pass those calls through approval, sandbox, |
| 59 | trusted-path, repository-law, and managed-policy gates. Full Access changes |
| 60 | ordinary approval behavior but does not bypass hard safety or repository law. |
| 61 | |
| 62 | `update_plan` remains registered only for saved-artifact compatibility and is |
| 63 | not model-visible. `tasks`, `Git`, `Run`, `Web`, `remember`, and other |
| 64 | specialized capabilities are searchable rather than first-turn ceremony. |
| 65 | |
| 66 | ## Deferred and dynamic tools |
| 67 | |
| 68 | `Web` is conditional and deferred. It is discoverable through `tool_search` |
| 69 | only when the active policy and runtime backend permit it. Read-only |
| 70 | children retain its read-only search/fetch evidence path; read-only authority |
| 71 | does not mean "unable to research." |
| 72 | |
| 73 | The durable `github`, `automation`, and `rlm` action families are also deferred |
| 74 | by default. `rlm` owns `open`, `eval`, `configure`, and `close` actions for a |
| 75 | persistent sandboxed Python session. Feature-gated native tools may be added to |
| 76 | the active or deferred catalog only when their implementation and host |
| 77 | dependencies are available. |
| 78 | |
| 79 | MCP tools are dynamic. Successfully connected servers register names such as |
| 80 | `mcp_<server>_<tool>` from `~/.codewhale/mcp.json`; a failed or disabled server |
| 81 | must not be presented as available. MCP and plugin tools are deferred unless a |
| 82 | user explicitly names them in `[tools].always_load`. |
| 83 | |
| 84 | `execute_tools` is deferred and engine-injected, alongside the synthetic |
| 85 | interpreter tools. It runs a JavaScript program whose only host surface is |
| 86 | `tools.call(name, args)`; nested calls must be read-only and auto-approved, |
| 87 | and anything else aborts the program with a host-owned receipt. It is hidden |
| 88 | from Plan mode and refused under a worker authority envelope. |
| 89 | |
| 90 | ### Conversation toolbox cache |
| 91 | |
| 92 | A successful search activation is remembered by name for the current |
| 93 | conversation. The cache holds at most eight deferred names and 16 KiB of |
| 94 | serialized schemas, evicts least-recently-used entries, and revalidates every |
| 95 | entry against the current catalog and policy before advertising it again. A |
| 96 | session sync clears it. The cache cannot resurrect a removed, denied, or |
| 97 | newly-eager tool. |
| 98 | |
| 99 | Each subagent gets its own policy-filtered deferred catalog, always-present |
| 100 | `tool_search`, and bounded activation cache. Forked messages and instructions |
| 101 | remain in context, but the child cache starts empty and discovers tools locally; |
| 102 | neither forked context nor a cache can become a discovery allowlist. A child can |
| 103 | still search every tool its own authority permits, including Web search/fetch |
| 104 | for read-only research roles. |
| 105 | |
| 106 | ## Inspect the model-client request tool payload |
| 107 | |
| 108 | Run `/tools` after a model turn to inspect a bounded projection of the exact |
| 109 | tool field in the latest prepared model-client request. `/tools json` emits the |
| 110 | same evidence as bounded machine-readable JSON. Both formats open in a pager; |
| 111 | they are not copied into transcript history. `/tool-studio` remains a human- |
| 112 | command compatibility alias; it is not a model tool. |
| 113 | |
| 114 | The snapshot distinguishes an absent tool field from a present empty array. It |
| 115 | reports the exact model-client tool JSON byte count and SHA-256 digest only when |
| 116 | measurement fits the one-MiB inspection bound; larger payloads stay unavailable. |
| 117 | Provider adapters may transform, sanitize, or omit those fields while building |
| 118 | a provider-specific wire body, so `/tools` marks provider delivery and the wire |
| 119 | payload unavailable. Capture and rendering are bounded: retained schemas, |
| 120 | descriptions, caller lists, catalog rows, turn IDs, and payload measurement all |
| 121 | carry explicit truncation, omission, or unavailable receipts. The snapshot stays |
| 122 | in memory only for the current session and is replaced on each prepared request. |
| 123 | |
| 124 | Provider, model, approval, registry provenance, and runtime capability metadata |
| 125 | are not fields in the request tool schema. `/tools` therefore reports them as |
| 126 | unavailable instead of joining against mutable state or inferring values. Use |
| 127 | the separate route and permission receipts for those facts. |
| 128 | |
| 129 | ## Modes and permission postures |
| 130 | |
| 131 | Modes and permission postures are separate controls: |
| 132 | |
| 133 | - **Plan** keeps the stable primitive vocabulary but centrally refuses shell |
| 134 | execution and file mutation. |
| 135 | - **Work** is ordinary interactive execution. |
| 136 | - **Operate** uses the same direct-tool authority as Work. Small work stays |
| 137 | direct; multi-step delegation uses a compact Workflow plan with dependencies, |
| 138 | bounded scopes, and completion evidence. Fleet manages the same sub-agents |
| 139 | and roles. One bounded, independent task can use a direct agent; `followup` |
| 140 | reuses that agent for continued work. |
| 141 | - **Ask**, **Auto-Review**, and **Full Access** control approval behavior within |
| 142 | an action-capable mode. They never widen Plan into write or shell access. |
| 143 | |
| 144 | See `docs/MODES.md` for the full mode and posture contract. |
| 145 | |
| 146 | ## Compatibility names |
| 147 | |
| 148 | The model-facing contract is the lowercase core above. Saved v0.9.x |
| 149 | transcripts and protocol clients may still call exact hidden compatibility |
| 150 | names such as `File`, `Bash`, and the older single-operation file names. Those |
| 151 | names never enter a new model catalog or `tool_search` result. |
| 152 | |
| 153 | Compatibility is execution compatibility, not fuzzy aliasing: an exact legacy |
| 154 | call must reach the handler for its legacy schema. It must not be rewritten |
| 155 | into a small lowercase primitive whose input shape is different. Unknown or |
| 156 | retired names still fail closed instead of guessing a destination. |
| 157 | |
| 158 | Specialized native families such as `Git`, `Run`, and `Web` are not aliases for |
| 159 | the lowercase core. They remain real, policy-filtered deferred tools and are |
| 160 | loaded through `tool_search` when needed. |
| 161 | |
| 162 | ## Long-running work |
| 163 | |
| 164 | `bash` runs one cancellable foreground command. It does not carry background, |
| 165 | TTY, wait, interact, or cancel action fields. Stateful process and terminal |
| 166 | control is specialized functionality that must be discovered explicitly; it |
| 167 | does not enlarge the first-turn shell schema. |
| 168 | |
| 169 | Use `tasks` when the work itself needs a durable lifecycle, structured gates, |
| 170 | artifacts, replayable timelines, or a stable task id. Large tool results should |
| 171 | remain behind bounded handles or artifacts instead of being copied wholesale |
| 172 | into the parent transcript. |
| 173 | |
| 174 | ## Parallel fan-out |
| 175 | |
| 176 | The sub-agent capacity source of truth is |
| 177 | `crates/tui/src/config/subagent_limits.rs`: |
| 178 | |
| 179 | - default configured concurrency: **64**; |
| 180 | - maximum configured concurrency: **128**; |
| 181 | - maximum admitted running-plus-queued work: **1024**. |
| 182 | |
| 183 | These are capacity ceilings, not advice to dispatch every available slot. A |
| 184 | manager should use the smallest useful fan-out, preserve a single owner for |
| 185 | fan-in, and verify worker receipts before reporting combined completion. |
| 186 | |
| 187 | RLM child-query batching is a different, cheaper cost class. Its |
| 188 | `sub_query_batch` helper accepts 1–16 one-shot children inside a live `rlm` |
| 189 | session; it is not a substitute for tool-carrying `agent` workers. |
| 190 | |
| 191 | ## Human inspection: `/tools` (`/tool-studio`) |
| 192 | |
| 193 | `/tools` renders a **read-only, bounded human projection** of the tool field of |
| 194 | the request that was prepared for one `(turn, step)`. It is not a second |
| 195 | registry and not an execution surface. |
| 196 | |
| 197 | **The seam.** The snapshot is built in `crates/tui/src/core/engine/turn_loop.rs` |
| 198 | immediately after `MessageRequest` is constructed, from `request.tools` — the |
| 199 | same value the model client is handed. The engine resolves the surrounding |
| 200 | per-turn data once in `engine.rs` (`ToolSurfaceContext`: flattened registry |
| 201 | facts, the MCP pool's own server attribution, the engine-injected catalog names, |
| 202 | and the resolved model client's receipt) and passes it as plain data, so the |
| 203 | per-step seam never re-locks the MCP pool or holds a tool object. |
| 204 | |
| 205 | **Turn and step identity.** The tool set can differ between steps of a turn, so |
| 206 | each snapshot is stamped with turn id and step and each seam emits its own. The |
| 207 | TUI keeps only the latest (`SessionState.last_tool_request_snapshot`). Before |
| 208 | the first seam there is no snapshot and `/tools` says so rather than rebuilding |
| 209 | a registry in the UI. |
| 210 | |
| 211 | Two kinds of fact are kept apart: |
| 212 | |
| 213 | - **Wire facts** come from the prepared request: name, description, schema, |
| 214 | `defer_loading` / `strict` / `allowed_callers` / `cache_control`, byte |
| 215 | accounting, and the catalog digest. |
| 216 | - **Surface facts** come from the `ToolSurfaceContext`: provenance |
| 217 | (`builtin` / `plugin` / `mcp` / `synthetic` / `unknown`), MCP server identity, |
| 218 | declared capabilities, declared approval requirement, and model visibility. |
| 219 | |
| 220 | Contract: |
| 221 | |
| 222 | - **One digest.** `active_tool_catalog_sha256` |
| 223 | (`crates/tui/src/core/engine/preview.rs`) is the single definition of the |
| 224 | active-tool-catalog hash. The request manifest publishes it as |
| 225 | `ToolSurfaceFacts::active_tool_catalog_sha256` and `/tools` reports the same |
| 226 | value for the same prepared request; neither surface keeps a hash of its own. |
| 227 | - **Nothing is guessed.** MCP server identity is shown only when the real pool |
| 228 | attributed that exact model tool name. `McpPool::mcp_model_tool_name` is the |
| 229 | single definition shared by the model catalog and the human attribution, and |
| 230 | an ambiguous name (two servers colliding on one model name) resolves to no |
| 231 | server. Synthetic provenance comes from |
| 232 | `default_synthetic_catalog_tool_names`, which is asserted against the engine's |
| 233 | own `is_synthetic_catalog_tool` predicate. A transmitted tool with no registry |
| 234 | entry reports `capabilities: unknown`, never "none". |
| 235 | - **Provider availability follows the resolved client.** It comes from |
| 236 | `Engine::tool_surface_provider_receipt`, never from "a tool registry exists". |
| 237 | With no client the receipt is `unavailable` even when the registry is full. |
| 238 | - **Unknown shrinks, it does not vanish.** `unavailable_for_this_request` always |
| 239 | contains `provider_wire_payload`: nothing on this path observes what the |
| 240 | provider adapter finally transmits. It additionally contains `provider` and |
| 241 | `model` without a resolved client, and `provenance` / `capabilities` / |
| 242 | `approval` when no surface context was captured. |
| 243 | - **Absent stays distinct from empty.** A request with no tools field is not a |
| 244 | request with an empty tools array; an unresolved field is `unknown` with a |
| 245 | reason, not a default. |
| 246 | - **Bounded.** Rendering is capped by tool count (32), name, description, schema |
| 247 | bytes, allowed-caller count, and a payload measurement bound, each with an |
| 248 | explicit truncation or omission receipt. Registered tools that this request |
| 249 | does *not* carry are reported as a bounded name list plus an exact count |
| 250 | rather than expanding the projection. |
| 251 | - **Inert.** The snapshot lives beside the transcript, never in |
| 252 | `session.messages`, so it cannot enter a model request or perturb the |
| 253 | provider's prefix cache. It never executes a tool, never reads credentials, |
| 254 | never reorders the catalog, and is never registered as a model-callable tool. |
| 255 | - **Delivery is never claimed.** The capture happens before connection setup, so |
| 256 | `delivery_status` stays `unknown`. |
| 257 | |
| 258 | ## Release verification |
| 259 | |
| 260 | Do not infer the public surface from handler function names. Verify the model |
| 261 | catalog and alias visibility at the exact candidate SHA: |
| 262 | |
| 263 | ```bash |
| 264 | python3 scripts/measure-runtime-contract.py |
| 265 | cargo test -p codewhale-tui --lib --locked core::engine::tests::default_active_contract_keeps_discovery_and_core_tools_eager -- --exact |
| 266 | cargo test -p codewhale-tui --lib --locked tools::file_tool::tests::primitive_schemas_are_separate_and_small_contract_shaped -- --exact |
| 267 | cargo test -p codewhale-tui --lib --locked tools::shell::tests::lowercase_bash_schema_is_small_contract -- --exact |
| 268 | cargo test --locked -p codewhale-tui --lib core::engine::tests::print_mode_tool_catalog_metrics -- --ignored --exact --nocapture |
| 269 | ``` |
| 270 | |
| 271 | Check the test names against the source before trusting a green run: `cargo test` |
| 272 | exits 0 with "0 passed; N filtered out" when a filter matches nothing, so a |
| 273 | misspelled filter is indistinguishable from a pass. (Three filters printed here |
| 274 | before v0.9.4 named tests that did not exist.) |
| 275 | |
| 276 | The provider-free receipt must report the eleven default-active names listed |
| 277 | above. A separate repository-wide tool count may include deferred, dynamic, |
| 278 | feature-gated, and compatibility-only registrations; it is not the number of |
| 279 | tools placed in the first-turn model catalog. |
| 280 |