Ctrl K
ComunidadGuías…

docs/TOOL_SURFACE.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Tool surface

> 阅读简体中文版:[zh_hans/TOOL_SURFACE.md](zh_hans/TOOL_SURFACE.md)。

This document describes the current model-facing tool contract. The v0.9.1
cutover that produced it is recorded in `docs/RUNTIME_SIMPLIFICATION_DESIGN.md`;
read the workspace version from `Cargo.toml`, not from this line. The registry
remains larger than the first-turn catalog so
saved transcripts can replay and uncommon capabilities can be loaded on demand.
The model should learn one canonical name for each common operation.

Implementation sources:

- `crates/engine/src/core/engine/tool_catalog.rs` owns the eager/deferred catalog.
- `crates/engine/src/tools/registry.rs` registers canonical tools and hidden aliases.
- `crates/engine/src/tools/{file,file_tool,shell}.rs` own the small foreground
  primitive behavior and schemas; the other native tools remain searchable.
- `docs/RUNTIME_SIMPLIFICATION_DESIGN.md` records the v0.9.1 cutover and receipt.

## Default-active contract

The 0.10.3 source candidate starts new turns with fourteen eager native names
plus synthetic `tool_search`. This is the current source contract, not a
claim about the published v0.10.2 release.

1. `read`
2. `write`
3. `edit`
4. `file_search`
5. `grep_files`
6. `bash`
7. `agent`
8. `todo_write`
9. `create_goal`
10. `get_goal`
11. `update_goal`
12. `task_shell_wait`
13. `present`
14. `load_skill`
15. `tool_search` (synthetic, always active)

The fourteen native names are `DEFAULT_ACTIVE_NATIVE_TOOLS` in
`crates/engine/src/core/engine/tool_catalog.rs`. An authority boundary may
remove `agent` at the maximum child depth, and Plan registers only the
foreground shell surface (so no `task_shell_wait`). Route size alone must not
change this core vocabulary. `workflow`, `context_archive_get` and
`context_archive_search` remain available through `tool_search` when policy
permits; they are no longer eager names.

The direct schemas deliberately stay small:

| Tool | Input | Purpose |
|---|---|---|
| `read` | `path`, optional `offset`, optional `limit` | Read a bounded file window with explicit continuation or truncation notices. |
| `write` | `path`, `content` | Create or replace a file. |
| `edit` | `path`, `edits` | Apply one or more unambiguous text replacements against one original snapshot. |
| `file_search` | `query`, optional `path`, `limit`, `extensions`, `exclude` | Find files by name, path fragment or glob and return bounded, ranked matches. Respects ignore files; `exclude` replaces default exclusions. |
| `bash` | `command`, optional `timeout`, optional `description` | Run one cancellable foreground shell command and return a bounded tail. The optional description is display-only. |
| `agent` | delegated task and optional scope/context controls | Start or inspect focused child work. |
| `grep_files` | `pattern`, optional search controls | Find matching content in workspace files. |
| `todo_write` | complete replacement list of `{content, status}` items | Keep optional, agent-owned progress notes for genuinely multi-step work. |
| `create_goal` | objective plus optional budget | Start the session goal the turn works toward. |
| `get_goal` | none | Read the active goal and its progress. |
| `update_goal` | terminal status | Mark the goal complete or blocked. |
| `task_shell_wait` | `task_id`, optional `wait`, `timeout_ms`, `cancel`, `gate` | Poll or stop a background shell task. Completion still arrives as a runtime event, so use it for early output, true barriers, or gate evidence. |
| `present` | `files`, optional `note` | Declare 1–32 existing workspace files as final deliverables with a read-only SHA-256 handoff receipt; nothing is copied or moved. |
| `load_skill` | optional `name`, `query` | Load a named skill's `SKILL.md` body and companion-file list; omit `name` or use `name="list"` to list eligible skills, or use `query` to search names and descriptions. |
| `tool_search` | `query`, optional matching controls | Discover policy-allowed deferred tools and add selected schemas to this conversation's toolbox. |

Mode is an authority decision, not a synonym system. Plan and Work use the
same primitive identities. Plan centrally refuses `write`, `edit`, and `bash`;
Work still passes those calls through approval, sandbox, trusted-path,
repository-law, and managed-policy gates. Full Access changes
ordinary approval behavior but does not bypass hard safety or repository law.

`update_plan` remains registered only for saved-artifact compatibility and is
not model-visible. `tasks`, `Git`, `Run`, `Web`, `remember`, and other
specialized capabilities are searchable rather than first-turn ceremony.

On the TUI's default (calm) shell card the Bash description is the headline,
shown as written apart from stripped terminal escapes, collapsed whitespace and
a length bound, and the command sits on its own `$` row directly under it in
every state and at every width, including automatic runs. A card without a
description leads with the `$` command instead. The labeled card
(`show_tool_details = true` with `calm_mode = false`) keeps the command in its
header and the description as a muted `note:` row. The description never
replaces the command and is excluded from execution, exact approval keys and
Auto-Review input. The original description remains in the recorded tool input
and exports. Approval prompts, `/jobs`, the activity inspector, ACP and other
Runtime clients keep showing the command; this change adds no description row
to those surfaces.

## Local code execution

`code_execution` (Python) and `js_execution` (Node.js) use the same
permission-aware local launcher as workspace task gates and test runs. Ordinary
approval retains the effective session policy. To retry a denied exact call
with wider permissions, supply `sandbox_permissions` (`workspace-write` or
`danger-full-access`) and a nonempty `justification`; Ask mode requires explicit
one-shot user approval. The approved policy affects only that call.

Both tools read source from stdin, return stdout/stderr/return_code, and keep
the existing timeout and process-tree cleanup. They are not persistent REPLs;
tracebacks refer to stdin rather than a temporary script path. The separate
RLM/REPL kernel is not changed by this launcher integration.

Platform/backend limits of the shared launcher still apply: no available local
wrapper means workspace-write remains unenforced, while read-only is refused.
An external sandbox session cannot use these local interpreter tools and must
use its shell execution path. See [sandbox limits](SANDBOX.md).

## Deferred and dynamic tools

`Web` is conditional and deferred. It is discoverable through `tool_search`
only when the active policy and runtime backend permit it. Read-only
children retain its read-only search/fetch evidence path; read-only authority
does not mean "unable to research."

The durable `github`, `automation`, and `rlm` action families are also deferred
by default. `rlm` owns `open`, `eval`, `configure`, and `close` actions for a
persistent local Python session (a subprocess with a scrubbed environment, not
an OS sandbox). Inline ```` ```repl ```` fences in a reply run in the same kind of
kernel only when `code_execution` is on the turn's surface (never in Plan mode),
only when the fence opens its own line, and only after `code_execution`'s
approval under the session posture. Feature-gated native tools may be added to
the active or deferred catalog only when their implementation and host
dependencies are available.

MCP tools are dynamic. Successfully connected servers register names such as
`mcp_<server>_<tool>` from `~/.codewhale/mcp.json`; a failed or disabled server
must not be presented as available. MCP and plugin tools are normally deferred. When the composition VM is eager and the turn admits exactly one compact named batch schema, `execute_tools` also accepts `{batch:{tool,arguments}}` instead of JavaScript. The VM forwards the data through the same nested gate, bounds and receipts; the server tool stays deferred unless explicitly requested. The current server routes and turn permissions narrow the shortcut’s member names; its nested dispatch checks the offered members after hooks. The server supplies a bounded guide to their arguments. Other tools stay searchable; `[tools].always_load` still makes individual schemas eager.

For an unstarted configured server, an MCP-focused `tool_search` first
performs bounded discovery under the current turn's server/tool ceiling.
It searches real server-provided schemas after connection, rather than
inventing `mcp_*` definitions. A search inside `execute_tools` describes
those schemas without activating them; a direct search uses the existing
bounded activation cache. Ordinary unrelated searches leave optional servers
unstarted. The CLI's standalone MCP inspection commands own a separate pool.

### Code mode (`execute_tools`)

For a known sequence, prefer a tool’s own batch operation. Use `execute_tools` when the next call depends on an intermediate result, and return the verified outcome. Search unknown names and schemas before acting. An unexpected state, refusal, uncertain delivery or direct-only decision ends that sequence.

`execute_tools` is engine-injected, alongside the synthetic interpreter tools.
It runs a JavaScript program whose only host surface is
`await tools.call(name, args)`, and it is the default way to compose several
tool calls — MCP and plugin tools included — without round-tripping every
intermediate result through the conversation. It is hidden from Plan mode and
refused under a worker authority envelope.

- **One gate.** In a session turn every nested call is sent back to the turn
  loop and planned exactly like a direct call: deny/allow lists, preparation
  (MCP `readOnlyHint`/`destructiveHint`), `tool_call_before` hooks, ask-rules,
  Auto-Review, repository law, and the Computer Use consent refusal. MCP calls
  run through the session MCP pool. Approving the program grants nothing, so
  `execute_tools` itself is auto-approved. If the permission posture changes
  while a program runs, its remaining nested calls are refused (an approved
  call survives only an equal or broader posture, as for a direct call) and
  the model retries them under the new posture.
- **Approvals suspend the program.** A nested call that needs approval raises
  the normal approval card (named `execute_tools program call: ...`) and the
  program waits; allow resumes it, deny fails only that nested call as an
  exception the program can catch. Time spent waiting on a decision does not
  count against the program's run deadline, which is the turn's remaining
  wall clock.
- **Receipts.** The result lists every nested call with its decision (`auto`,
  `approved`, `denied`, `refused`) and status (`ok`, `failed`, `refused`,
  `in_flight`). A program that hits its deadline still returns the receipt;
  `in_flight` calls were cancelled and may have partially run. Each nested
  result is `{content, metadata, truncated}`; an oversized result keeps that
  shape, and `truncated` names the original size and the spillover file with
  the full output.
- **Discovery without re-pinning.** Inside a program,
  `tools.call('tool_search', {query})` returns matching deferred tools with
  their input schemas and does not activate them, so the request's tool array
  and the session-pinned prefix do not change.
- **Stays direct:** `agent`, `workflow`, `request_user_input`, nested
  `execute_tools`, interactive shells, sandbox escalation, Computer Use
  consent and scripts, and MCP sign-in (`mcp_<server>_authenticate`).

Code mode is on by default (`[features] code_mode = true`), which makes
`execute_tools` eager from the first request; direct tools and `tool_search`
stay available either way. Set `code_mode = false` (or run with
`--disable code_mode`) to go back to deferring `execute_tools` behind
`tool_search`. The flag is session configuration, so the prompt prefix stays
stable within a session.

Sub-agents get `execute_tools` on the same terms, unless their role is limited
to the read-only evidence tools or an explicit tool list leaves it out. A
child's nested call is planned by the child's own gate, so its grant and its
approval path apply as for its direct calls, and it is admitted at execution
against the child's write-scope claim and live peers. A child's program also
stops at the child's tool timeout (time waiting on approvals is not counted)
or its work deadline, and still returns the receipt. A program in which no
nested call ran does not count as progress for the child's permission-denial
guard, but the guard does not yet count a refused nested call as a denial
either; only the child's step and time budgets end a loop of denied calls
retried inside programs. Without an engine turn
(a direct unit call), a program keeps the conservative profile: read-only,
auto-approved native calls only, no MCP.

### Conversation toolbox cache

A successful search activation is remembered by name for the current
conversation. Its soft targets are eight deferred names and 16 KiB of added
provider-wire schemas, measured after the route's production transformations
against the eager-tool baseline. An adapter that cannot measure the wire reports
unknown bytes; internal `Tool` serialization is not substituted. To make room it evicts tools that were never called first
(oldest activation first), then the least recently called; a tool called in the
current or previous turn is never evicted. When such tools already fill the
cache, a search still activates its best match over the bound and holds the
rest; a later activation restores the bound once those tools go unused. Every
entry is revalidated against the current catalog and policy before it is
advertised again. A session sync clears it. The cache cannot resurrect a
removed, denied, or newly-eager tool.

When a search's activation pushes a previously activated tool out of the
cache, that search's result lists it under `evicted_tool_references`, so the
model is told the tool is no longer callable rather than finding it missing
from the next request. A held match is listed under
`unavailable_tool_references`. Both carry `tool_search_input`, an exact regex
query for that one name that loads it again. Only tools that actually left the
active set are listed; a tool that became eager stays callable and is not
reported. Each search also returns the current `active_deferred_tools` snapshot
and wire-byte measurement in `toolbox`; a new admission explicitly supersedes
older eviction or unavailable notices for the same name. Other declared toolbox
changes, including revalidation and direct first-use activation, append one
current deferred-toolbox snapshot before the next model request. That snapshot
supersedes old availability notices without rewriting earlier history.

Each subagent gets its own policy-filtered deferred catalog, always-present
`tool_search`, and bounded activation cache. Forked messages and instructions
remain in context, but the child cache starts empty and discovers tools locally;
neither forked context nor a cache can become a discovery allowlist. A child can
still search every tool its own authority permits, including Web search/fetch
for read-only research roles.

## Inspect the model-client request tool payload

Run `/tools` after a model turn to inspect a bounded projection of the exact
tool field in the latest prepared model-client request. `/tools json` emits the
same evidence as bounded machine-readable JSON. Both formats open in a pager;
they are not copied into transcript history. `/tool-studio` remains a human-
command compatibility alias; it is not a model tool.

The snapshot distinguishes an absent tool field from a present empty array. It
reports the exact model-client tool JSON byte count and SHA-256 digest only when
measurement fits the one-MiB inspection bound; larger payloads stay unavailable.
Provider adapters may transform, sanitize, or omit those fields while building
a provider-specific wire body, so `/tools` marks provider delivery and the wire
payload unavailable. The separate per-request receipt described in [CACHE.md](CACHE.md)
measures the production wire schema and joins it with reported provider usage. Capture and rendering are bounded: retained schemas,
descriptions, caller lists, catalog rows, turn IDs, and payload measurement all
carry explicit truncation, omission, or unavailable receipts. The snapshot stays
in memory only for the current session and is replaced on each prepared request.

Provider, model, approval, registry provenance, and runtime capability metadata
are not fields in the request tool schema. `/tools` therefore reports them as
unavailable instead of joining against mutable state or inferring values. Use
the separate route and permission receipts for those facts.

## Modes and permission postures

Modes and permission postures are separate controls:

- **Plan** keeps the stable primitive vocabulary but centrally refuses shell
  execution and file mutation.
- **Work** is ordinary interactive execution.
- **Ask**, **Auto-Review**, and **Full Access** control approval behavior within
  an action-capable mode. They never widen Plan into write or shell access.

See `docs/MODES.md` for the full mode and posture contract.

## Compatibility names

The model-facing contract is the lowercase core above. Saved v0.9.x
transcripts and protocol clients may still call exact hidden compatibility
names such as `File`, `Bash`, and the older single-operation file names. Those
names never enter a new model catalog or `tool_search` result.

Compatibility is execution compatibility, not fuzzy aliasing: an exact legacy
call must reach the handler for its legacy schema. It must not be rewritten
into a small lowercase primitive whose input shape is different. Unknown or
retired names still fail closed instead of guessing a destination.

Specialized native families such as `Git`, `Run`, and `Web` are not aliases for
the lowercase core. They remain real, policy-filtered deferred tools and are
loaded through `tool_search` when needed.

## Long-running work

`bash` runs one cancellable foreground command. It does not carry background,
TTY, wait, interact, or cancel action fields. Stateful process and terminal
control stays out of the shell schema: `task_shell_wait` is eager for the
background jobs `bash` starts, while interactive terminal control remains
deferred behind `tool_search`; neither enlarges the first-turn shell schema.

Use `tasks` when the work itself needs a durable lifecycle, structured gates,
artifacts, replayable timelines, or a stable task id. Large tool results should
remain behind bounded handles or artifacts instead of being copied wholesale
into the parent transcript.

## Parallel fan-out

The sub-agent capacity source of truth is
`crates/tui/src/config/subagent_limits.rs`:

- default configured concurrency: **64**;
- maximum configured concurrency: **128**;
- maximum admitted running-plus-queued work: **1024**.

These are capacity ceilings, not advice to dispatch every available slot. A
manager should use the smallest useful fan-out, preserve a single owner for
fan-in, and verify worker receipts before reporting combined completion.

RLM child-query batching is a different, cheaper cost class. Its
`sub_query_batch` helper accepts 1–16 one-shot children inside a live `rlm`
session; it is not a substitute for tool-carrying `agent` workers.

## Human inspection: `/tools` (`/tool-studio`)

`/tools` renders a **read-only, bounded human projection** of the tool field of
the request that was prepared for one `(turn, step)`. It is not a second
registry and not an execution surface.

**The seam.** The snapshot is built in `crates/engine/src/core/engine/turn_loop.rs`
immediately after `MessageRequest` is constructed, from `request.tools` — the
same value the model client is handed. The engine resolves the surrounding
per-turn data once in `engine.rs` (`ToolSurfaceContext`: flattened registry
facts, the MCP pool's own server attribution, the engine-injected catalog names,
and the resolved model client's receipt) and passes it as plain data, so the
per-step seam never re-locks the MCP pool or holds a tool object.

**Turn and step identity.** The tool set can differ between steps of a turn, so
each snapshot is stamped with turn id and step and each seam emits its own. The
TUI keeps only the latest (`SessionState.last_tool_request_snapshot`). Before
the first seam there is no snapshot and `/tools` says so rather than rebuilding
a registry in the UI.

Two kinds of fact are kept apart:

- **Wire facts** come from the prepared request: name, description, schema,
  `defer_loading` / `strict` / `allowed_callers` / `cache_control`, byte
  accounting, and the catalog digest.
- **Surface facts** come from the `ToolSurfaceContext`: provenance
  (`builtin` / `plugin` / `mcp` / `synthetic` / `unknown`), MCP server identity,
  declared capabilities, declared approval requirement, and model visibility.

Contract:

- **One digest.** `active_tool_catalog_sha256`
  (`crates/engine/src/core/engine/preview.rs`) is the single definition of the
  active-tool-catalog hash. The request manifest publishes it as
  `ToolSurfaceFacts::active_tool_catalog_sha256` and `/tools` reports the same
  value for the same prepared request; neither surface keeps a hash of its own.
- **Nothing is guessed.** MCP server identity is shown only when the real pool
  attributed that exact model tool name. `McpPool::mcp_model_tool_name` is the
  single definition shared by the model catalog and the human attribution, and
  an ambiguous name (two servers colliding on one model name) resolves to no
  server. Synthetic provenance comes from
  `default_synthetic_catalog_tool_names`, which is asserted against the engine's
  own `is_synthetic_catalog_tool` predicate. A transmitted tool with no registry
  entry reports `capabilities: unknown`, never "none".
- **Provider availability follows the resolved client.** It comes from
  `Engine::tool_surface_provider_receipt`, never from "a tool registry exists".
  With no client the receipt is `unavailable` even when the registry is full.
- **Unknown shrinks, it does not vanish.** `unavailable_for_this_request` always
  contains `provider_wire_payload`: nothing on this path observes what the
  provider adapter finally transmits. It additionally contains `provider` and
  `model` without a resolved client, and `provenance` / `capabilities` /
  `approval` when no surface context was captured.
- **Absent stays distinct from empty.** A request with no tools field is not a
  request with an empty tools array; an unresolved field is `unknown` with a
  reason, not a default.
- **Bounded.** Rendering is capped by tool count (32), name, description, schema
  bytes, allowed-caller count, and a payload measurement bound, each with an
  explicit truncation or omission receipt. Registered tools that this request
  does *not* carry are reported as a bounded name list plus an exact count
  rather than expanding the projection.
- **Inert.** The snapshot lives beside the transcript, never in
  `session.messages`, so it cannot enter a model request or perturb the
  provider's prefix cache. It never executes a tool, never reads credentials,
  never reorders the catalog, and is never registered as a model-callable tool.
- **Delivery is never claimed.** The capture happens before connection setup, so
  `delivery_status` stays `unknown`.

## Release verification

Do not infer the public surface from handler function names. Verify the model
catalog and alias visibility at the exact candidate SHA:

```bash
python3 scripts/measure-runtime-contract.py
cargo test -p codewhale-tui --lib --locked core::engine::tests::default_active_contract_keeps_discovery_and_core_tools_eager -- --exact
cargo test -p codewhale-tui --lib --locked tools::file_tool::tests::primitive_schemas_are_separate_and_small_contract_shaped -- --exact
cargo test -p codewhale-tui --lib --locked tools::shell::tests::lowercase_bash_schema_is_small_contract -- --exact
cargo test --locked -p codewhale-tui --lib core::engine::tests::print_mode_tool_catalog_metrics -- --ignored --exact --nocapture
```

Check the test names against the source before trusting a green run: `cargo test`
exits 0 with "0 passed; N filtered out" when a filter matches nothing, so a
misspelled filter is indistinguishable from a pass. Each `--exact` command
above must report `1 passed` (the ignored metrics test reports `1 passed`
only because `--ignored` selects it); `0 passed` means the filter matched
nothing and the check did not run.

The provider-free receipt must report the thirteen default-active names listed
above. A separate repository-wide tool count may include deferred, dynamic,
feature-gated, and compatibility-only registrations; it is not the number of
tools placed in the first-turn model catalog.