detached a89c9e2

feat(subagent): consolidate the subagent tools into one action-based tool (#212)

* feat(subagent): consolidate the subagent tools into one action-based tool

Replace subagent_spawn, subagent_status, subagent_stop, and subagent_resume with a single subagent tool whose action argument selects spawn, status, stop, or resume. The four previous tools stay as internal implementations behind one facade, so their validation and result payloads are unchanged.

Launch-time gating is now per action: --tools subagent grants every action, --tools subagent:<action> grants one action, and --no-tools, an empty --tools list, and a packaged PermissionSet still disable all of them. The four legacy --tools names no longer select anything.

An argument that belongs only to another action is rejected with an in-band error naming the owning actions instead of being ignored, so a mis-selected action cannot silently run as a different one.

Refresh the prompt addenda, the profile manifest header, the --tools help row, docs/subagents.md, and README.md, and cover the facade, the gating matrix, registry shape, and the interactive refresh in tests.

* feat(subagent): scope confirmation grants per action and wait on resume

One action-based tool multiplexes four actions under a single name, which widened two contracts that this commit tightens.

Confirmation: core gains an optional ConfirmationKeyer, and the gate keys a remembered "always allow" grant on the effective key instead of the raw tool name. The facade returns subagent:<action>, so approving a read-only status call no longer pre-approves spawn, stop, or resume; every other tool keeps its per-name grant, and an undeterminable key falls back to the name.

Schema: action-owned properties now state when they are required or valid, a non-string action reports a type error instead of "action is required", and the resume caveat is back in the model-facing description. The unreachable per-action schema constants and Description/Schema methods are removed, with invariant tests tying the merged schema to the action/field map. The four internal subagent tool types no longer implement core.Tool; only the facade is model-facing.

Resume: the resume action accepts wait (1-300s). ResidentManager.ResumeWithTurn lets the caller own the follow-up turn ID so the tool can subscribe before the turn can finish, and the bounded-wait logic is shared with spawn. A timed-out wait reports timed_out and leaves the child active.
commit
a89c9e2ab373ca37d8c9cd0eb2651ff3ef684e65
author
bnema · · 2026-09-19 08:04 UTC
committer
GitHub · · 2026-09-19 08:04 UTC
parent
33c980b
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
32 files · +1472 -274
M README.md +6 -4
@@ -290,7 +290,7 @@ When the sandbox is on (see `/jail`), filesystem tools, `grep`, `glob`, `ast`, a
290290
291291 ### Headless orchestration
292292
293-`--orchestrate` explicitly enables bounded resident delegation for `-p`/`--print`, `--stream`, or `--json`; ordinary headless runs never delegate implicitly. This is a strict manager-only mode: the parent divides work into non-overlapping child scopes, coordinates active workers, and yields rather than implementing or repeating delegated work. The parent needs `subagent_spawn`: it is available with the default tool set, or must be named in `--tools subagent_spawn` (and is disabled by `--no-tools`). A packaged agent's `PermissionSet` does not expose `subagent_spawn`, so packaged agents cannot use orchestration. Interactive and RPC modes do not support this flag, and `--stats` cannot be combined with it.
293+`--orchestrate` explicitly enables bounded resident delegation for `-p`/`--print`, `--stream`, or `--json`; ordinary headless runs never delegate implicitly. This is a strict manager-only mode: the parent divides work into non-overlapping child scopes, coordinates active workers, and yields rather than implementing or repeating delegated work. The parent needs the `subagent` spawn action: it is available with the default tool set, or must be named in `--tools subagent:spawn` (and is disabled by `--no-tools`). A packaged agent's `PermissionSet` does not expose the subagent spawn action, so packaged agents cannot use orchestration. Interactive and RPC modes do not support this flag, and `--stats` cannot be combined with it.
294294
295295 Resident children inherit the parent CLI provider, model, and reasoning selection unless a selected profile or spawn explicitly overrides it. They do not inherit the parent's strict orchestrator role. Profile metadata from [docs/subagents.md](docs/subagents.md) is provided to the parent through `[subagents_list]` before selection; only the selected profile body and child-specific provider/model/reasoning configuration are loaded or applied afterward. Headless orchestration waits for every accepted child before its completion wave, while `required:true` additionally creates a durable obligation that survives terminal failure or interruption. Print mode writes only the final synthesis; stream mode renders every parent turn to stdout while tool diagnostics remain on stderr; JSON mode retains every parent event as parseable JSONL, including completion updates as existing `user_message` events. Child output is evidence for the next parent turn, not a host log.
296296
@@ -424,6 +424,8 @@ Resident subagents run alongside the main session as independent in-process agen
424424
425425 > **V0.x breaking change:** previous subprocess subagent state is unsupported and ignored. Outstanding jobs are never migrated or replayed; remove the old state directory when you no longer need it.
426426
427+> **V0.x breaking change:** the separate `subagent_spawn`, `subagent_status`, `subagent_stop`, and `subagent_resume` tools are replaced by one `subagent` tool with `spawn`, `status`, `stop`, and `resume` actions. `--tools subagent_spawn` no longer selects anything; use `--tools subagent:spawn`, or bare `--tools subagent` for every action. Under `--no-yolo` the "always allow" grant is remembered per action (`subagent:status` and `subagent:spawn` are separate grants), so approving a read-only `status` call never pre-approves `spawn`, `stop`, or `resume`.
428+
427429 > **Choose the workspace deliberately.** Use `isolation:"worktree"` for parallel coding so children cannot edit the host checkout. Use shared mode for read-only review or explicitly coordinated work. Worktree isolation is an accident-prevention guardrail, not a security sandbox.
428430
429431 ```text
@@ -446,15 +448,15 @@ Resident subagents run alongside the main session as independent in-process agen
446448
447449 The fair global scheduler defaults to six concurrent turns, one active turn per child. `subagents.max_concurrent` overrides that limit. Queueing has no default deadline; a positive `subagents.queue_timeout` durably fails a prompt that waits too long for a slot. Required child work remains an obligation until an explicit successful follow-up.
448450
449-Each child runs without a cumulative token budget or step cap: it inherits the parent's resolved `--max-steps`, unlimited by default, and each turn is bounded by the model context window with one compaction recovery. A child whose last-turn prompt reaches 85%, 90%, or 95% of its model context window receives one non-blocking reminder per band to finish up, injected into its next provider request. Retrieve a terminal result without a model call using `subagent_status` with `agent_id` and `include_result: true`. After checking the saved progress and side effects, an explicit resume continues the retained session. Required work stays unresolved until success. See [lifecycle and persistence](docs/subagents.md#lifecycle-and-persistence) for details.
451+Each child runs without a cumulative token budget or step cap: it inherits the parent's resolved `--max-steps`, unlimited by default, and each turn is bounded by the model context window with one compaction recovery. A child whose last-turn prompt reaches 85%, 90%, or 95% of its model context window receives one non-blocking reminder per band to finish up, injected into its next provider request. Retrieve a terminal result without a model call using the `subagent` tool's `status` action with `agent_id` and `include_result: true`. After checking the saved progress and side effects, an explicit resume continues the retained session, and its `wait` value blocks on that follow-up turn. Required work stays unresolved until success. See [lifecycle and persistence](docs/subagents.md#lifecycle-and-persistence) for details.
450452
451453 Worktree isolation captures a patch and changed-file list without applying it. Shared children use the host checkout. The available logical references are `subagent://<id>`, `subagent://<id>/history`, `subagent://<id>/result`, and `subagent://<id>/patch`.
452454
453-**Proactive delegation.** With `/settings` → **proactive delegation** on (or `/orchestrator`), the interactive primary remains the owner and implementer of the task. It delegates only concrete sidecars that can progress while the primary performs a preselected non-overlapping task; immediate blockers and tightly coupled work stay local. An active worker owns its scope until completion, so the primary does not repeat that investigation, review, testing, or implementation. If an explicit user request or workflow hands blocking work to a child, the primary yields for the host completion update instead. The canonical resident tools remain available for user-requested delegation when the setting is off, subject to launch-time permissions. Do not poll `subagent_status` or journal files solely to wait for completion.
455+**Proactive delegation.** With `/settings` → **proactive delegation** on (or `/orchestrator`), the interactive primary remains the owner and implementer of the task. It delegates only concrete sidecars that can progress while the primary performs a preselected non-overlapping task; immediate blockers and tightly coupled work stay local. An active worker owns its scope until completion, so the primary does not repeat that investigation, review, testing, or implementation. If an explicit user request or workflow hands blocking work to a child, the primary yields for the host completion update instead. The canonical resident `subagent` tool remains available for user-requested delegation when the setting is off, subject to launch-time permissions. Do not poll the status action or journal files solely to wait for completion.
454456
455457 ### Named subagent profiles
456458
457-zut discovers the common markdown/frontmatter profile layout from `~/.agents/agents/*.md` by default and from additional paths configured through `ZUT_AGENT_PROFILES`. Whenever `subagent_spawn` is available, the primary agent receives `[subagents_list]` metadata and should select the appropriately named profile for each worker when one matches; otherwise it can spawn a clearly described general worker without an `agent` field. If launch-time policy withholds `subagent_spawn`, profile metadata is omitted. An interactive primary with proactive delegation enabled continues locally, while an explicit headless `--orchestrate` run reports that its required delegation is unavailable instead of implementing directly. Profile bodies are applied only to the selected child. See [docs/subagents.md](docs/subagents.md) for discovery, supported fields, reasoning levels, and examples.
459+zut discovers the common markdown/frontmatter profile layout from `~/.agents/agents/*.md` by default and from additional paths configured through `ZUT_AGENT_PROFILES`. Whenever the `subagent` tool's `spawn` action is available, the primary agent receives `[subagents_list]` metadata and should select the appropriately named profile for each worker when one matches; otherwise it can spawn a clearly described general worker without an `agent` field. If launch-time policy withholds the `subagent` spawn action, profile metadata is omitted. An interactive primary with proactive delegation enabled continues locally, while an explicit headless `--orchestrate` run reports that its required delegation is unavailable instead of implementing directly. Profile bodies are applied only to the selected child. See [docs/subagents.md](docs/subagents.md) for discovery, supported fields, reasoning levels, and examples.
458460
459461 ### `/settings`
460462
@@ -290,7 +290,7 @@ When the sandbox is on (see `/jail`), filesystem tools, `grep`, `glob`, `ast`, a
290 290
291 ### Headless orchestration291 ### Headless orchestration
292 292
293-`--orchestrate` explicitly enables bounded resident delegation for `-p`/`--print`, `--stream`, or `--json`; ordinary headless runs never delegate implicitly. This is a strict manager-only mode: the parent divides work into non-overlapping child scopes, coordinates active workers, and yields rather than implementing or repeating delegated work. The parent needs `subagent_spawn`: it is available with the default tool set, or must be named in `--tools subagent_spawn` (and is disabled by `--no-tools`). A packaged agent's `PermissionSet` does not expose `subagent_spawn`, so packaged agents cannot use orchestration. Interactive and RPC modes do not support this flag, and `--stats` cannot be combined with it.293+`--orchestrate` explicitly enables bounded resident delegation for `-p`/`--print`, `--stream`, or `--json`; ordinary headless runs never delegate implicitly. This is a strict manager-only mode: the parent divides work into non-overlapping child scopes, coordinates active workers, and yields rather than implementing or repeating delegated work. The parent needs the `subagent` spawn action: it is available with the default tool set, or must be named in `--tools subagent:spawn` (and is disabled by `--no-tools`). A packaged agent's `PermissionSet` does not expose the subagent spawn action, so packaged agents cannot use orchestration. Interactive and RPC modes do not support this flag, and `--stats` cannot be combined with it.
294 294
295 Resident children inherit the parent CLI provider, model, and reasoning selection unless a selected profile or spawn explicitly overrides it. They do not inherit the parent's strict orchestrator role. Profile metadata from [docs/subagents.md](docs/subagents.md) is provided to the parent through `[subagents_list]` before selection; only the selected profile body and child-specific provider/model/reasoning configuration are loaded or applied afterward. Headless orchestration waits for every accepted child before its completion wave, while `required:true` additionally creates a durable obligation that survives terminal failure or interruption. Print mode writes only the final synthesis; stream mode renders every parent turn to stdout while tool diagnostics remain on stderr; JSON mode retains every parent event as parseable JSONL, including completion updates as existing `user_message` events. Child output is evidence for the next parent turn, not a host log.295 Resident children inherit the parent CLI provider, model, and reasoning selection unless a selected profile or spawn explicitly overrides it. They do not inherit the parent's strict orchestrator role. Profile metadata from [docs/subagents.md](docs/subagents.md) is provided to the parent through `[subagents_list]` before selection; only the selected profile body and child-specific provider/model/reasoning configuration are loaded or applied afterward. Headless orchestration waits for every accepted child before its completion wave, while `required:true` additionally creates a durable obligation that survives terminal failure or interruption. Print mode writes only the final synthesis; stream mode renders every parent turn to stdout while tool diagnostics remain on stderr; JSON mode retains every parent event as parseable JSONL, including completion updates as existing `user_message` events. Child output is evidence for the next parent turn, not a host log.
296 296
@@ -424,6 +424,8 @@ Resident subagents run alongside the main session as independent in-process agen
424 424
425 > **V0.x breaking change:** previous subprocess subagent state is unsupported and ignored. Outstanding jobs are never migrated or replayed; remove the old state directory when you no longer need it.425 > **V0.x breaking change:** previous subprocess subagent state is unsupported and ignored. Outstanding jobs are never migrated or replayed; remove the old state directory when you no longer need it.
426 426
427+> **V0.x breaking change:** the separate `subagent_spawn`, `subagent_status`, `subagent_stop`, and `subagent_resume` tools are replaced by one `subagent` tool with `spawn`, `status`, `stop`, and `resume` actions. `--tools subagent_spawn` no longer selects anything; use `--tools subagent:spawn`, or bare `--tools subagent` for every action. Under `--no-yolo` the "always allow" grant is remembered per action (`subagent:status` and `subagent:spawn` are separate grants), so approving a read-only `status` call never pre-approves `spawn`, `stop`, or `resume`.
428+
427 > **Choose the workspace deliberately.** Use `isolation:"worktree"` for parallel coding so children cannot edit the host checkout. Use shared mode for read-only review or explicitly coordinated work. Worktree isolation is an accident-prevention guardrail, not a security sandbox.429 > **Choose the workspace deliberately.** Use `isolation:"worktree"` for parallel coding so children cannot edit the host checkout. Use shared mode for read-only review or explicitly coordinated work. Worktree isolation is an accident-prevention guardrail, not a security sandbox.
428 430
429 ```text431 ```text
@@ -446,15 +448,15 @@ Resident subagents run alongside the main session as independent in-process agen
446 448
447 The fair global scheduler defaults to six concurrent turns, one active turn per child. `subagents.max_concurrent` overrides that limit. Queueing has no default deadline; a positive `subagents.queue_timeout` durably fails a prompt that waits too long for a slot. Required child work remains an obligation until an explicit successful follow-up.449 The fair global scheduler defaults to six concurrent turns, one active turn per child. `subagents.max_concurrent` overrides that limit. Queueing has no default deadline; a positive `subagents.queue_timeout` durably fails a prompt that waits too long for a slot. Required child work remains an obligation until an explicit successful follow-up.
448 450
449-Each child runs without a cumulative token budget or step cap: it inherits the parent's resolved `--max-steps`, unlimited by default, and each turn is bounded by the model context window with one compaction recovery. A child whose last-turn prompt reaches 85%, 90%, or 95% of its model context window receives one non-blocking reminder per band to finish up, injected into its next provider request. Retrieve a terminal result without a model call using `subagent_status` with `agent_id` and `include_result: true`. After checking the saved progress and side effects, an explicit resume continues the retained session. Required work stays unresolved until success. See [lifecycle and persistence](docs/subagents.md#lifecycle-and-persistence) for details.451+Each child runs without a cumulative token budget or step cap: it inherits the parent's resolved `--max-steps`, unlimited by default, and each turn is bounded by the model context window with one compaction recovery. A child whose last-turn prompt reaches 85%, 90%, or 95% of its model context window receives one non-blocking reminder per band to finish up, injected into its next provider request. Retrieve a terminal result without a model call using the `subagent` tool's `status` action with `agent_id` and `include_result: true`. After checking the saved progress and side effects, an explicit resume continues the retained session, and its `wait` value blocks on that follow-up turn. Required work stays unresolved until success. See [lifecycle and persistence](docs/subagents.md#lifecycle-and-persistence) for details.
450 452
451 Worktree isolation captures a patch and changed-file list without applying it. Shared children use the host checkout. The available logical references are `subagent://<id>`, `subagent://<id>/history`, `subagent://<id>/result`, and `subagent://<id>/patch`.453 Worktree isolation captures a patch and changed-file list without applying it. Shared children use the host checkout. The available logical references are `subagent://<id>`, `subagent://<id>/history`, `subagent://<id>/result`, and `subagent://<id>/patch`.
452 454
453-**Proactive delegation.** With `/settings` → **proactive delegation** on (or `/orchestrator`), the interactive primary remains the owner and implementer of the task. It delegates only concrete sidecars that can progress while the primary performs a preselected non-overlapping task; immediate blockers and tightly coupled work stay local. An active worker owns its scope until completion, so the primary does not repeat that investigation, review, testing, or implementation. If an explicit user request or workflow hands blocking work to a child, the primary yields for the host completion update instead. The canonical resident tools remain available for user-requested delegation when the setting is off, subject to launch-time permissions. Do not poll `subagent_status` or journal files solely to wait for completion.455+**Proactive delegation.** With `/settings` → **proactive delegation** on (or `/orchestrator`), the interactive primary remains the owner and implementer of the task. It delegates only concrete sidecars that can progress while the primary performs a preselected non-overlapping task; immediate blockers and tightly coupled work stay local. An active worker owns its scope until completion, so the primary does not repeat that investigation, review, testing, or implementation. If an explicit user request or workflow hands blocking work to a child, the primary yields for the host completion update instead. The canonical resident `subagent` tool remains available for user-requested delegation when the setting is off, subject to launch-time permissions. Do not poll the status action or journal files solely to wait for completion.
454 456
455 ### Named subagent profiles457 ### Named subagent profiles
456 458
457-zut discovers the common markdown/frontmatter profile layout from `~/.agents/agents/*.md` by default and from additional paths configured through `ZUT_AGENT_PROFILES`. Whenever `subagent_spawn` is available, the primary agent receives `[subagents_list]` metadata and should select the appropriately named profile for each worker when one matches; otherwise it can spawn a clearly described general worker without an `agent` field. If launch-time policy withholds `subagent_spawn`, profile metadata is omitted. An interactive primary with proactive delegation enabled continues locally, while an explicit headless `--orchestrate` run reports that its required delegation is unavailable instead of implementing directly. Profile bodies are applied only to the selected child. See [docs/subagents.md](docs/subagents.md) for discovery, supported fields, reasoning levels, and examples.459+zut discovers the common markdown/frontmatter profile layout from `~/.agents/agents/*.md` by default and from additional paths configured through `ZUT_AGENT_PROFILES`. Whenever the `subagent` tool's `spawn` action is available, the primary agent receives `[subagents_list]` metadata and should select the appropriately named profile for each worker when one matches; otherwise it can spawn a clearly described general worker without an `agent` field. If launch-time policy withholds the `subagent` spawn action, profile metadata is omitted. An interactive primary with proactive delegation enabled continues locally, while an explicit headless `--orchestrate` run reports that its required delegation is unavailable instead of implementing directly. Profile bodies are applied only to the selected child. See [docs/subagents.md](docs/subagents.md) for discovery, supported fields, reasoning levels, and examples.
458 460
459 ### `/settings`461 ### `/settings`
460 462
M docs/subagents.md +70 -49
@@ -4,6 +4,15 @@
44 > Previous state is ignored rather than read or migrated; remove it when you
55 > no longer need it. Existing child jobs are never resumed.
66
7+> **V0.x breaking change:** the separate `subagent_spawn`, `subagent_status`,
8+> `subagent_stop`, and `subagent_resume` tools are replaced by one `subagent`
9+> tool with `spawn`, `status`, `stop`, and `resume` actions.
10+> `--tools subagent_spawn` no longer selects anything; use
11+> `--tools subagent:spawn`, or bare `--tools subagent` for every action.
12+> Under `--no-yolo`, "always allow" is remembered per action
13+> (`subagent:status` and `subagent:spawn` are separate grants), so approving a
14+> read-only `status` call never pre-approves `spawn`, `stop`, or `resume`.
15+
716 Subagents are independent `core.Agent` conversations resident in the same zut
817 process as their parent. A child has a stable, private session identity, its own
918 provider client and tool registry, a cancellation boundary, and an authoritative
@@ -60,18 +69,19 @@ subagents/<child-id>/
6069 patch.diff # optional worktree capture
6170 ```
6271
63-Acceptance is durable before `subagent_spawn` reports success. Finalized user,
64-assistant, tool-call, and tool-result messages are journaled; streamed deltas
65-and hidden reasoning are not exposed as ordinary history. A tool call is stored
66-before execution and reconciliation repairs incomplete call/result pairs. A
67-context-overflow recovery appends one `child.compacted` checkpoint carrying the
68-replacement transcript; resume starts from the newest checkpoint, while the
69-paged history view still shows everything appended before it.
72+Acceptance is durable before the `subagent` tool's `spawn` action reports
73+success. Finalized user, assistant, tool-call, and tool-result messages are
74+journaled; streamed deltas and hidden reasoning are not exposed as ordinary
75+history. A tool call is stored before execution and reconciliation repairs
76+incomplete call/result pairs. A context-overflow recovery appends one
77+`child.compacted` checkpoint carrying the replacement transcript; resume
78+starts from the newest checkpoint, while the paged history view still shows
79+everything appended before it.
7080
7181 All children stop when the host exits. On the next start, queued or running
7282 turns are marked `interrupted`; zut never replays their task. Resume only with
73-an explicit new prompt through `subagent_resume`, the child-session composer,
74-or `/subagents resume <id> <prompt>`.
83+an explicit new prompt through the `subagent` tool's `resume` action, the
84+child-session composer, or `/subagents resume <id> <prompt>`.
7585
7686 Multiple zut processes may share a state root. Each resident journal has one
7787 host owner at a time. A process that finds a child owned elsewhere reports it
@@ -82,9 +92,9 @@ leaves any ambiguous journal corruption untouched. A repair retains the
8292 pre-repair transcript as `.transcript-backup-*`; it can contain private session
8393 data and is intentionally not removed automatically.
8494
85-`subagent_status` reports `owned_elsewhere: true` when another process owns a
86-child. The field is omitted when false. A foreign-owned child cannot be resumed,
87-or have its history or result read, from this process.
95+The `subagent` tool's `status` action reports `owned_elsewhere: true` when
96+another process owns a child. The field is omitted when false. A foreign-owned
97+child cannot be resumed, or have its history or result read, from this process.
8898
8999 The global scheduler admits the oldest eligible accepted prompt, runs at most
90100 one turn for each child, and defaults to six concurrent turns. Set
@@ -108,8 +118,8 @@ Compatibility notes: new journals write version 3 and carry no budget
108118 fields, baselines, or exhaustion states. Version 2 archives are read with
109119 explicit translation and their transcripts are never rewritten by
110120 translation. `result.json` keeps a deprecated `handoff` field that stays
111-empty for new results and remains readable for archives. `subagent_status`
112-no longer exposes `budget` or `budget_source` fields.
121+empty for new results and remains readable for archives. The `subagent` tool's
122+`status` action no longer exposes `budget` or `budget_source` fields.
113123
114124 ### Context reminders
115125
@@ -160,14 +170,14 @@ guarantee.
160170 Retrieve the saved result without executing a model:
161171
162172 ```json
163-{"agent_id":"<child-id>","include_result":true}
173+{"action":"status","agent_id":"<child-id>","include_result":true}
164174 ```
165175
166-Pass this to `subagent_status`, then reconcile the history and artifacts before
167-repeating any side effects. Explicit `subagent_resume` after a terminal failure
168-continues the retained session, workspace, and cumulative cost. It does not
169-automatically retry the task. Ordinary follow-ups continue the existing
170-session.
176+Pass this to the `subagent` tool's `status` action, then reconcile the history
177+and artifacts before repeating any side effects. The `subagent` tool's explicit
178+`resume` action after a terminal failure continues the retained session,
179+workspace, and cumulative cost. It does not automatically retry the task.
180+Ordinary follow-ups continue the existing session.
171181
172182 `required: true` makes a delegated result an obligation of the parent turn.
173183 Failed, cancelled, and interrupted required work remains
@@ -200,32 +210,41 @@ required capability.
200210
201211 ## Tools and slash commands
202212
203-The model-facing tools retain their logical names:
204-
205-- `subagent_spawn` accepts `task`, optional `agent`, `model` and `provider`,
206- `reasoning`, `fast_mode`, `required`, `wait`, and `isolation` (`shared` or
207- `worktree`).
208- `wait` is an explicit whole-second value from 1 through 300; when omitted,
209- spawning returns immediately. A timed-out wait leaves the
210- accepted child active, whether it is queued or running. Do not retry that
211- task until it reaches a terminal failure, cancellation, or interruption. It
212- returns a logical `subagent://<id>` reference.
213-- `subagent_status` returns bounded current state for one child or the current
214- set immediately. With `agent_id` and `include_result: true`, it also reads the
215- saved terminal result without model execution. Without this
216- option it remains metadata-only; foreign-owned results cannot be read.
217- Result-read errors distinguish foreign ownership, a missing saved result,
218- and permission denial without exposing filesystem paths or saved content.
219-- `subagent_stop` stops one live child.
220-- `subagent_resume` accepts an explicit follow-up prompt for an existing child.
221- After a terminal failure, inspect the saved result before resuming; resume
222- continues the retained session.
223-
224-Child execution started by `subagent_spawn` is asynchronous unless it receives
225-an explicit `wait` value. For an unwaited spawn, completion arrives through the
226-host’s typed completion update; `subagent_status` returns immediately and does
227-not wait for completion. Do not use sleep loops, repeated status calls, journal
228-files, or terminal UI inspection as a completion signal.
213+The model-facing tool keeps one logical name and four actions, selected by its
214+`action` argument. Launch-time gating is per action: `--tools subagent` grants
215+every action, `--tools subagent:<action>` grants one, and `--no-tools` disables
216+all of them.
217+
218+- `subagent` accepts an `action` of `spawn`, `status`, `stop`, or `resume`.
219+ - `spawn` accepts `task`, optional `agent`, `model` and `provider`,
220+ `reasoning`, `fast_mode`, `required`, `wait`, and `isolation` (`shared` or
221+ `worktree`).
222+ `wait` is an explicit whole-second value from 1 through 300; when omitted,
223+ spawning returns immediately. A timed-out wait leaves the
224+ accepted child active, whether it is queued or running. Do not retry that
225+ task until it reaches a terminal failure, cancellation, or interruption. It
226+ returns a logical `subagent://<id>` reference.
227+ - `status` accepts optional `agent_id` and `include_result`, and returns
228+ bounded current state for one child or the current set immediately. With
229+ `agent_id` and `include_result: true`, it also reads the saved terminal
230+ result without model execution. Without this option it remains
231+ metadata-only; foreign-owned results cannot be read. Result-read errors
232+ distinguish foreign ownership, a missing saved result, and permission
233+ denial without exposing filesystem paths or saved content.
234+ - `stop` accepts `agent_id` and stops one live child.
235+ - `resume` accepts `agent_id`, `prompt`, and optional `wait`. `prompt` is an
236+ explicit follow-up prompt for an existing child. After a terminal failure,
237+ inspect the saved result before resuming; resume continues the retained
238+ session. `wait` uses the same 1–300 second bound as `spawn` and waits for
239+ the accepted follow-up turn, returning its outcome or reporting the timeout
240+ while the child stays active.
241+
242+Child execution started by the `subagent` tool's `spawn` or `resume` action is
243+asynchronous unless it receives an explicit `wait` value. For an unwaited call,
244+completion arrives through the host’s typed completion update; the `subagent`
245+tool's `status` action returns immediately and does not wait for completion. Do
246+not use sleep loops, repeated status calls, journal files, or terminal UI
247+inspection as a completion signal.
229248 In interactive mode, a result received while the primary is busy enters the
230249 visible **sliding in** queue and reaches the model at its next safe boundary,
231250 without interrupting a model request or tool call and without waiting for other
@@ -233,7 +252,8 @@ children. Results already queued are not repeated when the parent turn ends.
233252 Undelivered results remain queued after a provider or compaction error for the
234253 next turn; they do not automatically retry the failed request. Explicit
235254 cancellation clears pending queue entries. The queue is in memory, while the
236-child’s saved result remains available through `subagent_status`.
255+child’s saved result remains available through the `subagent` tool's `status`
256+action.
237257 When the primary is idle, each child completion immediately starts the next
238258 parent turn instead of waiting for its siblings. The host update names the
239259 finished child and lists sibling agent IDs that are still running. A completion
@@ -269,8 +289,9 @@ list. zut never applies a captured patch automatically. Failed or interrupted
269289 worktrees are retained for inspection; an idle successful worktree is cleaned
270290 when its child is explicitly stopped.
271291
272-Use `subagent_status` with `agent_id` and `include_result: true` to retrieve the
273-saved result, `changed_files`, and `patch_ref` when a patch was captured. The
292+Use the `subagent` tool's `status` action with `agent_id` and
293+`include_result: true` to retrieve the saved result, `changed_files`, and
294+`patch_ref` when a patch was captured. The
274295 patch bytes are stored in `subagents/<child-id>/patch.diff` under the managed
275296 state root shown above; logical references are identifiers, not filesystem
276297 paths. Inspect the retained worktree and patch before resuming or applying
@@ -4,6 +4,15 @@
4 > Previous state is ignored rather than read or migrated; remove it when you4 > Previous state is ignored rather than read or migrated; remove it when you
5 > no longer need it. Existing child jobs are never resumed.5 > no longer need it. Existing child jobs are never resumed.
6 6
7+> **V0.x breaking change:** the separate `subagent_spawn`, `subagent_status`,
8+> `subagent_stop`, and `subagent_resume` tools are replaced by one `subagent`
9+> tool with `spawn`, `status`, `stop`, and `resume` actions.
10+> `--tools subagent_spawn` no longer selects anything; use
11+> `--tools subagent:spawn`, or bare `--tools subagent` for every action.
12+> Under `--no-yolo`, "always allow" is remembered per action
13+> (`subagent:status` and `subagent:spawn` are separate grants), so approving a
14+> read-only `status` call never pre-approves `spawn`, `stop`, or `resume`.
15+
7 Subagents are independent `core.Agent` conversations resident in the same zut16 Subagents are independent `core.Agent` conversations resident in the same zut
8 process as their parent. A child has a stable, private session identity, its own17 process as their parent. A child has a stable, private session identity, its own
9 provider client and tool registry, a cancellation boundary, and an authoritative18 provider client and tool registry, a cancellation boundary, and an authoritative
@@ -60,18 +69,19 @@ subagents/<child-id>/
60 patch.diff # optional worktree capture69 patch.diff # optional worktree capture
61 ```70 ```
62 71
63-Acceptance is durable before `subagent_spawn` reports success. Finalized user,72+Acceptance is durable before the `subagent` tool's `spawn` action reports
64-assistant, tool-call, and tool-result messages are journaled; streamed deltas73+success. Finalized user, assistant, tool-call, and tool-result messages are
65-and hidden reasoning are not exposed as ordinary history. A tool call is stored74+journaled; streamed deltas and hidden reasoning are not exposed as ordinary
66-before execution and reconciliation repairs incomplete call/result pairs. A75+history. A tool call is stored before execution and reconciliation repairs
67-context-overflow recovery appends one `child.compacted` checkpoint carrying the76+incomplete call/result pairs. A context-overflow recovery appends one
68-replacement transcript; resume starts from the newest checkpoint, while the77+`child.compacted` checkpoint carrying the replacement transcript; resume
69-paged history view still shows everything appended before it.78+starts from the newest checkpoint, while the paged history view still shows
79+everything appended before it.
70 80
71 All children stop when the host exits. On the next start, queued or running81 All children stop when the host exits. On the next start, queued or running
72 turns are marked `interrupted`; zut never replays their task. Resume only with82 turns are marked `interrupted`; zut never replays their task. Resume only with
73-an explicit new prompt through `subagent_resume`, the child-session composer,83+an explicit new prompt through the `subagent` tool's `resume` action, the
74-or `/subagents resume <id> <prompt>`.84+child-session composer, or `/subagents resume <id> <prompt>`.
75 85
76 Multiple zut processes may share a state root. Each resident journal has one86 Multiple zut processes may share a state root. Each resident journal has one
77 host owner at a time. A process that finds a child owned elsewhere reports it87 host owner at a time. A process that finds a child owned elsewhere reports it
@@ -82,9 +92,9 @@ leaves any ambiguous journal corruption untouched. A repair retains the
82 pre-repair transcript as `.transcript-backup-*`; it can contain private session92 pre-repair transcript as `.transcript-backup-*`; it can contain private session
83 data and is intentionally not removed automatically.93 data and is intentionally not removed automatically.
84 94
85-`subagent_status` reports `owned_elsewhere: true` when another process owns a95+The `subagent` tool's `status` action reports `owned_elsewhere: true` when
86-child. The field is omitted when false. A foreign-owned child cannot be resumed,96+another process owns a child. The field is omitted when false. A foreign-owned
87-or have its history or result read, from this process.97+child cannot be resumed, or have its history or result read, from this process.
88 98
89 The global scheduler admits the oldest eligible accepted prompt, runs at most99 The global scheduler admits the oldest eligible accepted prompt, runs at most
90 one turn for each child, and defaults to six concurrent turns. Set100 one turn for each child, and defaults to six concurrent turns. Set
@@ -108,8 +118,8 @@ Compatibility notes: new journals write version 3 and carry no budget
108 fields, baselines, or exhaustion states. Version 2 archives are read with118 fields, baselines, or exhaustion states. Version 2 archives are read with
109 explicit translation and their transcripts are never rewritten by119 explicit translation and their transcripts are never rewritten by
110 translation. `result.json` keeps a deprecated `handoff` field that stays120 translation. `result.json` keeps a deprecated `handoff` field that stays
111-empty for new results and remains readable for archives. `subagent_status`121+empty for new results and remains readable for archives. The `subagent` tool's
112-no longer exposes `budget` or `budget_source` fields.122+`status` action no longer exposes `budget` or `budget_source` fields.
113 123
114 ### Context reminders124 ### Context reminders
115 125
@@ -160,14 +170,14 @@ guarantee.
160 Retrieve the saved result without executing a model:170 Retrieve the saved result without executing a model:
161 171
162 ```json172 ```json
163-{"agent_id":"<child-id>","include_result":true}173+{"action":"status","agent_id":"<child-id>","include_result":true}
164 ```174 ```
165 175
166-Pass this to `subagent_status`, then reconcile the history and artifacts before176+Pass this to the `subagent` tool's `status` action, then reconcile the history
167-repeating any side effects. Explicit `subagent_resume` after a terminal failure177+and artifacts before repeating any side effects. The `subagent` tool's explicit
168-continues the retained session, workspace, and cumulative cost. It does not178+`resume` action after a terminal failure continues the retained session,
169-automatically retry the task. Ordinary follow-ups continue the existing179+workspace, and cumulative cost. It does not automatically retry the task.
170-session.180+Ordinary follow-ups continue the existing session.
171 181
172 `required: true` makes a delegated result an obligation of the parent turn.182 `required: true` makes a delegated result an obligation of the parent turn.
173 Failed, cancelled, and interrupted required work remains183 Failed, cancelled, and interrupted required work remains
@@ -200,32 +210,41 @@ required capability.
200 210
201 ## Tools and slash commands211 ## Tools and slash commands
202 212
203-The model-facing tools retain their logical names:213+The model-facing tool keeps one logical name and four actions, selected by its
204-214+`action` argument. Launch-time gating is per action: `--tools subagent` grants
205-- `subagent_spawn` accepts `task`, optional `agent`, `model` and `provider`,215+every action, `--tools subagent:<action>` grants one, and `--no-tools` disables
206- `reasoning`, `fast_mode`, `required`, `wait`, and `isolation` (`shared` or216+all of them.
207- `worktree`).217+
208- `wait` is an explicit whole-second value from 1 through 300; when omitted,218+- `subagent` accepts an `action` of `spawn`, `status`, `stop`, or `resume`.
209- spawning returns immediately. A timed-out wait leaves the219+ - `spawn` accepts `task`, optional `agent`, `model` and `provider`,
210- accepted child active, whether it is queued or running. Do not retry that220+ `reasoning`, `fast_mode`, `required`, `wait`, and `isolation` (`shared` or
211- task until it reaches a terminal failure, cancellation, or interruption. It221+ `worktree`).
212- returns a logical `subagent://<id>` reference.222+ `wait` is an explicit whole-second value from 1 through 300; when omitted,
213-- `subagent_status` returns bounded current state for one child or the current223+ spawning returns immediately. A timed-out wait leaves the
214- set immediately. With `agent_id` and `include_result: true`, it also reads the224+ accepted child active, whether it is queued or running. Do not retry that
215- saved terminal result without model execution. Without this225+ task until it reaches a terminal failure, cancellation, or interruption. It
216- option it remains metadata-only; foreign-owned results cannot be read.226+ returns a logical `subagent://<id>` reference.
217- Result-read errors distinguish foreign ownership, a missing saved result,227+ - `status` accepts optional `agent_id` and `include_result`, and returns
218- and permission denial without exposing filesystem paths or saved content.228+ bounded current state for one child or the current set immediately. With
219-- `subagent_stop` stops one live child.229+ `agent_id` and `include_result: true`, it also reads the saved terminal
220-- `subagent_resume` accepts an explicit follow-up prompt for an existing child.230+ result without model execution. Without this option it remains
221- After a terminal failure, inspect the saved result before resuming; resume231+ metadata-only; foreign-owned results cannot be read. Result-read errors
222- continues the retained session.232+ distinguish foreign ownership, a missing saved result, and permission
223-233+ denial without exposing filesystem paths or saved content.
224-Child execution started by `subagent_spawn` is asynchronous unless it receives234+ - `stop` accepts `agent_id` and stops one live child.
225-an explicit `wait` value. For an unwaited spawn, completion arrives through the235+ - `resume` accepts `agent_id`, `prompt`, and optional `wait`. `prompt` is an
226-host’s typed completion update; `subagent_status` returns immediately and does236+ explicit follow-up prompt for an existing child. After a terminal failure,
227-not wait for completion. Do not use sleep loops, repeated status calls, journal237+ inspect the saved result before resuming; resume continues the retained
228-files, or terminal UI inspection as a completion signal.238+ session. `wait` uses the same 1–300 second bound as `spawn` and waits for
239+ the accepted follow-up turn, returning its outcome or reporting the timeout
240+ while the child stays active.
241+
242+Child execution started by the `subagent` tool's `spawn` or `resume` action is
243+asynchronous unless it receives an explicit `wait` value. For an unwaited call,
244+completion arrives through the host’s typed completion update; the `subagent`
245+tool's `status` action returns immediately and does not wait for completion. Do
246+not use sleep loops, repeated status calls, journal files, or terminal UI
247+inspection as a completion signal.
229 In interactive mode, a result received while the primary is busy enters the248 In interactive mode, a result received while the primary is busy enters the
230 visible **sliding in** queue and reaches the model at its next safe boundary,249 visible **sliding in** queue and reaches the model at its next safe boundary,
231 without interrupting a model request or tool call and without waiting for other250 without interrupting a model request or tool call and without waiting for other
@@ -233,7 +252,8 @@ children. Results already queued are not repeated when the parent turn ends.
233 Undelivered results remain queued after a provider or compaction error for the252 Undelivered results remain queued after a provider or compaction error for the
234 next turn; they do not automatically retry the failed request. Explicit253 next turn; they do not automatically retry the failed request. Explicit
235 cancellation clears pending queue entries. The queue is in memory, while the254 cancellation clears pending queue entries. The queue is in memory, while the
236-child’s saved result remains available through `subagent_status`.255+child’s saved result remains available through the `subagent` tool's `status`
256+action.
237 When the primary is idle, each child completion immediately starts the next257 When the primary is idle, each child completion immediately starts the next
238 parent turn instead of waiting for its siblings. The host update names the258 parent turn instead of waiting for its siblings. The host update names the
239 finished child and lists sibling agent IDs that are still running. A completion259 finished child and lists sibling agent IDs that are still running. A completion
@@ -269,8 +289,9 @@ list. zut never applies a captured patch automatically. Failed or interrupted
269 worktrees are retained for inspection; an idle successful worktree is cleaned289 worktrees are retained for inspection; an idle successful worktree is cleaned
270 when its child is explicitly stopped.290 when its child is explicitly stopped.
271 291
272-Use `subagent_status` with `agent_id` and `include_result: true` to retrieve the292+Use the `subagent` tool's `status` action with `agent_id` and
273-saved result, `changed_files`, and `patch_ref` when a patch was captured. The293+`include_result: true` to retrieve the saved result, `changed_files`, and
294+`patch_ref` when a patch was captured. The
274 patch bytes are stored in `subagents/<child-id>/patch.diff` under the managed295 patch bytes are stored in `subagents/<child-id>/patch.diff` under the managed
275 state root shown above; logical references are identifiers, not filesystem296 state root shown above; logical references are identifiers, not filesystem
276 paths. Inspect the retained worktree and patch before resuming or applying297 paths. Inspect the retained worktree and patch before resuming or applying
M packages/agent/args.go +1 -1
@@ -469,7 +469,7 @@ func PrintHelp(version string) {
469469 row{"--cwd PATH", "treat PATH as the working directory"},
470470 row{"--no-tools", "disable all tools"},
471471 row{"--no-lsp", "disable the built-in LSP/linter tool"},
472- row{"--tools csv", "only enable listed tools (include lsp and web_search explicitly; web_search enables public-web navigation)"},
472+ row{"--tools csv", "only enable listed tools (include lsp and web_search explicitly; web_search enables public-web navigation; the subagent tool accepts subagent:<action>)"},
473473 row{"--no-yolo", "ask before running every tool call"},
474474 row{"-y, --yes", "accept zut run consent without prompting"},
475475 row{"--no-ext", "skip extension discovery for this run"},
@@ -469,7 +469,7 @@ func PrintHelp(version string) {
469 row{"--cwd PATH", "treat PATH as the working directory"},469 row{"--cwd PATH", "treat PATH as the working directory"},
470 row{"--no-tools", "disable all tools"},470 row{"--no-tools", "disable all tools"},
471 row{"--no-lsp", "disable the built-in LSP/linter tool"},471 row{"--no-lsp", "disable the built-in LSP/linter tool"},
472- row{"--tools csv", "only enable listed tools (include lsp and web_search explicitly; web_search enables public-web navigation)"},472+ row{"--tools csv", "only enable listed tools (include lsp and web_search explicitly; web_search enables public-web navigation; the subagent tool accepts subagent:<action>)"},
473 row{"--no-yolo", "ask before running every tool call"},473 row{"--no-yolo", "ask before running every tool call"},
474 row{"-y, --yes", "accept zut run consent without prompting"},474 row{"-y, --yes", "accept zut run consent without prompting"},
475 row{"--no-ext", "skip extension discovery for this run"},475 row{"--no-ext", "skip extension discovery for this run"},
M packages/agent/build.go +10 -6
@@ -1476,7 +1476,7 @@ func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) boo
14761476 }
14771477
14781478 func autoSubagentsToolAllowed(args Args) bool {
1479- return autoSubagentsToolAllowedFor(args, "subagent_spawn")
1479+ return subagentActionAllowed(args, tools.SubagentActionSpawn)
14801480 }
14811481
14821482 // orchestratorExplorationToolsAllowed keeps the primary orchestrator's
@@ -1486,15 +1486,15 @@ func orchestratorExplorationToolsAllowed(args Args) bool {
14861486 }
14871487
14881488 func autoSubagentsStatusToolAllowed(args Args) bool {
1489- return autoSubagentsToolAllowedFor(args, "subagent_status")
1489+ return subagentActionAllowed(args, tools.SubagentActionStatus)
14901490 }
14911491
14921492 func autoSubagentsStopToolAllowed(args Args) bool {
1493- return autoSubagentsToolAllowedFor(args, "subagent_stop")
1493+ return subagentActionAllowed(args, tools.SubagentActionStop)
14941494 }
14951495
14961496 func autoSubagentsResumeToolAllowed(args Args) bool {
1497- return autoSubagentsToolAllowedFor(args, "subagent_resume")
1497+ return subagentActionAllowed(args, tools.SubagentActionResume)
14981498 }
14991499
15001500 func autoSubagentsAnyToolAllowed(args Args) bool {
@@ -1504,7 +1504,11 @@ func autoSubagentsAnyToolAllowed(args Args) bool {
15041504 autoSubagentsResumeToolAllowed(args)
15051505 }
15061506
1507-func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
1507+// subagentActionAllowed reports whether the launch-time tool policy grants one
1508+// subagent action. A bare "subagent" entry grants every action; a
1509+// "subagent:<action>" entry grants that action alone. Unrecognized entries are
1510+// ignored, matching how every other --tools name is treated.
1511+func subagentActionAllowed(args Args, action string) bool {
15081512 if args.NoTools || args.PermissionSet != nil {
15091513 return false
15101514 }
@@ -1515,7 +1519,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
15151519 return true
15161520 }
15171521 for _, name := range args.Tools {
1518- if name == toolName {
1522+ if name == tools.SubagentToolName || name == tools.SubagentToolName+":"+action {
15191523 return true
15201524 }
15211525 }
@@ -1476,7 +1476,7 @@ func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) boo
1476 }1476 }
1477 1477
1478 func autoSubagentsToolAllowed(args Args) bool {1478 func autoSubagentsToolAllowed(args Args) bool {
1479- return autoSubagentsToolAllowedFor(args, "subagent_spawn")1479+ return subagentActionAllowed(args, tools.SubagentActionSpawn)
1480 }1480 }
1481 1481
1482 // orchestratorExplorationToolsAllowed keeps the primary orchestrator's1482 // orchestratorExplorationToolsAllowed keeps the primary orchestrator's
@@ -1486,15 +1486,15 @@ func orchestratorExplorationToolsAllowed(args Args) bool {
1486 }1486 }
1487 1487
1488 func autoSubagentsStatusToolAllowed(args Args) bool {1488 func autoSubagentsStatusToolAllowed(args Args) bool {
1489- return autoSubagentsToolAllowedFor(args, "subagent_status")1489+ return subagentActionAllowed(args, tools.SubagentActionStatus)
1490 }1490 }
1491 1491
1492 func autoSubagentsStopToolAllowed(args Args) bool {1492 func autoSubagentsStopToolAllowed(args Args) bool {
1493- return autoSubagentsToolAllowedFor(args, "subagent_stop")1493+ return subagentActionAllowed(args, tools.SubagentActionStop)
1494 }1494 }
1495 1495
1496 func autoSubagentsResumeToolAllowed(args Args) bool {1496 func autoSubagentsResumeToolAllowed(args Args) bool {
1497- return autoSubagentsToolAllowedFor(args, "subagent_resume")1497+ return subagentActionAllowed(args, tools.SubagentActionResume)
1498 }1498 }
1499 1499
1500 func autoSubagentsAnyToolAllowed(args Args) bool {1500 func autoSubagentsAnyToolAllowed(args Args) bool {
@@ -1504,7 +1504,11 @@ func autoSubagentsAnyToolAllowed(args Args) bool {
1504 autoSubagentsResumeToolAllowed(args)1504 autoSubagentsResumeToolAllowed(args)
1505 }1505 }
1506 1506
1507-func autoSubagentsToolAllowedFor(args Args, toolName string) bool {1507+// subagentActionAllowed reports whether the launch-time tool policy grants one
1508+// subagent action. A bare "subagent" entry grants every action; a
1509+// "subagent:<action>" entry grants that action alone. Unrecognized entries are
1510+// ignored, matching how every other --tools name is treated.
1511+func subagentActionAllowed(args Args, action string) bool {
1508 if args.NoTools || args.PermissionSet != nil {1512 if args.NoTools || args.PermissionSet != nil {
1509 return false1513 return false
1510 }1514 }
@@ -1515,7 +1519,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
1515 return true1519 return true
1516 }1520 }
1517 for _, name := range args.Tools {1521 for _, name := range args.Tools {
1518- if name == toolName {1522+ if name == tools.SubagentToolName || name == tools.SubagentToolName+":"+action {
1519 return true1523 return true
1520 }1524 }
1521 }1525 }
M packages/agent/build_orchestrator_test.go +1 -1
@@ -79,7 +79,7 @@ func TestOrchestratorAlwaysReceivesResearchTools(t *testing.T) {
7979 }{
8080 {
8181 name: "requested allowlist",
82- args: Args{Orchestrate: true, ToolsSet: true, Tools: []string{"subagent_spawn"}},
82+ args: Args{Orchestrate: true, ToolsSet: true, Tools: []string{"subagent:spawn"}},
8383 wantResearch: true,
8484 wantWeb: true,
8585 wantWriteTool: false,
@@ -79,7 +79,7 @@ func TestOrchestratorAlwaysReceivesResearchTools(t *testing.T) {
79 }{79 }{
80 {80 {
81 name: "requested allowlist",81 name: "requested allowlist",
82- args: Args{Orchestrate: true, ToolsSet: true, Tools: []string{"subagent_spawn"}},82+ args: Args{Orchestrate: true, ToolsSet: true, Tools: []string{"subagent:spawn"}},
83 wantResearch: true,83 wantResearch: true,
84 wantWeb: true,84 wantWeb: true,
85 wantWriteTool: false,85 wantWriteTool: false,
M packages/agent/cli.go +21 -1
@@ -1400,7 +1400,7 @@ var errInteractiveAgentChanged = errors.New("interactive agent or web-search pol
14001400 // skills and currently loaded extension tools) and updates the live
14011401 // agent's registry and system prompt. Used after /reload-ext and after
14021402 // zutfile entry.pre installs new skills or extensions.
1403-// mutateRegistry, if non-nil, can inject session-specific tools (e.g. subagent_spawn).
1403+// mutateRegistry, if non-nil, can inject session-specific tools (e.g. the subagent tool).
14041404 // interactive, when non-nil, serializes the final commit with agent replacement.
14051405 func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extToolAdapter ExtensionToolSource, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry, interactive *modes.Interactive) (subagents.WebSearchPolicy, error) {
14061406 if ag == nil {
@@ -1689,7 +1689,12 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
16891689 }
16901690 if confirmGate != nil {
16911691 var content strings.Builder
1692+ // The remembered "always allow" grant is keyed by tool name,
1693+ // or a finer per-action key when the tool asks for one (the
1694+ // subagent facade). Compute it from the effective args so a
1695+ // grant matches the call that will actually run.
16921696 _, currentTools := a.PromptConfig()
1697+ confirmationKey := confirmationKeyFor(currentTools, call.Name, effectiveArgs)
16931698 if tool, err := currentTools.Get(call.Name); err == nil {
16941699 if previewer, ok := tool.(core.ToolPreviewer); ok {
16951700 preview, err := previewer.Preview(ctx, effectiveArgs)
@@ -1709,6 +1714,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
17091714 ok, reason, _ := confirmGate.CheckToolCall(core.ToolCallConfirmation{
17101715 ID: call.ID,
17111716 Name: call.Name,
1717+ Key: confirmationKey,
17121718 Summary: core.BuildPreview(effectiveArgs, 120),
17131719 Content: content.String(),
17141720 Origin: call.Origin,
@@ -3286,3 +3292,17 @@ func printModels() {
32863292 m.DisplayName)
32873293 }
32883294 }
3295+
3296+// confirmationKeyFor returns the session-scoped confirmation grant key for a
3297+// tool call. A tool that implements core.ConfirmationKeyer may expose a finer
3298+// key than its name (the subagent facade keys one grant per action); every
3299+// other tool, and any unknown tool, keeps its own name. It mirrors the
3300+// confirmation gate's lookup so a remembered grant matches the call that will
3301+// actually run.
3302+func confirmationKeyFor(reg core.Registry, name string, args json.RawMessage) string {
3303+ tool, err := reg.Get(name)
3304+ if err != nil {
3305+ return name
3306+ }
3307+ return core.ConfirmationKey(tool, name, args)
3308+}
@@ -1400,7 +1400,7 @@ var errInteractiveAgentChanged = errors.New("interactive agent or web-search pol
1400 // skills and currently loaded extension tools) and updates the live1400 // skills and currently loaded extension tools) and updates the live
1401 // agent's registry and system prompt. Used after /reload-ext and after1401 // agent's registry and system prompt. Used after /reload-ext and after
1402 // zutfile entry.pre installs new skills or extensions.1402 // zutfile entry.pre installs new skills or extensions.
1403-// mutateRegistry, if non-nil, can inject session-specific tools (e.g. subagent_spawn).1403+// mutateRegistry, if non-nil, can inject session-specific tools (e.g. the subagent tool).
1404 // interactive, when non-nil, serializes the final commit with agent replacement.1404 // interactive, when non-nil, serializes the final commit with agent replacement.
1405 func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extToolAdapter ExtensionToolSource, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry, interactive *modes.Interactive) (subagents.WebSearchPolicy, error) {1405 func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extToolAdapter ExtensionToolSource, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry, interactive *modes.Interactive) (subagents.WebSearchPolicy, error) {
1406 if ag == nil {1406 if ag == nil {
@@ -1689,7 +1689,12 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1689 }1689 }
1690 if confirmGate != nil {1690 if confirmGate != nil {
1691 var content strings.Builder1691 var content strings.Builder
1692+ // The remembered "always allow" grant is keyed by tool name,
1693+ // or a finer per-action key when the tool asks for one (the
1694+ // subagent facade). Compute it from the effective args so a
1695+ // grant matches the call that will actually run.
1692 _, currentTools := a.PromptConfig()1696 _, currentTools := a.PromptConfig()
1697+ confirmationKey := confirmationKeyFor(currentTools, call.Name, effectiveArgs)
1693 if tool, err := currentTools.Get(call.Name); err == nil {1698 if tool, err := currentTools.Get(call.Name); err == nil {
1694 if previewer, ok := tool.(core.ToolPreviewer); ok {1699 if previewer, ok := tool.(core.ToolPreviewer); ok {
1695 preview, err := previewer.Preview(ctx, effectiveArgs)1700 preview, err := previewer.Preview(ctx, effectiveArgs)
@@ -1709,6 +1714,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1709 ok, reason, _ := confirmGate.CheckToolCall(core.ToolCallConfirmation{1714 ok, reason, _ := confirmGate.CheckToolCall(core.ToolCallConfirmation{
1710 ID: call.ID,1715 ID: call.ID,
1711 Name: call.Name,1716 Name: call.Name,
1717+ Key: confirmationKey,
1712 Summary: core.BuildPreview(effectiveArgs, 120),1718 Summary: core.BuildPreview(effectiveArgs, 120),
1713 Content: content.String(),1719 Content: content.String(),
1714 Origin: call.Origin,1720 Origin: call.Origin,
@@ -3286,3 +3292,17 @@ func printModels() {
3286 m.DisplayName)3292 m.DisplayName)
3287 }3293 }
3288 }3294 }
3295+
3296+// confirmationKeyFor returns the session-scoped confirmation grant key for a
3297+// tool call. A tool that implements core.ConfirmationKeyer may expose a finer
3298+// key than its name (the subagent facade keys one grant per action); every
3299+// other tool, and any unknown tool, keeps its own name. It mirrors the
3300+// confirmation gate's lookup so a remembered grant matches the call that will
3301+// actually run.
3302+func confirmationKeyFor(reg core.Registry, name string, args json.RawMessage) string {
3303+ tool, err := reg.Get(name)
3304+ if err != nil {
3305+ return name
3306+ }
3307+ return core.ConfirmationKey(tool, name, args)
3308+}
M packages/agent/cli_session_test.go +2 -2
@@ -894,8 +894,8 @@ func TestApplyInitialSessionResumeWithRuntimePreservesInheritedModelDefaults(t *
894894 if got := runtime.currentModel(); got != "stored-local-model" {
895895 t.Fatalf("runtime inherited model = %q, want stored-local-model", got)
896896 }
897- if _, ok := gotAgent.ToolsSnapshot()["subagent_spawn"]; !ok {
898- t.Fatal("resumed parent lost canonical subagent_spawn manager tool")
897+ if _, ok := gotAgent.ToolsSnapshot()["subagent"]; !ok {
898+ t.Fatal("resumed parent lost the canonical subagent manager tool")
899899 }
900900 }
901901
@@ -894,8 +894,8 @@ func TestApplyInitialSessionResumeWithRuntimePreservesInheritedModelDefaults(t *
894 if got := runtime.currentModel(); got != "stored-local-model" {894 if got := runtime.currentModel(); got != "stored-local-model" {
895 t.Fatalf("runtime inherited model = %q, want stored-local-model", got)895 t.Fatalf("runtime inherited model = %q, want stored-local-model", got)
896 }896 }
897- if _, ok := gotAgent.ToolsSnapshot()["subagent_spawn"]; !ok {897+ if _, ok := gotAgent.ToolsSnapshot()["subagent"]; !ok {
898- t.Fatal("resumed parent lost canonical subagent_spawn manager tool")898+ t.Fatal("resumed parent lost the canonical subagent manager tool")
899 }899 }
900 }900 }
901 901
A packages/agent/confirmation_key_test.go +37 -0
@@ -0,0 +1,37 @@
1+package agent
2+
3+import (
4+ "encoding/json"
5+ "testing"
6+
7+ "github.com/bnema/zut/packages/agent/tools"
8+ "github.com/bnema/zut/packages/core"
9+)
10+
11+// TestConfirmationKeyFor pins the confirmation grant key resolved at the tool
12+// call site: a multiplexed tool keys per action, a plain built-in keeps its
13+// name, and an unknown tool falls back to its raw name.
14+func TestConfirmationKeyFor(t *testing.T) {
15+ reg := core.Registry{
16+ tools.SubagentToolName: &tools.SubagentTool{},
17+ "read": &tools.GrepTool{},
18+ }
19+ cases := []struct {
20+ name string
21+ tool string
22+ args string
23+ want string
24+ }{
25+ {name: "facade action", tool: tools.SubagentToolName, args: `{"action":"spawn","task":"t"}`, want: "subagent:spawn"},
26+ {name: "facade plain action", tool: tools.SubagentToolName, args: `{"action":"resume","agent_id":"x","prompt":"p"}`, want: "subagent:resume"},
27+ {name: "built-in tool", tool: "read", args: `{"pattern":"x"}`, want: "read"},
28+ {name: "unknown tool", tool: "missing", args: `{}`, want: "missing"},
29+ }
30+ for _, tc := range cases {
31+ t.Run(tc.name, func(t *testing.T) {
32+ if got := confirmationKeyFor(reg, tc.tool, json.RawMessage(tc.args)); got != tc.want {
33+ t.Fatalf("confirmationKeyFor(%q, %s) = %q, want %q", tc.tool, tc.args, got, tc.want)
34+ }
35+ })
36+ }
37+}
@@ -0,0 +1,37 @@
1+package agent
2+
3+import (
4+ "encoding/json"
5+ "testing"
6+
7+ "github.com/bnema/zut/packages/agent/tools"
8+ "github.com/bnema/zut/packages/core"
9+)
10+
11+// TestConfirmationKeyFor pins the confirmation grant key resolved at the tool
12+// call site: a multiplexed tool keys per action, a plain built-in keeps its
13+// name, and an unknown tool falls back to its raw name.
14+func TestConfirmationKeyFor(t *testing.T) {
15+ reg := core.Registry{
16+ tools.SubagentToolName: &tools.SubagentTool{},
17+ "read": &tools.GrepTool{},
18+ }
19+ cases := []struct {
20+ name string
21+ tool string
22+ args string
23+ want string
24+ }{
25+ {name: "facade action", tool: tools.SubagentToolName, args: `{"action":"spawn","task":"t"}`, want: "subagent:spawn"},
26+ {name: "facade plain action", tool: tools.SubagentToolName, args: `{"action":"resume","agent_id":"x","prompt":"p"}`, want: "subagent:resume"},
27+ {name: "built-in tool", tool: "read", args: `{"pattern":"x"}`, want: "read"},
28+ {name: "unknown tool", tool: "missing", args: `{}`, want: "missing"},
29+ }
30+ for _, tc := range cases {
31+ t.Run(tc.name, func(t *testing.T) {
32+ if got := confirmationKeyFor(reg, tc.tool, json.RawMessage(tc.args)); got != tc.want {
33+ t.Fatalf("confirmationKeyFor(%q, %s) = %q, want %q", tc.tool, tc.args, got, tc.want)
34+ }
35+ })
36+ }
37+}
M packages/agent/modes/interactive_resident_subagents.go +8 -10
@@ -452,7 +452,7 @@ func (i *Interactive) autoSubagentsUnavailableHint() string {
452452 hints = append(hints, "resident subagent manager is unavailable in this mode")
453453 }
454454 if !autoSubagentsAnyToolAllowedConfig(i.cfg) {
455- hints = append(hints, "launch-time tool policy excludes subagent manager tools")
455+ hints = append(hints, "launch-time tool policy excludes every subagent action")
456456 }
457457 return strings.Join(hints, "; ")
458458 }
@@ -511,14 +511,15 @@ func (i *Interactive) applyAutoSubagentsTool() {
511511 current := i.agent.ToolsSnapshot()
512512 next := core.Registry{}
513513 for name, t := range current {
514- if name == "subagent_spawn" || name == "subagent_status" || name == "subagent_stop" || name == "subagent_resume" {
514+ if name == tools.SubagentToolName {
515515 continue
516516 }
517517 next[name] = t
518518 }
519519 if i.autoSubagentsAvailable() {
520+ facade := &tools.SubagentTool{}
520521 if i.autoSubagentsToolAllowed() {
521- canonical := &tools.SubagentSpawnTool{
522+ facade.Spawn = &tools.SubagentSpawnTool{
522523 ResidentManager: i.cfg.ResidentManager,
523524 BuildResidentSpec: i.cfg.BuildResidentSpec,
524525 Enabled: func() bool { return true },
@@ -527,29 +528,26 @@ func (i *Interactive) applyAutoSubagentsTool() {
527528 DefaultReasoning: func() string { return i.cfg.Reasoning },
528529 ResolveSubagent: i.cfg.ResolveSubagent,
529530 }
530- next[canonical.Name()] = canonical
531531 }
532532 if i.autoSubagentsStatusToolAllowed() {
533- statusTool := &tools.SubagentStatusTool{
533+ facade.Status = &tools.SubagentStatusTool{
534534 ResidentManager: i.cfg.ResidentManager,
535535 Enabled: func() bool { return true },
536536 }
537- next[statusTool.Name()] = statusTool
538537 }
539538 if i.autoSubagentsStopToolAllowed() {
540- stopTool := &tools.SubagentStopTool{
539+ facade.Stop = &tools.SubagentStopTool{
541540 ResidentManager: i.cfg.ResidentManager,
542541 Enabled: func() bool { return true },
543542 }
544- next[stopTool.Name()] = stopTool
545543 }
546544 if i.autoSubagentsResumeToolAllowed() {
547- resumeTool := &tools.SubagentResumeTool{
545+ facade.Resume = &tools.SubagentResumeTool{
548546 ResidentManager: i.cfg.ResidentManager,
549547 Enabled: func() bool { return true },
550548 }
551- next[resumeTool.Name()] = resumeTool
552549 }
550+ next[facade.Name()] = facade
553551 }
554552 i.agent.SetTools(next)
555553 }
@@ -452,7 +452,7 @@ func (i *Interactive) autoSubagentsUnavailableHint() string {
452 hints = append(hints, "resident subagent manager is unavailable in this mode")452 hints = append(hints, "resident subagent manager is unavailable in this mode")
453 }453 }
454 if !autoSubagentsAnyToolAllowedConfig(i.cfg) {454 if !autoSubagentsAnyToolAllowedConfig(i.cfg) {
455- hints = append(hints, "launch-time tool policy excludes subagent manager tools")455+ hints = append(hints, "launch-time tool policy excludes every subagent action")
456 }456 }
457 return strings.Join(hints, "; ")457 return strings.Join(hints, "; ")
458 }458 }
@@ -511,14 +511,15 @@ func (i *Interactive) applyAutoSubagentsTool() {
511 current := i.agent.ToolsSnapshot()511 current := i.agent.ToolsSnapshot()
512 next := core.Registry{}512 next := core.Registry{}
513 for name, t := range current {513 for name, t := range current {
514- if name == "subagent_spawn" || name == "subagent_status" || name == "subagent_stop" || name == "subagent_resume" {514+ if name == tools.SubagentToolName {
515 continue515 continue
516 }516 }
517 next[name] = t517 next[name] = t
518 }518 }
519 if i.autoSubagentsAvailable() {519 if i.autoSubagentsAvailable() {
520+ facade := &tools.SubagentTool{}
520 if i.autoSubagentsToolAllowed() {521 if i.autoSubagentsToolAllowed() {
521- canonical := &tools.SubagentSpawnTool{522+ facade.Spawn = &tools.SubagentSpawnTool{
522 ResidentManager: i.cfg.ResidentManager,523 ResidentManager: i.cfg.ResidentManager,
523 BuildResidentSpec: i.cfg.BuildResidentSpec,524 BuildResidentSpec: i.cfg.BuildResidentSpec,
524 Enabled: func() bool { return true },525 Enabled: func() bool { return true },
@@ -527,29 +528,26 @@ func (i *Interactive) applyAutoSubagentsTool() {
527 DefaultReasoning: func() string { return i.cfg.Reasoning },528 DefaultReasoning: func() string { return i.cfg.Reasoning },
528 ResolveSubagent: i.cfg.ResolveSubagent,529 ResolveSubagent: i.cfg.ResolveSubagent,
529 }530 }
530- next[canonical.Name()] = canonical
531 }531 }
532 if i.autoSubagentsStatusToolAllowed() {532 if i.autoSubagentsStatusToolAllowed() {
533- statusTool := &tools.SubagentStatusTool{533+ facade.Status = &tools.SubagentStatusTool{
534 ResidentManager: i.cfg.ResidentManager,534 ResidentManager: i.cfg.ResidentManager,
535 Enabled: func() bool { return true },535 Enabled: func() bool { return true },
536 }536 }
537- next[statusTool.Name()] = statusTool
538 }537 }
539 if i.autoSubagentsStopToolAllowed() {538 if i.autoSubagentsStopToolAllowed() {
540- stopTool := &tools.SubagentStopTool{539+ facade.Stop = &tools.SubagentStopTool{
541 ResidentManager: i.cfg.ResidentManager,540 ResidentManager: i.cfg.ResidentManager,
542 Enabled: func() bool { return true },541 Enabled: func() bool { return true },
543 }542 }
544- next[stopTool.Name()] = stopTool
545 }543 }
546 if i.autoSubagentsResumeToolAllowed() {544 if i.autoSubagentsResumeToolAllowed() {
547- resumeTool := &tools.SubagentResumeTool{545+ facade.Resume = &tools.SubagentResumeTool{
548 ResidentManager: i.cfg.ResidentManager,546 ResidentManager: i.cfg.ResidentManager,
549 Enabled: func() bool { return true },547 Enabled: func() bool { return true },
550 }548 }
551- next[resumeTool.Name()] = resumeTool
552 }549 }
550+ next[facade.Name()] = facade
553 }551 }
554 i.agent.SetTools(next)552 i.agent.SetTools(next)
555 }553 }
A packages/agent/modes/resident_subagent_tools_test.go +77 -0
@@ -0,0 +1,77 @@
1+package modes
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/zut/packages/agent/subagents"
8+ "github.com/bnema/zut/packages/agent/tools"
9+ "github.com/bnema/zut/packages/core"
10+)
11+
12+// TestApplyAutoSubagentsToolReplacesOneFacadeEntry covers the interactive
13+// refresh path: the previously installed facade is replaced by exactly one
14+// entry whose non-nil fields are the currently granted actions, and unrelated
15+// tools survive.
16+func TestApplyAutoSubagentsToolReplacesOneFacadeEntry(t *testing.T) {
17+ manager := subagents.NewResidentManager(t.TempDir(), nil)
18+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
19+ preSeeded := &tools.SubagentTool{}
20+ agent := core.NewAgent(nil, "model", "", core.Registry{
21+ "read": &tools.GrepTool{},
22+ // A previously installed facade, as a prior refresh would have left it.
23+ "subagent": preSeeded,
24+ })
25+ allowed, resumeAllowed := true, true
26+ interactive := NewInteractive(InteractiveConfig{
27+ Agent: agent,
28+ ResidentManager: manager,
29+ AutoSubagentsToolAllowed: &allowed,
30+ AutoSubagentsStatusToolAllowed: &allowed,
31+ AutoSubagentsStopToolAllowed: &allowed,
32+ AutoSubagentsResumeToolAllowed: &resumeAllowed,
33+ })
34+
35+ interactive.applyAutoSubagentsTool()
36+
37+ assertFacade := func(wantResume bool) {
38+ t.Helper()
39+ snapshot := agent.ToolsSnapshot()
40+ if _, ok := snapshot["read"]; !ok {
41+ t.Fatal("interactive refresh dropped an unrelated tool")
42+ }
43+ found := 0
44+ for name := range snapshot {
45+ switch name {
46+ case tools.SubagentToolName:
47+ found++
48+ case "subagent_spawn", "subagent_status", "subagent_stop", "subagent_resume":
49+ t.Fatalf("legacy tool key %q is present", name)
50+ }
51+ }
52+ if found != 1 {
53+ t.Fatalf("registry has %d %q entries, want 1", found, tools.SubagentToolName)
54+ }
55+ facade, ok := snapshot[tools.SubagentToolName].(*tools.SubagentTool)
56+ if !ok {
57+ t.Fatalf("registry entry %q = %T, want *tools.SubagentTool", tools.SubagentToolName, snapshot[tools.SubagentToolName])
58+ }
59+ if facade == preSeeded {
60+ t.Fatal("interactive refresh reused the pre-seeded facade instead of replacing it")
61+ }
62+ if facade.Spawn == nil || facade.Status == nil || facade.Stop == nil {
63+ t.Fatalf("facade actions = spawn:%v status:%v stop:%v", facade.Spawn != nil, facade.Status != nil, facade.Stop != nil)
64+ }
65+ if (facade.Resume != nil) != wantResume {
66+ t.Fatalf("facade resume = %v, want %v", facade.Resume != nil, wantResume)
67+ }
68+ }
69+
70+ assertFacade(true)
71+
72+ // A later policy change must replace the installed facade, not merge or
73+ // duplicate it.
74+ resumeAllowed = false
75+ interactive.applyAutoSubagentsTool()
76+ assertFacade(false)
77+}
@@ -0,0 +1,77 @@
1+package modes
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/zut/packages/agent/subagents"
8+ "github.com/bnema/zut/packages/agent/tools"
9+ "github.com/bnema/zut/packages/core"
10+)
11+
12+// TestApplyAutoSubagentsToolReplacesOneFacadeEntry covers the interactive
13+// refresh path: the previously installed facade is replaced by exactly one
14+// entry whose non-nil fields are the currently granted actions, and unrelated
15+// tools survive.
16+func TestApplyAutoSubagentsToolReplacesOneFacadeEntry(t *testing.T) {
17+ manager := subagents.NewResidentManager(t.TempDir(), nil)
18+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
19+ preSeeded := &tools.SubagentTool{}
20+ agent := core.NewAgent(nil, "model", "", core.Registry{
21+ "read": &tools.GrepTool{},
22+ // A previously installed facade, as a prior refresh would have left it.
23+ "subagent": preSeeded,
24+ })
25+ allowed, resumeAllowed := true, true
26+ interactive := NewInteractive(InteractiveConfig{
27+ Agent: agent,
28+ ResidentManager: manager,
29+ AutoSubagentsToolAllowed: &allowed,
30+ AutoSubagentsStatusToolAllowed: &allowed,
31+ AutoSubagentsStopToolAllowed: &allowed,
32+ AutoSubagentsResumeToolAllowed: &resumeAllowed,
33+ })
34+
35+ interactive.applyAutoSubagentsTool()
36+
37+ assertFacade := func(wantResume bool) {
38+ t.Helper()
39+ snapshot := agent.ToolsSnapshot()
40+ if _, ok := snapshot["read"]; !ok {
41+ t.Fatal("interactive refresh dropped an unrelated tool")
42+ }
43+ found := 0
44+ for name := range snapshot {
45+ switch name {
46+ case tools.SubagentToolName:
47+ found++
48+ case "subagent_spawn", "subagent_status", "subagent_stop", "subagent_resume":
49+ t.Fatalf("legacy tool key %q is present", name)
50+ }
51+ }
52+ if found != 1 {
53+ t.Fatalf("registry has %d %q entries, want 1", found, tools.SubagentToolName)
54+ }
55+ facade, ok := snapshot[tools.SubagentToolName].(*tools.SubagentTool)
56+ if !ok {
57+ t.Fatalf("registry entry %q = %T, want *tools.SubagentTool", tools.SubagentToolName, snapshot[tools.SubagentToolName])
58+ }
59+ if facade == preSeeded {
60+ t.Fatal("interactive refresh reused the pre-seeded facade instead of replacing it")
61+ }
62+ if facade.Spawn == nil || facade.Status == nil || facade.Stop == nil {
63+ t.Fatalf("facade actions = spawn:%v status:%v stop:%v", facade.Spawn != nil, facade.Status != nil, facade.Stop != nil)
64+ }
65+ if (facade.Resume != nil) != wantResume {
66+ t.Fatalf("facade resume = %v, want %v", facade.Resume != nil, wantResume)
67+ }
68+ }
69+
70+ assertFacade(true)
71+
72+ // A later policy change must replace the installed facade, not merge or
73+ // duplicate it.
74+ resumeAllowed = false
75+ interactive.applyAutoSubagentsTool()
76+ assertFacade(false)
77+}
M packages/agent/orchestration.go +1 -1
@@ -29,7 +29,7 @@ func validateOrchestrationArgs(args Args) error {
2929 return fmt.Errorf("--orchestrate cannot be combined with --stats")
3030 }
3131 if !autoSubagentsToolAllowed(args) {
32- return fmt.Errorf("--orchestrate requires the subagent_spawn tool")
32+ return fmt.Errorf("--orchestrate requires the subagent spawn action")
3333 }
3434 return nil
3535 }
@@ -29,7 +29,7 @@ func validateOrchestrationArgs(args Args) error {
29 return fmt.Errorf("--orchestrate cannot be combined with --stats")29 return fmt.Errorf("--orchestrate cannot be combined with --stats")
30 }30 }
31 if !autoSubagentsToolAllowed(args) {31 if !autoSubagentsToolAllowed(args) {
32- return fmt.Errorf("--orchestrate requires the subagent_spawn tool")32+ return fmt.Errorf("--orchestrate requires the subagent spawn action")
33 }33 }
34 return nil34 return nil
35 }35 }
M packages/agent/orchestration_cli_e2e_test.go +2 -2
@@ -317,9 +317,9 @@ func requestIsParent(body []byte) bool {
317317
318318 func writeToolCall(w http.ResponseWriter, id, task string) {
319319 w.Header().Set("content-type", "text/event-stream")
320- arguments, _ := json.Marshal(map[string]string{"task": task})
320+ arguments, _ := json.Marshal(map[string]any{"action": "spawn", "task": task})
321321 chunk := map[string]any{
322- "choices": []any{map[string]any{"index": 0, "delta": map[string]any{"tool_calls": []any{map[string]any{"index": 0, "id": id, "type": "function", "function": map[string]string{"name": "subagent_spawn", "arguments": string(arguments)}}}}, "finish_reason": "tool_calls"}},
322+ "choices": []any{map[string]any{"index": 0, "delta": map[string]any{"tool_calls": []any{map[string]any{"index": 0, "id": id, "type": "function", "function": map[string]string{"name": "subagent", "arguments": string(arguments)}}}}, "finish_reason": "tool_calls"}},
323323 "usage": map[string]int{"prompt_tokens": 8, "completion_tokens": 2},
324324 }
325325 data, _ := json.Marshal(chunk)
@@ -317,9 +317,9 @@ func requestIsParent(body []byte) bool {
317 317
318 func writeToolCall(w http.ResponseWriter, id, task string) {318 func writeToolCall(w http.ResponseWriter, id, task string) {
319 w.Header().Set("content-type", "text/event-stream")319 w.Header().Set("content-type", "text/event-stream")
320- arguments, _ := json.Marshal(map[string]string{"task": task})320+ arguments, _ := json.Marshal(map[string]any{"action": "spawn", "task": task})
321 chunk := map[string]any{321 chunk := map[string]any{
322- "choices": []any{map[string]any{"index": 0, "delta": map[string]any{"tool_calls": []any{map[string]any{"index": 0, "id": id, "type": "function", "function": map[string]string{"name": "subagent_spawn", "arguments": string(arguments)}}}}, "finish_reason": "tool_calls"}},322+ "choices": []any{map[string]any{"index": 0, "delta": map[string]any{"tool_calls": []any{map[string]any{"index": 0, "id": id, "type": "function", "function": map[string]string{"name": "subagent", "arguments": string(arguments)}}}}, "finish_reason": "tool_calls"}},
323 "usage": map[string]int{"prompt_tokens": 8, "completion_tokens": 2},323 "usage": map[string]int{"prompt_tokens": 8, "completion_tokens": 2},
324 }324 }
325 data, _ := json.Marshal(chunk)325 data, _ := json.Marshal(chunk)
A packages/agent/orchestration_validate_test.go +29 -0
@@ -0,0 +1,29 @@
1+package agent
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestValidateOrchestrationArgsRequiresSpawnAction(t *testing.T) {
9+ base := Args{Orchestrate: true, Mode: ModePrint}
10+ denied := base
11+ denied.ToolsSet = true
12+ denied.Tools = []string{"read"}
13+ err := validateOrchestrationArgs(denied)
14+ if err == nil || !strings.Contains(err.Error(), "requires the subagent spawn action") {
15+ t.Fatalf("validateOrchestrationArgs error = %v", err)
16+ }
17+ allowed := base
18+ allowed.ToolsSet = true
19+ allowed.Tools = []string{"subagent:spawn"}
20+ if err := validateOrchestrationArgs(allowed); err != nil {
21+ t.Fatalf("spawn action must satisfy orchestration: %v", err)
22+ }
23+ resumeOnly := base
24+ resumeOnly.ToolsSet = true
25+ resumeOnly.Tools = []string{"subagent:resume"}
26+ if err := validateOrchestrationArgs(resumeOnly); err == nil || !strings.Contains(err.Error(), "requires the subagent spawn action") {
27+ t.Fatalf("resume-only error = %v", err)
28+ }
29+}
@@ -0,0 +1,29 @@
1+package agent
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestValidateOrchestrationArgsRequiresSpawnAction(t *testing.T) {
9+ base := Args{Orchestrate: true, Mode: ModePrint}
10+ denied := base
11+ denied.ToolsSet = true
12+ denied.Tools = []string{"read"}
13+ err := validateOrchestrationArgs(denied)
14+ if err == nil || !strings.Contains(err.Error(), "requires the subagent spawn action") {
15+ t.Fatalf("validateOrchestrationArgs error = %v", err)
16+ }
17+ allowed := base
18+ allowed.ToolsSet = true
19+ allowed.Tools = []string{"subagent:spawn"}
20+ if err := validateOrchestrationArgs(allowed); err != nil {
21+ t.Fatalf("spawn action must satisfy orchestration: %v", err)
22+ }
23+ resumeOnly := base
24+ resumeOnly.ToolsSet = true
25+ resumeOnly.Tools = []string{"subagent:resume"}
26+ if err := validateOrchestrationArgs(resumeOnly); err == nil || !strings.Contains(err.Error(), "requires the subagent spawn action") {
27+ t.Fatalf("resume-only error = %v", err)
28+ }
29+}
M packages/agent/settings_store.go +8 -8
@@ -314,9 +314,9 @@ const StrictOrchestratorSystemAddendum = `Strict subagent orchestration is enabl
314314
315315 const subagentLifecycleAddendum = `Workers start without this conversation's context. Shared-worktree workers edit the same working directory, so coordinate dependent work and avoid conflicting edits. Use isolation:"worktree" for parallel coding when workers need separate trees; its changes are returned as a patch and never applied automatically. Do not invent feature scope beyond the user's request. Child workers cannot recursively spawn more sub-agents in v1.
316316
317-Use required:true whenever your answer or a declared workflow depends on that worker, especially for required validation or finalization. Required workers remain asynchronous unless you explicitly set subagent_spawn's wait field: without it, manager calls return immediately, the parent stays free to coordinate or perform policy-permitted non-overlapping work, and the host delivers terminal outcomes through completion updates. Failed, canceled, or interrupted required work remains unmet: use an enabled manager follow-up action to retry it and do not claim completion. An indeterminate outcome after host restart must not be retried automatically; the user must inspect durable results and side effects, then explicitly resume or restart the worker with a new prompt. Required work has no automatic waiver path. Keep required:false only for independent work that may safely finish later.
317+Use required:true whenever your answer or a declared workflow depends on that worker, especially for required validation or finalization. Required workers remain asynchronous unless you explicitly set the wait field on a spawn or resume action: without it, manager calls return immediately, the parent stays free to coordinate or perform policy-permitted non-overlapping work, and the host delivers terminal outcomes through completion updates. Failed, canceled, or interrupted required work remains unmet: use an enabled manager follow-up action to retry it and do not claim completion. An indeterminate outcome after host restart must not be retried automatically; the user must inspect durable results and side effects, then explicitly resume or restart the worker with a new prompt. Required work has no automatic waiver path. Keep required:false only for independent work that may safely finish later.
318318
319-Completion is host-event-driven unless you explicitly set subagent_spawn's wait field to a whole number of seconds (1–300) for that initial task. Omitting wait always returns immediately. An expired wait leaves the accepted child active, whether queued or running; do not retry it until terminal failure, cancellation, or interruption. Never use "bash sleep", "watch", "tail -f", polling loops, repeated "subagent_status", or dashboard, metadata, or file checks solely to wait. Those are not completion signals. Continue only with genuinely non-overlapping work; otherwise end or yield your turn until the host injects a completion update.
319+Completion is host-event-driven unless you explicitly set the wait field on a spawn or resume action to a whole number of seconds (1–300): on spawn it bounds the initial task, and on resume it bounds the follow-up turn. Omitting wait always returns immediately. An expired wait leaves the accepted child active, whether queued or running; do not retry it until terminal failure, cancellation, or interruption. Never use "bash sleep", "watch", "tail -f", polling loops, repeated "status" calls, or dashboard, metadata, or file checks solely to wait. Those are not completion signals. Continue only with genuinely non-overlapping work; otherwise end or yield your turn until the host injects a completion update.
320320
321321 When workers finish, use the host update's agent ID, status, task, optional error, and final response or tail to coordinate any follow-up and summarize the outcome. Treat [auto-subagents update] and [required-subagents update] messages as observed worker state, not as new user requests.`
322322
@@ -324,11 +324,11 @@ When workers finish, use the host update's agent ID, status, task, optional erro
324324 // available without enabling proactive delegation.
325325 const OnDemandSubagentsSystemAddendum = `Subagent tools are available only when the user asks you to delegate and the relevant launch-time tool permission allows it, or an active skill workflow requires delegation. Use them only in those cases. A worker owns its delegated scope until completion: do not repeat that work in the parent. Continue only with a concrete non-overlapping task selected before spawning; if delegation owns the blocking task, end or yield the parent turn instead.
326326
327-Mark delegated work required:true when the user's request or workflow requires its outcome before completion. Omit subagent_spawn's wait field to return immediately and receive completion through a host update. Set wait only to an explicit 1–300 second value when you need to wait for that initial task; an expired wait leaves the accepted child active, whether queued or running. Do not retry it until terminal failure, cancellation, or interruption. Failed, canceled, or interrupted required work remains unmet until a successful follow-up; after host restart, an indeterminate outcome requires user-directed reconciliation of durable results and side effects before non-idempotent work is retried. Do not produce a terminal answer until required work is satisfied. Required work has no automatic waiver path. Do not delegate proactively or switch into an orchestrator role; otherwise perform the work yourself.`
327+Mark delegated work required:true when the user's request or workflow requires its outcome before completion. Omit the wait field on a spawn or resume action to return immediately and receive completion through a host update. Set wait only to an explicit 1–300 second value when you need to wait for that task or follow-up turn; an expired wait leaves the accepted child active, whether queued or running. Do not retry it until terminal failure, cancellation, or interruption. Failed, canceled, or interrupted required work remains unmet until a successful follow-up; after host restart, an indeterminate outcome requires user-directed reconciliation of durable results and side effects before non-idempotent work is retried. Do not produce a terminal answer until required work is satisfied. Required work has no automatic waiver path. Do not delegate proactively or switch into an orchestrator role; otherwise perform the work yourself.`
328328
329-const StrictOrchestratorDelegationUnavailableAddendum = `Delegation is unavailable in this session because the launch-time tool policy does not expose subagent_spawn. Remain the primary-agent orchestrator: report this limitation to the user rather than implementing, debugging/testing, or reviewing directly, and end or yield your turn until delegation is available. Do not treat subagent_status or other tools as a substitute for subagent_spawn.`
329+const StrictOrchestratorDelegationUnavailableAddendum = `Delegation is unavailable in this session because the launch-time tool policy does not expose the subagent spawn action. Remain the primary-agent orchestrator: report this limitation to the user rather than implementing, debugging/testing, or reviewing directly, and end or yield your turn until delegation is available. Do not treat the status action or other tools as a substitute for spawning.`
330330
331-const ProactiveSubagentsDelegationUnavailableAddendum = `Proactive delegation is unavailable in this session because the launch-time tool policy does not expose subagent_spawn. Continue the user's task locally; do not report delegation as a blocker.`
331+const ProactiveSubagentsDelegationUnavailableAddendum = `Proactive delegation is unavailable in this session because the launch-time tool policy does not expose the subagent spawn action. Continue the user's task locally; do not report delegation as a blocker.`
332332
333333 // ProactiveSubagentsSystemAddendumFor returns the interactive collaboration
334334 // contract with guidance only for manager actions exposed at launch time.
@@ -346,11 +346,11 @@ func subagentsSystemAddendumFor(base, unavailable string, strict, spawnToolAllow
346346 addendum := base + "\n\n" + subagentLifecycleAddendum
347347 switch {
348348 case stopToolAllowed && resumeToolAllowed:
349- addendum += "\n\nManager lifecycle actions are available: use subagent_stop to request termination of a stuck worker, and subagent_resume with an agent id and follow-up prompt to continue an idle worker or restart a stopped worker with its existing session context."
349+ addendum += "\n\nManager lifecycle actions are available: use the subagent tool's stop action to request termination of a stuck worker, and its resume action with an agent id and follow-up prompt to continue an idle worker or restart a stopped worker with its existing session context."
350350 case stopToolAllowed:
351- addendum += "\n\nA manager lifecycle action is available: use subagent_stop to request termination of a stuck worker."
351+ addendum += "\n\nA manager lifecycle action is available: use the subagent tool's stop action to request termination of a stuck worker."
352352 case resumeToolAllowed:
353- addendum += "\n\nA manager lifecycle action is available: use subagent_resume with an agent id and follow-up prompt to continue an idle worker or restart a stopped worker with its existing session context."
353+ addendum += "\n\nA manager lifecycle action is available: use the subagent tool's resume action with an agent id and follow-up prompt to continue an idle worker or restart a stopped worker with its existing session context."
354354 }
355355 if !spawnToolAllowed {
356356 if stopToolAllowed || resumeToolAllowed {
@@ -314,9 +314,9 @@ const StrictOrchestratorSystemAddendum = `Strict subagent orchestration is enabl
314 314
315 const subagentLifecycleAddendum = `Workers start without this conversation's context. Shared-worktree workers edit the same working directory, so coordinate dependent work and avoid conflicting edits. Use isolation:"worktree" for parallel coding when workers need separate trees; its changes are returned as a patch and never applied automatically. Do not invent feature scope beyond the user's request. Child workers cannot recursively spawn more sub-agents in v1.315 const subagentLifecycleAddendum = `Workers start without this conversation's context. Shared-worktree workers edit the same working directory, so coordinate dependent work and avoid conflicting edits. Use isolation:"worktree" for parallel coding when workers need separate trees; its changes are returned as a patch and never applied automatically. Do not invent feature scope beyond the user's request. Child workers cannot recursively spawn more sub-agents in v1.
316 316
317-Use required:true whenever your answer or a declared workflow depends on that worker, especially for required validation or finalization. Required workers remain asynchronous unless you explicitly set subagent_spawn's wait field: without it, manager calls return immediately, the parent stays free to coordinate or perform policy-permitted non-overlapping work, and the host delivers terminal outcomes through completion updates. Failed, canceled, or interrupted required work remains unmet: use an enabled manager follow-up action to retry it and do not claim completion. An indeterminate outcome after host restart must not be retried automatically; the user must inspect durable results and side effects, then explicitly resume or restart the worker with a new prompt. Required work has no automatic waiver path. Keep required:false only for independent work that may safely finish later.317+Use required:true whenever your answer or a declared workflow depends on that worker, especially for required validation or finalization. Required workers remain asynchronous unless you explicitly set the wait field on a spawn or resume action: without it, manager calls return immediately, the parent stays free to coordinate or perform policy-permitted non-overlapping work, and the host delivers terminal outcomes through completion updates. Failed, canceled, or interrupted required work remains unmet: use an enabled manager follow-up action to retry it and do not claim completion. An indeterminate outcome after host restart must not be retried automatically; the user must inspect durable results and side effects, then explicitly resume or restart the worker with a new prompt. Required work has no automatic waiver path. Keep required:false only for independent work that may safely finish later.
318 318
319-Completion is host-event-driven unless you explicitly set subagent_spawn's wait field to a whole number of seconds (1–300) for that initial task. Omitting wait always returns immediately. An expired wait leaves the accepted child active, whether queued or running; do not retry it until terminal failure, cancellation, or interruption. Never use "bash sleep", "watch", "tail -f", polling loops, repeated "subagent_status", or dashboard, metadata, or file checks solely to wait. Those are not completion signals. Continue only with genuinely non-overlapping work; otherwise end or yield your turn until the host injects a completion update.319+Completion is host-event-driven unless you explicitly set the wait field on a spawn or resume action to a whole number of seconds (1–300): on spawn it bounds the initial task, and on resume it bounds the follow-up turn. Omitting wait always returns immediately. An expired wait leaves the accepted child active, whether queued or running; do not retry it until terminal failure, cancellation, or interruption. Never use "bash sleep", "watch", "tail -f", polling loops, repeated "status" calls, or dashboard, metadata, or file checks solely to wait. Those are not completion signals. Continue only with genuinely non-overlapping work; otherwise end or yield your turn until the host injects a completion update.
320 320
321 When workers finish, use the host update's agent ID, status, task, optional error, and final response or tail to coordinate any follow-up and summarize the outcome. Treat [auto-subagents update] and [required-subagents update] messages as observed worker state, not as new user requests.`321 When workers finish, use the host update's agent ID, status, task, optional error, and final response or tail to coordinate any follow-up and summarize the outcome. Treat [auto-subagents update] and [required-subagents update] messages as observed worker state, not as new user requests.`
322 322
@@ -324,11 +324,11 @@ When workers finish, use the host update's agent ID, status, task, optional erro
324 // available without enabling proactive delegation.324 // available without enabling proactive delegation.
325 const OnDemandSubagentsSystemAddendum = `Subagent tools are available only when the user asks you to delegate and the relevant launch-time tool permission allows it, or an active skill workflow requires delegation. Use them only in those cases. A worker owns its delegated scope until completion: do not repeat that work in the parent. Continue only with a concrete non-overlapping task selected before spawning; if delegation owns the blocking task, end or yield the parent turn instead.325 const OnDemandSubagentsSystemAddendum = `Subagent tools are available only when the user asks you to delegate and the relevant launch-time tool permission allows it, or an active skill workflow requires delegation. Use them only in those cases. A worker owns its delegated scope until completion: do not repeat that work in the parent. Continue only with a concrete non-overlapping task selected before spawning; if delegation owns the blocking task, end or yield the parent turn instead.
326 326
327-Mark delegated work required:true when the user's request or workflow requires its outcome before completion. Omit subagent_spawn's wait field to return immediately and receive completion through a host update. Set wait only to an explicit 1–300 second value when you need to wait for that initial task; an expired wait leaves the accepted child active, whether queued or running. Do not retry it until terminal failure, cancellation, or interruption. Failed, canceled, or interrupted required work remains unmet until a successful follow-up; after host restart, an indeterminate outcome requires user-directed reconciliation of durable results and side effects before non-idempotent work is retried. Do not produce a terminal answer until required work is satisfied. Required work has no automatic waiver path. Do not delegate proactively or switch into an orchestrator role; otherwise perform the work yourself.`327+Mark delegated work required:true when the user's request or workflow requires its outcome before completion. Omit the wait field on a spawn or resume action to return immediately and receive completion through a host update. Set wait only to an explicit 1–300 second value when you need to wait for that task or follow-up turn; an expired wait leaves the accepted child active, whether queued or running. Do not retry it until terminal failure, cancellation, or interruption. Failed, canceled, or interrupted required work remains unmet until a successful follow-up; after host restart, an indeterminate outcome requires user-directed reconciliation of durable results and side effects before non-idempotent work is retried. Do not produce a terminal answer until required work is satisfied. Required work has no automatic waiver path. Do not delegate proactively or switch into an orchestrator role; otherwise perform the work yourself.`
328 328
329-const StrictOrchestratorDelegationUnavailableAddendum = `Delegation is unavailable in this session because the launch-time tool policy does not expose subagent_spawn. Remain the primary-agent orchestrator: report this limitation to the user rather than implementing, debugging/testing, or reviewing directly, and end or yield your turn until delegation is available. Do not treat subagent_status or other tools as a substitute for subagent_spawn.`329+const StrictOrchestratorDelegationUnavailableAddendum = `Delegation is unavailable in this session because the launch-time tool policy does not expose the subagent spawn action. Remain the primary-agent orchestrator: report this limitation to the user rather than implementing, debugging/testing, or reviewing directly, and end or yield your turn until delegation is available. Do not treat the status action or other tools as a substitute for spawning.`
330 330
331-const ProactiveSubagentsDelegationUnavailableAddendum = `Proactive delegation is unavailable in this session because the launch-time tool policy does not expose subagent_spawn. Continue the user's task locally; do not report delegation as a blocker.`331+const ProactiveSubagentsDelegationUnavailableAddendum = `Proactive delegation is unavailable in this session because the launch-time tool policy does not expose the subagent spawn action. Continue the user's task locally; do not report delegation as a blocker.`
332 332
333 // ProactiveSubagentsSystemAddendumFor returns the interactive collaboration333 // ProactiveSubagentsSystemAddendumFor returns the interactive collaboration
334 // contract with guidance only for manager actions exposed at launch time.334 // contract with guidance only for manager actions exposed at launch time.
@@ -346,11 +346,11 @@ func subagentsSystemAddendumFor(base, unavailable string, strict, spawnToolAllow
346 addendum := base + "\n\n" + subagentLifecycleAddendum346 addendum := base + "\n\n" + subagentLifecycleAddendum
347 switch {347 switch {
348 case stopToolAllowed && resumeToolAllowed:348 case stopToolAllowed && resumeToolAllowed:
349- addendum += "\n\nManager lifecycle actions are available: use subagent_stop to request termination of a stuck worker, and subagent_resume with an agent id and follow-up prompt to continue an idle worker or restart a stopped worker with its existing session context."349+ addendum += "\n\nManager lifecycle actions are available: use the subagent tool's stop action to request termination of a stuck worker, and its resume action with an agent id and follow-up prompt to continue an idle worker or restart a stopped worker with its existing session context."
350 case stopToolAllowed:350 case stopToolAllowed:
351- addendum += "\n\nA manager lifecycle action is available: use subagent_stop to request termination of a stuck worker."351+ addendum += "\n\nA manager lifecycle action is available: use the subagent tool's stop action to request termination of a stuck worker."
352 case resumeToolAllowed:352 case resumeToolAllowed:
353- addendum += "\n\nA manager lifecycle action is available: use subagent_resume with an agent id and follow-up prompt to continue an idle worker or restart a stopped worker with its existing session context."353+ addendum += "\n\nA manager lifecycle action is available: use the subagent tool's resume action with an agent id and follow-up prompt to continue an idle worker or restart a stopped worker with its existing session context."
354 }354 }
355 if !spawnToolAllowed {355 if !spawnToolAllowed {
356 if stopToolAllowed || resumeToolAllowed {356 if stopToolAllowed || resumeToolAllowed {
M packages/agent/settings_store_test.go +14 -8
@@ -6,6 +6,12 @@ import (
66 )
77
88 func TestSubagentsSystemAddendaListOnlyEnabledLifecycleTools(t *testing.T) {
9+ // Substrings that identify the stop and resume action guidance.
10+ const (
11+ stopAction = "stop action to request termination of a stuck worker"
12+ resumeAction = "resume action with an agent id and follow-up prompt"
13+ noSpawnNotice = "Spawning new workers is unavailable"
14+ )
915 builders := []struct {
1016 name string
1117 build func(bool, bool, bool) string
@@ -25,14 +31,14 @@ func TestSubagentsSystemAddendaListOnlyEnabledLifecycleTools(t *testing.T) {
2531 unwanted []string
2632 available bool
2733 }{
28- {name: "none", spawn: true, unwanted: []string{"subagent_stop", "subagent_resume"}, available: true},
29- {name: "fully unavailable", unwanted: []string{"subagent_stop", "subagent_resume"}},
30- {name: "stop", spawn: true, stop: true, want: []string{"subagent_stop"}, unwanted: []string{"subagent_resume"}, available: true},
31- {name: "resume", spawn: true, resume: true, want: []string{"subagent_resume"}, unwanted: []string{"subagent_stop"}, available: true},
32- {name: "both", spawn: true, stop: true, resume: true, want: []string{"subagent_stop", "subagent_resume"}, available: true},
33- {name: "stop without spawn", stop: true, want: []string{"subagent_stop", "Spawning new workers is unavailable"}, unwanted: []string{"subagent_resume"}},
34- {name: "resume without spawn", resume: true, want: []string{"subagent_resume", "Spawning new workers is unavailable"}, unwanted: []string{"subagent_stop"}},
35- {name: "both without spawn", stop: true, resume: true, want: []string{"subagent_stop", "subagent_resume", "Spawning new workers is unavailable"}},
34+ {name: "none", spawn: true, unwanted: []string{stopAction, resumeAction}, available: true},
35+ {name: "fully unavailable", unwanted: []string{stopAction, resumeAction}},
36+ {name: "stop", spawn: true, stop: true, want: []string{stopAction}, unwanted: []string{resumeAction}, available: true},
37+ {name: "resume", spawn: true, resume: true, want: []string{resumeAction}, unwanted: []string{stopAction}, available: true},
38+ {name: "both", spawn: true, stop: true, resume: true, want: []string{stopAction, resumeAction}, available: true},
39+ {name: "stop without spawn", stop: true, want: []string{stopAction, noSpawnNotice}, unwanted: []string{resumeAction}},
40+ {name: "resume without spawn", resume: true, want: []string{resumeAction, noSpawnNotice}, unwanted: []string{stopAction}},
41+ {name: "both without spawn", stop: true, resume: true, want: []string{stopAction, resumeAction, noSpawnNotice}},
3642 } {
3743 t.Run(tc.name, func(t *testing.T) {
3844 got := builder.build(tc.spawn, tc.stop, tc.resume)
@@ -6,6 +6,12 @@ import (
6 )6 )
7 7
8 func TestSubagentsSystemAddendaListOnlyEnabledLifecycleTools(t *testing.T) {8 func TestSubagentsSystemAddendaListOnlyEnabledLifecycleTools(t *testing.T) {
9+ // Substrings that identify the stop and resume action guidance.
10+ const (
11+ stopAction = "stop action to request termination of a stuck worker"
12+ resumeAction = "resume action with an agent id and follow-up prompt"
13+ noSpawnNotice = "Spawning new workers is unavailable"
14+ )
9 builders := []struct {15 builders := []struct {
10 name string16 name string
11 build func(bool, bool, bool) string17 build func(bool, bool, bool) string
@@ -25,14 +31,14 @@ func TestSubagentsSystemAddendaListOnlyEnabledLifecycleTools(t *testing.T) {
25 unwanted []string31 unwanted []string
26 available bool32 available bool
27 }{33 }{
28- {name: "none", spawn: true, unwanted: []string{"subagent_stop", "subagent_resume"}, available: true},34+ {name: "none", spawn: true, unwanted: []string{stopAction, resumeAction}, available: true},
29- {name: "fully unavailable", unwanted: []string{"subagent_stop", "subagent_resume"}},35+ {name: "fully unavailable", unwanted: []string{stopAction, resumeAction}},
30- {name: "stop", spawn: true, stop: true, want: []string{"subagent_stop"}, unwanted: []string{"subagent_resume"}, available: true},36+ {name: "stop", spawn: true, stop: true, want: []string{stopAction}, unwanted: []string{resumeAction}, available: true},
31- {name: "resume", spawn: true, resume: true, want: []string{"subagent_resume"}, unwanted: []string{"subagent_stop"}, available: true},37+ {name: "resume", spawn: true, resume: true, want: []string{resumeAction}, unwanted: []string{stopAction}, available: true},
32- {name: "both", spawn: true, stop: true, resume: true, want: []string{"subagent_stop", "subagent_resume"}, available: true},38+ {name: "both", spawn: true, stop: true, resume: true, want: []string{stopAction, resumeAction}, available: true},
33- {name: "stop without spawn", stop: true, want: []string{"subagent_stop", "Spawning new workers is unavailable"}, unwanted: []string{"subagent_resume"}},39+ {name: "stop without spawn", stop: true, want: []string{stopAction, noSpawnNotice}, unwanted: []string{resumeAction}},
34- {name: "resume without spawn", resume: true, want: []string{"subagent_resume", "Spawning new workers is unavailable"}, unwanted: []string{"subagent_stop"}},40+ {name: "resume without spawn", resume: true, want: []string{resumeAction, noSpawnNotice}, unwanted: []string{stopAction}},
35- {name: "both without spawn", stop: true, resume: true, want: []string{"subagent_stop", "subagent_resume", "Spawning new workers is unavailable"}},41+ {name: "both without spawn", stop: true, resume: true, want: []string{stopAction, resumeAction, noSpawnNotice}},
36 } {42 } {
37 t.Run(tc.name, func(t *testing.T) {43 t.Run(tc.name, func(t *testing.T) {
38 got := builder.build(tc.spawn, tc.stop, tc.resume)44 got := builder.build(tc.spawn, tc.stop, tc.resume)
M packages/agent/subagent_factory.go +1 -1
@@ -283,7 +283,7 @@ func residentChildRegistry(catalogue core.Registry, names []string) (core.Regist
283283 if name == "" {
284284 return nil, fmt.Errorf("resident child has an empty tool name")
285285 }
286- if name == tools.SubagentSpawnToolName || name == tools.SubagentStatusToolName || name == tools.SubagentStopToolName || name == tools.SubagentResumeToolName || name == "update_goal" {
286+ if name == tools.SubagentToolName || name == "update_goal" {
287287 return nil, fmt.Errorf("resident child tool %q is not allowed", name)
288288 }
289289 tool, ok := catalogue[name]
@@ -283,7 +283,7 @@ func residentChildRegistry(catalogue core.Registry, names []string) (core.Regist
283 if name == "" {283 if name == "" {
284 return nil, fmt.Errorf("resident child has an empty tool name")284 return nil, fmt.Errorf("resident child has an empty tool name")
285 }285 }
286- if name == tools.SubagentSpawnToolName || name == tools.SubagentStatusToolName || name == tools.SubagentStopToolName || name == tools.SubagentResumeToolName || name == "update_goal" {286+ if name == tools.SubagentToolName || name == "update_goal" {
287 return nil, fmt.Errorf("resident child tool %q is not allowed", name)287 return nil, fmt.Errorf("resident child tool %q is not allowed", name)
288 }288 }
289 tool, ok := catalogue[name]289 tool, ok := catalogue[name]
M packages/agent/subagent_factory_test.go +5 -5
@@ -207,10 +207,10 @@ func TestResidentChildRunnerInjectsContextReminderIntoNextRequest(t *testing.T)
207207
208208 func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T) {
209209 catalogue := core.Registry{
210- "read": nil,
211- "bash": nil,
212- "subagent_spawn": nil,
213- "update_goal": nil,
210+ "read": nil,
211+ "bash": nil,
212+ "subagent": nil,
213+ "update_goal": nil,
214214 }
215215 registry, err := residentChildRegistry(catalogue, []string{"read"})
216216 if err != nil {
@@ -219,7 +219,7 @@ func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T
219219 if len(registry) != 1 {
220220 t.Fatalf("registry = %#v", registry)
221221 }
222- for _, name := range []string{"subagent_spawn", "update_goal", "missing"} {
222+ for _, name := range []string{"subagent", "update_goal", "missing"} {
223223 _, err := residentChildRegistry(catalogue, []string{name})
224224 if err == nil || !strings.Contains(err.Error(), name) {
225225 t.Fatalf("residentChildRegistry(%q) error = %v", name, err)
@@ -207,10 +207,10 @@ func TestResidentChildRunnerInjectsContextReminderIntoNextRequest(t *testing.T)
207 207
208 func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T) {208 func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T) {
209 catalogue := core.Registry{209 catalogue := core.Registry{
210- "read": nil,210+ "read": nil,
211- "bash": nil,211+ "bash": nil,
212- "subagent_spawn": nil,212+ "subagent": nil,
213- "update_goal": nil,213+ "update_goal": nil,
214 }214 }
215 registry, err := residentChildRegistry(catalogue, []string{"read"})215 registry, err := residentChildRegistry(catalogue, []string{"read"})
216 if err != nil {216 if err != nil {
@@ -219,7 +219,7 @@ func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T
219 if len(registry) != 1 {219 if len(registry) != 1 {
220 t.Fatalf("registry = %#v", registry)220 t.Fatalf("registry = %#v", registry)
221 }221 }
222- for _, name := range []string{"subagent_spawn", "update_goal", "missing"} {222+ for _, name := range []string{"subagent", "update_goal", "missing"} {
223 _, err := residentChildRegistry(catalogue, []string{name})223 _, err := residentChildRegistry(catalogue, []string{name})
224 if err == nil || !strings.Contains(err.Error(), name) {224 if err == nil || !strings.Contains(err.Error(), name) {
225 t.Fatalf("residentChildRegistry(%q) error = %v", name, err)225 t.Fatalf("residentChildRegistry(%q) error = %v", name, err)
M packages/agent/subagent_runtime.go +7 -9
@@ -358,7 +358,7 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too
358358 allTools := make([]string, 0, len(catalogue))
359359 for name := range catalogue {
360360 switch name {
361- case tools.SubagentSpawnToolName, tools.SubagentStatusToolName, tools.SubagentStopToolName, tools.SubagentResumeToolName, "update_goal":
361+ case tools.SubagentToolName, "update_goal":
362362 continue
363363 }
364364 if _, ok := resolved.ToolRegistry[name]; !ok {
@@ -454,8 +454,9 @@ func (rt *subagentRuntime) InjectTools(reg core.Registry) core.Registry {
454454 if reg == nil || rt == nil || rt.resident == nil || !autoSubagentsAnyToolAllowed(rt.args) {
455455 return reg
456456 }
457+ facade := &tools.SubagentTool{}
457458 if autoSubagentsToolAllowed(rt.args) {
458- spawn := &tools.SubagentSpawnTool{
459+ facade.Spawn = &tools.SubagentSpawnTool{
459460 ResidentManager: rt.residentManagerForTools(),
460461 Enabled: func() bool { return true },
461462 DefaultModel: func() string {
@@ -472,29 +473,26 @@ func (rt *subagentRuntime) InjectTools(reg core.Registry) core.Registry {
472473 return rt.buildResidentChildSpec(ctx, request, reg)
473474 },
474475 }
475- reg[spawn.Name()] = spawn
476476 }
477477 if autoSubagentsStatusToolAllowed(rt.args) {
478- status := &tools.SubagentStatusTool{
478+ facade.Status = &tools.SubagentStatusTool{
479479 ResidentManager: rt.residentManagerForTools(),
480480 Enabled: func() bool { return true },
481481 }
482- reg[status.Name()] = status
483482 }
484483 if autoSubagentsStopToolAllowed(rt.args) {
485- stop := &tools.SubagentStopTool{
484+ facade.Stop = &tools.SubagentStopTool{
486485 ResidentManager: rt.residentManagerForTools(),
487486 Enabled: func() bool { return true },
488487 }
489- reg[stop.Name()] = stop
490488 }
491489 if autoSubagentsResumeToolAllowed(rt.args) {
492- resume := &tools.SubagentResumeTool{
490+ facade.Resume = &tools.SubagentResumeTool{
493491 ResidentManager: rt.residentManagerForTools(),
494492 Enabled: func() bool { return true },
495493 }
496- reg[resume.Name()] = resume
497494 }
495+ reg[facade.Name()] = facade
498496 return reg
499497 }
500498
@@ -358,7 +358,7 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too
358 allTools := make([]string, 0, len(catalogue))358 allTools := make([]string, 0, len(catalogue))
359 for name := range catalogue {359 for name := range catalogue {
360 switch name {360 switch name {
361- case tools.SubagentSpawnToolName, tools.SubagentStatusToolName, tools.SubagentStopToolName, tools.SubagentResumeToolName, "update_goal":361+ case tools.SubagentToolName, "update_goal":
362 continue362 continue
363 }363 }
364 if _, ok := resolved.ToolRegistry[name]; !ok {364 if _, ok := resolved.ToolRegistry[name]; !ok {
@@ -454,8 +454,9 @@ func (rt *subagentRuntime) InjectTools(reg core.Registry) core.Registry {
454 if reg == nil || rt == nil || rt.resident == nil || !autoSubagentsAnyToolAllowed(rt.args) {454 if reg == nil || rt == nil || rt.resident == nil || !autoSubagentsAnyToolAllowed(rt.args) {
455 return reg455 return reg
456 }456 }
457+ facade := &tools.SubagentTool{}
457 if autoSubagentsToolAllowed(rt.args) {458 if autoSubagentsToolAllowed(rt.args) {
458- spawn := &tools.SubagentSpawnTool{459+ facade.Spawn = &tools.SubagentSpawnTool{
459 ResidentManager: rt.residentManagerForTools(),460 ResidentManager: rt.residentManagerForTools(),
460 Enabled: func() bool { return true },461 Enabled: func() bool { return true },
461 DefaultModel: func() string {462 DefaultModel: func() string {
@@ -472,29 +473,26 @@ func (rt *subagentRuntime) InjectTools(reg core.Registry) core.Registry {
472 return rt.buildResidentChildSpec(ctx, request, reg)473 return rt.buildResidentChildSpec(ctx, request, reg)
473 },474 },
474 }475 }
475- reg[spawn.Name()] = spawn
476 }476 }
477 if autoSubagentsStatusToolAllowed(rt.args) {477 if autoSubagentsStatusToolAllowed(rt.args) {
478- status := &tools.SubagentStatusTool{478+ facade.Status = &tools.SubagentStatusTool{
479 ResidentManager: rt.residentManagerForTools(),479 ResidentManager: rt.residentManagerForTools(),
480 Enabled: func() bool { return true },480 Enabled: func() bool { return true },
481 }481 }
482- reg[status.Name()] = status
483 }482 }
484 if autoSubagentsStopToolAllowed(rt.args) {483 if autoSubagentsStopToolAllowed(rt.args) {
485- stop := &tools.SubagentStopTool{484+ facade.Stop = &tools.SubagentStopTool{
486 ResidentManager: rt.residentManagerForTools(),485 ResidentManager: rt.residentManagerForTools(),
487 Enabled: func() bool { return true },486 Enabled: func() bool { return true },
488 }487 }
489- reg[stop.Name()] = stop
490 }488 }
491 if autoSubagentsResumeToolAllowed(rt.args) {489 if autoSubagentsResumeToolAllowed(rt.args) {
492- resume := &tools.SubagentResumeTool{490+ facade.Resume = &tools.SubagentResumeTool{
493 ResidentManager: rt.residentManagerForTools(),491 ResidentManager: rt.residentManagerForTools(),
494 Enabled: func() bool { return true },492 Enabled: func() bool { return true },
495 }493 }
496- reg[resume.Name()] = resume
497 }494 }
495+ reg[facade.Name()] = facade
498 return reg496 return reg
499 }497 }
500 498
M packages/agent/subagent_runtime_test.go +60 -2
@@ -152,7 +152,7 @@ func TestResidentChildSpecOmitsParentOnlyTools(t *testing.T) {
152152 "read": nil,
153153 "schedule": nil,
154154 "host-only-tool": nil,
155- "subagent_spawn": nil,
155+ "subagent": nil,
156156 "update_goal": nil,
157157 }
158158 spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, parent)
@@ -165,7 +165,7 @@ func TestResidentChildSpecOmitsParentOnlyTools(t *testing.T) {
165165 if !slices.Contains(spec.Tools, "read") {
166166 t.Fatalf("generic child lost supported tool: %v", spec.Tools)
167167 }
168- for _, name := range []string{"subagent_spawn", "update_goal"} {
168+ for _, name := range []string{"subagent", "update_goal"} {
169169 if slices.Contains(spec.Tools, name) {
170170 t.Fatalf("generic child kept forbidden tool %q: %v", name, spec.Tools)
171171 }
@@ -342,3 +342,61 @@ func TestResidentChildSpecDisabledSkillsDoNotLeak(t *testing.T) {
342342 t.Fatalf("skill-disabled spec failed validation: %v", err)
343343 }
344344 }
345+
346+// TestInjectToolsRegistersOneSubagentFacade pins the registry contract: the four
347+// legacy tools collapse into exactly one facade entry whose non-nil fields are
348+// the granted actions.
349+func TestInjectToolsRegistersOneSubagentFacade(t *testing.T) {
350+ t.Setenv("ZUT_HOME", t.TempDir())
351+ cwd := t.TempDir()
352+ cwdArgs := func() Args {
353+ return Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true}
354+ }
355+ cases := []struct {
356+ name string
357+ args Args
358+ spawn, status, stop, resume bool
359+ }{
360+ {name: "default grants every action", args: cwdArgs(), spawn: true, status: true, stop: true, resume: true},
361+ {name: "resume suffix grants resume only", args: func() Args {
362+ args := cwdArgs()
363+ args.ToolsSet = true
364+ args.Tools = []string{"subagent:resume"}
365+ return args
366+ }(), resume: true},
367+ }
368+ legacy := []string{"subagent_spawn", "subagent_status", "subagent_stop", "subagent_resume"}
369+ for _, tc := range cases {
370+ t.Run(tc.name, func(t *testing.T) {
371+ runtime := newSubagentRuntime(subagentRuntimeConfig{
372+ Args: tc.args,
373+ Root: t.TempDir(),
374+ RepoRoot: cwd,
375+ Provider: "openai", Model: "gpt-5.6-sol",
376+ })
377+ t.Cleanup(func() { _ = runtime.Close(context.Background()) })
378+ registry := runtime.InjectTools(core.Registry{})
379+ if len(registry) != 1 {
380+ t.Fatalf("injected registry has %d entries, want 1", len(registry))
381+ }
382+ tool, ok := registry[tools.SubagentToolName]
383+ if !ok {
384+ t.Fatalf("injected registry has no %q entry", tools.SubagentToolName)
385+ }
386+ facade, ok := tool.(*tools.SubagentTool)
387+ if !ok {
388+ t.Fatalf("registry entry %q = %T, want *tools.SubagentTool", tools.SubagentToolName, tool)
389+ }
390+ got := [4]bool{facade.Spawn != nil, facade.Status != nil, facade.Stop != nil, facade.Resume != nil}
391+ want := [4]bool{tc.spawn, tc.status, tc.stop, tc.resume}
392+ if got != want {
393+ t.Fatalf("facade actions = %v, want %v", got, want)
394+ }
395+ for _, name := range legacy {
396+ if _, ok := registry[name]; ok {
397+ t.Fatalf("injected registry still exposes legacy tool %q", name)
398+ }
399+ }
400+ })
401+ }
402+}
@@ -152,7 +152,7 @@ func TestResidentChildSpecOmitsParentOnlyTools(t *testing.T) {
152 "read": nil,152 "read": nil,
153 "schedule": nil,153 "schedule": nil,
154 "host-only-tool": nil,154 "host-only-tool": nil,
155- "subagent_spawn": nil,155+ "subagent": nil,
156 "update_goal": nil,156 "update_goal": nil,
157 }157 }
158 spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, parent)158 spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, parent)
@@ -165,7 +165,7 @@ func TestResidentChildSpecOmitsParentOnlyTools(t *testing.T) {
165 if !slices.Contains(spec.Tools, "read") {165 if !slices.Contains(spec.Tools, "read") {
166 t.Fatalf("generic child lost supported tool: %v", spec.Tools)166 t.Fatalf("generic child lost supported tool: %v", spec.Tools)
167 }167 }
168- for _, name := range []string{"subagent_spawn", "update_goal"} {168+ for _, name := range []string{"subagent", "update_goal"} {
169 if slices.Contains(spec.Tools, name) {169 if slices.Contains(spec.Tools, name) {
170 t.Fatalf("generic child kept forbidden tool %q: %v", name, spec.Tools)170 t.Fatalf("generic child kept forbidden tool %q: %v", name, spec.Tools)
171 }171 }
@@ -342,3 +342,61 @@ func TestResidentChildSpecDisabledSkillsDoNotLeak(t *testing.T) {
342 t.Fatalf("skill-disabled spec failed validation: %v", err)342 t.Fatalf("skill-disabled spec failed validation: %v", err)
343 }343 }
344 }344 }
345+
346+// TestInjectToolsRegistersOneSubagentFacade pins the registry contract: the four
347+// legacy tools collapse into exactly one facade entry whose non-nil fields are
348+// the granted actions.
349+func TestInjectToolsRegistersOneSubagentFacade(t *testing.T) {
350+ t.Setenv("ZUT_HOME", t.TempDir())
351+ cwd := t.TempDir()
352+ cwdArgs := func() Args {
353+ return Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true}
354+ }
355+ cases := []struct {
356+ name string
357+ args Args
358+ spawn, status, stop, resume bool
359+ }{
360+ {name: "default grants every action", args: cwdArgs(), spawn: true, status: true, stop: true, resume: true},
361+ {name: "resume suffix grants resume only", args: func() Args {
362+ args := cwdArgs()
363+ args.ToolsSet = true
364+ args.Tools = []string{"subagent:resume"}
365+ return args
366+ }(), resume: true},
367+ }
368+ legacy := []string{"subagent_spawn", "subagent_status", "subagent_stop", "subagent_resume"}
369+ for _, tc := range cases {
370+ t.Run(tc.name, func(t *testing.T) {
371+ runtime := newSubagentRuntime(subagentRuntimeConfig{
372+ Args: tc.args,
373+ Root: t.TempDir(),
374+ RepoRoot: cwd,
375+ Provider: "openai", Model: "gpt-5.6-sol",
376+ })
377+ t.Cleanup(func() { _ = runtime.Close(context.Background()) })
378+ registry := runtime.InjectTools(core.Registry{})
379+ if len(registry) != 1 {
380+ t.Fatalf("injected registry has %d entries, want 1", len(registry))
381+ }
382+ tool, ok := registry[tools.SubagentToolName]
383+ if !ok {
384+ t.Fatalf("injected registry has no %q entry", tools.SubagentToolName)
385+ }
386+ facade, ok := tool.(*tools.SubagentTool)
387+ if !ok {
388+ t.Fatalf("registry entry %q = %T, want *tools.SubagentTool", tools.SubagentToolName, tool)
389+ }
390+ got := [4]bool{facade.Spawn != nil, facade.Status != nil, facade.Stop != nil, facade.Resume != nil}
391+ want := [4]bool{tc.spawn, tc.status, tc.stop, tc.resume}
392+ if got != want {
393+ t.Fatalf("facade actions = %v, want %v", got, want)
394+ }
395+ for _, name := range legacy {
396+ if _, ok := registry[name]; ok {
397+ t.Fatalf("injected registry still exposes legacy tool %q", name)
398+ }
399+ }
400+ })
401+ }
402+}
A packages/agent/subagent_tool_policy_test.go +50 -0
@@ -0,0 +1,50 @@
1+package agent
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/zut/packages/agent/tools"
7+)
8+
9+// TestSubagentActionAllowedMatrix pins the launch-time gating contract: a bare
10+// "subagent" entry grants every action, "subagent:<action>" grants one action,
11+// and every pre-existing disable path still disables all of them.
12+func TestSubagentActionAllowedMatrix(t *testing.T) {
13+ cases := []struct {
14+ name string
15+ args Args
16+ spawn bool
17+ status bool
18+ stop bool
19+ resume bool
20+ }{
21+ {name: "default grants every action", args: Args{}, spawn: true, status: true, stop: true, resume: true},
22+ {name: "unrelated allowlist denies all", args: Args{ToolsSet: true, Tools: []string{"read"}}},
23+ {name: "bare subagent grants every action", args: Args{ToolsSet: true, Tools: []string{"subagent"}}, spawn: true, status: true, stop: true, resume: true},
24+ {name: "bare subagent wins beside a suffix", args: Args{ToolsSet: true, Tools: []string{"read", "subagent:resume", "subagent"}}, spawn: true, status: true, stop: true, resume: true},
25+ {name: "one action suffix grants one action", args: Args{ToolsSet: true, Tools: []string{"subagent:resume"}}, resume: true},
26+ {name: "two action suffixes grant two actions", args: Args{ToolsSet: true, Tools: []string{"subagent:spawn", "subagent:stop"}}, spawn: true, stop: true},
27+ {name: "unknown action suffix denies all", args: Args{ToolsSet: true, Tools: []string{"subagent:bogus"}}},
28+ {name: "empty allowlist denies all", args: Args{ToolsSet: true, Tools: []string{}}},
29+ {name: "no tools denies all", args: Args{NoTools: true}},
30+ {name: "packaged permission set denies all", args: Args{PermissionSet: &tools.PermissionSet{}}},
31+ }
32+ for _, tc := range cases {
33+ t.Run(tc.name, func(t *testing.T) {
34+ got := [4]bool{
35+ autoSubagentsToolAllowed(tc.args),
36+ autoSubagentsStatusToolAllowed(tc.args),
37+ autoSubagentsStopToolAllowed(tc.args),
38+ autoSubagentsResumeToolAllowed(tc.args),
39+ }
40+ want := [4]bool{tc.spawn, tc.status, tc.stop, tc.resume}
41+ if got != want {
42+ t.Fatalf("actions = %v, want %v (args %+v)", got, want, tc.args)
43+ }
44+ wantAny := tc.spawn || tc.status || tc.stop || tc.resume
45+ if any := autoSubagentsAnyToolAllowed(tc.args); any != wantAny {
46+ t.Fatalf("autoSubagentsAnyToolAllowed = %v, want %v (args %+v)", any, wantAny, tc.args)
47+ }
48+ })
49+ }
50+}
@@ -0,0 +1,50 @@
1+package agent
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/zut/packages/agent/tools"
7+)
8+
9+// TestSubagentActionAllowedMatrix pins the launch-time gating contract: a bare
10+// "subagent" entry grants every action, "subagent:<action>" grants one action,
11+// and every pre-existing disable path still disables all of them.
12+func TestSubagentActionAllowedMatrix(t *testing.T) {
13+ cases := []struct {
14+ name string
15+ args Args
16+ spawn bool
17+ status bool
18+ stop bool
19+ resume bool
20+ }{
21+ {name: "default grants every action", args: Args{}, spawn: true, status: true, stop: true, resume: true},
22+ {name: "unrelated allowlist denies all", args: Args{ToolsSet: true, Tools: []string{"read"}}},
23+ {name: "bare subagent grants every action", args: Args{ToolsSet: true, Tools: []string{"subagent"}}, spawn: true, status: true, stop: true, resume: true},
24+ {name: "bare subagent wins beside a suffix", args: Args{ToolsSet: true, Tools: []string{"read", "subagent:resume", "subagent"}}, spawn: true, status: true, stop: true, resume: true},
25+ {name: "one action suffix grants one action", args: Args{ToolsSet: true, Tools: []string{"subagent:resume"}}, resume: true},
26+ {name: "two action suffixes grant two actions", args: Args{ToolsSet: true, Tools: []string{"subagent:spawn", "subagent:stop"}}, spawn: true, stop: true},
27+ {name: "unknown action suffix denies all", args: Args{ToolsSet: true, Tools: []string{"subagent:bogus"}}},
28+ {name: "empty allowlist denies all", args: Args{ToolsSet: true, Tools: []string{}}},
29+ {name: "no tools denies all", args: Args{NoTools: true}},
30+ {name: "packaged permission set denies all", args: Args{PermissionSet: &tools.PermissionSet{}}},
31+ }
32+ for _, tc := range cases {
33+ t.Run(tc.name, func(t *testing.T) {
34+ got := [4]bool{
35+ autoSubagentsToolAllowed(tc.args),
36+ autoSubagentsStatusToolAllowed(tc.args),
37+ autoSubagentsStopToolAllowed(tc.args),
38+ autoSubagentsResumeToolAllowed(tc.args),
39+ }
40+ want := [4]bool{tc.spawn, tc.status, tc.stop, tc.resume}
41+ if got != want {
42+ t.Fatalf("actions = %v, want %v (args %+v)", got, want, tc.args)
43+ }
44+ wantAny := tc.spawn || tc.status || tc.stop || tc.resume
45+ if any := autoSubagentsAnyToolAllowed(tc.args); any != wantAny {
46+ t.Fatalf("autoSubagentsAnyToolAllowed = %v, want %v (args %+v)", any, wantAny, tc.args)
47+ }
48+ })
49+ }
50+}
M packages/agent/subagents/profiles.go +1 -1
@@ -161,7 +161,7 @@ func SystemPromptAddendum(profiles []*Profile) string {
161161 }
162162 var sb strings.Builder
163163 sb.WriteString("[subagents_list]\n")
164- sb.WriteString("Named subagents available to subagent_spawn. Choose the profile whose description best matches the independent task and pass its name as the tool's agent field. The selected profile's instructions, model, thinking level, tool limits, and fast-mode preference are applied to the child.\n")
164+ sb.WriteString("Named subagents available to the subagent tool's spawn action. Choose the profile whose description best matches the independent task and pass its name as the tool's agent field. The selected profile's instructions, model, thinking level, tool limits, and fast-mode preference are applied to the child.\n")
165165 for _, profile := range profiles {
166166 if profile == nil {
167167 continue
@@ -161,7 +161,7 @@ func SystemPromptAddendum(profiles []*Profile) string {
161 }161 }
162 var sb strings.Builder162 var sb strings.Builder
163 sb.WriteString("[subagents_list]\n")163 sb.WriteString("[subagents_list]\n")
164- sb.WriteString("Named subagents available to subagent_spawn. Choose the profile whose description best matches the independent task and pass its name as the tool's agent field. The selected profile's instructions, model, thinking level, tool limits, and fast-mode preference are applied to the child.\n")164+ sb.WriteString("Named subagents available to the subagent tool's spawn action. Choose the profile whose description best matches the independent task and pass its name as the tool's agent field. The selected profile's instructions, model, thinking level, tool limits, and fast-mode preference are applied to the child.\n")
165 for _, profile := range profiles {165 for _, profile := range profiles {
166 if profile == nil {166 if profile == nil {
167 continue167 continue
M packages/agent/subagents/resident_manager.go +12 -1
@@ -439,6 +439,18 @@ func (m *ResidentManager) dispatch() {
439439 // resident child. A disk-only child is rebuilt only for this explicit new
440440 // prompt; reconciliation itself never constructs or replays one.
441441 func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) error {
442+ return m.ResumeWithTurn(ctx, childID, prompt, uuid.NewString())
443+}
444+
445+// ResumeWithTurn is Resume with a caller-owned turn ID. The ID names the
446+// accepted follow-up turn in its completion, so a caller that must observe the
447+// outcome can subscribe with WatchCompletion before the turn can finish. The
448+// ID must be unique per child: replay rejects a duplicate turn.accepted record,
449+// so reusing one makes the journal unreplayable.
450+func (m *ResidentManager) ResumeWithTurn(ctx context.Context, childID, prompt, turnID string) error {
451+ if strings.TrimSpace(turnID) == "" {
452+ turnID = uuid.NewString()
453+ }
442454 if m == nil {
443455 return errors.New("resident manager: unavailable")
444456 }
@@ -537,7 +549,6 @@ func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) er
537549 if child == nil {
538550 return errors.New("resident manager: child is not live")
539551 }
540- turnID := uuid.NewString()
541552 if child.journal == nil {
542553 _, err := child.resumeAccepted(ctx, turnID, prompt)
543554 return err
@@ -439,6 +439,18 @@ func (m *ResidentManager) dispatch() {
439 // resident child. A disk-only child is rebuilt only for this explicit new439 // resident child. A disk-only child is rebuilt only for this explicit new
440 // prompt; reconciliation itself never constructs or replays one.440 // prompt; reconciliation itself never constructs or replays one.
441 func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) error {441 func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) error {
442+ return m.ResumeWithTurn(ctx, childID, prompt, uuid.NewString())
443+}
444+
445+// ResumeWithTurn is Resume with a caller-owned turn ID. The ID names the
446+// accepted follow-up turn in its completion, so a caller that must observe the
447+// outcome can subscribe with WatchCompletion before the turn can finish. The
448+// ID must be unique per child: replay rejects a duplicate turn.accepted record,
449+// so reusing one makes the journal unreplayable.
450+func (m *ResidentManager) ResumeWithTurn(ctx context.Context, childID, prompt, turnID string) error {
451+ if strings.TrimSpace(turnID) == "" {
452+ turnID = uuid.NewString()
453+ }
442 if m == nil {454 if m == nil {
443 return errors.New("resident manager: unavailable")455 return errors.New("resident manager: unavailable")
444 }456 }
@@ -537,7 +549,6 @@ func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) er
537 if child == nil {549 if child == nil {
538 return errors.New("resident manager: child is not live")550 return errors.New("resident manager: child is not live")
539 }551 }
540- turnID := uuid.NewString()
541 if child.journal == nil {552 if child.journal == nil {
542 _, err := child.resumeAccepted(ctx, turnID, prompt)553 _, err := child.resumeAccepted(ctx, turnID, prompt)
543 return err554 return err
M packages/agent/subagents/resident_manager_test.go +67 -0
@@ -81,6 +81,73 @@ func TestResidentManagerCompletedAnswerCanResume(t *testing.T) {
8181 }
8282 }
8383
84+// ResumeWithTurn lets a caller subscribe to the exact follow-up turn before it
85+// can complete.
86+func TestResidentManagerResumeWithTurnNamesTheAcceptedTurn(t *testing.T) {
87+ manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, _ *ResidentJournal) (ResidentTurnRunner, error) {
88+ return func(context.Context, string) error { return nil }, nil
89+ })
90+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
91+ initial, cancelInitial := manager.WatchCompletion("turn-child", "initial-turn")
92+ defer cancelInitial()
93+ if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "turn-child", InitialTurnID: "initial-turn", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}, "start"); err != nil {
94+ t.Fatal(err)
95+ }
96+ select {
97+ case <-initial:
98+ case <-time.After(time.Second):
99+ t.Fatal("initial turn did not complete")
100+ }
101+ followUp, cancelFollowUp := manager.WatchCompletion("turn-child", "follow-up-turn")
102+ defer cancelFollowUp()
103+ if err := manager.ResumeWithTurn(context.Background(), "turn-child", "continue", "follow-up-turn"); err != nil {
104+ t.Fatalf("ResumeWithTurn: %v", err)
105+ }
106+ select {
107+ case completion := <-followUp:
108+ if completion.TurnID != "follow-up-turn" {
109+ t.Fatalf("completion turn ID = %q", completion.TurnID)
110+ }
111+ if completion.Err != nil {
112+ t.Fatalf("completion error = %v", completion.Err)
113+ }
114+ case <-time.After(time.Second):
115+ t.Fatal("no completion for the caller-owned turn")
116+ }
117+}
118+
119+// A blank or whitespace caller turn ID falls back to a generated unique ID, so
120+// the completion is still addressable and the journal stays replayable.
121+func TestResidentManagerResumeWithTurnGeneratesIDForBlank(t *testing.T) {
122+ completions := make(chan ResidentCompletion, 4)
123+ manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, _ *ResidentJournal) (ResidentTurnRunner, error) {
124+ return func(context.Context, string) error { return nil }, nil
125+ })
126+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
127+ initial, cancelInitial := manager.WatchCompletion("blank-turn-child", "initial-turn")
128+ defer cancelInitial()
129+ if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "blank-turn-child", InitialTurnID: "initial-turn", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}, "start"); err != nil {
130+ t.Fatal(err)
131+ }
132+ select {
133+ case <-initial:
134+ case <-time.After(5 * time.Second):
135+ t.Fatal("initial turn did not complete")
136+ }
137+ manager.SetCompletionObserver(func(completion ResidentCompletion) { completions <- completion })
138+ if err := manager.ResumeWithTurn(context.Background(), "blank-turn-child", "continue", " "); err != nil {
139+ t.Fatalf("ResumeWithTurn: %v", err)
140+ }
141+ select {
142+ case completion := <-completions:
143+ if completion.TurnID == "" || strings.TrimSpace(completion.TurnID) == "" {
144+ t.Fatalf("completion turn ID = %q, want a generated non-blank ID", completion.TurnID)
145+ }
146+ case <-time.After(5 * time.Second):
147+ t.Fatal("no completion for the blank caller turn ID")
148+ }
149+}
150+
84151 func TestResidentManagerCompletionCarriesFinalSummary(t *testing.T) {
85152 completed := make(chan ResidentCompletion, 1)
86153 manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) {
@@ -81,6 +81,73 @@ func TestResidentManagerCompletedAnswerCanResume(t *testing.T) {
81 }81 }
82 }82 }
83 83
84+// ResumeWithTurn lets a caller subscribe to the exact follow-up turn before it
85+// can complete.
86+func TestResidentManagerResumeWithTurnNamesTheAcceptedTurn(t *testing.T) {
87+ manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, _ *ResidentJournal) (ResidentTurnRunner, error) {
88+ return func(context.Context, string) error { return nil }, nil
89+ })
90+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
91+ initial, cancelInitial := manager.WatchCompletion("turn-child", "initial-turn")
92+ defer cancelInitial()
93+ if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "turn-child", InitialTurnID: "initial-turn", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}, "start"); err != nil {
94+ t.Fatal(err)
95+ }
96+ select {
97+ case <-initial:
98+ case <-time.After(time.Second):
99+ t.Fatal("initial turn did not complete")
100+ }
101+ followUp, cancelFollowUp := manager.WatchCompletion("turn-child", "follow-up-turn")
102+ defer cancelFollowUp()
103+ if err := manager.ResumeWithTurn(context.Background(), "turn-child", "continue", "follow-up-turn"); err != nil {
104+ t.Fatalf("ResumeWithTurn: %v", err)
105+ }
106+ select {
107+ case completion := <-followUp:
108+ if completion.TurnID != "follow-up-turn" {
109+ t.Fatalf("completion turn ID = %q", completion.TurnID)
110+ }
111+ if completion.Err != nil {
112+ t.Fatalf("completion error = %v", completion.Err)
113+ }
114+ case <-time.After(time.Second):
115+ t.Fatal("no completion for the caller-owned turn")
116+ }
117+}
118+
119+// A blank or whitespace caller turn ID falls back to a generated unique ID, so
120+// the completion is still addressable and the journal stays replayable.
121+func TestResidentManagerResumeWithTurnGeneratesIDForBlank(t *testing.T) {
122+ completions := make(chan ResidentCompletion, 4)
123+ manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, _ *ResidentJournal) (ResidentTurnRunner, error) {
124+ return func(context.Context, string) error { return nil }, nil
125+ })
126+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
127+ initial, cancelInitial := manager.WatchCompletion("blank-turn-child", "initial-turn")
128+ defer cancelInitial()
129+ if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "blank-turn-child", InitialTurnID: "initial-turn", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}, "start"); err != nil {
130+ t.Fatal(err)
131+ }
132+ select {
133+ case <-initial:
134+ case <-time.After(5 * time.Second):
135+ t.Fatal("initial turn did not complete")
136+ }
137+ manager.SetCompletionObserver(func(completion ResidentCompletion) { completions <- completion })
138+ if err := manager.ResumeWithTurn(context.Background(), "blank-turn-child", "continue", " "); err != nil {
139+ t.Fatalf("ResumeWithTurn: %v", err)
140+ }
141+ select {
142+ case completion := <-completions:
143+ if completion.TurnID == "" || strings.TrimSpace(completion.TurnID) == "" {
144+ t.Fatalf("completion turn ID = %q, want a generated non-blank ID", completion.TurnID)
145+ }
146+ case <-time.After(5 * time.Second):
147+ t.Fatal("no completion for the blank caller turn ID")
148+ }
149+}
150+
84 func TestResidentManagerCompletionCarriesFinalSummary(t *testing.T) {151 func TestResidentManagerCompletionCarriesFinalSummary(t *testing.T) {
85 completed := make(chan ResidentCompletion, 1)152 completed := make(chan ResidentCompletion, 1)
86 manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) {153 manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) {
M packages/agent/tools/resident_tools_test.go +167 -3
@@ -6,6 +6,7 @@ import (
66 "errors"
77 "strings"
88 "sync"
9+ "sync/atomic"
910 "testing"
1011 "time"
1112
@@ -106,9 +107,9 @@ func TestResidentToolsUseManagerOnly(t *testing.T) {
106107 }
107108
108109 func TestSubagentSpawnGuidanceRequiresIndependentOwnership(t *testing.T) {
109- spawn := &SubagentSpawnTool{}
110+ facade := &SubagentTool{}
110111 for _, want := range []string{"independent sidecar", "keep immediate blockers local", "never duplicate it in the parent", "end or yield the parent turn"} {
111- if got := spawn.Description(); !strings.Contains(got, want) {
112+ if got := facade.Description(); !strings.Contains(got, want) {
112113 t.Fatalf("description missing %q: %s", want, got)
113114 }
114115 }
@@ -117,7 +118,7 @@ func TestSubagentSpawnGuidanceRequiresIndependentOwnership(t *testing.T) {
117118 Description string `json:"description"`
118119 } `json:"properties"`
119120 }
120- if err := json.Unmarshal(spawn.Schema(), &schema); err != nil {
121+ if err := json.Unmarshal(facade.Schema(), &schema); err != nil {
121122 t.Fatal(err)
122123 }
123124 task := schema.Properties["task"].Description
@@ -314,6 +315,169 @@ func TestResidentToolsRejectUnknownFields(t *testing.T) {
314315 }
315316 }
316317
318+// A resume wait subscribes to the accepted follow-up turn before it can finish,
319+// so it returns that turn's completion instead of timing out.
320+func TestResidentResumeWaitReturnsFollowUpCompletion(t *testing.T) {
321+ manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
322+ return func(_ context.Context, prompt string) error {
323+ return journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "answer for " + prompt}}}})
324+ }, nil
325+ })
326+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
327+ initial, cancelInitial := manager.WatchCompletion("waited-resume", "initial-turn")
328+ defer cancelInitial()
329+ if _, err := manager.Spawn(context.Background(), subagents.ResidentChildSpec{ID: "waited-resume", InitialTurnID: "initial-turn", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}, "start"); err != nil {
330+ t.Fatal(err)
331+ }
332+ select {
333+ case <-initial:
334+ case <-time.After(5 * time.Second):
335+ t.Fatal("initial turn did not complete")
336+ }
337+ resume := &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }}
338+ result, err := resume.Execute(context.Background(), json.RawMessage(`{"agent_id":"waited-resume","prompt":"confirm","wait":5}`), nil)
339+ if err != nil || result.IsError {
340+ t.Fatalf("resume = (%#v, %v)", result, err)
341+ }
342+ response, ok := result.Details.(subagentActionResponse)
343+ if !ok || response.Wait == nil || response.Wait.TimedOut {
344+ t.Fatalf("wait outcome = %#v", result.Details)
345+ }
346+ if response.Wait.Status != string(subagents.ResidentCompleted) || response.Wait.Summary != "answer for confirm" {
347+ t.Fatalf("wait outcome = %#v", response.Wait)
348+ }
349+ if response.Action != "resumed" || response.Agent.ID != "waited-resume" {
350+ t.Fatalf("response = %#v", response)
351+ }
352+}
353+
354+// An expired resume wait reports the timeout and leaves the child active.
355+func TestResidentResumeWaitExpiryLeavesChildActive(t *testing.T) {
356+ started := make(chan struct{}, 1)
357+ release := make(chan struct{})
358+ var releaseOnce sync.Once
359+ releaseBlocker := func() { releaseOnce.Do(func() { close(release) }) }
360+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
361+ return func(ctx context.Context, _ string) error {
362+ started <- struct{}{}
363+ select {
364+ case <-release:
365+ return nil
366+ case <-ctx.Done():
367+ return ctx.Err()
368+ }
369+ }, nil
370+ })
371+ t.Cleanup(func() {
372+ releaseBlocker()
373+ _ = manager.Close(context.Background())
374+ })
375+ if _, err := manager.Spawn(context.Background(), subagents.ResidentChildSpec{ID: "blocked-resume", InitialTurnID: "initial-turn", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}, "blocker"); err != nil {
376+ t.Fatal(err)
377+ }
378+ select {
379+ case <-started:
380+ case <-time.After(5 * time.Second):
381+ t.Fatal("blocking turn did not start")
382+ }
383+ resume := &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }}
384+ result, err := resume.Execute(context.Background(), json.RawMessage(`{"agent_id":"blocked-resume","prompt":"follow up","wait":1}`), nil)
385+ if err != nil || result.IsError {
386+ t.Fatalf("resume = (%#v, %v)", result, err)
387+ }
388+ response, ok := result.Details.(subagentActionResponse)
389+ if !ok || response.Wait == nil || !response.Wait.TimedOut || response.Wait.Seconds != 1 {
390+ t.Fatalf("wait outcome = %#v", result.Details)
391+ }
392+ if state, found := manager.State("blocked-resume"); !found || state != subagents.ResidentRunning {
393+ t.Fatalf("child state = %q (found=%t), want running", state, found)
394+ }
395+ releaseBlocker()
396+}
397+
398+// A cancelled parent context wins over an expiring wait: the tool returns the
399+// host context error rather than a timeout result.
400+func TestResidentResumeWaitCancellationBeatsTimeout(t *testing.T) {
401+ release := make(chan struct{})
402+ var releaseOnce sync.Once
403+ releaseBlocker := func() { releaseOnce.Do(func() { close(release) }) }
404+ var calls atomic.Int32
405+ followUpStarted := make(chan struct{}, 1)
406+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
407+ return func(ctx context.Context, _ string) error {
408+ if calls.Add(1) == 1 {
409+ return nil
410+ }
411+ followUpStarted <- struct{}{}
412+ select {
413+ case <-release:
414+ return nil
415+ case <-ctx.Done():
416+ return ctx.Err()
417+ }
418+ }, nil
419+ })
420+ t.Cleanup(func() {
421+ releaseBlocker()
422+ _ = manager.Close(context.Background())
423+ })
424+ initial, cancelInitial := manager.WatchCompletion("cancel-resume", "initial-turn")
425+ defer cancelInitial()
426+ if _, err := manager.Spawn(context.Background(), subagents.ResidentChildSpec{ID: "cancel-resume", InitialTurnID: "initial-turn", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}, "start"); err != nil {
427+ t.Fatal(err)
428+ }
429+ select {
430+ case <-initial:
431+ case <-time.After(5 * time.Second):
432+ t.Fatal("initial turn did not complete")
433+ }
434+
435+ resume := &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }}
436+ ctx, cancel := context.WithCancel(context.Background())
437+ defer cancel()
438+ type outcome struct {
439+ result core.ToolResult
440+ err error
441+ }
442+ done := make(chan outcome, 1)
443+ go func() {
444+ result, err := resume.Execute(ctx, json.RawMessage(`{"agent_id":"cancel-resume","prompt":"follow up","wait":300}`), nil)
445+ done <- outcome{result: result, err: err}
446+ }()
447+ select {
448+ case <-followUpStarted:
449+ case <-time.After(5 * time.Second):
450+ t.Fatal("follow-up turn did not start")
451+ }
452+ cancel()
453+ select {
454+ case got := <-done:
455+ if !errors.Is(got.err, context.Canceled) {
456+ t.Fatalf("Execute error = %v, want context.Canceled (result=%#v)", got.err, got.result)
457+ }
458+ if got.result.IsError || len(got.result.Content) != 0 {
459+ t.Fatalf("result = %#v, want an empty non-error result on host cancellation", got.result)
460+ }
461+ case <-time.After(5 * time.Second):
462+ t.Fatal("resume did not return after cancellation")
463+ }
464+}
465+
466+func TestResidentResumeRejectsWaitOutsideBounds(t *testing.T) {
467+ manager := subagents.NewResidentManager(t.TempDir(), nil)
468+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
469+ resume := &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }}
470+ for _, raw := range []string{
471+ `{"agent_id":"child","prompt":"x","wait":0}`,
472+ `{"agent_id":"child","prompt":"x","wait":301}`,
473+ } {
474+ result, err := resume.Execute(context.Background(), json.RawMessage(raw), nil)
475+ if err != nil || !result.IsError || !strings.Contains(toolResultText(t, result), "wait must be between 1 and 300 seconds") {
476+ t.Fatalf("Execute(%s) = (%#v, %v)", raw, result, err)
477+ }
478+ }
479+}
480+
317481 func toolResultText(t *testing.T, result core.ToolResult) string {
318482 t.Helper()
319483 if len(result.Content) != 1 {
@@ -6,6 +6,7 @@ import (
6 "errors"6 "errors"
7 "strings"7 "strings"
8 "sync"8 "sync"
9+ "sync/atomic"
9 "testing"10 "testing"
10 "time"11 "time"
11 12
@@ -106,9 +107,9 @@ func TestResidentToolsUseManagerOnly(t *testing.T) {
106 }107 }
107 108
108 func TestSubagentSpawnGuidanceRequiresIndependentOwnership(t *testing.T) {109 func TestSubagentSpawnGuidanceRequiresIndependentOwnership(t *testing.T) {
109- spawn := &SubagentSpawnTool{}110+ facade := &SubagentTool{}
110 for _, want := range []string{"independent sidecar", "keep immediate blockers local", "never duplicate it in the parent", "end or yield the parent turn"} {111 for _, want := range []string{"independent sidecar", "keep immediate blockers local", "never duplicate it in the parent", "end or yield the parent turn"} {
111- if got := spawn.Description(); !strings.Contains(got, want) {112+ if got := facade.Description(); !strings.Contains(got, want) {
112 t.Fatalf("description missing %q: %s", want, got)113 t.Fatalf("description missing %q: %s", want, got)
113 }114 }
114 }115 }
@@ -117,7 +118,7 @@ func TestSubagentSpawnGuidanceRequiresIndependentOwnership(t *testing.T) {
117 Description string `json:"description"`118 Description string `json:"description"`
118 } `json:"properties"`119 } `json:"properties"`
119 }120 }
120- if err := json.Unmarshal(spawn.Schema(), &schema); err != nil {121+ if err := json.Unmarshal(facade.Schema(), &schema); err != nil {
121 t.Fatal(err)122 t.Fatal(err)
122 }123 }
123 task := schema.Properties["task"].Description124 task := schema.Properties["task"].Description
@@ -314,6 +315,169 @@ func TestResidentToolsRejectUnknownFields(t *testing.T) {
314 }315 }
315 }316 }
316 317
318+// A resume wait subscribes to the accepted follow-up turn before it can finish,
319+// so it returns that turn's completion instead of timing out.
320+func TestResidentResumeWaitReturnsFollowUpCompletion(t *testing.T) {
321+ manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
322+ return func(_ context.Context, prompt string) error {
323+ return journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "answer for " + prompt}}}})
324+ }, nil
325+ })
326+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
327+ initial, cancelInitial := manager.WatchCompletion("waited-resume", "initial-turn")
328+ defer cancelInitial()
329+ if _, err := manager.Spawn(context.Background(), subagents.ResidentChildSpec{ID: "waited-resume", InitialTurnID: "initial-turn", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}, "start"); err != nil {
330+ t.Fatal(err)
331+ }
332+ select {
333+ case <-initial:
334+ case <-time.After(5 * time.Second):
335+ t.Fatal("initial turn did not complete")
336+ }
337+ resume := &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }}
338+ result, err := resume.Execute(context.Background(), json.RawMessage(`{"agent_id":"waited-resume","prompt":"confirm","wait":5}`), nil)
339+ if err != nil || result.IsError {
340+ t.Fatalf("resume = (%#v, %v)", result, err)
341+ }
342+ response, ok := result.Details.(subagentActionResponse)
343+ if !ok || response.Wait == nil || response.Wait.TimedOut {
344+ t.Fatalf("wait outcome = %#v", result.Details)
345+ }
346+ if response.Wait.Status != string(subagents.ResidentCompleted) || response.Wait.Summary != "answer for confirm" {
347+ t.Fatalf("wait outcome = %#v", response.Wait)
348+ }
349+ if response.Action != "resumed" || response.Agent.ID != "waited-resume" {
350+ t.Fatalf("response = %#v", response)
351+ }
352+}
353+
354+// An expired resume wait reports the timeout and leaves the child active.
355+func TestResidentResumeWaitExpiryLeavesChildActive(t *testing.T) {
356+ started := make(chan struct{}, 1)
357+ release := make(chan struct{})
358+ var releaseOnce sync.Once
359+ releaseBlocker := func() { releaseOnce.Do(func() { close(release) }) }
360+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
361+ return func(ctx context.Context, _ string) error {
362+ started <- struct{}{}
363+ select {
364+ case <-release:
365+ return nil
366+ case <-ctx.Done():
367+ return ctx.Err()
368+ }
369+ }, nil
370+ })
371+ t.Cleanup(func() {
372+ releaseBlocker()
373+ _ = manager.Close(context.Background())
374+ })
375+ if _, err := manager.Spawn(context.Background(), subagents.ResidentChildSpec{ID: "blocked-resume", InitialTurnID: "initial-turn", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}, "blocker"); err != nil {
376+ t.Fatal(err)
377+ }
378+ select {
379+ case <-started:
380+ case <-time.After(5 * time.Second):
381+ t.Fatal("blocking turn did not start")
382+ }
383+ resume := &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }}
384+ result, err := resume.Execute(context.Background(), json.RawMessage(`{"agent_id":"blocked-resume","prompt":"follow up","wait":1}`), nil)
385+ if err != nil || result.IsError {
386+ t.Fatalf("resume = (%#v, %v)", result, err)
387+ }
388+ response, ok := result.Details.(subagentActionResponse)
389+ if !ok || response.Wait == nil || !response.Wait.TimedOut || response.Wait.Seconds != 1 {
390+ t.Fatalf("wait outcome = %#v", result.Details)
391+ }
392+ if state, found := manager.State("blocked-resume"); !found || state != subagents.ResidentRunning {
393+ t.Fatalf("child state = %q (found=%t), want running", state, found)
394+ }
395+ releaseBlocker()
396+}
397+
398+// A cancelled parent context wins over an expiring wait: the tool returns the
399+// host context error rather than a timeout result.
400+func TestResidentResumeWaitCancellationBeatsTimeout(t *testing.T) {
401+ release := make(chan struct{})
402+ var releaseOnce sync.Once
403+ releaseBlocker := func() { releaseOnce.Do(func() { close(release) }) }
404+ var calls atomic.Int32
405+ followUpStarted := make(chan struct{}, 1)
406+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
407+ return func(ctx context.Context, _ string) error {
408+ if calls.Add(1) == 1 {
409+ return nil
410+ }
411+ followUpStarted <- struct{}{}
412+ select {
413+ case <-release:
414+ return nil
415+ case <-ctx.Done():
416+ return ctx.Err()
417+ }
418+ }, nil
419+ })
420+ t.Cleanup(func() {
421+ releaseBlocker()
422+ _ = manager.Close(context.Background())
423+ })
424+ initial, cancelInitial := manager.WatchCompletion("cancel-resume", "initial-turn")
425+ defer cancelInitial()
426+ if _, err := manager.Spawn(context.Background(), subagents.ResidentChildSpec{ID: "cancel-resume", InitialTurnID: "initial-turn", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}, "start"); err != nil {
427+ t.Fatal(err)
428+ }
429+ select {
430+ case <-initial:
431+ case <-time.After(5 * time.Second):
432+ t.Fatal("initial turn did not complete")
433+ }
434+
435+ resume := &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }}
436+ ctx, cancel := context.WithCancel(context.Background())
437+ defer cancel()
438+ type outcome struct {
439+ result core.ToolResult
440+ err error
441+ }
442+ done := make(chan outcome, 1)
443+ go func() {
444+ result, err := resume.Execute(ctx, json.RawMessage(`{"agent_id":"cancel-resume","prompt":"follow up","wait":300}`), nil)
445+ done <- outcome{result: result, err: err}
446+ }()
447+ select {
448+ case <-followUpStarted:
449+ case <-time.After(5 * time.Second):
450+ t.Fatal("follow-up turn did not start")
451+ }
452+ cancel()
453+ select {
454+ case got := <-done:
455+ if !errors.Is(got.err, context.Canceled) {
456+ t.Fatalf("Execute error = %v, want context.Canceled (result=%#v)", got.err, got.result)
457+ }
458+ if got.result.IsError || len(got.result.Content) != 0 {
459+ t.Fatalf("result = %#v, want an empty non-error result on host cancellation", got.result)
460+ }
461+ case <-time.After(5 * time.Second):
462+ t.Fatal("resume did not return after cancellation")
463+ }
464+}
465+
466+func TestResidentResumeRejectsWaitOutsideBounds(t *testing.T) {
467+ manager := subagents.NewResidentManager(t.TempDir(), nil)
468+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
469+ resume := &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }}
470+ for _, raw := range []string{
471+ `{"agent_id":"child","prompt":"x","wait":0}`,
472+ `{"agent_id":"child","prompt":"x","wait":301}`,
473+ } {
474+ result, err := resume.Execute(context.Background(), json.RawMessage(raw), nil)
475+ if err != nil || !result.IsError || !strings.Contains(toolResultText(t, result), "wait must be between 1 and 300 seconds") {
476+ t.Fatalf("Execute(%s) = (%#v, %v)", raw, result, err)
477+ }
478+ }
479+}
480+
317 func toolResultText(t *testing.T, result core.ToolResult) string {481 func toolResultText(t *testing.T, result core.ToolResult) string {
318 t.Helper()482 t.Helper()
319 if len(result.Content) != 1 {483 if len(result.Content) != 1 {
A packages/agent/tools/subagent.go +252 -0
@@ -0,0 +1,252 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "slices"
9+ "sort"
10+ "strings"
11+
12+ "github.com/bnema/zut/packages/core"
13+)
14+
15+// SubagentTool is the single model-facing subagent tool. One call carries one
16+// action; each action delegates to an internal implementation, and a nil
17+// implementation means that action is unavailable under the launch-time policy.
18+type SubagentTool struct {
19+ Spawn *SubagentSpawnTool
20+ Status *SubagentStatusTool
21+ Stop *SubagentStopTool
22+ Resume *SubagentResumeTool
23+}
24+
25+// subagentSpawnGuidance is the operational contract for delegation. It is the
26+// facade description tail and the internal spawn tool description, so the text
27+// cannot drift between them.
28+const subagentSpawnGuidance = "Delegate a concrete, bounded scope to a resident sub-agent. For proactive delegation, use an independent sidecar only when the parent has useful non-overlapping work; keep immediate blockers local. A worker owns its scope until completion, so never duplicate it in the parent. If delegation owns the blocking task, end or yield the parent turn. Omit wait to return immediately and receive completion through [auto-subagents update]; set wait to an explicit 1–300 second value only when this turn should wait for the initial task. Set required=true when the outcome is mandatory before the parent's terminal response; failures remain recoverable through the resume action. Never use bash sleep, watch, tail -f, polling loops, repeated status calls, dashboard, metadata, or file checks solely to wait."
29+
30+// subagentSchema merges the four action schemas into the flat, model-facing
31+// schema the project keeps for provider compatibility. Every property is
32+// annotated with the action that owns it; agent_id carries one merged
33+// description because it means "omit to list" for status and "required" for
34+// stop and resume. Because the schema advertises every action's arguments at
35+// once, a field that belongs to a different action is reported instead of
36+// ignored, so a mis-selected action cannot silently run as another one. Fields
37+// that no action owns still reach the sub-tool, whose strict decoder rejects
38+// them. TestSubagentFacadeSchemaCoversActionFields keeps this schema in sync
39+// with subagentActionFields.
40+const subagentSchema = `{
41+ "type": "object",
42+ "properties": {
43+ "action": {
44+ "type": "string",
45+ "enum": ["spawn", "status", "stop", "resume"],
46+ "description": "One action per call. spawn delegates a new resident sub-agent. status reads bounded state for one child or lists the current set. stop requests termination of a stuck child. resume continues an existing child with a new prompt, keeping its session context, and can wait for that follow-up turn."
47+ },
48+ "task": {
49+ "type": "string",
50+ "description": "The full task description for the sub-agent. Assign a concrete, bounded scope and explicit ownership that does not overlap other active work. Be specific: the child normally has the main agent's built-in tools, including lsp when enabled, but a selected profile can restrict its tools; it starts with NO context from this conversation. Shared isolation uses this working directory; worktree isolation captures a patch without merging it. Required when action is spawn."
51+ },
52+ "agent": {
53+ "type": "string",
54+ "description": "Optional named markdown profile from [subagents_list]. The child applies that profile's system prompt, model, thinking level, tool limits, and fast-mode preference. Omit for a generic child. Only valid when action is spawn."
55+ },
56+ "model": {
57+ "type": "string",
58+ "description": "Optional model id to pin the sub-agent to. Normally omit both model and provider so the sub-agent inherits the host session's resolved provider/model/auth route, or omit them when using an agent profile. Do not infer provider from model name. If you override this, also provide provider. Only valid when action is spawn."
59+ },
60+ "provider": {
61+ "type": "string",
62+ "description": "Optional provider id. Normally omit both model and provider so the sub-agent inherits the host session. If you override this, also provide model. Note: openai means public OpenAI API-key auth; openai-codex means ChatGPT/Codex subscription auth. Only valid when action is spawn."
63+ },
64+ "reasoning": {
65+ "type": "string",
66+ "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"],
67+ "description": "Optional reasoning level for the child. Overrides the selected profile's thinking level when provided. Only valid when action is spawn."
68+ },
69+ "fast_mode": {
70+ "type": "boolean",
71+ "description": "Optional fast-mode override for the child. Omit to inherit the selected profile or host setting. Only valid when action is spawn."
72+ },
73+ "required": {
74+ "type": "boolean",
75+ "description": "Set true when the parent must receive this delegated result before it can finish. The worker remains asynchronous and reports through a host completion update. A bounded wait expiring does not terminate the accepted child and must not be retried while it remains active. Terminal failure or cancellation remains unmet until a successful follow-up. An outcome unobserved across host restart requires explicit user reconciliation. Only valid when action is spawn."
76+ },
77+ "wait": {
78+ "type": "integer",
79+ "minimum": 1,
80+ "maximum": 300,
81+ "description": "Optional explicit number of seconds to wait for this sub-agent's initial task to finish. Omit to return immediately. The sub-agent continues in the background if this wait expires. With resume it waits for the accepted follow-up turn instead. Only valid when action is spawn or resume."
82+ },
83+ "isolation": {
84+ "type": "string",
85+ "enum": ["shared", "worktree"],
86+ "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it. Only valid when action is spawn."
87+ },
88+ "agent_id": {
89+ "type": "string",
90+ "description": "Child id or unique id prefix. With status, omit it to list all resident sub-agents. Required when action is stop or resume."
91+ },
92+ "include_result": {
93+ "type": "boolean",
94+ "description": "With status and agent_id, retrieve the saved terminal result without running the child. Only valid when action is status."
95+ },
96+ "prompt": {
97+ "type": "string",
98+ "description": "New manager follow-up for the sub-agent. Its earlier task and conversation remain available in the retained session. Required when action is resume. After a terminal failure, inspect its saved result first; resume continues the retained session without discarding progress or satisfying required work. Combine with wait to block on that follow-up turn."
99+ }
100+ },
101+ "required": ["action"]
102+}`
103+
104+// subagentActionFields lists the arguments each action owns. A field owned by
105+// the selected action, or by no action, is forwarded; a field owned by another
106+// action is rejected before dispatch.
107+var subagentActionFields = map[string][]string{
108+ SubagentActionSpawn: {"task", "agent", "model", "provider", "reasoning", "fast_mode", "required", "wait", "isolation"},
109+ SubagentActionStatus: {"agent_id", "include_result"},
110+ SubagentActionStop: {"agent_id"},
111+ SubagentActionResume: {"agent_id", "prompt", "wait"},
112+}
113+
114+var (
115+ errSubagentActionRequired = errors.New("subagent action is required")
116+ errSubagentActionType = errors.New("subagent action must be a string")
117+ errSubagentActionUnknown = errors.New("subagent action is unknown")
118+)
119+
120+// subagentForeignFieldError reports an argument that belongs only to other
121+// actions than the one the caller selected.
122+type subagentForeignFieldError struct {
123+ field string
124+ action string
125+ owners []string
126+}
127+
128+func (e *subagentForeignFieldError) Error() string {
129+ if len(e.owners) == 1 {
130+ return fmt.Sprintf("subagent: %s is not valid for action %s; it belongs to action %s", e.field, e.action, e.owners[0])
131+ }
132+ return fmt.Sprintf("subagent: %s is not valid for action %s; it belongs to the %s actions", e.field, e.action, strings.Join(e.owners, ", "))
133+}
134+
135+func (t *SubagentTool) Name() string { return SubagentToolName }
136+func (t *SubagentTool) Description() string {
137+ return "Spawn, inspect, resume, and stop resident sub-agents. One action per call.\n\n" + subagentSpawnGuidance
138+}
139+func (t *SubagentTool) Schema() json.RawMessage { return json.RawMessage(subagentSchema) }
140+
141+// ConfirmationKey implements core.ConfirmationKeyer. The facade multiplexes
142+// four actions under one tool name, so the session "always allow" grant is
143+// scoped per action instead of per name. A missing, empty, or non-string
144+// action returns the plain base name so a malformed call cannot widen the
145+// remembered grant.
146+func (t *SubagentTool) ConfirmationKey(args json.RawMessage) string {
147+ var values map[string]json.RawMessage
148+ if err := json.Unmarshal(args, &values); err != nil {
149+ return SubagentToolName
150+ }
151+ var action string
152+ if err := json.Unmarshal(values["action"], &action); err != nil {
153+ return SubagentToolName
154+ }
155+ action = strings.TrimSpace(action)
156+ if action == "" {
157+ return SubagentToolName
158+ }
159+ return SubagentToolName + ":" + action
160+}
161+
162+var _ core.ConfirmationKeyer = (*SubagentTool)(nil)
163+
164+func (t *SubagentTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
165+ action, payload, err := parseSubagentAction(raw)
166+ switch {
167+ case errors.Is(err, errSubagentActionRequired):
168+ return protocolToolError("subagent: action is required")
169+ case errors.Is(err, errSubagentActionType):
170+ return protocolToolError("subagent: action must be a string")
171+ case errors.Is(err, errSubagentActionUnknown):
172+ return protocolToolError("subagent: unknown action")
173+ }
174+ var foreign *subagentForeignFieldError
175+ if errors.As(err, &foreign) {
176+ return protocolToolError(foreign.Error())
177+ }
178+ if err != nil {
179+ return core.ToolResult{}, err
180+ }
181+ switch action {
182+ case SubagentActionSpawn:
183+ if t.Spawn == nil {
184+ return protocolToolError("subagent: spawn action is unavailable in this mode")
185+ }
186+ return t.Spawn.Execute(ctx, payload, progress)
187+ case SubagentActionStatus:
188+ if t.Status == nil {
189+ return protocolToolError("subagent: status action is unavailable in this mode")
190+ }
191+ return t.Status.Execute(ctx, payload, progress)
192+ case SubagentActionStop:
193+ if t.Stop == nil {
194+ return protocolToolError("subagent: stop action is unavailable in this mode")
195+ }
196+ return t.Stop.Execute(ctx, payload, progress)
197+ case SubagentActionResume:
198+ if t.Resume == nil {
199+ return protocolToolError("subagent: resume action is unavailable in this mode")
200+ }
201+ return t.Resume.Execute(ctx, payload, progress)
202+ }
203+ return protocolToolError("subagent: unknown action")
204+}
205+
206+// parseSubagentAction validates the action and returns the payload that action
207+// owns. Raw field bytes are forwarded unchanged so argument validation and
208+// number handling stay in the sub-tools.
209+func parseSubagentAction(raw json.RawMessage) (string, json.RawMessage, error) {
210+ var values map[string]json.RawMessage
211+ if err := json.Unmarshal(raw, &values); err != nil {
212+ return "", nil, fmt.Errorf("invalid args: %w", err)
213+ }
214+ rawAction, present := values["action"]
215+ if !present {
216+ return "", nil, errSubagentActionRequired
217+ }
218+ var action string
219+ if err := json.Unmarshal(rawAction, &action); err != nil {
220+ return "", nil, fmt.Errorf("%w: %v", errSubagentActionType, err)
221+ }
222+ action = strings.TrimSpace(action)
223+ if action == "" {
224+ return "", nil, errSubagentActionRequired
225+ }
226+ if _, ok := subagentActionFields[action]; !ok {
227+ return "", nil, errSubagentActionUnknown
228+ }
229+ delete(values, "action")
230+ // A field can belong to several actions (agent_id serves status, stop and
231+ // resume), so collect every owner before deciding.
232+ owners := make(map[string][]string, len(subagentActionFields))
233+ for ownerAction, fields := range subagentActionFields {
234+ for _, name := range fields {
235+ owners[name] = append(owners[name], ownerAction)
236+ }
237+ }
238+ payload := make(map[string]json.RawMessage, len(values))
239+ for name, value := range values {
240+ fieldOwners, claimed := owners[name]
241+ if claimed && !slices.Contains(fieldOwners, action) {
242+ sort.Strings(fieldOwners)
243+ return "", nil, &subagentForeignFieldError{field: name, action: action, owners: fieldOwners}
244+ }
245+ payload[name] = value
246+ }
247+ encoded, err := json.Marshal(payload)
248+ if err != nil {
249+ return "", nil, err
250+ }
251+ return action, encoded, nil
252+}
@@ -0,0 +1,252 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "slices"
9+ "sort"
10+ "strings"
11+
12+ "github.com/bnema/zut/packages/core"
13+)
14+
15+// SubagentTool is the single model-facing subagent tool. One call carries one
16+// action; each action delegates to an internal implementation, and a nil
17+// implementation means that action is unavailable under the launch-time policy.
18+type SubagentTool struct {
19+ Spawn *SubagentSpawnTool
20+ Status *SubagentStatusTool
21+ Stop *SubagentStopTool
22+ Resume *SubagentResumeTool
23+}
24+
25+// subagentSpawnGuidance is the operational contract for delegation. It is the
26+// facade description tail and the internal spawn tool description, so the text
27+// cannot drift between them.
28+const subagentSpawnGuidance = "Delegate a concrete, bounded scope to a resident sub-agent. For proactive delegation, use an independent sidecar only when the parent has useful non-overlapping work; keep immediate blockers local. A worker owns its scope until completion, so never duplicate it in the parent. If delegation owns the blocking task, end or yield the parent turn. Omit wait to return immediately and receive completion through [auto-subagents update]; set wait to an explicit 1–300 second value only when this turn should wait for the initial task. Set required=true when the outcome is mandatory before the parent's terminal response; failures remain recoverable through the resume action. Never use bash sleep, watch, tail -f, polling loops, repeated status calls, dashboard, metadata, or file checks solely to wait."
29+
30+// subagentSchema merges the four action schemas into the flat, model-facing
31+// schema the project keeps for provider compatibility. Every property is
32+// annotated with the action that owns it; agent_id carries one merged
33+// description because it means "omit to list" for status and "required" for
34+// stop and resume. Because the schema advertises every action's arguments at
35+// once, a field that belongs to a different action is reported instead of
36+// ignored, so a mis-selected action cannot silently run as another one. Fields
37+// that no action owns still reach the sub-tool, whose strict decoder rejects
38+// them. TestSubagentFacadeSchemaCoversActionFields keeps this schema in sync
39+// with subagentActionFields.
40+const subagentSchema = `{
41+ "type": "object",
42+ "properties": {
43+ "action": {
44+ "type": "string",
45+ "enum": ["spawn", "status", "stop", "resume"],
46+ "description": "One action per call. spawn delegates a new resident sub-agent. status reads bounded state for one child or lists the current set. stop requests termination of a stuck child. resume continues an existing child with a new prompt, keeping its session context, and can wait for that follow-up turn."
47+ },
48+ "task": {
49+ "type": "string",
50+ "description": "The full task description for the sub-agent. Assign a concrete, bounded scope and explicit ownership that does not overlap other active work. Be specific: the child normally has the main agent's built-in tools, including lsp when enabled, but a selected profile can restrict its tools; it starts with NO context from this conversation. Shared isolation uses this working directory; worktree isolation captures a patch without merging it. Required when action is spawn."
51+ },
52+ "agent": {
53+ "type": "string",
54+ "description": "Optional named markdown profile from [subagents_list]. The child applies that profile's system prompt, model, thinking level, tool limits, and fast-mode preference. Omit for a generic child. Only valid when action is spawn."
55+ },
56+ "model": {
57+ "type": "string",
58+ "description": "Optional model id to pin the sub-agent to. Normally omit both model and provider so the sub-agent inherits the host session's resolved provider/model/auth route, or omit them when using an agent profile. Do not infer provider from model name. If you override this, also provide provider. Only valid when action is spawn."
59+ },
60+ "provider": {
61+ "type": "string",
62+ "description": "Optional provider id. Normally omit both model and provider so the sub-agent inherits the host session. If you override this, also provide model. Note: openai means public OpenAI API-key auth; openai-codex means ChatGPT/Codex subscription auth. Only valid when action is spawn."
63+ },
64+ "reasoning": {
65+ "type": "string",
66+ "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"],
67+ "description": "Optional reasoning level for the child. Overrides the selected profile's thinking level when provided. Only valid when action is spawn."
68+ },
69+ "fast_mode": {
70+ "type": "boolean",
71+ "description": "Optional fast-mode override for the child. Omit to inherit the selected profile or host setting. Only valid when action is spawn."
72+ },
73+ "required": {
74+ "type": "boolean",
75+ "description": "Set true when the parent must receive this delegated result before it can finish. The worker remains asynchronous and reports through a host completion update. A bounded wait expiring does not terminate the accepted child and must not be retried while it remains active. Terminal failure or cancellation remains unmet until a successful follow-up. An outcome unobserved across host restart requires explicit user reconciliation. Only valid when action is spawn."
76+ },
77+ "wait": {
78+ "type": "integer",
79+ "minimum": 1,
80+ "maximum": 300,
81+ "description": "Optional explicit number of seconds to wait for this sub-agent's initial task to finish. Omit to return immediately. The sub-agent continues in the background if this wait expires. With resume it waits for the accepted follow-up turn instead. Only valid when action is spawn or resume."
82+ },
83+ "isolation": {
84+ "type": "string",
85+ "enum": ["shared", "worktree"],
86+ "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it. Only valid when action is spawn."
87+ },
88+ "agent_id": {
89+ "type": "string",
90+ "description": "Child id or unique id prefix. With status, omit it to list all resident sub-agents. Required when action is stop or resume."
91+ },
92+ "include_result": {
93+ "type": "boolean",
94+ "description": "With status and agent_id, retrieve the saved terminal result without running the child. Only valid when action is status."
95+ },
96+ "prompt": {
97+ "type": "string",
98+ "description": "New manager follow-up for the sub-agent. Its earlier task and conversation remain available in the retained session. Required when action is resume. After a terminal failure, inspect its saved result first; resume continues the retained session without discarding progress or satisfying required work. Combine with wait to block on that follow-up turn."
99+ }
100+ },
101+ "required": ["action"]
102+}`
103+
104+// subagentActionFields lists the arguments each action owns. A field owned by
105+// the selected action, or by no action, is forwarded; a field owned by another
106+// action is rejected before dispatch.
107+var subagentActionFields = map[string][]string{
108+ SubagentActionSpawn: {"task", "agent", "model", "provider", "reasoning", "fast_mode", "required", "wait", "isolation"},
109+ SubagentActionStatus: {"agent_id", "include_result"},
110+ SubagentActionStop: {"agent_id"},
111+ SubagentActionResume: {"agent_id", "prompt", "wait"},
112+}
113+
114+var (
115+ errSubagentActionRequired = errors.New("subagent action is required")
116+ errSubagentActionType = errors.New("subagent action must be a string")
117+ errSubagentActionUnknown = errors.New("subagent action is unknown")
118+)
119+
120+// subagentForeignFieldError reports an argument that belongs only to other
121+// actions than the one the caller selected.
122+type subagentForeignFieldError struct {
123+ field string
124+ action string
125+ owners []string
126+}
127+
128+func (e *subagentForeignFieldError) Error() string {
129+ if len(e.owners) == 1 {
130+ return fmt.Sprintf("subagent: %s is not valid for action %s; it belongs to action %s", e.field, e.action, e.owners[0])
131+ }
132+ return fmt.Sprintf("subagent: %s is not valid for action %s; it belongs to the %s actions", e.field, e.action, strings.Join(e.owners, ", "))
133+}
134+
135+func (t *SubagentTool) Name() string { return SubagentToolName }
136+func (t *SubagentTool) Description() string {
137+ return "Spawn, inspect, resume, and stop resident sub-agents. One action per call.\n\n" + subagentSpawnGuidance
138+}
139+func (t *SubagentTool) Schema() json.RawMessage { return json.RawMessage(subagentSchema) }
140+
141+// ConfirmationKey implements core.ConfirmationKeyer. The facade multiplexes
142+// four actions under one tool name, so the session "always allow" grant is
143+// scoped per action instead of per name. A missing, empty, or non-string
144+// action returns the plain base name so a malformed call cannot widen the
145+// remembered grant.
146+func (t *SubagentTool) ConfirmationKey(args json.RawMessage) string {
147+ var values map[string]json.RawMessage
148+ if err := json.Unmarshal(args, &values); err != nil {
149+ return SubagentToolName
150+ }
151+ var action string
152+ if err := json.Unmarshal(values["action"], &action); err != nil {
153+ return SubagentToolName
154+ }
155+ action = strings.TrimSpace(action)
156+ if action == "" {
157+ return SubagentToolName
158+ }
159+ return SubagentToolName + ":" + action
160+}
161+
162+var _ core.ConfirmationKeyer = (*SubagentTool)(nil)
163+
164+func (t *SubagentTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
165+ action, payload, err := parseSubagentAction(raw)
166+ switch {
167+ case errors.Is(err, errSubagentActionRequired):
168+ return protocolToolError("subagent: action is required")
169+ case errors.Is(err, errSubagentActionType):
170+ return protocolToolError("subagent: action must be a string")
171+ case errors.Is(err, errSubagentActionUnknown):
172+ return protocolToolError("subagent: unknown action")
173+ }
174+ var foreign *subagentForeignFieldError
175+ if errors.As(err, &foreign) {
176+ return protocolToolError(foreign.Error())
177+ }
178+ if err != nil {
179+ return core.ToolResult{}, err
180+ }
181+ switch action {
182+ case SubagentActionSpawn:
183+ if t.Spawn == nil {
184+ return protocolToolError("subagent: spawn action is unavailable in this mode")
185+ }
186+ return t.Spawn.Execute(ctx, payload, progress)
187+ case SubagentActionStatus:
188+ if t.Status == nil {
189+ return protocolToolError("subagent: status action is unavailable in this mode")
190+ }
191+ return t.Status.Execute(ctx, payload, progress)
192+ case SubagentActionStop:
193+ if t.Stop == nil {
194+ return protocolToolError("subagent: stop action is unavailable in this mode")
195+ }
196+ return t.Stop.Execute(ctx, payload, progress)
197+ case SubagentActionResume:
198+ if t.Resume == nil {
199+ return protocolToolError("subagent: resume action is unavailable in this mode")
200+ }
201+ return t.Resume.Execute(ctx, payload, progress)
202+ }
203+ return protocolToolError("subagent: unknown action")
204+}
205+
206+// parseSubagentAction validates the action and returns the payload that action
207+// owns. Raw field bytes are forwarded unchanged so argument validation and
208+// number handling stay in the sub-tools.
209+func parseSubagentAction(raw json.RawMessage) (string, json.RawMessage, error) {
210+ var values map[string]json.RawMessage
211+ if err := json.Unmarshal(raw, &values); err != nil {
212+ return "", nil, fmt.Errorf("invalid args: %w", err)
213+ }
214+ rawAction, present := values["action"]
215+ if !present {
216+ return "", nil, errSubagentActionRequired
217+ }
218+ var action string
219+ if err := json.Unmarshal(rawAction, &action); err != nil {
220+ return "", nil, fmt.Errorf("%w: %v", errSubagentActionType, err)
221+ }
222+ action = strings.TrimSpace(action)
223+ if action == "" {
224+ return "", nil, errSubagentActionRequired
225+ }
226+ if _, ok := subagentActionFields[action]; !ok {
227+ return "", nil, errSubagentActionUnknown
228+ }
229+ delete(values, "action")
230+ // A field can belong to several actions (agent_id serves status, stop and
231+ // resume), so collect every owner before deciding.
232+ owners := make(map[string][]string, len(subagentActionFields))
233+ for ownerAction, fields := range subagentActionFields {
234+ for _, name := range fields {
235+ owners[name] = append(owners[name], ownerAction)
236+ }
237+ }
238+ payload := make(map[string]json.RawMessage, len(values))
239+ for name, value := range values {
240+ fieldOwners, claimed := owners[name]
241+ if claimed && !slices.Contains(fieldOwners, action) {
242+ sort.Strings(fieldOwners)
243+ return "", nil, &subagentForeignFieldError{field: name, action: action, owners: fieldOwners}
244+ }
245+ payload[name] = value
246+ }
247+ encoded, err := json.Marshal(payload)
248+ if err != nil {
249+ return "", nil, err
250+ }
251+ return action, encoded, nil
252+}
M packages/agent/tools/subagent_resume.go +35 -27
@@ -8,11 +8,13 @@ import (
88
99 "github.com/bnema/zut/packages/agent/subagents"
1010 "github.com/bnema/zut/packages/core"
11+ "github.com/google/uuid"
1112 )
1213
1314 // SubagentResumeTool gives a sub-agent a follow-up turn while preserving its
1415 // existing session context. Every explicit follow-up is accepted durably and
15-// runs FIFO after any active resident turn.
16+// runs FIFO after any active resident turn. An explicit bounded wait may return
17+// that turn's completion or expire while the child stays active.
1618 type SubagentResumeTool struct {
1719 ResidentManager *subagents.ResidentManager
1820 Enabled func() bool
@@ -21,32 +23,12 @@ type SubagentResumeTool struct {
2123 type subagentResumeArgs struct {
2224 AgentID string `json:"agent_id"`
2325 Prompt string `json:"prompt"`
26+ Wait *int `json:"wait,omitempty"`
2427 }
2528
26-const subagentResumeSchema = `{
27- "type": "object",
28- "properties": {
29- "agent_id": {
30- "type": "string",
31- "description": "Child id or unique id prefix for the resident sub-agent to continue."
32- },
33- "prompt": {
34- "type": "string",
35- "description": "New manager follow-up for the sub-agent. Its earlier task and conversation remain available in the retained session."
36- }
37- },
38- "required": ["agent_id", "prompt"]
39-}`
40-
41-func (t *SubagentResumeTool) Name() string { return SubagentResumeToolName }
42-
43-func (t *SubagentResumeTool) Description() string {
44- return "Continue a resident sub-agent with a follow-up prompt and retained session. After a terminal failure, inspect its saved result first; explicit resume continues the retained session without discarding progress or satisfying required work."
45-}
46-
47-func (t *SubagentResumeTool) Schema() json.RawMessage {
48- return json.RawMessage(subagentResumeSchema)
49-}
29+// Name returns the shared facade name: this type is an internal
30+// implementation and is never registered on its own.
31+func (t *SubagentResumeTool) Name() string { return SubagentToolName }
5032
5133 func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
5234 if ctx != nil {
@@ -75,15 +57,41 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _
7557 if strings.TrimSpace(args.Prompt) == "" {
7658 return protocolToolError(prefix + ": prompt is required")
7759 }
60+ if args.Wait != nil && (*args.Wait < 1 || *args.Wait > maxSubagentWaitSeconds) {
61+ return protocolToolError(fmt.Sprintf("%s: wait must be between 1 and %d seconds", prefix, maxSubagentWaitSeconds))
62+ }
7863 snapshot, ok := findResidentStatusSnapshot(t.ResidentManager.Snapshot(), id)
7964 if !ok {
8065 return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
8166 }
82- if err := t.ResidentManager.Resume(ctx, snapshot.ID, args.Prompt); err != nil {
67+ // The watch is registered before the follow-up is accepted, so a turn that
68+ // finishes immediately cannot complete before this call subscribes.
69+ turnID := uuid.NewString()
70+ var completionResult <-chan subagents.ResidentCompletion
71+ cancelWait := func() {}
72+ if args.Wait != nil {
73+ completionResult, cancelWait = t.ResidentManager.WatchCompletion(snapshot.ID, turnID)
74+ }
75+ defer cancelWait()
76+ if err := t.ResidentManager.ResumeWithTurn(ctx, snapshot.ID, args.Prompt, turnID); err != nil {
8377 return protocolToolError(prefix + ": " + err.Error())
8478 }
79+ var outcome *subagentWaitOutcome
80+ if args.Wait != nil {
81+ completion, timedOut, err := waitForResidentCompletion(ctx, completionResult, *args.Wait, prefix)
82+ if err != nil {
83+ return core.ToolResult{}, err
84+ }
85+ outcome = &subagentWaitOutcome{Seconds: *args.Wait, TimedOut: timedOut}
86+ if !timedOut {
87+ result := completion.Completion()
88+ outcome.Status = result.Status
89+ outcome.Error = result.Error
90+ outcome.Summary = result.Summary
91+ }
92+ }
8593 if updated, ok := t.ResidentManager.SnapshotFor(snapshot.ID); ok {
8694 snapshot = updated
8795 }
88- return renderResidentAction("resumed", publicResidentStatus(snapshot))
96+ return renderResidentActionWait("resumed", publicResidentStatus(snapshot), outcome)
8997 }
@@ -8,11 +8,13 @@ import (
8 8
9 "github.com/bnema/zut/packages/agent/subagents"9 "github.com/bnema/zut/packages/agent/subagents"
10 "github.com/bnema/zut/packages/core"10 "github.com/bnema/zut/packages/core"
11+ "github.com/google/uuid"
11 )12 )
12 13
13 // SubagentResumeTool gives a sub-agent a follow-up turn while preserving its14 // SubagentResumeTool gives a sub-agent a follow-up turn while preserving its
14 // existing session context. Every explicit follow-up is accepted durably and15 // existing session context. Every explicit follow-up is accepted durably and
15-// runs FIFO after any active resident turn.16+// runs FIFO after any active resident turn. An explicit bounded wait may return
17+// that turn's completion or expire while the child stays active.
16 type SubagentResumeTool struct {18 type SubagentResumeTool struct {
17 ResidentManager *subagents.ResidentManager19 ResidentManager *subagents.ResidentManager
18 Enabled func() bool20 Enabled func() bool
@@ -21,32 +23,12 @@ type SubagentResumeTool struct {
21 type subagentResumeArgs struct {23 type subagentResumeArgs struct {
22 AgentID string `json:"agent_id"`24 AgentID string `json:"agent_id"`
23 Prompt string `json:"prompt"`25 Prompt string `json:"prompt"`
26+ Wait *int `json:"wait,omitempty"`
24 }27 }
25 28
26-const subagentResumeSchema = `{29+// Name returns the shared facade name: this type is an internal
27- "type": "object",30+// implementation and is never registered on its own.
28- "properties": {31+func (t *SubagentResumeTool) Name() string { return SubagentToolName }
29- "agent_id": {
30- "type": "string",
31- "description": "Child id or unique id prefix for the resident sub-agent to continue."
32- },
33- "prompt": {
34- "type": "string",
35- "description": "New manager follow-up for the sub-agent. Its earlier task and conversation remain available in the retained session."
36- }
37- },
38- "required": ["agent_id", "prompt"]
39-}`
40-
41-func (t *SubagentResumeTool) Name() string { return SubagentResumeToolName }
42-
43-func (t *SubagentResumeTool) Description() string {
44- return "Continue a resident sub-agent with a follow-up prompt and retained session. After a terminal failure, inspect its saved result first; explicit resume continues the retained session without discarding progress or satisfying required work."
45-}
46-
47-func (t *SubagentResumeTool) Schema() json.RawMessage {
48- return json.RawMessage(subagentResumeSchema)
49-}
50 32
51 func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {33 func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
52 if ctx != nil {34 if ctx != nil {
@@ -75,15 +57,41 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _
75 if strings.TrimSpace(args.Prompt) == "" {57 if strings.TrimSpace(args.Prompt) == "" {
76 return protocolToolError(prefix + ": prompt is required")58 return protocolToolError(prefix + ": prompt is required")
77 }59 }
60+ if args.Wait != nil && (*args.Wait < 1 || *args.Wait > maxSubagentWaitSeconds) {
61+ return protocolToolError(fmt.Sprintf("%s: wait must be between 1 and %d seconds", prefix, maxSubagentWaitSeconds))
62+ }
78 snapshot, ok := findResidentStatusSnapshot(t.ResidentManager.Snapshot(), id)63 snapshot, ok := findResidentStatusSnapshot(t.ResidentManager.Snapshot(), id)
79 if !ok {64 if !ok {
80 return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))65 return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
81 }66 }
82- if err := t.ResidentManager.Resume(ctx, snapshot.ID, args.Prompt); err != nil {67+ // The watch is registered before the follow-up is accepted, so a turn that
68+ // finishes immediately cannot complete before this call subscribes.
69+ turnID := uuid.NewString()
70+ var completionResult <-chan subagents.ResidentCompletion
71+ cancelWait := func() {}
72+ if args.Wait != nil {
73+ completionResult, cancelWait = t.ResidentManager.WatchCompletion(snapshot.ID, turnID)
74+ }
75+ defer cancelWait()
76+ if err := t.ResidentManager.ResumeWithTurn(ctx, snapshot.ID, args.Prompt, turnID); err != nil {
83 return protocolToolError(prefix + ": " + err.Error())77 return protocolToolError(prefix + ": " + err.Error())
84 }78 }
79+ var outcome *subagentWaitOutcome
80+ if args.Wait != nil {
81+ completion, timedOut, err := waitForResidentCompletion(ctx, completionResult, *args.Wait, prefix)
82+ if err != nil {
83+ return core.ToolResult{}, err
84+ }
85+ outcome = &subagentWaitOutcome{Seconds: *args.Wait, TimedOut: timedOut}
86+ if !timedOut {
87+ result := completion.Completion()
88+ outcome.Status = result.Status
89+ outcome.Error = result.Error
90+ outcome.Summary = result.Summary
91+ }
92+ }
85 if updated, ok := t.ResidentManager.SnapshotFor(snapshot.ID); ok {93 if updated, ok := t.ResidentManager.SnapshotFor(snapshot.ID); ok {
86 snapshot = updated94 snapshot = updated
87 }95 }
88- return renderResidentAction("resumed", publicResidentStatus(snapshot))96+ return renderResidentActionWait("resumed", publicResidentStatus(snapshot), outcome)
89 }97 }
M packages/agent/tools/subagent_spawn.go +43 -79
@@ -25,10 +25,14 @@ import (
2525 // primary-agent prompt decides whether delegation is proactive or only on an
2626 // explicit user request.
2727 const (
28- SubagentSpawnToolName = "subagent_spawn"
29- SubagentStatusToolName = "subagent_status"
30- SubagentStopToolName = "subagent_stop"
31- SubagentResumeToolName = "subagent_resume"
28+ // SubagentToolName is the single model-facing subagent tool. One call
29+ // carries one action; see the SubagentAction* constants.
30+ SubagentToolName = "subagent"
31+
32+ SubagentActionSpawn = "spawn"
33+ SubagentActionStatus = "status"
34+ SubagentActionStop = "stop"
35+ SubagentActionResume = "resume"
3236
3337 maxSubagentWaitSeconds = 5 * 60
3438 )
@@ -79,60 +83,9 @@ type subagentSpawnArgs struct {
7983 Isolation string `json:"isolation,omitempty"`
8084 }
8185
82-const subagentSpawnSchemaTemplate = `{
83- "type": "object",
84- "properties": {
85- "task": {
86- "type": "string",
87- "description": "The full task description for the sub-agent. Assign a concrete, bounded scope and explicit ownership that does not overlap other active work. Be specific: the child normally has the main agent's built-in tools, including lsp when enabled, but a selected profile can restrict its tools; it starts with NO context from this conversation. Shared isolation uses this working directory; worktree isolation captures a patch without merging it."
88- },
89- "agent": {
90- "type": "string",
91- "description": "Optional named markdown profile from [subagents_list]. The child applies that profile's system prompt, model, thinking level, tool limits, and fast-mode preference. Omit for a generic child."
92- },
93- "model": {
94- "type": "string",
95- "description": "Optional model id to pin the sub-agent to. Normally omit both model and provider so the sub-agent inherits the host session's resolved provider/model/auth route, or omit them when using an agent profile. Do not infer provider from model name. If you override this, also provide provider."
96- },
97- "provider": {
98- "type": "string",
99- "description": "Optional provider id. Normally omit both model and provider so the sub-agent inherits the host session. If you override this, also provide model. Note: openai means public OpenAI API-key auth; openai-codex means ChatGPT/Codex subscription auth."
100- },
101- "reasoning": {
102- "type": "string",
103- "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"],
104- "description": "Optional reasoning level for the child. Overrides the selected profile's thinking level when provided."
105- },
106- "fast_mode": {
107- "type": "boolean",
108- "description": "Optional fast-mode override for the child. Omit to inherit the selected profile or host setting."
109- },
110- "required": {
111- "type": "boolean",
112- "description": "Set true when the parent must receive this delegated result before it can finish. The worker remains asynchronous and reports through a host completion update. A bounded wait expiring does not terminate the accepted child and must not be retried while it remains active. Terminal failure or cancellation remains unmet until a successful follow-up. An outcome unobserved across host restart requires explicit user reconciliation."
113- },
114- "wait": {
115- "type": "integer",
116- "minimum": 1,
117- "maximum": 300,
118- "description": "Optional explicit number of seconds to wait for this sub-agent's initial task to finish. Omit to return immediately. The sub-agent continues in the background if this wait expires."
119- },
120- "isolation": {
121- "type": "string",
122- "enum": ["shared", "worktree"],
123- "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it."
124- }
125- },
126- "required": ["task"]
127-}`
128-
129-func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName }
130-func (t *SubagentSpawnTool) Description() string {
131- return "Delegate a concrete, bounded scope to a resident sub-agent. For proactive delegation, use an independent sidecar only when the parent has useful non-overlapping work; keep immediate blockers local. A worker owns its scope until completion, so never duplicate it in the parent. If delegation owns the blocking task, end or yield the parent turn. Omit wait to return immediately and receive completion through [auto-subagents update]; set wait to an explicit 1–300 second value only when this turn should wait for the initial task. Set required=true when the outcome is mandatory before the parent's terminal response; failures remain recoverable through subagent_resume. Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, dashboard, metadata, or file checks solely to wait."
132-}
133-func (t *SubagentSpawnTool) Schema() json.RawMessage {
134- return json.RawMessage(subagentSpawnSchemaTemplate)
135-}
86+// Name returns the shared facade name: the four subagent tool types are
87+// internal implementations and are never registered on their own.
88+func (t *SubagentSpawnTool) Name() string { return SubagentToolName }
13689
13790 func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
13891 prefix := t.Name()
@@ -241,27 +194,9 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
241194 if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil {
242195 return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
243196 }
244- waitCtx, cancel := context.WithTimeout(ctx, time.Duration(*a.Wait)*time.Second)
245- defer cancel()
246- select {
247- case result, ok := <-completionResult:
248- if !ok {
249- return core.ToolResult{}, fmt.Errorf("%s: completion wait ended unexpectedly", prefix)
250- }
251- completion = result
252- case <-waitCtx.Done():
253- if ctx.Err() != nil {
254- return core.ToolResult{}, ctx.Err()
255- }
256- select {
257- case result, ok := <-completionResult:
258- if !ok {
259- return core.ToolResult{}, fmt.Errorf("%s: completion wait ended unexpectedly", prefix)
260- }
261- completion = result
262- default:
263- waitTimedOut = true
264- }
197+ completion, waitTimedOut, err = waitForResidentCompletion(ctx, completionResult, *a.Wait, prefix)
198+ if err != nil {
199+ return core.ToolResult{}, err
265200 }
266201 } else if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil {
267202 return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
@@ -308,6 +243,35 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
308243 return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: sb.String()}}}, nil
309244 }
310245
246+// waitForResidentCompletion blocks until the watched resident turn reports its
247+// completion or the explicit bounded wait expires. A cancelled parent context
248+// wins over an expiring wait, and a completion that arrives in the same instant
249+// as the deadline is reported instead of a timeout.
250+func waitForResidentCompletion(ctx context.Context, completionResult <-chan subagents.ResidentCompletion, seconds int, prefix string) (completion subagents.ResidentCompletion, timedOut bool, err error) {
251+ waitCtx, cancel := context.WithTimeout(ctx, time.Duration(seconds)*time.Second)
252+ defer cancel()
253+ select {
254+ case result, ok := <-completionResult:
255+ if !ok {
256+ return subagents.ResidentCompletion{}, false, fmt.Errorf("%s: completion wait ended unexpectedly", prefix)
257+ }
258+ return result, false, nil
259+ case <-waitCtx.Done():
260+ if ctx.Err() != nil {
261+ return subagents.ResidentCompletion{}, false, ctx.Err()
262+ }
263+ select {
264+ case result, ok := <-completionResult:
265+ if !ok {
266+ return subagents.ResidentCompletion{}, false, fmt.Errorf("%s: completion wait ended unexpectedly", prefix)
267+ }
268+ return result, false, nil
269+ default:
270+ return subagents.ResidentCompletion{}, true, nil
271+ }
272+ }
273+}
274+
311275 func decodeSubagentArgs(raw json.RawMessage, target any) error {
312276 decoder := json.NewDecoder(bytes.NewReader(raw))
313277 decoder.DisallowUnknownFields()
@@ -25,10 +25,14 @@ import (
25 // primary-agent prompt decides whether delegation is proactive or only on an25 // primary-agent prompt decides whether delegation is proactive or only on an
26 // explicit user request.26 // explicit user request.
27 const (27 const (
28- SubagentSpawnToolName = "subagent_spawn"28+ // SubagentToolName is the single model-facing subagent tool. One call
29- SubagentStatusToolName = "subagent_status"29+ // carries one action; see the SubagentAction* constants.
30- SubagentStopToolName = "subagent_stop"30+ SubagentToolName = "subagent"
31- SubagentResumeToolName = "subagent_resume"31+
32+ SubagentActionSpawn = "spawn"
33+ SubagentActionStatus = "status"
34+ SubagentActionStop = "stop"
35+ SubagentActionResume = "resume"
32 36
33 maxSubagentWaitSeconds = 5 * 6037 maxSubagentWaitSeconds = 5 * 60
34 )38 )
@@ -79,60 +83,9 @@ type subagentSpawnArgs struct {
79 Isolation string `json:"isolation,omitempty"`83 Isolation string `json:"isolation,omitempty"`
80 }84 }
81 85
82-const subagentSpawnSchemaTemplate = `{86+// Name returns the shared facade name: the four subagent tool types are
83- "type": "object",87+// internal implementations and are never registered on their own.
84- "properties": {88+func (t *SubagentSpawnTool) Name() string { return SubagentToolName }
85- "task": {
86- "type": "string",
87- "description": "The full task description for the sub-agent. Assign a concrete, bounded scope and explicit ownership that does not overlap other active work. Be specific: the child normally has the main agent's built-in tools, including lsp when enabled, but a selected profile can restrict its tools; it starts with NO context from this conversation. Shared isolation uses this working directory; worktree isolation captures a patch without merging it."
88- },
89- "agent": {
90- "type": "string",
91- "description": "Optional named markdown profile from [subagents_list]. The child applies that profile's system prompt, model, thinking level, tool limits, and fast-mode preference. Omit for a generic child."
92- },
93- "model": {
94- "type": "string",
95- "description": "Optional model id to pin the sub-agent to. Normally omit both model and provider so the sub-agent inherits the host session's resolved provider/model/auth route, or omit them when using an agent profile. Do not infer provider from model name. If you override this, also provide provider."
96- },
97- "provider": {
98- "type": "string",
99- "description": "Optional provider id. Normally omit both model and provider so the sub-agent inherits the host session. If you override this, also provide model. Note: openai means public OpenAI API-key auth; openai-codex means ChatGPT/Codex subscription auth."
100- },
101- "reasoning": {
102- "type": "string",
103- "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"],
104- "description": "Optional reasoning level for the child. Overrides the selected profile's thinking level when provided."
105- },
106- "fast_mode": {
107- "type": "boolean",
108- "description": "Optional fast-mode override for the child. Omit to inherit the selected profile or host setting."
109- },
110- "required": {
111- "type": "boolean",
112- "description": "Set true when the parent must receive this delegated result before it can finish. The worker remains asynchronous and reports through a host completion update. A bounded wait expiring does not terminate the accepted child and must not be retried while it remains active. Terminal failure or cancellation remains unmet until a successful follow-up. An outcome unobserved across host restart requires explicit user reconciliation."
113- },
114- "wait": {
115- "type": "integer",
116- "minimum": 1,
117- "maximum": 300,
118- "description": "Optional explicit number of seconds to wait for this sub-agent's initial task to finish. Omit to return immediately. The sub-agent continues in the background if this wait expires."
119- },
120- "isolation": {
121- "type": "string",
122- "enum": ["shared", "worktree"],
123- "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it."
124- }
125- },
126- "required": ["task"]
127-}`
128-
129-func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName }
130-func (t *SubagentSpawnTool) Description() string {
131- return "Delegate a concrete, bounded scope to a resident sub-agent. For proactive delegation, use an independent sidecar only when the parent has useful non-overlapping work; keep immediate blockers local. A worker owns its scope until completion, so never duplicate it in the parent. If delegation owns the blocking task, end or yield the parent turn. Omit wait to return immediately and receive completion through [auto-subagents update]; set wait to an explicit 1–300 second value only when this turn should wait for the initial task. Set required=true when the outcome is mandatory before the parent's terminal response; failures remain recoverable through subagent_resume. Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, dashboard, metadata, or file checks solely to wait."
132-}
133-func (t *SubagentSpawnTool) Schema() json.RawMessage {
134- return json.RawMessage(subagentSpawnSchemaTemplate)
135-}
136 89
137 func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {90 func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
138 prefix := t.Name()91 prefix := t.Name()
@@ -241,27 +194,9 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
241 if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil {194 if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil {
242 return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)195 return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
243 }196 }
244- waitCtx, cancel := context.WithTimeout(ctx, time.Duration(*a.Wait)*time.Second)197+ completion, waitTimedOut, err = waitForResidentCompletion(ctx, completionResult, *a.Wait, prefix)
245- defer cancel()198+ if err != nil {
246- select {199+ return core.ToolResult{}, err
247- case result, ok := <-completionResult:
248- if !ok {
249- return core.ToolResult{}, fmt.Errorf("%s: completion wait ended unexpectedly", prefix)
250- }
251- completion = result
252- case <-waitCtx.Done():
253- if ctx.Err() != nil {
254- return core.ToolResult{}, ctx.Err()
255- }
256- select {
257- case result, ok := <-completionResult:
258- if !ok {
259- return core.ToolResult{}, fmt.Errorf("%s: completion wait ended unexpectedly", prefix)
260- }
261- completion = result
262- default:
263- waitTimedOut = true
264- }
265 }200 }
266 } else if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil {201 } else if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil {
267 return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)202 return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
@@ -308,6 +243,35 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
308 return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: sb.String()}}}, nil243 return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: sb.String()}}}, nil
309 }244 }
310 245
246+// waitForResidentCompletion blocks until the watched resident turn reports its
247+// completion or the explicit bounded wait expires. A cancelled parent context
248+// wins over an expiring wait, and a completion that arrives in the same instant
249+// as the deadline is reported instead of a timeout.
250+func waitForResidentCompletion(ctx context.Context, completionResult <-chan subagents.ResidentCompletion, seconds int, prefix string) (completion subagents.ResidentCompletion, timedOut bool, err error) {
251+ waitCtx, cancel := context.WithTimeout(ctx, time.Duration(seconds)*time.Second)
252+ defer cancel()
253+ select {
254+ case result, ok := <-completionResult:
255+ if !ok {
256+ return subagents.ResidentCompletion{}, false, fmt.Errorf("%s: completion wait ended unexpectedly", prefix)
257+ }
258+ return result, false, nil
259+ case <-waitCtx.Done():
260+ if ctx.Err() != nil {
261+ return subagents.ResidentCompletion{}, false, ctx.Err()
262+ }
263+ select {
264+ case result, ok := <-completionResult:
265+ if !ok {
266+ return subagents.ResidentCompletion{}, false, fmt.Errorf("%s: completion wait ended unexpectedly", prefix)
267+ }
268+ return result, false, nil
269+ default:
270+ return subagents.ResidentCompletion{}, true, nil
271+ }
272+ }
273+}
274+
311 func decodeSubagentArgs(raw json.RawMessage, target any) error {275 func decodeSubagentArgs(raw json.RawMessage, target any) error {
312 decoder := json.NewDecoder(bytes.NewReader(raw))276 decoder := json.NewDecoder(bytes.NewReader(raw))
313 decoder.DisallowUnknownFields()277 decoder.DisallowUnknownFields()
M packages/agent/tools/subagent_status.go +4 -24
@@ -44,29 +44,9 @@ type subagentStatusEntry struct {
4444 OwnedElsewhere bool `json:"owned_elsewhere,omitempty"`
4545 }
4646
47-const subagentStatusSchema = `{
48- "type": "object",
49- "properties": {
50- "agent_id": {
51- "type": "string",
52- "description": "Optional child id or unique id prefix. Omit it to list all resident sub-agents."
53- },
54- "include_result": {
55- "type": "boolean",
56- "description": "With agent_id, retrieve the saved terminal result without running the child."
57- }
58- }
59-}`
60-
61-func (t *SubagentStatusTool) Name() string { return SubagentStatusToolName }
62-
63-func (t *SubagentStatusTool) Description() string {
64- return "Query live status for one background sub-agent or list all visible workers without waiting for completion. Set include_result with agent_id to retrieve its saved result without model execution."
65-}
66-
67-func (t *SubagentStatusTool) Schema() json.RawMessage {
68- return json.RawMessage(subagentStatusSchema)
69-}
47+// Name returns the shared facade name: this type is an internal
48+// implementation and is never registered on its own.
49+func (t *SubagentStatusTool) Name() string { return SubagentToolName }
7050
7151 func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
7252 if ctx != nil {
@@ -151,7 +131,7 @@ func publicResidentStatus(snapshot subagents.ResidentSnapshot) subagentStatusEnt
151131 func renderSubagentStatus(response subagentStatusResponse) (core.ToolResult, error) {
152132 data, err := json.Marshal(response)
153133 if err != nil {
154- return core.ToolResult{}, fmt.Errorf("%s: encode status: %w", "subagent_status", err)
134+ return core.ToolResult{}, fmt.Errorf("%s: encode status: %w", SubagentToolName, err)
155135 }
156136 return core.ToolResult{
157137 Content: []provider.Content{provider.TextBlock{Text: string(data)}},
@@ -44,29 +44,9 @@ type subagentStatusEntry struct {
44 OwnedElsewhere bool `json:"owned_elsewhere,omitempty"`44 OwnedElsewhere bool `json:"owned_elsewhere,omitempty"`
45 }45 }
46 46
47-const subagentStatusSchema = `{47+// Name returns the shared facade name: this type is an internal
48- "type": "object",48+// implementation and is never registered on its own.
49- "properties": {49+func (t *SubagentStatusTool) Name() string { return SubagentToolName }
50- "agent_id": {
51- "type": "string",
52- "description": "Optional child id or unique id prefix. Omit it to list all resident sub-agents."
53- },
54- "include_result": {
55- "type": "boolean",
56- "description": "With agent_id, retrieve the saved terminal result without running the child."
57- }
58- }
59-}`
60-
61-func (t *SubagentStatusTool) Name() string { return SubagentStatusToolName }
62-
63-func (t *SubagentStatusTool) Description() string {
64- return "Query live status for one background sub-agent or list all visible workers without waiting for completion. Set include_result with agent_id to retrieve its saved result without model execution."
65-}
66-
67-func (t *SubagentStatusTool) Schema() json.RawMessage {
68- return json.RawMessage(subagentStatusSchema)
69-}
70 50
71 func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {51 func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
72 if ctx != nil {52 if ctx != nil {
@@ -151,7 +131,7 @@ func publicResidentStatus(snapshot subagents.ResidentSnapshot) subagentStatusEnt
151 func renderSubagentStatus(response subagentStatusResponse) (core.ToolResult, error) {131 func renderSubagentStatus(response subagentStatusResponse) (core.ToolResult, error) {
152 data, err := json.Marshal(response)132 data, err := json.Marshal(response)
153 if err != nil {133 if err != nil {
154- return core.ToolResult{}, fmt.Errorf("%s: encode status: %w", "subagent_status", err)134+ return core.ToolResult{}, fmt.Errorf("%s: encode status: %w", SubagentToolName, err)
155 }135 }
156 return core.ToolResult{136 return core.ToolResult{
157 Content: []provider.Content{provider.TextBlock{Text: string(data)}},137 Content: []provider.Content{provider.TextBlock{Text: string(data)}},
M packages/agent/tools/subagent_stop.go +22 -22
@@ -22,31 +22,25 @@ type subagentStopArgs struct {
2222 AgentID string `json:"agent_id"`
2323 }
2424
25-type subagentActionResponse struct {
26- Action string `json:"action"`
27- Agent subagentStatusEntry `json:"agent"`
25+// subagentWaitOutcome reports the result of an explicit bounded wait on one
26+// resident turn. An expired wait leaves the child active.
27+type subagentWaitOutcome struct {
28+ Seconds int `json:"seconds"`
29+ TimedOut bool `json:"timed_out,omitempty"`
30+ Status string `json:"status,omitempty"`
31+ Error string `json:"error,omitempty"`
32+ Summary string `json:"summary,omitempty"`
2833 }
2934
30-const subagentStopSchema = `{
31- "type": "object",
32- "properties": {
33- "agent_id": {
34- "type": "string",
35- "description": "Child id or unique id prefix for the stuck resident sub-agent to terminate."
36- }
37- },
38- "required": ["agent_id"]
39-}`
40-
41-func (t *SubagentStopTool) Name() string { return SubagentStopToolName }
42-
43-func (t *SubagentStopTool) Description() string {
44- return "Request termination of a stuck resident sub-agent."
35+type subagentActionResponse struct {
36+ Action string `json:"action"`
37+ Agent subagentStatusEntry `json:"agent"`
38+ Wait *subagentWaitOutcome `json:"wait,omitempty"`
4539 }
4640
47-func (t *SubagentStopTool) Schema() json.RawMessage {
48- return json.RawMessage(subagentStopSchema)
49-}
41+// Name returns the shared facade name: this type is an internal
42+// implementation and is never registered on its own.
43+func (t *SubagentStopTool) Name() string { return SubagentToolName }
5044
5145 func (t *SubagentStopTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
5246 if ctx != nil {
@@ -86,7 +80,13 @@ func (t *SubagentStopTool) Execute(ctx context.Context, raw json.RawMessage, _ f
8680 }
8781
8882 func renderResidentAction(action string, entry subagentStatusEntry) (core.ToolResult, error) {
89- response := subagentActionResponse{Action: action, Agent: entry}
83+ return renderResidentActionWait(action, entry, nil)
84+}
85+
86+// renderResidentActionWait renders an action response plus the outcome of an
87+// explicit bounded wait, if the caller requested one.
88+func renderResidentActionWait(action string, entry subagentStatusEntry, wait *subagentWaitOutcome) (core.ToolResult, error) {
89+ response := subagentActionResponse{Action: action, Agent: entry, Wait: wait}
9090 data, err := json.Marshal(response)
9191 if err != nil {
9292 return core.ToolResult{}, fmt.Errorf("subagent action: encode response: %w", err)
@@ -22,31 +22,25 @@ type subagentStopArgs struct {
22 AgentID string `json:"agent_id"`22 AgentID string `json:"agent_id"`
23 }23 }
24 24
25-type subagentActionResponse struct {25+// subagentWaitOutcome reports the result of an explicit bounded wait on one
26- Action string `json:"action"`26+// resident turn. An expired wait leaves the child active.
27- Agent subagentStatusEntry `json:"agent"`27+type subagentWaitOutcome struct {
28+ Seconds int `json:"seconds"`
29+ TimedOut bool `json:"timed_out,omitempty"`
30+ Status string `json:"status,omitempty"`
31+ Error string `json:"error,omitempty"`
32+ Summary string `json:"summary,omitempty"`
28 }33 }
29 34
30-const subagentStopSchema = `{35+type subagentActionResponse struct {
31- "type": "object",36+ Action string `json:"action"`
32- "properties": {37+ Agent subagentStatusEntry `json:"agent"`
33- "agent_id": {38+ Wait *subagentWaitOutcome `json:"wait,omitempty"`
34- "type": "string",
35- "description": "Child id or unique id prefix for the stuck resident sub-agent to terminate."
36- }
37- },
38- "required": ["agent_id"]
39-}`
40-
41-func (t *SubagentStopTool) Name() string { return SubagentStopToolName }
42-
43-func (t *SubagentStopTool) Description() string {
44- return "Request termination of a stuck resident sub-agent."
45 }39 }
46 40
47-func (t *SubagentStopTool) Schema() json.RawMessage {41+// Name returns the shared facade name: this type is an internal
48- return json.RawMessage(subagentStopSchema)42+// implementation and is never registered on its own.
49-}43+func (t *SubagentStopTool) Name() string { return SubagentToolName }
50 44
51 func (t *SubagentStopTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {45 func (t *SubagentStopTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
52 if ctx != nil {46 if ctx != nil {
@@ -86,7 +80,13 @@ func (t *SubagentStopTool) Execute(ctx context.Context, raw json.RawMessage, _ f
86 }80 }
87 81
88 func renderResidentAction(action string, entry subagentStatusEntry) (core.ToolResult, error) {82 func renderResidentAction(action string, entry subagentStatusEntry) (core.ToolResult, error) {
89- response := subagentActionResponse{Action: action, Agent: entry}83+ return renderResidentActionWait(action, entry, nil)
84+}
85+
86+// renderResidentActionWait renders an action response plus the outcome of an
87+// explicit bounded wait, if the caller requested one.
88+func renderResidentActionWait(action string, entry subagentStatusEntry, wait *subagentWaitOutcome) (core.ToolResult, error) {
89+ response := subagentActionResponse{Action: action, Agent: entry, Wait: wait}
90 data, err := json.Marshal(response)90 data, err := json.Marshal(response)
91 if err != nil {91 if err != nil {
92 return core.ToolResult{}, fmt.Errorf("subagent action: encode response: %w", err)92 return core.ToolResult{}, fmt.Errorf("subagent action: encode response: %w", err)
A packages/agent/tools/subagent_test.go +351 -0
@@ -0,0 +1,351 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "reflect"
7+ "strings"
8+ "testing"
9+
10+ "github.com/bnema/zut/packages/agent/subagents"
11+ "github.com/bnema/zut/packages/core"
12+ "github.com/bnema/zut/packages/provider"
13+)
14+
15+func facadeResultText(t *testing.T, result core.ToolResult) string {
16+ t.Helper()
17+ var sb strings.Builder
18+ for _, block := range result.Content {
19+ text, ok := block.(provider.TextBlock)
20+ if !ok {
21+ t.Fatalf("content block = %T, want provider.TextBlock", block)
22+ }
23+ sb.WriteString(text.Text)
24+ }
25+ return sb.String()
26+}
27+
28+func newFacadeManager(t *testing.T) *subagents.ResidentManager {
29+ t.Helper()
30+ manager := subagents.NewResidentManager(t.TempDir(), nil)
31+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
32+ return manager
33+}
34+
35+// allActionsFacade wires one enabled internal tool per action so dispatch
36+// targets can be observed independently.
37+func allActionsFacade(t *testing.T) *SubagentTool {
38+ t.Helper()
39+ manager := newFacadeManager(t)
40+ enabled := func() bool { return true }
41+ return &SubagentTool{
42+ Spawn: &SubagentSpawnTool{ResidentManager: manager, Enabled: enabled},
43+ Status: &SubagentStatusTool{ResidentManager: manager, Enabled: enabled},
44+ Stop: &SubagentStopTool{ResidentManager: manager, Enabled: enabled},
45+ Resume: &SubagentResumeTool{ResidentManager: manager, Enabled: enabled},
46+ }
47+}
48+
49+func TestSubagentFacadeNameAndSchema(t *testing.T) {
50+ facade := &SubagentTool{}
51+ if got := facade.Name(); got != "subagent" {
52+ t.Fatalf("Name() = %q, want subagent", got)
53+ }
54+ var schema struct {
55+ Required []string `json:"required"`
56+ Properties map[string]json.RawMessage `json:"properties"`
57+ }
58+ if err := json.Unmarshal(facade.Schema(), &schema); err != nil {
59+ t.Fatalf("schema is not valid JSON: %v", err)
60+ }
61+ if len(schema.Required) != 1 || schema.Required[0] != "action" {
62+ t.Fatalf("required = %v, want [action]", schema.Required)
63+ }
64+ for _, field := range []string{
65+ "action", "task", "agent", "model", "provider", "reasoning", "fast_mode",
66+ "required", "wait", "isolation", "agent_id", "include_result", "prompt",
67+ } {
68+ if _, ok := schema.Properties[field]; !ok {
69+ t.Fatalf("schema is missing property %q", field)
70+ }
71+ }
72+ if got := facade.Description(); got != "Spawn, inspect, resume, and stop resident sub-agents. One action per call.\n\n"+subagentSpawnGuidance {
73+ t.Fatalf("description = %q", got)
74+ }
75+}
76+
77+// facadeSchemaProperties parses the model-facing facade schema into its
78+// property descriptions.
79+func facadeSchemaProperties(t *testing.T) map[string]string {
80+ t.Helper()
81+ var schema struct {
82+ Properties map[string]struct {
83+ Description string `json:"description"`
84+ } `json:"properties"`
85+ }
86+ if err := json.Unmarshal((&SubagentTool{}).Schema(), &schema); err != nil {
87+ t.Fatalf("schema is not valid JSON: %v", err)
88+ }
89+ props := make(map[string]string, len(schema.Properties))
90+ for name, prop := range schema.Properties {
91+ props[name] = prop.Description
92+ }
93+ return props
94+}
95+
96+// TestSubagentFacadeSchemaCoversActionFields ties the flat, merged schema to
97+// the runtime action/field map, so a field added to an action cannot silently
98+// disappear from what the model sees.
99+func TestSubagentFacadeSchemaCoversActionFields(t *testing.T) {
100+ props := facadeSchemaProperties(t)
101+ for action, fields := range subagentActionFields {
102+ for _, field := range fields {
103+ if _, ok := props[field]; !ok {
104+ t.Fatalf("action %s field %q is missing from the facade schema", action, field)
105+ }
106+ }
107+ }
108+}
109+
110+// subagentActionAnnotation pins the action-scoping sentence each
111+// action-owned property must carry. Every field in subagentActionFields must
112+// appear here, and vice versa, so the merged schema cannot advertise an
113+// action-owned field as unconditionally valid.
114+var subagentActionAnnotation = map[string]string{
115+ "task": "Required when action is spawn.",
116+ "agent": "Only valid when action is spawn.",
117+ "model": "Only valid when action is spawn.",
118+ "provider": "Only valid when action is spawn.",
119+ "reasoning": "Only valid when action is spawn.",
120+ "fast_mode": "Only valid when action is spawn.",
121+ "required": "Only valid when action is spawn.",
122+ "wait": "Only valid when action is spawn or resume.",
123+ "isolation": "Only valid when action is spawn.",
124+ "agent_id": "Required when action is stop or resume.",
125+ "include_result": "Only valid when action is status.",
126+ "prompt": "Required when action is resume.",
127+}
128+
129+func TestSubagentFacadeSchemaAnnotatesActionFields(t *testing.T) {
130+ props := facadeSchemaProperties(t)
131+ for action, fields := range subagentActionFields {
132+ for _, field := range fields {
133+ annotation, pinned := subagentActionAnnotation[field]
134+ if !pinned {
135+ t.Fatalf("action %s owns %q with no pinned annotation", action, field)
136+ }
137+ desc, ok := props[field]
138+ if !ok {
139+ t.Fatalf("schema is missing property %q owned by action %s", field, action)
140+ }
141+ if !strings.Contains(desc, annotation) {
142+ t.Fatalf("property %q description %q is missing %q", field, desc, annotation)
143+ }
144+ }
145+ }
146+ inFields := map[string]bool{}
147+ for _, fields := range subagentActionFields {
148+ for _, field := range fields {
149+ inFields[field] = true
150+ }
151+ }
152+ for field := range subagentActionAnnotation {
153+ if !inFields[field] {
154+ t.Fatalf("annotation table names %q, which no action owns", field)
155+ }
156+ }
157+}
158+
159+// TestSubagentFacadeResumeCaveat keeps the former resume-tool warning in the
160+// facade schema: a terminal failure must be inspected before resume, and a
161+// follow-up continues the retained session rather than satisfying the work.
162+func TestSubagentFacadeResumeCaveat(t *testing.T) {
163+ props := facadeSchemaProperties(t)
164+ prompt := props["prompt"]
165+ for _, want := range []string{"inspect its saved result first", "without discarding progress or satisfying required work"} {
166+ if !strings.Contains(prompt, want) {
167+ t.Fatalf("prompt description %q is missing %q", prompt, want)
168+ }
169+ }
170+}
171+
172+// TestSubagentFacadeConfirmationKey covers the per-action confirmation grant:
173+// each action yields its own key, and anything malformed falls back to the
174+// plain base name so a remembered grant cannot widen.
175+func TestSubagentFacadeConfirmationKey(t *testing.T) {
176+ facade := &SubagentTool{}
177+ for _, tc := range []struct {
178+ raw string
179+ want string
180+ }{
181+ {raw: `{"action":"spawn","task":"t"}`, want: "subagent:spawn"},
182+ {raw: `{"action":"status"}`, want: "subagent:status"},
183+ {raw: `{"action":"stop","agent_id":"x"}`, want: "subagent:stop"},
184+ {raw: `{"action":"resume","agent_id":"x","prompt":"p"}`, want: "subagent:resume"},
185+ {raw: `{}`, want: "subagent"},
186+ {raw: `{"action":""}`, want: "subagent"},
187+ {raw: `{"action":" "}`, want: "subagent"},
188+ {raw: `{"action":42}`, want: "subagent"},
189+ {raw: `{"action":["spawn"]}`, want: "subagent"},
190+ {raw: `{"action":{}}`, want: "subagent"},
191+ {raw: `{"action":true}`, want: "subagent"},
192+ {raw: `{not json`, want: "subagent"},
193+ } {
194+ if got := facade.ConfirmationKey(json.RawMessage(tc.raw)); got != tc.want {
195+ t.Fatalf("ConfirmationKey(%s) = %q, want %q", tc.raw, got, tc.want)
196+ }
197+ }
198+ seen := map[string]bool{}
199+ for _, action := range []string{"spawn", "status", "stop", "resume"} {
200+ key := facade.ConfirmationKey(json.RawMessage(`{"action":"` + action + `"}`))
201+ if seen[key] {
202+ t.Fatalf("action %s reuses key %q", action, key)
203+ }
204+ seen[key] = true
205+ }
206+}
207+
208+func TestSubagentFacadeRequiresAction(t *testing.T) {
209+ facade := allActionsFacade(t)
210+ for _, raw := range []string{`{}`, `{"action":""}`, `{"action":" "}`} {
211+ result, err := facade.Execute(context.Background(), json.RawMessage(raw), nil)
212+ if err != nil {
213+ t.Fatalf("%s: unexpected host error: %v", raw, err)
214+ }
215+ if !result.IsError || facadeResultText(t, result) != "subagent: action is required" {
216+ t.Fatalf("%s: result = %#v", raw, result)
217+ }
218+ }
219+ for _, raw := range []string{`{"action":42}`, `{"action":["spawn"]}`, `{"action":{}}`, `{"action":true}`} {
220+ result, err := facade.Execute(context.Background(), json.RawMessage(raw), nil)
221+ if err != nil {
222+ t.Fatalf("%s: unexpected host error: %v", raw, err)
223+ }
224+ if !result.IsError || facadeResultText(t, result) != "subagent: action must be a string" {
225+ t.Fatalf("%s: result = %#v, want action-must-be-a-string", raw, result)
226+ }
227+ }
228+}
229+
230+func TestSubagentFacadeRejectsUnknownActionAndMalformedArgs(t *testing.T) {
231+ facade := allActionsFacade(t)
232+ result, err := facade.Execute(context.Background(), json.RawMessage(`{"action":"explode"}`), nil)
233+ if err != nil {
234+ t.Fatalf("unexpected host error: %v", err)
235+ }
236+ if !result.IsError || facadeResultText(t, result) != "subagent: unknown action" {
237+ t.Fatalf("unknown action result = %#v", result)
238+ }
239+ if _, err := facade.Execute(context.Background(), json.RawMessage(`{"action":`), nil); err == nil || !strings.Contains(err.Error(), "invalid args") {
240+ t.Fatalf("malformed args error = %v", err)
241+ }
242+}
243+
244+func TestSubagentFacadeReportsUnavailableActions(t *testing.T) {
245+ facade := &SubagentTool{}
246+ for action, want := range map[string]string{
247+ "spawn": "subagent: spawn action is unavailable in this mode",
248+ "status": "subagent: status action is unavailable in this mode",
249+ "stop": "subagent: stop action is unavailable in this mode",
250+ "resume": "subagent: resume action is unavailable in this mode",
251+ } {
252+ raw := json.RawMessage(`{"action":"` + action + `"}`)
253+ result, err := facade.Execute(context.Background(), raw, nil)
254+ if err != nil {
255+ t.Fatalf("%s: unexpected host error: %v", action, err)
256+ }
257+ if !result.IsError || facadeResultText(t, result) != want {
258+ t.Fatalf("%s: result = %#v, want %q", action, result, want)
259+ }
260+ }
261+}
262+
263+// Each action must reach only its own internal tool.
264+func TestSubagentFacadeDispatchesToOwningAction(t *testing.T) {
265+ facade := allActionsFacade(t)
266+ for action, raw := range map[string]string{
267+ "status": `{"action":"status","agent_id":"nope"}`,
268+ "stop": `{"action":"stop","agent_id":"nope"}`,
269+ "resume": `{"action":"resume","agent_id":"nope","prompt":"follow up"}`,
270+ } {
271+ result, err := facade.Execute(context.Background(), json.RawMessage(raw), nil)
272+ if err != nil {
273+ t.Fatalf("%s: unexpected host error: %v", action, err)
274+ }
275+ if !result.IsError || !strings.Contains(facadeResultText(t, result), `no such agent "nope"`) {
276+ t.Fatalf("%s: result = %#v", action, result)
277+ }
278+ }
279+ result, err := facade.Execute(context.Background(), json.RawMessage(`{"action":"resume","agent_id":"nope"}`), nil)
280+ if err != nil {
281+ t.Fatalf("unexpected host error: %v", err)
282+ }
283+ if !result.IsError || !strings.Contains(facadeResultText(t, result), "prompt is required") {
284+ t.Fatalf("resume without prompt = %#v", result)
285+ }
286+}
287+
288+// A field owned by another action is rejected before dispatch; a field no
289+// action owns still reaches the sub-tool and is rejected by its strict decoder.
290+func TestSubagentFacadeRejectsForeignFieldsAndKeepsUnknownOnes(t *testing.T) {
291+ facade := allActionsFacade(t)
292+ result, err := facade.Execute(context.Background(), json.RawMessage(`{"action":"stop","agent_id":"nope","wait":5}`), nil)
293+ if err != nil {
294+ t.Fatalf("unexpected host error: %v", err)
295+ }
296+ if !result.IsError || facadeResultText(t, result) != "subagent: wait is not valid for action stop; it belongs to the resume, spawn actions" {
297+ t.Fatalf("foreign field result = %#v", result)
298+ }
299+ result, err = facade.Execute(context.Background(), json.RawMessage(`{"action":"status","wait":5}`), nil)
300+ if err != nil {
301+ t.Fatalf("unexpected host error: %v", err)
302+ }
303+ if !result.IsError || facadeResultText(t, result) != "subagent: wait is not valid for action status; it belongs to the resume, spawn actions" {
304+ t.Fatalf("status foreign field result = %#v", result)
305+ }
306+ result, err = facade.Execute(context.Background(), json.RawMessage(`{"action":"spawn","task":"t","agent_id":"child"}`), nil)
307+ if err != nil {
308+ t.Fatalf("unexpected host error: %v", err)
309+ }
310+ if !result.IsError || facadeResultText(t, result) != "subagent: agent_id is not valid for action spawn; it belongs to the resume, status, stop actions" {
311+ t.Fatalf("multi-owner foreign field result = %#v", result)
312+ }
313+ _, err = facade.Execute(context.Background(), json.RawMessage(`{"action":"stop","agent_id":"nope","bogus":1}`), nil)
314+ if err == nil || !strings.Contains(err.Error(), "unknown field") {
315+ t.Fatalf("unknown field error = %v", err)
316+ }
317+}
318+
319+// The parser forwards the selected action's raw field bytes unchanged, so the
320+// sub-tools keep their own number and validation semantics.
321+func TestSubagentFacadeForwardsActionPayload(t *testing.T) {
322+ cases := []struct {
323+ raw string
324+ want string
325+ }{
326+ {raw: `{"action":"spawn","task":"review","wait":300}`, want: `{"task":"review","wait":300}`},
327+ {raw: `{"action":"status","agent_id":"child","include_result":true}`, want: `{"agent_id":"child","include_result":true}`},
328+ {raw: `{"action":"resume","agent_id":"child","prompt":"confirm"}`, want: `{"agent_id":"child","prompt":"confirm"}`},
329+ {raw: `{"action":"resume","agent_id":"child","prompt":"confirm","wait":30}`, want: `{"agent_id":"child","prompt":"confirm","wait":30}`},
330+ {raw: `{"action":"stop","agent_id":"child"}`, want: `{"agent_id":"child"}`},
331+ }
332+ for _, tc := range cases {
333+ action, payload, err := parseSubagentAction(json.RawMessage(tc.raw))
334+ if err != nil {
335+ t.Fatalf("%s: parse error %v", tc.raw, err)
336+ }
337+ var got, want map[string]json.RawMessage
338+ if err := json.Unmarshal(payload, &got); err != nil {
339+ t.Fatalf("%s: payload %s is not an object: %v", tc.raw, payload, err)
340+ }
341+ if err := json.Unmarshal([]byte(tc.want), &want); err != nil {
342+ t.Fatal(err)
343+ }
344+ if !reflect.DeepEqual(got, want) {
345+ t.Fatalf("%s: payload = %s, want %s", tc.raw, payload, tc.want)
346+ }
347+ if action == "" {
348+ t.Fatalf("%s: empty action", tc.raw)
349+ }
350+ }
351+}
@@ -0,0 +1,351 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "reflect"
7+ "strings"
8+ "testing"
9+
10+ "github.com/bnema/zut/packages/agent/subagents"
11+ "github.com/bnema/zut/packages/core"
12+ "github.com/bnema/zut/packages/provider"
13+)
14+
15+func facadeResultText(t *testing.T, result core.ToolResult) string {
16+ t.Helper()
17+ var sb strings.Builder
18+ for _, block := range result.Content {
19+ text, ok := block.(provider.TextBlock)
20+ if !ok {
21+ t.Fatalf("content block = %T, want provider.TextBlock", block)
22+ }
23+ sb.WriteString(text.Text)
24+ }
25+ return sb.String()
26+}
27+
28+func newFacadeManager(t *testing.T) *subagents.ResidentManager {
29+ t.Helper()
30+ manager := subagents.NewResidentManager(t.TempDir(), nil)
31+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
32+ return manager
33+}
34+
35+// allActionsFacade wires one enabled internal tool per action so dispatch
36+// targets can be observed independently.
37+func allActionsFacade(t *testing.T) *SubagentTool {
38+ t.Helper()
39+ manager := newFacadeManager(t)
40+ enabled := func() bool { return true }
41+ return &SubagentTool{
42+ Spawn: &SubagentSpawnTool{ResidentManager: manager, Enabled: enabled},
43+ Status: &SubagentStatusTool{ResidentManager: manager, Enabled: enabled},
44+ Stop: &SubagentStopTool{ResidentManager: manager, Enabled: enabled},
45+ Resume: &SubagentResumeTool{ResidentManager: manager, Enabled: enabled},
46+ }
47+}
48+
49+func TestSubagentFacadeNameAndSchema(t *testing.T) {
50+ facade := &SubagentTool{}
51+ if got := facade.Name(); got != "subagent" {
52+ t.Fatalf("Name() = %q, want subagent", got)
53+ }
54+ var schema struct {
55+ Required []string `json:"required"`
56+ Properties map[string]json.RawMessage `json:"properties"`
57+ }
58+ if err := json.Unmarshal(facade.Schema(), &schema); err != nil {
59+ t.Fatalf("schema is not valid JSON: %v", err)
60+ }
61+ if len(schema.Required) != 1 || schema.Required[0] != "action" {
62+ t.Fatalf("required = %v, want [action]", schema.Required)
63+ }
64+ for _, field := range []string{
65+ "action", "task", "agent", "model", "provider", "reasoning", "fast_mode",
66+ "required", "wait", "isolation", "agent_id", "include_result", "prompt",
67+ } {
68+ if _, ok := schema.Properties[field]; !ok {
69+ t.Fatalf("schema is missing property %q", field)
70+ }
71+ }
72+ if got := facade.Description(); got != "Spawn, inspect, resume, and stop resident sub-agents. One action per call.\n\n"+subagentSpawnGuidance {
73+ t.Fatalf("description = %q", got)
74+ }
75+}
76+
77+// facadeSchemaProperties parses the model-facing facade schema into its
78+// property descriptions.
79+func facadeSchemaProperties(t *testing.T) map[string]string {
80+ t.Helper()
81+ var schema struct {
82+ Properties map[string]struct {
83+ Description string `json:"description"`
84+ } `json:"properties"`
85+ }
86+ if err := json.Unmarshal((&SubagentTool{}).Schema(), &schema); err != nil {
87+ t.Fatalf("schema is not valid JSON: %v", err)
88+ }
89+ props := make(map[string]string, len(schema.Properties))
90+ for name, prop := range schema.Properties {
91+ props[name] = prop.Description
92+ }
93+ return props
94+}
95+
96+// TestSubagentFacadeSchemaCoversActionFields ties the flat, merged schema to
97+// the runtime action/field map, so a field added to an action cannot silently
98+// disappear from what the model sees.
99+func TestSubagentFacadeSchemaCoversActionFields(t *testing.T) {
100+ props := facadeSchemaProperties(t)
101+ for action, fields := range subagentActionFields {
102+ for _, field := range fields {
103+ if _, ok := props[field]; !ok {
104+ t.Fatalf("action %s field %q is missing from the facade schema", action, field)
105+ }
106+ }
107+ }
108+}
109+
110+// subagentActionAnnotation pins the action-scoping sentence each
111+// action-owned property must carry. Every field in subagentActionFields must
112+// appear here, and vice versa, so the merged schema cannot advertise an
113+// action-owned field as unconditionally valid.
114+var subagentActionAnnotation = map[string]string{
115+ "task": "Required when action is spawn.",
116+ "agent": "Only valid when action is spawn.",
117+ "model": "Only valid when action is spawn.",
118+ "provider": "Only valid when action is spawn.",
119+ "reasoning": "Only valid when action is spawn.",
120+ "fast_mode": "Only valid when action is spawn.",
121+ "required": "Only valid when action is spawn.",
122+ "wait": "Only valid when action is spawn or resume.",
123+ "isolation": "Only valid when action is spawn.",
124+ "agent_id": "Required when action is stop or resume.",
125+ "include_result": "Only valid when action is status.",
126+ "prompt": "Required when action is resume.",
127+}
128+
129+func TestSubagentFacadeSchemaAnnotatesActionFields(t *testing.T) {
130+ props := facadeSchemaProperties(t)
131+ for action, fields := range subagentActionFields {
132+ for _, field := range fields {
133+ annotation, pinned := subagentActionAnnotation[field]
134+ if !pinned {
135+ t.Fatalf("action %s owns %q with no pinned annotation", action, field)
136+ }
137+ desc, ok := props[field]
138+ if !ok {
139+ t.Fatalf("schema is missing property %q owned by action %s", field, action)
140+ }
141+ if !strings.Contains(desc, annotation) {
142+ t.Fatalf("property %q description %q is missing %q", field, desc, annotation)
143+ }
144+ }
145+ }
146+ inFields := map[string]bool{}
147+ for _, fields := range subagentActionFields {
148+ for _, field := range fields {
149+ inFields[field] = true
150+ }
151+ }
152+ for field := range subagentActionAnnotation {
153+ if !inFields[field] {
154+ t.Fatalf("annotation table names %q, which no action owns", field)
155+ }
156+ }
157+}
158+
159+// TestSubagentFacadeResumeCaveat keeps the former resume-tool warning in the
160+// facade schema: a terminal failure must be inspected before resume, and a
161+// follow-up continues the retained session rather than satisfying the work.
162+func TestSubagentFacadeResumeCaveat(t *testing.T) {
163+ props := facadeSchemaProperties(t)
164+ prompt := props["prompt"]
165+ for _, want := range []string{"inspect its saved result first", "without discarding progress or satisfying required work"} {
166+ if !strings.Contains(prompt, want) {
167+ t.Fatalf("prompt description %q is missing %q", prompt, want)
168+ }
169+ }
170+}
171+
172+// TestSubagentFacadeConfirmationKey covers the per-action confirmation grant:
173+// each action yields its own key, and anything malformed falls back to the
174+// plain base name so a remembered grant cannot widen.
175+func TestSubagentFacadeConfirmationKey(t *testing.T) {
176+ facade := &SubagentTool{}
177+ for _, tc := range []struct {
178+ raw string
179+ want string
180+ }{
181+ {raw: `{"action":"spawn","task":"t"}`, want: "subagent:spawn"},
182+ {raw: `{"action":"status"}`, want: "subagent:status"},
183+ {raw: `{"action":"stop","agent_id":"x"}`, want: "subagent:stop"},
184+ {raw: `{"action":"resume","agent_id":"x","prompt":"p"}`, want: "subagent:resume"},
185+ {raw: `{}`, want: "subagent"},
186+ {raw: `{"action":""}`, want: "subagent"},
187+ {raw: `{"action":" "}`, want: "subagent"},
188+ {raw: `{"action":42}`, want: "subagent"},
189+ {raw: `{"action":["spawn"]}`, want: "subagent"},
190+ {raw: `{"action":{}}`, want: "subagent"},
191+ {raw: `{"action":true}`, want: "subagent"},
192+ {raw: `{not json`, want: "subagent"},
193+ } {
194+ if got := facade.ConfirmationKey(json.RawMessage(tc.raw)); got != tc.want {
195+ t.Fatalf("ConfirmationKey(%s) = %q, want %q", tc.raw, got, tc.want)
196+ }
197+ }
198+ seen := map[string]bool{}
199+ for _, action := range []string{"spawn", "status", "stop", "resume"} {
200+ key := facade.ConfirmationKey(json.RawMessage(`{"action":"` + action + `"}`))
201+ if seen[key] {
202+ t.Fatalf("action %s reuses key %q", action, key)
203+ }
204+ seen[key] = true
205+ }
206+}
207+
208+func TestSubagentFacadeRequiresAction(t *testing.T) {
209+ facade := allActionsFacade(t)
210+ for _, raw := range []string{`{}`, `{"action":""}`, `{"action":" "}`} {
211+ result, err := facade.Execute(context.Background(), json.RawMessage(raw), nil)
212+ if err != nil {
213+ t.Fatalf("%s: unexpected host error: %v", raw, err)
214+ }
215+ if !result.IsError || facadeResultText(t, result) != "subagent: action is required" {
216+ t.Fatalf("%s: result = %#v", raw, result)
217+ }
218+ }
219+ for _, raw := range []string{`{"action":42}`, `{"action":["spawn"]}`, `{"action":{}}`, `{"action":true}`} {
220+ result, err := facade.Execute(context.Background(), json.RawMessage(raw), nil)
221+ if err != nil {
222+ t.Fatalf("%s: unexpected host error: %v", raw, err)
223+ }
224+ if !result.IsError || facadeResultText(t, result) != "subagent: action must be a string" {
225+ t.Fatalf("%s: result = %#v, want action-must-be-a-string", raw, result)
226+ }
227+ }
228+}
229+
230+func TestSubagentFacadeRejectsUnknownActionAndMalformedArgs(t *testing.T) {
231+ facade := allActionsFacade(t)
232+ result, err := facade.Execute(context.Background(), json.RawMessage(`{"action":"explode"}`), nil)
233+ if err != nil {
234+ t.Fatalf("unexpected host error: %v", err)
235+ }
236+ if !result.IsError || facadeResultText(t, result) != "subagent: unknown action" {
237+ t.Fatalf("unknown action result = %#v", result)
238+ }
239+ if _, err := facade.Execute(context.Background(), json.RawMessage(`{"action":`), nil); err == nil || !strings.Contains(err.Error(), "invalid args") {
240+ t.Fatalf("malformed args error = %v", err)
241+ }
242+}
243+
244+func TestSubagentFacadeReportsUnavailableActions(t *testing.T) {
245+ facade := &SubagentTool{}
246+ for action, want := range map[string]string{
247+ "spawn": "subagent: spawn action is unavailable in this mode",
248+ "status": "subagent: status action is unavailable in this mode",
249+ "stop": "subagent: stop action is unavailable in this mode",
250+ "resume": "subagent: resume action is unavailable in this mode",
251+ } {
252+ raw := json.RawMessage(`{"action":"` + action + `"}`)
253+ result, err := facade.Execute(context.Background(), raw, nil)
254+ if err != nil {
255+ t.Fatalf("%s: unexpected host error: %v", action, err)
256+ }
257+ if !result.IsError || facadeResultText(t, result) != want {
258+ t.Fatalf("%s: result = %#v, want %q", action, result, want)
259+ }
260+ }
261+}
262+
263+// Each action must reach only its own internal tool.
264+func TestSubagentFacadeDispatchesToOwningAction(t *testing.T) {
265+ facade := allActionsFacade(t)
266+ for action, raw := range map[string]string{
267+ "status": `{"action":"status","agent_id":"nope"}`,
268+ "stop": `{"action":"stop","agent_id":"nope"}`,
269+ "resume": `{"action":"resume","agent_id":"nope","prompt":"follow up"}`,
270+ } {
271+ result, err := facade.Execute(context.Background(), json.RawMessage(raw), nil)
272+ if err != nil {
273+ t.Fatalf("%s: unexpected host error: %v", action, err)
274+ }
275+ if !result.IsError || !strings.Contains(facadeResultText(t, result), `no such agent "nope"`) {
276+ t.Fatalf("%s: result = %#v", action, result)
277+ }
278+ }
279+ result, err := facade.Execute(context.Background(), json.RawMessage(`{"action":"resume","agent_id":"nope"}`), nil)
280+ if err != nil {
281+ t.Fatalf("unexpected host error: %v", err)
282+ }
283+ if !result.IsError || !strings.Contains(facadeResultText(t, result), "prompt is required") {
284+ t.Fatalf("resume without prompt = %#v", result)
285+ }
286+}
287+
288+// A field owned by another action is rejected before dispatch; a field no
289+// action owns still reaches the sub-tool and is rejected by its strict decoder.
290+func TestSubagentFacadeRejectsForeignFieldsAndKeepsUnknownOnes(t *testing.T) {
291+ facade := allActionsFacade(t)
292+ result, err := facade.Execute(context.Background(), json.RawMessage(`{"action":"stop","agent_id":"nope","wait":5}`), nil)
293+ if err != nil {
294+ t.Fatalf("unexpected host error: %v", err)
295+ }
296+ if !result.IsError || facadeResultText(t, result) != "subagent: wait is not valid for action stop; it belongs to the resume, spawn actions" {
297+ t.Fatalf("foreign field result = %#v", result)
298+ }
299+ result, err = facade.Execute(context.Background(), json.RawMessage(`{"action":"status","wait":5}`), nil)
300+ if err != nil {
301+ t.Fatalf("unexpected host error: %v", err)
302+ }
303+ if !result.IsError || facadeResultText(t, result) != "subagent: wait is not valid for action status; it belongs to the resume, spawn actions" {
304+ t.Fatalf("status foreign field result = %#v", result)
305+ }
306+ result, err = facade.Execute(context.Background(), json.RawMessage(`{"action":"spawn","task":"t","agent_id":"child"}`), nil)
307+ if err != nil {
308+ t.Fatalf("unexpected host error: %v", err)
309+ }
310+ if !result.IsError || facadeResultText(t, result) != "subagent: agent_id is not valid for action spawn; it belongs to the resume, status, stop actions" {
311+ t.Fatalf("multi-owner foreign field result = %#v", result)
312+ }
313+ _, err = facade.Execute(context.Background(), json.RawMessage(`{"action":"stop","agent_id":"nope","bogus":1}`), nil)
314+ if err == nil || !strings.Contains(err.Error(), "unknown field") {
315+ t.Fatalf("unknown field error = %v", err)
316+ }
317+}
318+
319+// The parser forwards the selected action's raw field bytes unchanged, so the
320+// sub-tools keep their own number and validation semantics.
321+func TestSubagentFacadeForwardsActionPayload(t *testing.T) {
322+ cases := []struct {
323+ raw string
324+ want string
325+ }{
326+ {raw: `{"action":"spawn","task":"review","wait":300}`, want: `{"task":"review","wait":300}`},
327+ {raw: `{"action":"status","agent_id":"child","include_result":true}`, want: `{"agent_id":"child","include_result":true}`},
328+ {raw: `{"action":"resume","agent_id":"child","prompt":"confirm"}`, want: `{"agent_id":"child","prompt":"confirm"}`},
329+ {raw: `{"action":"resume","agent_id":"child","prompt":"confirm","wait":30}`, want: `{"agent_id":"child","prompt":"confirm","wait":30}`},
330+ {raw: `{"action":"stop","agent_id":"child"}`, want: `{"agent_id":"child"}`},
331+ }
332+ for _, tc := range cases {
333+ action, payload, err := parseSubagentAction(json.RawMessage(tc.raw))
334+ if err != nil {
335+ t.Fatalf("%s: parse error %v", tc.raw, err)
336+ }
337+ var got, want map[string]json.RawMessage
338+ if err := json.Unmarshal(payload, &got); err != nil {
339+ t.Fatalf("%s: payload %s is not an object: %v", tc.raw, payload, err)
340+ }
341+ if err := json.Unmarshal([]byte(tc.want), &want); err != nil {
342+ t.Fatal(err)
343+ }
344+ if !reflect.DeepEqual(got, want) {
345+ t.Fatalf("%s: payload = %s, want %s", tc.raw, payload, tc.want)
346+ }
347+ if action == "" {
348+ t.Fatalf("%s: empty action", tc.raw)
349+ }
350+ }
351+}
M packages/core/confirm.go +48 -7
@@ -16,8 +16,10 @@ type ConfirmDecision struct {
1616 // Allow=false. Examples: "user declined", "user refused: rm -rf
1717 // looks dangerous".
1818 Reason string
19- // RememberTool, when true, auto-allows every future call of the
20- // same tool name for the rest of the session without prompting.
19+ // RememberTool, when true, auto-allows every future call with the
20+ // same confirmation key for the rest of the session without prompting.
21+ // The key is the tool name unless the tool implements ConfirmationKeyer
22+ // to expose a finer grant (for example one action of a multiplexed tool).
2123 RememberTool bool
2224 // RememberAll, when true, auto-allows every future call of any
2325 // tool for the rest of the session without prompting.
@@ -47,6 +49,35 @@ type ToolCallConfirmation struct {
4749 // Origin routes a confirmation to its owning host view. It is opaque to
4850 // the confirmation gate and never shown to the model.
4951 Origin string
52+ // Key is the session-scoped identity the gate remembers for "always
53+ // allow". It lets one tool expose several confirmable actions
54+ // ("subagent:status") without granting the others. Empty falls back to
55+ // Name so every other tool keeps the per-name grant.
56+ Key string
57+}
58+
59+// ConfirmationKeyer is optionally implemented by tools whose confirmation
60+// grant should be narrower than the tool name. The returned key is what the
61+// ConfirmGate stores for "always allow".
62+//
63+// Implementations must fail closed: when the effective key cannot be derived
64+// from args, return an empty string so the gate falls back to the tool name,
65+// which never widens an existing grant.
66+type ConfirmationKeyer interface {
67+ ConfirmationKey(args json.RawMessage) string
68+}
69+
70+// ConfirmationKey resolves the session grant identity for one tool call. A
71+// tool that implements ConfirmationKeyer may report a finer key (for example
72+// one action of a multiplexed tool); an empty key falls back to name, so an
73+// undetermined key cannot widen the remembered grant beyond the tool name.
74+func ConfirmationKey(tool Tool, name string, args json.RawMessage) string {
75+ if keyer, ok := tool.(ConfirmationKeyer); ok {
76+ if key := keyer.ConfirmationKey(args); key != "" {
77+ return key
78+ }
79+ }
80+ return name
5081 }
5182
5283 // ToolCallConfirmer is optionally implemented by confirmation UIs that can
@@ -57,9 +88,10 @@ type ToolCallConfirmer interface {
5788
5889 // ConfirmGate wraps a Confirmer with session-scoped memory for the
5990 // "allow, always" decisions. Once the user picks RememberTool on a
60-// given tool name, the gate short-circuits subsequent calls of that
61-// name. Once the user picks RememberAll, the gate short-circuits
62-// everything for the rest of the session.
91+// given confirmation key (the tool name, or a finer per-action key when
92+// the tool implements ConfirmationKeyer), the gate short-circuits
93+// subsequent calls with that key. Once the user picks RememberAll, the
94+// gate short-circuits everything for the rest of the session.
6395 //
6496 // Safe for concurrent use: the allow-lists are guarded by a mutex
6597 // because the agent can queue tool calls from different goroutines.
@@ -102,7 +134,7 @@ func (g *ConfirmGate) CheckToolCall(call ToolCallConfirmation) (bool, string, js
102134 g.mu.Unlock()
103135 return true, "", nil
104136 }
105- if g.allowedTool[call.Name] {
137+ if g.allowedTool[call.confirmationKey()] {
106138 g.mu.Unlock()
107139 return true, "", nil
108140 }
@@ -125,7 +157,7 @@ func (g *ConfirmGate) CheckToolCall(call ToolCallConfirmation) (bool, string, js
125157 g.allowAll = true
126158 }
127159 if decision.RememberTool {
128- g.allowedTool[call.Name] = true
160+ g.allowedTool[call.confirmationKey()] = true
129161 }
130162 }
131163 g.mu.Unlock()
@@ -137,6 +169,15 @@ func (g *ConfirmGate) CheckToolCall(call ToolCallConfirmation) (bool, string, js
137169 return decision.Allow, reason, nil
138170 }
139171
172+// confirmationKey is the grant identity for the call: an explicit action-aware
173+// key when the caller supplied one, else the registered tool name.
174+func (call ToolCallConfirmation) confirmationKey() string {
175+ if call.Key != "" {
176+ return call.Key
177+ }
178+ return call.Name
179+}
180+
140181 // Reset clears the session memory. Invoked when the user toggles
141182 // yolo mode back on via /yolo or closes the session.
142183 func (g *ConfirmGate) Reset() {
@@ -16,8 +16,10 @@ type ConfirmDecision struct {
16 // Allow=false. Examples: "user declined", "user refused: rm -rf16 // Allow=false. Examples: "user declined", "user refused: rm -rf
17 // looks dangerous".17 // looks dangerous".
18 Reason string18 Reason string
19- // RememberTool, when true, auto-allows every future call of the19+ // RememberTool, when true, auto-allows every future call with the
20- // same tool name for the rest of the session without prompting.20+ // same confirmation key for the rest of the session without prompting.
21+ // The key is the tool name unless the tool implements ConfirmationKeyer
22+ // to expose a finer grant (for example one action of a multiplexed tool).
21 RememberTool bool23 RememberTool bool
22 // RememberAll, when true, auto-allows every future call of any24 // RememberAll, when true, auto-allows every future call of any
23 // tool for the rest of the session without prompting.25 // tool for the rest of the session without prompting.
@@ -47,6 +49,35 @@ type ToolCallConfirmation struct {
47 // Origin routes a confirmation to its owning host view. It is opaque to49 // Origin routes a confirmation to its owning host view. It is opaque to
48 // the confirmation gate and never shown to the model.50 // the confirmation gate and never shown to the model.
49 Origin string51 Origin string
52+ // Key is the session-scoped identity the gate remembers for "always
53+ // allow". It lets one tool expose several confirmable actions
54+ // ("subagent:status") without granting the others. Empty falls back to
55+ // Name so every other tool keeps the per-name grant.
56+ Key string
57+}
58+
59+// ConfirmationKeyer is optionally implemented by tools whose confirmation
60+// grant should be narrower than the tool name. The returned key is what the
61+// ConfirmGate stores for "always allow".
62+//
63+// Implementations must fail closed: when the effective key cannot be derived
64+// from args, return an empty string so the gate falls back to the tool name,
65+// which never widens an existing grant.
66+type ConfirmationKeyer interface {
67+ ConfirmationKey(args json.RawMessage) string
68+}
69+
70+// ConfirmationKey resolves the session grant identity for one tool call. A
71+// tool that implements ConfirmationKeyer may report a finer key (for example
72+// one action of a multiplexed tool); an empty key falls back to name, so an
73+// undetermined key cannot widen the remembered grant beyond the tool name.
74+func ConfirmationKey(tool Tool, name string, args json.RawMessage) string {
75+ if keyer, ok := tool.(ConfirmationKeyer); ok {
76+ if key := keyer.ConfirmationKey(args); key != "" {
77+ return key
78+ }
79+ }
80+ return name
50 }81 }
51 82
52 // ToolCallConfirmer is optionally implemented by confirmation UIs that can83 // ToolCallConfirmer is optionally implemented by confirmation UIs that can
@@ -57,9 +88,10 @@ type ToolCallConfirmer interface {
57 88
58 // ConfirmGate wraps a Confirmer with session-scoped memory for the89 // ConfirmGate wraps a Confirmer with session-scoped memory for the
59 // "allow, always" decisions. Once the user picks RememberTool on a90 // "allow, always" decisions. Once the user picks RememberTool on a
60-// given tool name, the gate short-circuits subsequent calls of that91+// given confirmation key (the tool name, or a finer per-action key when
61-// name. Once the user picks RememberAll, the gate short-circuits92+// the tool implements ConfirmationKeyer), the gate short-circuits
62-// everything for the rest of the session.93+// subsequent calls with that key. Once the user picks RememberAll, the
94+// gate short-circuits everything for the rest of the session.
63 //95 //
64 // Safe for concurrent use: the allow-lists are guarded by a mutex96 // Safe for concurrent use: the allow-lists are guarded by a mutex
65 // because the agent can queue tool calls from different goroutines.97 // because the agent can queue tool calls from different goroutines.
@@ -102,7 +134,7 @@ func (g *ConfirmGate) CheckToolCall(call ToolCallConfirmation) (bool, string, js
102 g.mu.Unlock()134 g.mu.Unlock()
103 return true, "", nil135 return true, "", nil
104 }136 }
105- if g.allowedTool[call.Name] {137+ if g.allowedTool[call.confirmationKey()] {
106 g.mu.Unlock()138 g.mu.Unlock()
107 return true, "", nil139 return true, "", nil
108 }140 }
@@ -125,7 +157,7 @@ func (g *ConfirmGate) CheckToolCall(call ToolCallConfirmation) (bool, string, js
125 g.allowAll = true157 g.allowAll = true
126 }158 }
127 if decision.RememberTool {159 if decision.RememberTool {
128- g.allowedTool[call.Name] = true160+ g.allowedTool[call.confirmationKey()] = true
129 }161 }
130 }162 }
131 g.mu.Unlock()163 g.mu.Unlock()
@@ -137,6 +169,15 @@ func (g *ConfirmGate) CheckToolCall(call ToolCallConfirmation) (bool, string, js
137 return decision.Allow, reason, nil169 return decision.Allow, reason, nil
138 }170 }
139 171
172+// confirmationKey is the grant identity for the call: an explicit action-aware
173+// key when the caller supplied one, else the registered tool name.
174+func (call ToolCallConfirmation) confirmationKey() string {
175+ if call.Key != "" {
176+ return call.Key
177+ }
178+ return call.Name
179+}
180+
140 // Reset clears the session memory. Invoked when the user toggles181 // Reset clears the session memory. Invoked when the user toggles
141 // yolo mode back on via /yolo or closes the session.182 // yolo mode back on via /yolo or closes the session.
142 func (g *ConfirmGate) Reset() {183 func (g *ConfirmGate) Reset() {
M packages/core/confirm_test.go +60 -0
@@ -1,6 +1,7 @@
11 package core
22
33 import (
4+ "context"
45 "encoding/json"
56 "strings"
67 "sync"
@@ -210,3 +211,62 @@ func TestBuildPreview(t *testing.T) {
210211 func hasEllipsis(s string) bool {
211212 return strings.HasSuffix(s, "...")
212213 }
214+
215+// keyedFakeTool implements Tool and ConfirmationKeyer. An empty key exercises
216+// the fallback path back to the registered tool name.
217+type keyedFakeTool struct{ key string }
218+
219+func (t keyedFakeTool) Name() string { return "subagent" }
220+func (t keyedFakeTool) Description() string { return "" }
221+func (t keyedFakeTool) Schema() json.RawMessage {
222+ return nil
223+}
224+func (t keyedFakeTool) ConfirmationKey(json.RawMessage) string { return t.key }
225+func (t keyedFakeTool) Execute(context.Context, json.RawMessage, func(string)) (ToolResult, error) {
226+ return ToolResult{}, nil
227+}
228+
229+// plainFakeTool implements Tool but not ConfirmationKeyer.
230+type plainFakeTool struct{}
231+
232+func (plainFakeTool) Name() string { return "read" }
233+func (plainFakeTool) Description() string { return "" }
234+func (plainFakeTool) Schema() json.RawMessage {
235+ return nil
236+}
237+func (plainFakeTool) Execute(context.Context, json.RawMessage, func(string)) (ToolResult, error) {
238+ return ToolResult{}, nil
239+}
240+
241+func TestConfirmationKeyFallsBackToToolName(t *testing.T) {
242+ args := json.RawMessage(`{"action":"status"}`)
243+ if got := ConfirmationKey(keyedFakeTool{key: "subagent:status"}, "subagent", args); got != "subagent:status" {
244+ t.Fatalf("keyed tool key = %q, want subagent:status", got)
245+ }
246+ if got := ConfirmationKey(keyedFakeTool{}, "subagent", args); got != "subagent" {
247+ t.Fatalf("empty key should fall back to the tool name, got %q", got)
248+ }
249+ if got := ConfirmationKey(plainFakeTool{}, "read", args); got != "read" {
250+ t.Fatalf("non-keyer should use the tool name, got %q", got)
251+ }
252+}
253+
254+func TestConfirmGateRememberKeyScopesGrant(t *testing.T) {
255+ rc := &recordingConfirmer{replies: []ConfirmDecision{
256+ {Allow: true, RememberTool: true},
257+ {Allow: false, Reason: "no"},
258+ }}
259+ g := NewConfirmGate(rc)
260+ if allow, _, _ := g.CheckToolCall(ToolCallConfirmation{Name: "subagent", Key: "subagent:status", Summary: "status"}); !allow {
261+ t.Fatal("status call should allow")
262+ }
263+ if allow, _, _ := g.CheckToolCall(ToolCallConfirmation{Name: "subagent", Key: "subagent:status", Summary: "status again"}); !allow {
264+ t.Fatal("remembered key should short-circuit")
265+ }
266+ if allow, reason, _ := g.CheckToolCall(ToolCallConfirmation{Name: "subagent", Key: "subagent:spawn", Summary: "spawn"}); allow || reason != "no" {
267+ t.Fatalf("a sibling action must re-prompt; allow=%v reason=%q", allow, reason)
268+ }
269+ if len(rc.calls) != 2 {
270+ t.Fatalf("confirmer calls = %d, want 2 (status then spawn)", len(rc.calls))
271+ }
272+}
@@ -1,6 +1,7 @@
1 package core1 package core
2 2
3 import (3 import (
4+ "context"
4 "encoding/json"5 "encoding/json"
5 "strings"6 "strings"
6 "sync"7 "sync"
@@ -210,3 +211,62 @@ func TestBuildPreview(t *testing.T) {
210 func hasEllipsis(s string) bool {211 func hasEllipsis(s string) bool {
211 return strings.HasSuffix(s, "...")212 return strings.HasSuffix(s, "...")
212 }213 }
214+
215+// keyedFakeTool implements Tool and ConfirmationKeyer. An empty key exercises
216+// the fallback path back to the registered tool name.
217+type keyedFakeTool struct{ key string }
218+
219+func (t keyedFakeTool) Name() string { return "subagent" }
220+func (t keyedFakeTool) Description() string { return "" }
221+func (t keyedFakeTool) Schema() json.RawMessage {
222+ return nil
223+}
224+func (t keyedFakeTool) ConfirmationKey(json.RawMessage) string { return t.key }
225+func (t keyedFakeTool) Execute(context.Context, json.RawMessage, func(string)) (ToolResult, error) {
226+ return ToolResult{}, nil
227+}
228+
229+// plainFakeTool implements Tool but not ConfirmationKeyer.
230+type plainFakeTool struct{}
231+
232+func (plainFakeTool) Name() string { return "read" }
233+func (plainFakeTool) Description() string { return "" }
234+func (plainFakeTool) Schema() json.RawMessage {
235+ return nil
236+}
237+func (plainFakeTool) Execute(context.Context, json.RawMessage, func(string)) (ToolResult, error) {
238+ return ToolResult{}, nil
239+}
240+
241+func TestConfirmationKeyFallsBackToToolName(t *testing.T) {
242+ args := json.RawMessage(`{"action":"status"}`)
243+ if got := ConfirmationKey(keyedFakeTool{key: "subagent:status"}, "subagent", args); got != "subagent:status" {
244+ t.Fatalf("keyed tool key = %q, want subagent:status", got)
245+ }
246+ if got := ConfirmationKey(keyedFakeTool{}, "subagent", args); got != "subagent" {
247+ t.Fatalf("empty key should fall back to the tool name, got %q", got)
248+ }
249+ if got := ConfirmationKey(plainFakeTool{}, "read", args); got != "read" {
250+ t.Fatalf("non-keyer should use the tool name, got %q", got)
251+ }
252+}
253+
254+func TestConfirmGateRememberKeyScopesGrant(t *testing.T) {
255+ rc := &recordingConfirmer{replies: []ConfirmDecision{
256+ {Allow: true, RememberTool: true},
257+ {Allow: false, Reason: "no"},
258+ }}
259+ g := NewConfirmGate(rc)
260+ if allow, _, _ := g.CheckToolCall(ToolCallConfirmation{Name: "subagent", Key: "subagent:status", Summary: "status"}); !allow {
261+ t.Fatal("status call should allow")
262+ }
263+ if allow, _, _ := g.CheckToolCall(ToolCallConfirmation{Name: "subagent", Key: "subagent:status", Summary: "status again"}); !allow {
264+ t.Fatal("remembered key should short-circuit")
265+ }
266+ if allow, reason, _ := g.CheckToolCall(ToolCallConfirmation{Name: "subagent", Key: "subagent:spawn", Summary: "spawn"}); allow || reason != "no" {
267+ t.Fatalf("a sibling action must re-prompt; allow=%v reason=%q", allow, reason)
268+ }
269+ if len(rc.calls) != 2 {
270+ t.Fatalf("confirmer calls = %d, want 2 (status then spawn)", len(rc.calls))
271+ }
272+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close