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 Unverified
B5690EEEBB952194The 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 | |||
| 290 | 290 | ||
| 291 | 291 | ### 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 | 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 | 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 | 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 | 431 | ```text | |
| @@ -446,15 +448,15 @@ Resident subagents run alongside the main session as independent in-process agen | |||
| 446 | 448 | ||
| 447 | 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 | 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 | 457 | ### 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 | 461 | ### `/settings` | |
| 460 | 462 | ||
| @@ -290,7 +290,7 @@ When the sandbox is on (see `/jail`), filesystem tools, `grep`, `glob`, `ast`, a | |||||
| 290 | 290 | ||||
| 291 | ### Headless orchestration | 291 | ### 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 | ```text | 431 | ```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 profiles | 457 | ### 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 @@ | |||
| 4 | 4 | > Previous state is ignored rather than read or migrated; remove it when you | |
| 5 | 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 | 16 | Subagents are independent `core.Agent` conversations resident in the same zut | |
| 8 | 17 | process as their parent. A child has a stable, private session identity, its own | |
| 9 | 18 | provider client and tool registry, a cancellation boundary, and an authoritative | |
| @@ -60,18 +69,19 @@ subagents/<child-id>/ | |||
| 60 | 69 | patch.diff # optional worktree capture | |
| 61 | 70 | ``` | |
| 62 | 71 | ||
| 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. | |
| 70 | 80 | ||
| 71 | 81 | All children stop when the host exits. On the next start, queued or running | |
| 72 | 82 | 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>`. | |
| 75 | 85 | ||
| 76 | 86 | Multiple zut processes may share a state root. Each resident journal has one | |
| 77 | 87 | 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 | 92 | pre-repair transcript as `.transcript-backup-*`; it can contain private session | |
| 83 | 93 | data and is intentionally not removed automatically. | |
| 84 | 94 | ||
| 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. | |
| 88 | 98 | ||
| 89 | 99 | The global scheduler admits the oldest eligible accepted prompt, runs at most | |
| 90 | 100 | 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 | 118 | fields, baselines, or exhaustion states. Version 2 archives are read with | |
| 109 | 119 | explicit translation and their transcripts are never rewritten by | |
| 110 | 120 | 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. | |
| 113 | 123 | ||
| 114 | 124 | ### Context reminders | |
| 115 | 125 | ||
| @@ -160,14 +170,14 @@ guarantee. | |||
| 160 | 170 | Retrieve the saved result without executing a model: | |
| 161 | 171 | ||
| 162 | 172 | ```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 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. | |
| 171 | 181 | ||
| 172 | 182 | `required: true` makes a delegated result an obligation of the parent turn. | |
| 173 | 183 | Failed, cancelled, and interrupted required work remains | |
| @@ -200,32 +210,41 @@ required capability. | |||
| 200 | 210 | ||
| 201 | 211 | ## Tools and slash commands | |
| 202 | 212 | ||
| 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. | |
| 229 | 248 | In interactive mode, a result received while the primary is busy enters the | |
| 230 | 249 | visible **sliding in** queue and reaches the model at its next safe boundary, | |
| 231 | 250 | 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 | 252 | Undelivered results remain queued after a provider or compaction error for the | |
| 234 | 253 | next turn; they do not automatically retry the failed request. Explicit | |
| 235 | 254 | 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 | 257 | When the primary is idle, each child completion immediately starts the next | |
| 238 | 258 | parent turn instead of waiting for its siblings. The host update names the | |
| 239 | 259 | 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 | 289 | worktrees are retained for inspection; an idle successful worktree is cleaned | |
| 270 | 290 | when its child is explicitly stopped. | |
| 271 | 291 | ||
| 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 | |
| 274 | 295 | patch bytes are stored in `subagents/<child-id>/patch.diff` under the managed | |
| 275 | 296 | state root shown above; logical references are identifiers, not filesystem | |
| 276 | 297 | 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 you | 4 | > 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 zut | 16 | 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 own | 17 | 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 authoritative | 18 | provider client and tool registry, a cancellation boundary, and an authoritative | ||
| @@ -60,18 +69,19 @@ subagents/<child-id>/ | |||||
| 60 | patch.diff # optional worktree capture | 69 | 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 deltas | 73 | + | 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 stored | 74 | + | journaled; streamed deltas and hidden reasoning are not exposed as ordinary |
| 66 | - | before execution and reconciliation repairs incomplete call/result pairs. A | 75 | + | history. A tool call is stored before execution and reconciliation repairs |
| 67 | - | context-overflow recovery appends one `child.compacted` checkpoint carrying the | 76 | + | incomplete call/result pairs. A context-overflow recovery appends one |
| 68 | - | replacement transcript; resume starts from the newest checkpoint, while the | 77 | + | `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 running | 81 | 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 with | 82 | 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 one | 86 | 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 it | 87 | 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 session | 92 | 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 a | 95 | + | 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 most | 99 | The global scheduler admits the oldest eligible accepted prompt, runs at most | ||
| 90 | one turn for each child, and defaults to six concurrent turns. Set | 100 | 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 with | 118 | fields, baselines, or exhaustion states. Version 2 archives are read with | ||
| 109 | explicit translation and their transcripts are never rewritten by | 119 | explicit translation and their transcripts are never rewritten by | ||
| 110 | translation. `result.json` keeps a deprecated `handoff` field that stays | 120 | 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 reminders | 124 | ### 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 | ```json | 172 | ```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 before | 176 | + | Pass this to the `subagent` tool's `status` action, then reconcile the history |
| 167 | - | repeating any side effects. Explicit `subagent_resume` after a terminal failure | 177 | + | and artifacts before repeating any side effects. The `subagent` tool's explicit |
| 168 | - | continues the retained session, workspace, and cumulative cost. It does not | 178 | + | `resume` action after a terminal failure continues the retained session, |
| 169 | - | automatically retry the task. Ordinary follow-ups continue the existing | 179 | + | 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 remains | 183 | Failed, cancelled, and interrupted required work remains | ||
| @@ -200,32 +210,41 @@ required capability. | |||||
| 200 | 210 | ||||
| 201 | ## Tools and slash commands | 211 | ## 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` or | 216 | + | 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 the | 219 | + | - `spawn` accepts `task`, optional `agent`, `model` and `provider`, |
| 210 | - | accepted child active, whether it is queued or running. Do not retry that | 220 | + | `reasoning`, `fast_mode`, `required`, `wait`, and `isolation` (`shared` or |
| 211 | - | task until it reaches a terminal failure, cancellation, or interruption. It | 221 | + | `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 current | 223 | + | spawning returns immediately. A timed-out wait leaves the |
| 214 | - | set immediately. With `agent_id` and `include_result: true`, it also reads the | 224 | + | accepted child active, whether it is queued or running. Do not retry that |
| 215 | - | saved terminal result without model execution. Without this | 225 | + | 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; resume | 231 | + | 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 receives | 234 | + | - `stop` accepts `agent_id` and stops one live child. |
| 225 | - | an explicit `wait` value. For an unwaited spawn, completion arrives through the | 235 | + | - `resume` accepts `agent_id`, `prompt`, and optional `wait`. `prompt` is an |
| 226 | - | host’s typed completion update; `subagent_status` returns immediately and does | 236 | + | 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, journal | 237 | + | 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 the | 248 | 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 other | 250 | 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 the | 252 | Undelivered results remain queued after a provider or compaction error for the | ||
| 234 | next turn; they do not automatically retry the failed request. Explicit | 253 | next turn; they do not automatically retry the failed request. Explicit | ||
| 235 | cancellation clears pending queue entries. The queue is in memory, while the | 254 | 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 next | 257 | 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 the | 258 | 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 completion | 259 | 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 cleaned | 289 | 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 the | 292 | + | Use the `subagent` tool's `status` action with `agent_id` and |
| 273 | - | saved result, `changed_files`, and `patch_ref` when a patch was captured. The | 293 | + | `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 managed | 295 | patch bytes are stored in `subagents/<child-id>/patch.diff` under the managed | ||
| 275 | state root shown above; logical references are identifiers, not filesystem | 296 | state root shown above; logical references are identifiers, not filesystem | ||
| 276 | paths. Inspect the retained worktree and patch before resuming or applying | 297 | 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) { | |||
| 469 | 469 | row{"--cwd PATH", "treat PATH as the working directory"}, | |
| 470 | 470 | row{"--no-tools", "disable all tools"}, | |
| 471 | 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 | 473 | row{"--no-yolo", "ask before running every tool call"}, | |
| 474 | 474 | row{"-y, --yes", "accept zut run consent without prompting"}, | |
| 475 | 475 | 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 | |||
| 1476 | 1476 | } | |
| 1477 | 1477 | ||
| 1478 | 1478 | func autoSubagentsToolAllowed(args Args) bool { | |
| 1479 | - | return autoSubagentsToolAllowedFor(args, "subagent_spawn") | |
| 1479 | + | return subagentActionAllowed(args, tools.SubagentActionSpawn) | |
| 1480 | 1480 | } | |
| 1481 | 1481 | ||
| 1482 | 1482 | // orchestratorExplorationToolsAllowed keeps the primary orchestrator's | |
| @@ -1486,15 +1486,15 @@ func orchestratorExplorationToolsAllowed(args Args) bool { | |||
| 1486 | 1486 | } | |
| 1487 | 1487 | ||
| 1488 | 1488 | func autoSubagentsStatusToolAllowed(args Args) bool { | |
| 1489 | - | return autoSubagentsToolAllowedFor(args, "subagent_status") | |
| 1489 | + | return subagentActionAllowed(args, tools.SubagentActionStatus) | |
| 1490 | 1490 | } | |
| 1491 | 1491 | ||
| 1492 | 1492 | func autoSubagentsStopToolAllowed(args Args) bool { | |
| 1493 | - | return autoSubagentsToolAllowedFor(args, "subagent_stop") | |
| 1493 | + | return subagentActionAllowed(args, tools.SubagentActionStop) | |
| 1494 | 1494 | } | |
| 1495 | 1495 | ||
| 1496 | 1496 | func autoSubagentsResumeToolAllowed(args Args) bool { | |
| 1497 | - | return autoSubagentsToolAllowedFor(args, "subagent_resume") | |
| 1497 | + | return subagentActionAllowed(args, tools.SubagentActionResume) | |
| 1498 | 1498 | } | |
| 1499 | 1499 | ||
| 1500 | 1500 | func autoSubagentsAnyToolAllowed(args Args) bool { | |
| @@ -1504,7 +1504,11 @@ func autoSubagentsAnyToolAllowed(args Args) bool { | |||
| 1504 | 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 | 1512 | if args.NoTools || args.PermissionSet != nil { | |
| 1509 | 1513 | return false | |
| 1510 | 1514 | } | |
| @@ -1515,7 +1519,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool { | |||
| 1515 | 1519 | return true | |
| 1516 | 1520 | } | |
| 1517 | 1521 | for _, name := range args.Tools { | |
| 1518 | - | if name == toolName { | |
| 1522 | + | if name == tools.SubagentToolName || name == tools.SubagentToolName+":"+action { | |
| 1519 | 1523 | return true | |
| 1520 | 1524 | } | |
| 1521 | 1525 | } | |
| @@ -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's | 1482 | // 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 false | 1513 | return false | ||
| 1510 | } | 1514 | } | ||
| @@ -1515,7 +1519,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool { | |||||
| 1515 | return true | 1519 | 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 true | 1523 | return true | ||
| 1520 | } | 1524 | } | ||
| 1521 | } | 1525 | } | ||
M packages/agent/build_orchestrator_test.go +1 -1
| @@ -79,7 +79,7 @@ func TestOrchestratorAlwaysReceivesResearchTools(t *testing.T) { | |||
| 79 | 79 | }{ | |
| 80 | 80 | { | |
| 81 | 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 | 83 | wantResearch: true, | |
| 84 | 84 | wantWeb: true, | |
| 85 | 85 | 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 | |||
| 1400 | 1400 | // skills and currently loaded extension tools) and updates the live | |
| 1401 | 1401 | // agent's registry and system prompt. Used after /reload-ext and after | |
| 1402 | 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 | 1404 | // interactive, when non-nil, serializes the final commit with agent replacement. | |
| 1405 | 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 | 1406 | if ag == nil { | |
| @@ -1689,7 +1689,12 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1689 | 1689 | } | |
| 1690 | 1690 | if confirmGate != nil { | |
| 1691 | 1691 | 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 | 1696 | _, currentTools := a.PromptConfig() | |
| 1697 | + | confirmationKey := confirmationKeyFor(currentTools, call.Name, effectiveArgs) | |
| 1693 | 1698 | if tool, err := currentTools.Get(call.Name); err == nil { | |
| 1694 | 1699 | if previewer, ok := tool.(core.ToolPreviewer); ok { | |
| 1695 | 1700 | preview, err := previewer.Preview(ctx, effectiveArgs) | |
| @@ -1709,6 +1714,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1709 | 1714 | ok, reason, _ := confirmGate.CheckToolCall(core.ToolCallConfirmation{ | |
| 1710 | 1715 | ID: call.ID, | |
| 1711 | 1716 | Name: call.Name, | |
| 1717 | + | Key: confirmationKey, | |
| 1712 | 1718 | Summary: core.BuildPreview(effectiveArgs, 120), | |
| 1713 | 1719 | Content: content.String(), | |
| 1714 | 1720 | Origin: call.Origin, | |
| @@ -3286,3 +3292,17 @@ func printModels() { | |||
| 3286 | 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 | + | } | |
| @@ -1400,7 +1400,7 @@ var errInteractiveAgentChanged = errors.New("interactive agent or web-search pol | |||||
| 1400 | // skills and currently loaded extension tools) and updates the live | 1400 | // skills and currently loaded extension tools) and updates the live | ||
| 1401 | // agent's registry and system prompt. Used after /reload-ext and after | 1401 | // 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.Builder | 1691 | 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 * | |||
| 894 | 894 | if got := runtime.currentModel(); got != "stored-local-model" { | |
| 895 | 895 | t.Fatalf("runtime inherited model = %q, want stored-local-model", got) | |
| 896 | 896 | } | |
| 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") | |
| 899 | 899 | } | |
| 900 | 900 | } | |
| 901 | 901 | ||
| @@ -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 { | |||
| 452 | 452 | hints = append(hints, "resident subagent manager is unavailable in this mode") | |
| 453 | 453 | } | |
| 454 | 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 | 457 | return strings.Join(hints, "; ") | |
| 458 | 458 | } | |
| @@ -511,14 +511,15 @@ func (i *Interactive) applyAutoSubagentsTool() { | |||
| 511 | 511 | current := i.agent.ToolsSnapshot() | |
| 512 | 512 | next := core.Registry{} | |
| 513 | 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 | 515 | continue | |
| 516 | 516 | } | |
| 517 | 517 | next[name] = t | |
| 518 | 518 | } | |
| 519 | 519 | if i.autoSubagentsAvailable() { | |
| 520 | + | facade := &tools.SubagentTool{} | |
| 520 | 521 | if i.autoSubagentsToolAllowed() { | |
| 521 | - | canonical := &tools.SubagentSpawnTool{ | |
| 522 | + | facade.Spawn = &tools.SubagentSpawnTool{ | |
| 522 | 523 | ResidentManager: i.cfg.ResidentManager, | |
| 523 | 524 | BuildResidentSpec: i.cfg.BuildResidentSpec, | |
| 524 | 525 | Enabled: func() bool { return true }, | |
| @@ -527,29 +528,26 @@ func (i *Interactive) applyAutoSubagentsTool() { | |||
| 527 | 528 | DefaultReasoning: func() string { return i.cfg.Reasoning }, | |
| 528 | 529 | ResolveSubagent: i.cfg.ResolveSubagent, | |
| 529 | 530 | } | |
| 530 | - | next[canonical.Name()] = canonical | |
| 531 | 531 | } | |
| 532 | 532 | if i.autoSubagentsStatusToolAllowed() { | |
| 533 | - | statusTool := &tools.SubagentStatusTool{ | |
| 533 | + | facade.Status = &tools.SubagentStatusTool{ | |
| 534 | 534 | ResidentManager: i.cfg.ResidentManager, | |
| 535 | 535 | Enabled: func() bool { return true }, | |
| 536 | 536 | } | |
| 537 | - | next[statusTool.Name()] = statusTool | |
| 538 | 537 | } | |
| 539 | 538 | if i.autoSubagentsStopToolAllowed() { | |
| 540 | - | stopTool := &tools.SubagentStopTool{ | |
| 539 | + | facade.Stop = &tools.SubagentStopTool{ | |
| 541 | 540 | ResidentManager: i.cfg.ResidentManager, | |
| 542 | 541 | Enabled: func() bool { return true }, | |
| 543 | 542 | } | |
| 544 | - | next[stopTool.Name()] = stopTool | |
| 545 | 543 | } | |
| 546 | 544 | if i.autoSubagentsResumeToolAllowed() { | |
| 547 | - | resumeTool := &tools.SubagentResumeTool{ | |
| 545 | + | facade.Resume = &tools.SubagentResumeTool{ | |
| 548 | 546 | ResidentManager: i.cfg.ResidentManager, | |
| 549 | 547 | Enabled: func() bool { return true }, | |
| 550 | 548 | } | |
| 551 | - | next[resumeTool.Name()] = resumeTool | |
| 552 | 549 | } | |
| 550 | + | next[facade.Name()] = facade | |
| 553 | 551 | } | |
| 554 | 552 | i.agent.SetTools(next) | |
| 555 | 553 | } | |
| @@ -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 | continue | 515 | continue | ||
| 516 | } | 516 | } | ||
| 517 | next[name] = t | 517 | 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 { | |||
| 29 | 29 | return fmt.Errorf("--orchestrate cannot be combined with --stats") | |
| 30 | 30 | } | |
| 31 | 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 | 34 | return nil | |
| 35 | 35 | } | |
| @@ -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 nil | 34 | return nil | ||
| 35 | } | 35 | } | ||
M packages/agent/orchestration_cli_e2e_test.go +2 -2
| @@ -317,9 +317,9 @@ func requestIsParent(body []byte) bool { | |||
| 317 | 317 | ||
| 318 | 318 | func writeToolCall(w http.ResponseWriter, id, task string) { | |
| 319 | 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 | 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 | 323 | "usage": map[string]int{"prompt_tokens": 8, "completion_tokens": 2}, | |
| 324 | 324 | } | |
| 325 | 325 | 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 | |||
| 314 | 314 | ||
| 315 | 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 | 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 | 324 | // available without enabling proactive delegation. | |
| 325 | 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 | 333 | // ProactiveSubagentsSystemAddendumFor returns the interactive collaboration | |
| 334 | 334 | // contract with guidance only for manager actions exposed at launch time. | |
| @@ -346,11 +346,11 @@ func subagentsSystemAddendumFor(base, unavailable string, strict, spawnToolAllow | |||
| 346 | 346 | addendum := base + "\n\n" + subagentLifecycleAddendum | |
| 347 | 347 | switch { | |
| 348 | 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 | 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 | 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 | 355 | if !spawnToolAllowed { | |
| 356 | 356 | 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 collaboration | 333 | // 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" + subagentLifecycleAddendum | 346 | 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 ( | |||
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | 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 | 15 | builders := []struct { | |
| 10 | 16 | name string | |
| 11 | 17 | build func(bool, bool, bool) string | |
| @@ -25,14 +31,14 @@ func TestSubagentsSystemAddendaListOnlyEnabledLifecycleTools(t *testing.T) { | |||
| 25 | 31 | unwanted []string | |
| 26 | 32 | available bool | |
| 27 | 33 | }{ | |
| 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}}, | |
| 36 | 42 | } { | |
| 37 | 43 | t.Run(tc.name, func(t *testing.T) { | |
| 38 | 44 | 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 string | 16 | name string | ||
| 11 | build func(bool, bool, bool) string | 17 | build func(bool, bool, bool) string | ||
| @@ -25,14 +31,14 @@ func TestSubagentsSystemAddendaListOnlyEnabledLifecycleTools(t *testing.T) { | |||||
| 25 | unwanted []string | 31 | unwanted []string | ||
| 26 | available bool | 32 | 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 | |||
| 283 | 283 | if name == "" { | |
| 284 | 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 | 287 | return nil, fmt.Errorf("resident child tool %q is not allowed", name) | |
| 288 | 288 | } | |
| 289 | 289 | 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) | |||
| 207 | 207 | ||
| 208 | 208 | func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T) { | |
| 209 | 209 | 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, | |
| 214 | 214 | } | |
| 215 | 215 | registry, err := residentChildRegistry(catalogue, []string{"read"}) | |
| 216 | 216 | if err != nil { | |
| @@ -219,7 +219,7 @@ func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T | |||
| 219 | 219 | if len(registry) != 1 { | |
| 220 | 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 | 223 | _, err := residentChildRegistry(catalogue, []string{name}) | |
| 224 | 224 | if err == nil || !strings.Contains(err.Error(), name) { | |
| 225 | 225 | 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 | |||
| 358 | 358 | allTools := make([]string, 0, len(catalogue)) | |
| 359 | 359 | for name := range catalogue { | |
| 360 | 360 | switch name { | |
| 361 | - | case tools.SubagentSpawnToolName, tools.SubagentStatusToolName, tools.SubagentStopToolName, tools.SubagentResumeToolName, "update_goal": | |
| 361 | + | case tools.SubagentToolName, "update_goal": | |
| 362 | 362 | continue | |
| 363 | 363 | } | |
| 364 | 364 | if _, ok := resolved.ToolRegistry[name]; !ok { | |
| @@ -454,8 +454,9 @@ func (rt *subagentRuntime) InjectTools(reg core.Registry) core.Registry { | |||
| 454 | 454 | if reg == nil || rt == nil || rt.resident == nil || !autoSubagentsAnyToolAllowed(rt.args) { | |
| 455 | 455 | return reg | |
| 456 | 456 | } | |
| 457 | + | facade := &tools.SubagentTool{} | |
| 457 | 458 | if autoSubagentsToolAllowed(rt.args) { | |
| 458 | - | spawn := &tools.SubagentSpawnTool{ | |
| 459 | + | facade.Spawn = &tools.SubagentSpawnTool{ | |
| 459 | 460 | ResidentManager: rt.residentManagerForTools(), | |
| 460 | 461 | Enabled: func() bool { return true }, | |
| 461 | 462 | DefaultModel: func() string { | |
| @@ -472,29 +473,26 @@ func (rt *subagentRuntime) InjectTools(reg core.Registry) core.Registry { | |||
| 472 | 473 | return rt.buildResidentChildSpec(ctx, request, reg) | |
| 473 | 474 | }, | |
| 474 | 475 | } | |
| 475 | - | reg[spawn.Name()] = spawn | |
| 476 | 476 | } | |
| 477 | 477 | if autoSubagentsStatusToolAllowed(rt.args) { | |
| 478 | - | status := &tools.SubagentStatusTool{ | |
| 478 | + | facade.Status = &tools.SubagentStatusTool{ | |
| 479 | 479 | ResidentManager: rt.residentManagerForTools(), | |
| 480 | 480 | Enabled: func() bool { return true }, | |
| 481 | 481 | } | |
| 482 | - | reg[status.Name()] = status | |
| 483 | 482 | } | |
| 484 | 483 | if autoSubagentsStopToolAllowed(rt.args) { | |
| 485 | - | stop := &tools.SubagentStopTool{ | |
| 484 | + | facade.Stop = &tools.SubagentStopTool{ | |
| 486 | 485 | ResidentManager: rt.residentManagerForTools(), | |
| 487 | 486 | Enabled: func() bool { return true }, | |
| 488 | 487 | } | |
| 489 | - | reg[stop.Name()] = stop | |
| 490 | 488 | } | |
| 491 | 489 | if autoSubagentsResumeToolAllowed(rt.args) { | |
| 492 | - | resume := &tools.SubagentResumeTool{ | |
| 490 | + | facade.Resume = &tools.SubagentResumeTool{ | |
| 493 | 491 | ResidentManager: rt.residentManagerForTools(), | |
| 494 | 492 | Enabled: func() bool { return true }, | |
| 495 | 493 | } | |
| 496 | - | reg[resume.Name()] = resume | |
| 497 | 494 | } | |
| 495 | + | reg[facade.Name()] = facade | |
| 498 | 496 | return reg | |
| 499 | 497 | } | |
| 500 | 498 | ||
| @@ -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 | continue | 362 | 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 reg | 455 | 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 reg | 496 | return reg | ||
| 499 | } | 497 | } | ||
| 500 | 498 | ||||
M packages/agent/subagent_runtime_test.go +60 -2
| @@ -152,7 +152,7 @@ func TestResidentChildSpecOmitsParentOnlyTools(t *testing.T) { | |||
| 152 | 152 | "read": nil, | |
| 153 | 153 | "schedule": nil, | |
| 154 | 154 | "host-only-tool": nil, | |
| 155 | - | "subagent_spawn": nil, | |
| 155 | + | "subagent": nil, | |
| 156 | 156 | "update_goal": nil, | |
| 157 | 157 | } | |
| 158 | 158 | spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, parent) | |
| @@ -165,7 +165,7 @@ func TestResidentChildSpecOmitsParentOnlyTools(t *testing.T) { | |||
| 165 | 165 | if !slices.Contains(spec.Tools, "read") { | |
| 166 | 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 | 169 | if slices.Contains(spec.Tools, name) { | |
| 170 | 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 | 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 | + | } | |
| @@ -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 { | |||
| 161 | 161 | } | |
| 162 | 162 | var sb strings.Builder | |
| 163 | 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 | 165 | for _, profile := range profiles { | |
| 166 | 166 | if profile == nil { | |
| 167 | 167 | continue | |
| @@ -161,7 +161,7 @@ func SystemPromptAddendum(profiles []*Profile) string { | |||||
| 161 | } | 161 | } | ||
| 162 | var sb strings.Builder | 162 | 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 | continue | 167 | continue | ||
M packages/agent/subagents/resident_manager.go +12 -1
| @@ -439,6 +439,18 @@ func (m *ResidentManager) dispatch() { | |||
| 439 | 439 | // resident child. A disk-only child is rebuilt only for this explicit new | |
| 440 | 440 | // prompt; reconciliation itself never constructs or replays one. | |
| 441 | 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 | 454 | if m == nil { | |
| 443 | 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 | 549 | if child == nil { | |
| 538 | 550 | return errors.New("resident manager: child is not live") | |
| 539 | 551 | } | |
| 540 | - | turnID := uuid.NewString() | |
| 541 | 552 | if child.journal == nil { | |
| 542 | 553 | _, err := child.resumeAccepted(ctx, turnID, prompt) | |
| 543 | 554 | return err | |
| @@ -439,6 +439,18 @@ func (m *ResidentManager) dispatch() { | |||||
| 439 | // resident child. A disk-only child is rebuilt only for this explicit new | 439 | // 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 err | 554 | return err | ||
M packages/agent/subagents/resident_manager_test.go +67 -0
| @@ -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 | 151 | func TestResidentManagerCompletionCarriesFinalSummary(t *testing.T) { | |
| 85 | 152 | completed := make(chan ResidentCompletion, 1) | |
| 86 | 153 | 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 ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "strings" | |
| 8 | 8 | "sync" | |
| 9 | + | "sync/atomic" | |
| 9 | 10 | "testing" | |
| 10 | 11 | "time" | |
| 11 | 12 | ||
| @@ -106,9 +107,9 @@ func TestResidentToolsUseManagerOnly(t *testing.T) { | |||
| 106 | 107 | } | |
| 107 | 108 | ||
| 108 | 109 | func TestSubagentSpawnGuidanceRequiresIndependentOwnership(t *testing.T) { | |
| 109 | - | spawn := &SubagentSpawnTool{} | |
| 110 | + | facade := &SubagentTool{} | |
| 110 | 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 | 113 | t.Fatalf("description missing %q: %s", want, got) | |
| 113 | 114 | } | |
| 114 | 115 | } | |
| @@ -117,7 +118,7 @@ func TestSubagentSpawnGuidanceRequiresIndependentOwnership(t *testing.T) { | |||
| 117 | 118 | Description string `json:"description"` | |
| 118 | 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 | 122 | t.Fatal(err) | |
| 122 | 123 | } | |
| 123 | 124 | 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 | 481 | func toolResultText(t *testing.T, result core.ToolResult) string { | |
| 318 | 482 | t.Helper() | |
| 319 | 483 | 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"].Description | 124 | 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 ( | |||
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/zut/packages/agent/subagents" | |
| 10 | 10 | "github.com/bnema/zut/packages/core" | |
| 11 | + | "github.com/google/uuid" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| 13 | 14 | // SubagentResumeTool gives a sub-agent a follow-up turn while preserving its | |
| 14 | 15 | // 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 | 18 | type SubagentResumeTool struct { | |
| 17 | 19 | ResidentManager *subagents.ResidentManager | |
| 18 | 20 | Enabled func() bool | |
| @@ -21,32 +23,12 @@ type SubagentResumeTool struct { | |||
| 21 | 23 | type subagentResumeArgs struct { | |
| 22 | 24 | AgentID string `json:"agent_id"` | |
| 23 | 25 | Prompt string `json:"prompt"` | |
| 26 | + | Wait *int `json:"wait,omitempty"` | |
| 24 | 27 | } | |
| 25 | 28 | ||
| 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 } | |
| 50 | 32 | ||
| 51 | 33 | func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) { | |
| 52 | 34 | if ctx != nil { | |
| @@ -75,15 +57,41 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 75 | 57 | if strings.TrimSpace(args.Prompt) == "" { | |
| 76 | 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 | 63 | snapshot, ok := findResidentStatusSnapshot(t.ResidentManager.Snapshot(), id) | |
| 79 | 64 | if !ok { | |
| 80 | 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 | 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 | 93 | if updated, ok := t.ResidentManager.SnapshotFor(snapshot.ID); ok { | |
| 86 | 94 | snapshot = updated | |
| 87 | 95 | } | |
| 88 | - | return renderResidentAction("resumed", publicResidentStatus(snapshot)) | |
| 96 | + | return renderResidentActionWait("resumed", publicResidentStatus(snapshot), outcome) | |
| 89 | 97 | } | |
| @@ -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 its | 14 | // SubagentResumeTool gives a sub-agent a follow-up turn while preserving its | ||
| 14 | // existing session context. Every explicit follow-up is accepted durably and | 15 | // 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.ResidentManager | 19 | ResidentManager *subagents.ResidentManager | ||
| 18 | Enabled func() bool | 20 | 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 = updated | 94 | 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 ( | |||
| 25 | 25 | // primary-agent prompt decides whether delegation is proactive or only on an | |
| 26 | 26 | // explicit user request. | |
| 27 | 27 | 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" | |
| 32 | 36 | ||
| 33 | 37 | maxSubagentWaitSeconds = 5 * 60 | |
| 34 | 38 | ) | |
| @@ -79,60 +83,9 @@ type subagentSpawnArgs struct { | |||
| 79 | 83 | Isolation string `json:"isolation,omitempty"` | |
| 80 | 84 | } | |
| 81 | 85 | ||
| 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 } | |
| 136 | 89 | ||
| 137 | 90 | func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) { | |
| 138 | 91 | prefix := t.Name() | |
| @@ -241,27 +194,9 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 241 | 194 | if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil { | |
| 242 | 195 | return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err) | |
| 243 | 196 | } | |
| 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 | |
| 265 | 200 | } | |
| 266 | 201 | } else if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil { | |
| 267 | 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 | 243 | 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 | 275 | func decodeSubagentArgs(raw json.RawMessage, target any) error { | |
| 312 | 276 | decoder := json.NewDecoder(bytes.NewReader(raw)) | |
| 313 | 277 | decoder.DisallowUnknownFields() | |
| @@ -25,10 +25,14 @@ import ( | |||||
| 25 | // primary-agent prompt decides whether delegation is proactive or only on an | 25 | // 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 * 60 | 37 | 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()}}}, nil | 243 | 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 { | |||
| 44 | 44 | OwnedElsewhere bool `json:"owned_elsewhere,omitempty"` | |
| 45 | 45 | } | |
| 46 | 46 | ||
| 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 } | |
| 70 | 50 | ||
| 71 | 51 | func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) { | |
| 72 | 52 | if ctx != nil { | |
| @@ -151,7 +131,7 @@ func publicResidentStatus(snapshot subagents.ResidentSnapshot) subagentStatusEnt | |||
| 151 | 131 | func renderSubagentStatus(response subagentStatusResponse) (core.ToolResult, error) { | |
| 152 | 132 | data, err := json.Marshal(response) | |
| 153 | 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 | 136 | return core.ToolResult{ | |
| 157 | 137 | 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 { | |||
| 22 | 22 | AgentID string `json:"agent_id"` | |
| 23 | 23 | } | |
| 24 | 24 | ||
| 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"` | |
| 28 | 33 | } | |
| 29 | 34 | ||
| 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"` | |
| 45 | 39 | } | |
| 46 | 40 | ||
| 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 } | |
| 50 | 44 | ||
| 51 | 45 | func (t *SubagentStopTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) { | |
| 52 | 46 | if ctx != nil { | |
| @@ -86,7 +80,13 @@ func (t *SubagentStopTool) Execute(ctx context.Context, raw json.RawMessage, _ f | |||
| 86 | 80 | } | |
| 87 | 81 | ||
| 88 | 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 | 90 | data, err := json.Marshal(response) | |
| 91 | 91 | if err != nil { | |
| 92 | 92 | 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 { | |||
| 16 | 16 | // Allow=false. Examples: "user declined", "user refused: rm -rf | |
| 17 | 17 | // looks dangerous". | |
| 18 | 18 | 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). | |
| 21 | 23 | RememberTool bool | |
| 22 | 24 | // RememberAll, when true, auto-allows every future call of any | |
| 23 | 25 | // tool for the rest of the session without prompting. | |
| @@ -47,6 +49,35 @@ type ToolCallConfirmation struct { | |||
| 47 | 49 | // Origin routes a confirmation to its owning host view. It is opaque to | |
| 48 | 50 | // the confirmation gate and never shown to the model. | |
| 49 | 51 | 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 | 83 | // ToolCallConfirmer is optionally implemented by confirmation UIs that can | |
| @@ -57,9 +88,10 @@ type ToolCallConfirmer interface { | |||
| 57 | 88 | ||
| 58 | 89 | // ConfirmGate wraps a Confirmer with session-scoped memory for the | |
| 59 | 90 | // "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. | |
| 63 | 95 | // | |
| 64 | 96 | // Safe for concurrent use: the allow-lists are guarded by a mutex | |
| 65 | 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 | 134 | g.mu.Unlock() | |
| 103 | 135 | return true, "", nil | |
| 104 | 136 | } | |
| 105 | - | if g.allowedTool[call.Name] { | |
| 137 | + | if g.allowedTool[call.confirmationKey()] { | |
| 106 | 138 | g.mu.Unlock() | |
| 107 | 139 | return true, "", nil | |
| 108 | 140 | } | |
| @@ -125,7 +157,7 @@ func (g *ConfirmGate) CheckToolCall(call ToolCallConfirmation) (bool, string, js | |||
| 125 | 157 | g.allowAll = true | |
| 126 | 158 | } | |
| 127 | 159 | if decision.RememberTool { | |
| 128 | - | g.allowedTool[call.Name] = true | |
| 160 | + | g.allowedTool[call.confirmationKey()] = true | |
| 129 | 161 | } | |
| 130 | 162 | } | |
| 131 | 163 | g.mu.Unlock() | |
| @@ -137,6 +169,15 @@ func (g *ConfirmGate) CheckToolCall(call ToolCallConfirmation) (bool, string, js | |||
| 137 | 169 | 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 | 181 | // Reset clears the session memory. Invoked when the user toggles | |
| 141 | 182 | // yolo mode back on via /yolo or closes the session. | |
| 142 | 183 | func (g *ConfirmGate) Reset() { | |
| @@ -16,8 +16,10 @@ type ConfirmDecision struct { | |||||
| 16 | // Allow=false. Examples: "user declined", "user refused: rm -rf | 16 | // Allow=false. Examples: "user declined", "user refused: rm -rf | ||
| 17 | // looks dangerous". | 17 | // looks dangerous". | ||
| 18 | Reason string | 18 | Reason string | ||
| 19 | - | // RememberTool, when true, auto-allows every future call of the | 19 | + | // 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 bool | 23 | RememberTool bool | ||
| 22 | // RememberAll, when true, auto-allows every future call of any | 24 | // 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 to | 49 | // 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 string | 51 | 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 can | 83 | // 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 the | 89 | // ConfirmGate wraps a Confirmer with session-scoped memory for the | ||
| 59 | // "allow, always" decisions. Once the user picks RememberTool on a | 90 | // "allow, always" decisions. Once the user picks RememberTool on a | ||
| 60 | - | // given tool name, the gate short-circuits subsequent calls of that | 91 | + | // given confirmation key (the tool name, or a finer per-action key when |
| 61 | - | // name. Once the user picks RememberAll, the gate short-circuits | 92 | + | // 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 mutex | 96 | // 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, "", nil | 135 | 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, "", nil | 139 | return true, "", nil | ||
| 108 | } | 140 | } | ||
| @@ -125,7 +157,7 @@ func (g *ConfirmGate) CheckToolCall(call ToolCallConfirmation) (bool, string, js | |||||
| 125 | g.allowAll = true | 157 | g.allowAll = true | ||
| 126 | } | 158 | } | ||
| 127 | if decision.RememberTool { | 159 | if decision.RememberTool { | ||
| 128 | - | g.allowedTool[call.Name] = true | 160 | + | 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, nil | 169 | 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 toggles | 181 | // 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 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "encoding/json" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "sync" | |
| @@ -210,3 +211,62 @@ func TestBuildPreview(t *testing.T) { | |||
| 210 | 211 | func hasEllipsis(s string) bool { | |
| 211 | 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 | + | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package core | 1 | 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 | + | } | |||