fix(subagents): steer running children and stop echoing undelivered follow-ups (#227)
* fix(subagents): steer running children and stop echoing undelivered follow-ups resume now injects a follow-up into a running child's turn by default (mode steer), with mode queue keeping the separate-turn behavior. A new interrupt action cancels only the running turn and keeps the child resumable. Stop and interrupt report unread steers as undelivered and never-started queued turns as dropped instead of echoing them as interrupted tasks, and status exposes pending_followups. The child runtime becomes a ResidentRuntime interface so the manager can reach the child's core.Agent queue. Fixes #226 * fix(subagents): expose interrupt in prompts and report undelivered wait outcomes Prompt addenda now take the full set of granted actions and describe interrupt. A waited resume reports steers its turn never delivered, dropped follow-ups keep non-cancellation errors, and /subagents resume says whether it steered or queued. Removes the unused wait wrapper, unexports resumeWithTurn, and extends gating tests to the interrupt action. * test(subagents): cover interrupt racing natural turn completion
- commit
- 22e42bf5b305cea847ac6df9aeee89459c3381ee
- author
- bnema · · 2026-09-25 05:25 UTC
- committer
- GitHub · · 2026-09-25 05:25 UTC
- parent
- efd47b3
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown - refs
- tag: v0.44.1
41 files · +1570 -431
M README.md +2 -2
| @@ -425,7 +425,7 @@ Resident subagents run alongside the main session as independent in-process agen | |||
| 425 | 425 | ||
| 426 | 426 | > **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. | |
| 427 | 427 | ||
| 428 | - | > **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 | + | > **V0.x breaking change:** the separate `subagent_spawn`, `subagent_status`, `subagent_stop`, and `subagent_resume` tools are replaced by one `subagent` tool whose actions are selected by an `action` argument. `--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 any other action. | |
| 429 | 429 | ||
| 430 | 430 | > **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. | |
| 431 | 431 | ||
| @@ -449,7 +449,7 @@ Resident subagents run alongside the main session as independent in-process agen | |||
| 449 | 449 | ||
| 450 | 450 | 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. | |
| 451 | 451 | ||
| 452 | - | 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. | |
| 452 | + | 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 the turn that answers it. A resume to a running child steers its current turn by default (`mode: "queue"` runs it as the next turn instead), and the `interrupt` action cancels only the running turn so a later resume can ask the child to wrap up with its context intact. Required work stays unresolved until success. See [lifecycle and persistence](docs/subagents.md#lifecycle-and-persistence) for details. | |
| 453 | 453 | ||
| 454 | 454 | 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`. | |
| 455 | 455 | ||
| @@ -425,7 +425,7 @@ Resident subagents run alongside the main session as independent in-process agen | |||||
| 425 | 425 | ||||
| 426 | > **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 | > **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. | ||
| 427 | 427 | ||||
| 428 | - | > **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 | + | > **V0.x breaking change:** the separate `subagent_spawn`, `subagent_status`, `subagent_stop`, and `subagent_resume` tools are replaced by one `subagent` tool whose actions are selected by an `action` argument. `--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 any other action. |
| 429 | 429 | ||||
| 430 | > **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. | 430 | > **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. | ||
| 431 | 431 | ||||
| @@ -449,7 +449,7 @@ Resident subagents run alongside the main session as independent in-process agen | |||||
| 449 | 449 | ||||
| 450 | 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. | 450 | 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. | ||
| 451 | 451 | ||||
| 452 | - | 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. | 452 | + | 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 the turn that answers it. A resume to a running child steers its current turn by default (`mode: "queue"` runs it as the next turn instead), and the `interrupt` action cancels only the running turn so a later resume can ask the child to wrap up with its context intact. Required work stays unresolved until success. See [lifecycle and persistence](docs/subagents.md#lifecycle-and-persistence) for details. |
| 453 | 453 | ||||
| 454 | 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`. | 454 | 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`. | ||
| 455 | 455 | ||||
M docs/subagents.md +35 -12
| @@ -6,12 +6,12 @@ | |||
| 6 | 6 | ||
| 7 | 7 | > **V0.x breaking change:** the separate `subagent_spawn`, `subagent_status`, | |
| 8 | 8 | > `subagent_stop`, and `subagent_resume` tools are replaced by one `subagent` | |
| 9 | - | > tool with `spawn`, `status`, `stop`, and `resume` actions. | |
| 9 | + | > tool whose actions are selected by an `action` argument. | |
| 10 | 10 | > `--tools subagent_spawn` no longer selects anything; use | |
| 11 | 11 | > `--tools subagent:spawn`, or bare `--tools subagent` for every action. | |
| 12 | 12 | > Under `--no-yolo`, "always allow" is remembered per action | |
| 13 | 13 | > (`subagent:status` and `subagent:spawn` are separate grants), so approving a | |
| 14 | - | > read-only `status` call never pre-approves `spawn`, `stop`, or `resume`. | |
| 14 | + | > read-only `status` call never pre-approves any other action. | |
| 15 | 15 | ||
| 16 | 16 | Subagents are independent `core.Agent` conversations resident in the same zut | |
| 17 | 17 | process as their parent. A child has a stable, private session identity, its own | |
| @@ -217,12 +217,13 @@ required capability. | |||
| 217 | 217 | ||
| 218 | 218 | ## Tools and slash commands | |
| 219 | 219 | ||
| 220 | - | The model-facing tool keeps one logical name and four actions, selected by its | |
| 220 | + | The model-facing tool keeps one logical name and five actions, selected by its | |
| 221 | 221 | `action` argument. Launch-time gating is per action: `--tools subagent` grants | |
| 222 | 222 | every action, `--tools subagent:<action>` grants one, and `--no-tools` disables | |
| 223 | 223 | all of them. | |
| 224 | 224 | ||
| 225 | - | - `subagent` accepts an `action` of `spawn`, `status`, `stop`, or `resume`. | |
| 225 | + | - `subagent` accepts an `action` of `spawn`, `status`, `stop`, `resume`, or | |
| 226 | + | `interrupt`. | |
| 226 | 227 | - `spawn` accepts `task`, optional `agent`, `model` and `provider`, | |
| 227 | 228 | `reasoning`, `fast_mode`, `required`, `wait`, and `isolation` (`shared` or | |
| 228 | 229 | `worktree`). | |
| @@ -244,14 +245,35 @@ all of them. | |||
| 244 | 245 | reasoning is never exposed. `watch` (1–60 seconds, requires `agent_id`) | |
| 245 | 246 | observes that activity and returns a `timeline` of phase and tool changes. | |
| 246 | 247 | It returns early when the child stops running. Use it to inspect progress, | |
| 247 | - | not to wait for completion. | |
| 248 | + | not to wait for completion. `pending_followups` counts follow-ups the | |
| 249 | + | child has accepted but not read yet. | |
| 248 | 250 | - `stop` accepts `agent_id` and stops one live child. | |
| 249 | - | - `resume` accepts `agent_id`, `prompt`, and optional `wait`. `prompt` is an | |
| 250 | - | explicit follow-up prompt for an existing child. After a terminal failure, | |
| 251 | - | inspect the saved result before resuming; resume continues the retained | |
| 252 | - | session. `wait` uses the same 1–300 second bound as `spawn` and waits for | |
| 253 | - | the accepted follow-up turn, returning its outcome or reporting the timeout | |
| 254 | - | while the child stays active. | |
| 251 | + | - `resume` accepts `agent_id`, `prompt`, optional `mode`, and optional | |
| 252 | + | `wait`. `prompt` is an explicit follow-up prompt for an existing child. | |
| 253 | + | After a terminal failure, inspect the saved result before resuming; resume | |
| 254 | + | continues the retained session. `mode` controls how a follow-up reaches a | |
| 255 | + | running child: | |
| 256 | + | - `steer` (default) injects it into the current turn at the next tool or | |
| 257 | + | model boundary, like typing while the main agent works. It adds no new | |
| 258 | + | turn and no extra completion. | |
| 259 | + | - `queue` runs it as a separate turn after the current one. | |
| 260 | + | ||
| 261 | + | An idle or stopped child starts a new turn in either mode, and a steer | |
| 262 | + | never jumps ahead of already queued turns. The response's `delivery` is | |
| 263 | + | `steered` or `queued`. `wait` uses the same 1–300 second bound as `spawn` | |
| 264 | + | and waits for the turn that answers the follow-up, returning its outcome or | |
| 265 | + | reporting the timeout while the child stays active. A returned outcome can | |
| 266 | + | be `dropped` when a queued follow-up never started, and it lists | |
| 267 | + | `undelivered` steers the child never read. | |
| 268 | + | - `interrupt` accepts `agent_id` and cancels only the child's running turn. | |
| 269 | + | Unlike `stop`, the child stays live with its transcript, so a later | |
| 270 | + | `resume` such as "report what you have" continues with full context. | |
| 271 | + | Queued follow-ups are dropped. It fails when no turn is running. | |
| 272 | + | ||
| 273 | + | When a turn ends before the child reads a steered follow-up, for example after | |
| 274 | + | `stop`, `interrupt`, or a failure, the completion update lists it as | |
| 275 | + | `undelivered`. Queued follow-ups that never started are reported as `dropped`. | |
| 276 | + | Neither appears as an interrupted task. | |
| 255 | 277 | ||
| 256 | 278 | Child execution started by the `subagent` tool's `spawn` or `resume` action is | |
| 257 | 279 | asynchronous unless it receives an explicit `wait` value. For an unwaited call, | |
| @@ -282,7 +304,8 @@ as `completed`, a relative update time, and a short ID. Press Down from the | |||
| 282 | 304 | main composer to open this list without clearing a draft when a child exists. | |
| 283 | 305 | Use arrows and Enter to open a child session; `/subagents new <task>` creates a child; `/subagents logs <id>` | |
| 284 | 306 | opens its history; `/subagents result <id>` shows the bounded final summary; | |
| 285 | - | `kill <id>` stops it; and `resume <id> <prompt>` continues it. | |
| 307 | + | `kill <id>` stops it; and `resume <id> <prompt>` continues it. A prompt sent | |
| 308 | + | from the child composer or `resume` to a running child steers its current turn. | |
| 286 | 309 | ||
| 287 | 310 | The child session uses the same transcript renderer as the main session. | |
| 288 | 311 | Recent history is bounded and loaded asynchronously; PgUp asks for an older | |
| @@ -6,12 +6,12 @@ | |||||
| 6 | 6 | ||||
| 7 | > **V0.x breaking change:** the separate `subagent_spawn`, `subagent_status`, | 7 | > **V0.x breaking change:** the separate `subagent_spawn`, `subagent_status`, | ||
| 8 | > `subagent_stop`, and `subagent_resume` tools are replaced by one `subagent` | 8 | > `subagent_stop`, and `subagent_resume` tools are replaced by one `subagent` | ||
| 9 | - | > tool with `spawn`, `status`, `stop`, and `resume` actions. | 9 | + | > tool whose actions are selected by an `action` argument. |
| 10 | > `--tools subagent_spawn` no longer selects anything; use | 10 | > `--tools subagent_spawn` no longer selects anything; use | ||
| 11 | > `--tools subagent:spawn`, or bare `--tools subagent` for every action. | 11 | > `--tools subagent:spawn`, or bare `--tools subagent` for every action. | ||
| 12 | > Under `--no-yolo`, "always allow" is remembered per action | 12 | > Under `--no-yolo`, "always allow" is remembered per action | ||
| 13 | > (`subagent:status` and `subagent:spawn` are separate grants), so approving a | 13 | > (`subagent:status` and `subagent:spawn` are separate grants), so approving a | ||
| 14 | - | > read-only `status` call never pre-approves `spawn`, `stop`, or `resume`. | 14 | + | > read-only `status` call never pre-approves any other action. |
| 15 | 15 | ||||
| 16 | Subagents are independent `core.Agent` conversations resident in the same zut | 16 | Subagents are independent `core.Agent` conversations resident in the same zut | ||
| 17 | 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 | ||
| @@ -217,12 +217,13 @@ required capability. | |||||
| 217 | 217 | ||||
| 218 | ## Tools and slash commands | 218 | ## Tools and slash commands | ||
| 219 | 219 | ||||
| 220 | - | The model-facing tool keeps one logical name and four actions, selected by its | 220 | + | The model-facing tool keeps one logical name and five actions, selected by its |
| 221 | `action` argument. Launch-time gating is per action: `--tools subagent` grants | 221 | `action` argument. Launch-time gating is per action: `--tools subagent` grants | ||
| 222 | every action, `--tools subagent:<action>` grants one, and `--no-tools` disables | 222 | every action, `--tools subagent:<action>` grants one, and `--no-tools` disables | ||
| 223 | all of them. | 223 | all of them. | ||
| 224 | 224 | ||||
| 225 | - | - `subagent` accepts an `action` of `spawn`, `status`, `stop`, or `resume`. | 225 | + | - `subagent` accepts an `action` of `spawn`, `status`, `stop`, `resume`, or |
| 226 | + | `interrupt`. | |||
| 226 | - `spawn` accepts `task`, optional `agent`, `model` and `provider`, | 227 | - `spawn` accepts `task`, optional `agent`, `model` and `provider`, | ||
| 227 | `reasoning`, `fast_mode`, `required`, `wait`, and `isolation` (`shared` or | 228 | `reasoning`, `fast_mode`, `required`, `wait`, and `isolation` (`shared` or | ||
| 228 | `worktree`). | 229 | `worktree`). | ||
| @@ -244,14 +245,35 @@ all of them. | |||||
| 244 | reasoning is never exposed. `watch` (1–60 seconds, requires `agent_id`) | 245 | reasoning is never exposed. `watch` (1–60 seconds, requires `agent_id`) | ||
| 245 | observes that activity and returns a `timeline` of phase and tool changes. | 246 | observes that activity and returns a `timeline` of phase and tool changes. | ||
| 246 | It returns early when the child stops running. Use it to inspect progress, | 247 | It returns early when the child stops running. Use it to inspect progress, | ||
| 247 | - | not to wait for completion. | 248 | + | not to wait for completion. `pending_followups` counts follow-ups the |
| 249 | + | child has accepted but not read yet. | |||
| 248 | - `stop` accepts `agent_id` and stops one live child. | 250 | - `stop` accepts `agent_id` and stops one live child. | ||
| 249 | - | - `resume` accepts `agent_id`, `prompt`, and optional `wait`. `prompt` is an | 251 | + | - `resume` accepts `agent_id`, `prompt`, optional `mode`, and optional |
| 250 | - | explicit follow-up prompt for an existing child. After a terminal failure, | 252 | + | `wait`. `prompt` is an explicit follow-up prompt for an existing child. |
| 251 | - | inspect the saved result before resuming; resume continues the retained | 253 | + | After a terminal failure, inspect the saved result before resuming; resume |
| 252 | - | session. `wait` uses the same 1–300 second bound as `spawn` and waits for | 254 | + | continues the retained session. `mode` controls how a follow-up reaches a |
| 253 | - | the accepted follow-up turn, returning its outcome or reporting the timeout | 255 | + | running child: |
| 254 | - | while the child stays active. | 256 | + | - `steer` (default) injects it into the current turn at the next tool or |
| 257 | + | model boundary, like typing while the main agent works. It adds no new | |||
| 258 | + | turn and no extra completion. | |||
| 259 | + | - `queue` runs it as a separate turn after the current one. | |||
| 260 | + | ||||
| 261 | + | An idle or stopped child starts a new turn in either mode, and a steer | |||
| 262 | + | never jumps ahead of already queued turns. The response's `delivery` is | |||
| 263 | + | `steered` or `queued`. `wait` uses the same 1–300 second bound as `spawn` | |||
| 264 | + | and waits for the turn that answers the follow-up, returning its outcome or | |||
| 265 | + | reporting the timeout while the child stays active. A returned outcome can | |||
| 266 | + | be `dropped` when a queued follow-up never started, and it lists | |||
| 267 | + | `undelivered` steers the child never read. | |||
| 268 | + | - `interrupt` accepts `agent_id` and cancels only the child's running turn. | |||
| 269 | + | Unlike `stop`, the child stays live with its transcript, so a later | |||
| 270 | + | `resume` such as "report what you have" continues with full context. | |||
| 271 | + | Queued follow-ups are dropped. It fails when no turn is running. | |||
| 272 | + | ||||
| 273 | + | When a turn ends before the child reads a steered follow-up, for example after | |||
| 274 | + | `stop`, `interrupt`, or a failure, the completion update lists it as | |||
| 275 | + | `undelivered`. Queued follow-ups that never started are reported as `dropped`. | |||
| 276 | + | Neither appears as an interrupted task. | |||
| 255 | 277 | ||||
| 256 | Child execution started by the `subagent` tool's `spawn` or `resume` action is | 278 | Child execution started by the `subagent` tool's `spawn` or `resume` action is | ||
| 257 | asynchronous unless it receives an explicit `wait` value. For an unwaited call, | 279 | asynchronous unless it receives an explicit `wait` value. For an unwaited call, | ||
| @@ -282,7 +304,8 @@ as `completed`, a relative update time, and a short ID. Press Down from the | |||||
| 282 | main composer to open this list without clearing a draft when a child exists. | 304 | main composer to open this list without clearing a draft when a child exists. | ||
| 283 | Use arrows and Enter to open a child session; `/subagents new <task>` creates a child; `/subagents logs <id>` | 305 | Use arrows and Enter to open a child session; `/subagents new <task>` creates a child; `/subagents logs <id>` | ||
| 284 | opens its history; `/subagents result <id>` shows the bounded final summary; | 306 | opens its history; `/subagents result <id>` shows the bounded final summary; | ||
| 285 | - | `kill <id>` stops it; and `resume <id> <prompt>` continues it. | 307 | + | `kill <id>` stops it; and `resume <id> <prompt>` continues it. A prompt sent |
| 308 | + | from the child composer or `resume` to a running child steers its current turn. | |||
| 286 | 309 | ||||
| 287 | The child session uses the same transcript renderer as the main session. | 310 | The child session uses the same transcript renderer as the main session. | ||
| 288 | Recent history is bounded and loaded asynchronously; PgUp asks for an older | 311 | Recent history is bounded and loaded asynchronously; PgUp asks for an older | ||
M packages/agent/build.go +18 -11
| @@ -940,17 +940,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 940 | 940 | if primaryOrchestrator { | |
| 941 | 941 | // Headless orchestration owns its strict prompt contract rather than | |
| 942 | 942 | // inheriting the interactive collaboration setting. | |
| 943 | - | append_ = append(append_, StrictOrchestratorSystemAddendumFor( | |
| 944 | - | autoSubagentsToolAllowed(args), | |
| 945 | - | autoSubagentsStopToolAllowed(args), | |
| 946 | - | autoSubagentsResumeToolAllowed(args), | |
| 947 | - | )) | |
| 943 | + | append_ = append(append_, StrictOrchestratorSystemAddendumFor(subagentActionsAllowed(args))) | |
| 948 | 944 | } else if primaryInteractive && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled { | |
| 949 | - | append_ = append(append_, ProactiveSubagentsSystemAddendumFor( | |
| 950 | - | autoSubagentsToolAllowed(args), | |
| 951 | - | autoSubagentsStopToolAllowed(args), | |
| 952 | - | autoSubagentsResumeToolAllowed(args), | |
| 953 | - | )) | |
| 945 | + | append_ = append(append_, ProactiveSubagentsSystemAddendumFor(subagentActionsAllowed(args))) | |
| 954 | 946 | } else if primaryInteractive && autoSubagentsAnyToolAllowed(args) { | |
| 955 | 947 | append_ = append(append_, OnDemandSubagentsSystemAddendum) | |
| 956 | 948 | } | |
| @@ -1519,11 +1511,26 @@ func autoSubagentsResumeToolAllowed(args Args) bool { | |||
| 1519 | 1511 | return subagentActionAllowed(args, tools.SubagentActionResume) | |
| 1520 | 1512 | } | |
| 1521 | 1513 | ||
| 1514 | + | func autoSubagentsInterruptToolAllowed(args Args) bool { | |
| 1515 | + | return subagentActionAllowed(args, tools.SubagentActionInterrupt) | |
| 1516 | + | } | |
| 1517 | + | ||
| 1518 | + | // subagentActionsAllowed collects the launch-time subagent action grants. | |
| 1519 | + | func subagentActionsAllowed(args Args) SubagentActions { | |
| 1520 | + | return SubagentActions{ | |
| 1521 | + | Spawn: autoSubagentsToolAllowed(args), | |
| 1522 | + | Stop: autoSubagentsStopToolAllowed(args), | |
| 1523 | + | Resume: autoSubagentsResumeToolAllowed(args), | |
| 1524 | + | Interrupt: autoSubagentsInterruptToolAllowed(args), | |
| 1525 | + | } | |
| 1526 | + | } | |
| 1527 | + | ||
| 1522 | 1528 | func autoSubagentsAnyToolAllowed(args Args) bool { | |
| 1523 | 1529 | return autoSubagentsToolAllowed(args) || | |
| 1524 | 1530 | autoSubagentsStatusToolAllowed(args) || | |
| 1525 | 1531 | autoSubagentsStopToolAllowed(args) || | |
| 1526 | - | autoSubagentsResumeToolAllowed(args) | |
| 1532 | + | autoSubagentsResumeToolAllowed(args) || | |
| 1533 | + | autoSubagentsInterruptToolAllowed(args) | |
| 1527 | 1534 | } | |
| 1528 | 1535 | ||
| 1529 | 1536 | // subagentActionAllowed reports whether the launch-time tool policy grants one | |
| @@ -940,17 +940,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 940 | if primaryOrchestrator { | 940 | if primaryOrchestrator { | ||
| 941 | // Headless orchestration owns its strict prompt contract rather than | 941 | // Headless orchestration owns its strict prompt contract rather than | ||
| 942 | // inheriting the interactive collaboration setting. | 942 | // inheriting the interactive collaboration setting. | ||
| 943 | - | append_ = append(append_, StrictOrchestratorSystemAddendumFor( | 943 | + | append_ = append(append_, StrictOrchestratorSystemAddendumFor(subagentActionsAllowed(args))) |
| 944 | - | autoSubagentsToolAllowed(args), | |||
| 945 | - | autoSubagentsStopToolAllowed(args), | |||
| 946 | - | autoSubagentsResumeToolAllowed(args), | |||
| 947 | - | )) | |||
| 948 | } else if primaryInteractive && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled { | 944 | } else if primaryInteractive && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled { | ||
| 949 | - | append_ = append(append_, ProactiveSubagentsSystemAddendumFor( | 945 | + | append_ = append(append_, ProactiveSubagentsSystemAddendumFor(subagentActionsAllowed(args))) |
| 950 | - | autoSubagentsToolAllowed(args), | |||
| 951 | - | autoSubagentsStopToolAllowed(args), | |||
| 952 | - | autoSubagentsResumeToolAllowed(args), | |||
| 953 | - | )) | |||
| 954 | } else if primaryInteractive && autoSubagentsAnyToolAllowed(args) { | 946 | } else if primaryInteractive && autoSubagentsAnyToolAllowed(args) { | ||
| 955 | append_ = append(append_, OnDemandSubagentsSystemAddendum) | 947 | append_ = append(append_, OnDemandSubagentsSystemAddendum) | ||
| 956 | } | 948 | } | ||
| @@ -1519,11 +1511,26 @@ func autoSubagentsResumeToolAllowed(args Args) bool { | |||||
| 1519 | return subagentActionAllowed(args, tools.SubagentActionResume) | 1511 | return subagentActionAllowed(args, tools.SubagentActionResume) | ||
| 1520 | } | 1512 | } | ||
| 1521 | 1513 | ||||
| 1514 | + | func autoSubagentsInterruptToolAllowed(args Args) bool { | |||
| 1515 | + | return subagentActionAllowed(args, tools.SubagentActionInterrupt) | |||
| 1516 | + | } | |||
| 1517 | + | ||||
| 1518 | + | // subagentActionsAllowed collects the launch-time subagent action grants. | |||
| 1519 | + | func subagentActionsAllowed(args Args) SubagentActions { | |||
| 1520 | + | return SubagentActions{ | |||
| 1521 | + | Spawn: autoSubagentsToolAllowed(args), | |||
| 1522 | + | Stop: autoSubagentsStopToolAllowed(args), | |||
| 1523 | + | Resume: autoSubagentsResumeToolAllowed(args), | |||
| 1524 | + | Interrupt: autoSubagentsInterruptToolAllowed(args), | |||
| 1525 | + | } | |||
| 1526 | + | } | |||
| 1527 | + | ||||
| 1522 | func autoSubagentsAnyToolAllowed(args Args) bool { | 1528 | func autoSubagentsAnyToolAllowed(args Args) bool { | ||
| 1523 | return autoSubagentsToolAllowed(args) || | 1529 | return autoSubagentsToolAllowed(args) || | ||
| 1524 | autoSubagentsStatusToolAllowed(args) || | 1530 | autoSubagentsStatusToolAllowed(args) || | ||
| 1525 | autoSubagentsStopToolAllowed(args) || | 1531 | autoSubagentsStopToolAllowed(args) || | ||
| 1526 | - | autoSubagentsResumeToolAllowed(args) | 1532 | + | autoSubagentsResumeToolAllowed(args) || |
| 1533 | + | autoSubagentsInterruptToolAllowed(args) | |||
| 1527 | } | 1534 | } | ||
| 1528 | 1535 | ||||
| 1529 | // subagentActionAllowed reports whether the launch-time tool policy grants one | 1536 | // subagentActionAllowed reports whether the launch-time tool policy grants one | ||
M packages/agent/cli.go +67 -69
| @@ -2689,6 +2689,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2689 | 2689 | autoSubagentsStatusToolAllowedForSession := autoSubagentsStatusToolAllowed(args) | |
| 2690 | 2690 | autoSubagentsStopToolAllowedForSession := autoSubagentsStopToolAllowed(args) | |
| 2691 | 2691 | autoSubagentsResumeToolAllowedForSession := autoSubagentsResumeToolAllowed(args) | |
| 2692 | + | autoSubagentsInterruptToolAllowedForSession := autoSubagentsInterruptToolAllowed(args) | |
| 2692 | 2693 | webSearchToolAllowedForInvocation := webSearchToolAllowedForSession(args) | |
| 2693 | 2694 | webSearchInvocationOverride := webSearchExplicitlyEnabledForSession(args) | |
| 2694 | 2695 | setWebSearchAvailable := func(available bool) { | |
| @@ -2701,75 +2702,72 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2701 | 2702 | } | |
| 2702 | 2703 | ||
| 2703 | 2704 | iv = modes.NewInteractive(modes.InteractiveConfig{ | |
| 2704 | - | Terminal: term, | |
| 2705 | - | Theme: theme, | |
| 2706 | - | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | |
| 2707 | - | TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled, | |
| 2708 | - | TerminalTitleEnabled: initialCfg.TerminalTitleEnabled, | |
| 2709 | - | AutoSubagentsEnabled: initialCfg.AutoSubagentsEnabled, | |
| 2710 | - | PonytailEnabled: initialCfg.PonytailEnabled, | |
| 2711 | - | WebSearchEnabled: initialCfg.WebSearchEnabled, | |
| 2712 | - | WebSearchToolAllowed: &webSearchToolAllowedForInvocation, | |
| 2713 | - | WebSearchInvocationOverride: webSearchInvocationOverride, | |
| 2714 | - | AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession, | |
| 2715 | - | AutoSubagentsStatusToolAllowed: &autoSubagentsStatusToolAllowedForSession, | |
| 2716 | - | AutoSubagentsStopToolAllowed: &autoSubagentsStopToolAllowedForSession, | |
| 2717 | - | AutoSubagentsResumeToolAllowed: &autoSubagentsResumeToolAllowedForSession, | |
| 2718 | - | FastMode: &fastMode, | |
| 2719 | - | LSPEnabled: initialCfg.LSPEnabled, | |
| 2720 | - | SubagentLSPEnabled: initialCfg.SubagentLSPEnabled, | |
| 2721 | - | AutoCompactThreshold: initialCfg.AutoCompactThreshold, | |
| 2722 | - | GoalMaxTokenBudget: initialCfg.Goals.MaxTokenBudget, | |
| 2723 | - | JailByDefault: initialCfg.JailByDefault, | |
| 2724 | - | QuickModelShortcuts: quickModelShortcuts, | |
| 2725 | - | ActiveModelProfile: initialCfg.ActiveModelProfile, | |
| 2726 | - | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | |
| 2727 | - | RespectGitignore: initialCfg.RespectGitignore, | |
| 2728 | - | CompactMode: initialCfg.CompactMode, | |
| 2729 | - | TUIInputStyle: initialCfg.TUIInputStyle, | |
| 2730 | - | TUIStatusPosition: initialCfg.TUIStatusPosition, | |
| 2731 | - | TUIWorkingPosition: initialCfg.TUIWorkingPosition, | |
| 2732 | - | ThemeName: initialCfg.Theme, | |
| 2733 | - | EffectiveThemeName: themePreference.Effective, | |
| 2734 | - | ThemeForced: themePreference.Forced, | |
| 2735 | - | TerminalProfile: theme.Terminal, | |
| 2736 | - | FlatTools: initialCfg.FlatToolRender(), | |
| 2737 | - | CompactUser: initialCfg.CompactUserInput(), | |
| 2738 | - | ExtensionThemes: extMgr.ThemeOptions, | |
| 2739 | - | AutoSubagentsSystemAddendum: ProactiveSubagentsSystemAddendumFor( | |
| 2740 | - | autoSubagentsToolAllowedForSession, | |
| 2741 | - | autoSubagentsStopToolAllowedForSession, | |
| 2742 | - | autoSubagentsResumeToolAllowedForSession, | |
| 2743 | - | ), | |
| 2744 | - | OnDemandSubagentsSystemAddendum: OnDemandSubagentsSystemAddendum, | |
| 2745 | - | SubagentsSystemAddendum: subagentsAddendum, | |
| 2746 | - | SettingsStore: configSettingsStore{}, | |
| 2747 | - | Model: r.Model, | |
| 2748 | - | Provider: r.Provider, | |
| 2749 | - | AuthMethod: r.AuthMethod, | |
| 2750 | - | BaseURL: r.BaseURL, | |
| 2751 | - | Reasoning: r.Reasoning, | |
| 2752 | - | OnReasoningChanged: runtime.SetReasoning, | |
| 2753 | - | OnFastModeChanged: runtime.SetFastMode, | |
| 2754 | - | SystemPrompt: r.SystemPrompt, | |
| 2755 | - | WritingGuidance: WritingGuidance(), | |
| 2756 | - | Tools: r.ToolRegistry, | |
| 2757 | - | MaxSteps: r.MaxSteps, | |
| 2758 | - | CWD: r.CWD, | |
| 2759 | - | StartupAgentName: args.AgentName, | |
| 2760 | - | StartupContextPaths: instructionContextPaths(r.ContextFiles), | |
| 2761 | - | StartupExtensionNames: startupExtensionNames(extMgr.All()), | |
| 2762 | - | StartupExtensionErrors: startupExtensionErrors, | |
| 2763 | - | StartupSkillNames: startupSkillNames(startupSkills), | |
| 2764 | - | ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup, | |
| 2765 | - | ZutHome: ZutHome(), | |
| 2766 | - | SessionsRoot: agentSessionsRoot(ZutHome(), args), | |
| 2767 | - | SessionsDisabled: args.NoSess, | |
| 2768 | - | Version: version, | |
| 2769 | - | UpdateInfoChan: updateCh, | |
| 2770 | - | Sandbox: sharedSandbox, | |
| 2771 | - | Agent: ag, | |
| 2772 | - | InitialCompactHandoff: currentCompactHandoff(), | |
| 2705 | + | Terminal: term, | |
| 2706 | + | Theme: theme, | |
| 2707 | + | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | |
| 2708 | + | TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled, | |
| 2709 | + | TerminalTitleEnabled: initialCfg.TerminalTitleEnabled, | |
| 2710 | + | AutoSubagentsEnabled: initialCfg.AutoSubagentsEnabled, | |
| 2711 | + | PonytailEnabled: initialCfg.PonytailEnabled, | |
| 2712 | + | WebSearchEnabled: initialCfg.WebSearchEnabled, | |
| 2713 | + | WebSearchToolAllowed: &webSearchToolAllowedForInvocation, | |
| 2714 | + | WebSearchInvocationOverride: webSearchInvocationOverride, | |
| 2715 | + | AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession, | |
| 2716 | + | AutoSubagentsStatusToolAllowed: &autoSubagentsStatusToolAllowedForSession, | |
| 2717 | + | AutoSubagentsStopToolAllowed: &autoSubagentsStopToolAllowedForSession, | |
| 2718 | + | AutoSubagentsResumeToolAllowed: &autoSubagentsResumeToolAllowedForSession, | |
| 2719 | + | AutoSubagentsInterruptToolAllowed: &autoSubagentsInterruptToolAllowedForSession, | |
| 2720 | + | FastMode: &fastMode, | |
| 2721 | + | LSPEnabled: initialCfg.LSPEnabled, | |
| 2722 | + | SubagentLSPEnabled: initialCfg.SubagentLSPEnabled, | |
| 2723 | + | AutoCompactThreshold: initialCfg.AutoCompactThreshold, | |
| 2724 | + | GoalMaxTokenBudget: initialCfg.Goals.MaxTokenBudget, | |
| 2725 | + | JailByDefault: initialCfg.JailByDefault, | |
| 2726 | + | QuickModelShortcuts: quickModelShortcuts, | |
| 2727 | + | ActiveModelProfile: initialCfg.ActiveModelProfile, | |
| 2728 | + | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | |
| 2729 | + | RespectGitignore: initialCfg.RespectGitignore, | |
| 2730 | + | CompactMode: initialCfg.CompactMode, | |
| 2731 | + | TUIInputStyle: initialCfg.TUIInputStyle, | |
| 2732 | + | TUIStatusPosition: initialCfg.TUIStatusPosition, | |
| 2733 | + | TUIWorkingPosition: initialCfg.TUIWorkingPosition, | |
| 2734 | + | ThemeName: initialCfg.Theme, | |
| 2735 | + | EffectiveThemeName: themePreference.Effective, | |
| 2736 | + | ThemeForced: themePreference.Forced, | |
| 2737 | + | TerminalProfile: theme.Terminal, | |
| 2738 | + | FlatTools: initialCfg.FlatToolRender(), | |
| 2739 | + | CompactUser: initialCfg.CompactUserInput(), | |
| 2740 | + | ExtensionThemes: extMgr.ThemeOptions, | |
| 2741 | + | AutoSubagentsSystemAddendum: ProactiveSubagentsSystemAddendumFor(subagentActionsAllowed(args)), | |
| 2742 | + | OnDemandSubagentsSystemAddendum: OnDemandSubagentsSystemAddendum, | |
| 2743 | + | SubagentsSystemAddendum: subagentsAddendum, | |
| 2744 | + | SettingsStore: configSettingsStore{}, | |
| 2745 | + | Model: r.Model, | |
| 2746 | + | Provider: r.Provider, | |
| 2747 | + | AuthMethod: r.AuthMethod, | |
| 2748 | + | BaseURL: r.BaseURL, | |
| 2749 | + | Reasoning: r.Reasoning, | |
| 2750 | + | OnReasoningChanged: runtime.SetReasoning, | |
| 2751 | + | OnFastModeChanged: runtime.SetFastMode, | |
| 2752 | + | SystemPrompt: r.SystemPrompt, | |
| 2753 | + | WritingGuidance: WritingGuidance(), | |
| 2754 | + | Tools: r.ToolRegistry, | |
| 2755 | + | MaxSteps: r.MaxSteps, | |
| 2756 | + | CWD: r.CWD, | |
| 2757 | + | StartupAgentName: args.AgentName, | |
| 2758 | + | StartupContextPaths: instructionContextPaths(r.ContextFiles), | |
| 2759 | + | StartupExtensionNames: startupExtensionNames(extMgr.All()), | |
| 2760 | + | StartupExtensionErrors: startupExtensionErrors, | |
| 2761 | + | StartupSkillNames: startupSkillNames(startupSkills), | |
| 2762 | + | ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup, | |
| 2763 | + | ZutHome: ZutHome(), | |
| 2764 | + | SessionsRoot: agentSessionsRoot(ZutHome(), args), | |
| 2765 | + | SessionsDisabled: args.NoSess, | |
| 2766 | + | Version: version, | |
| 2767 | + | UpdateInfoChan: updateCh, | |
| 2768 | + | Sandbox: sharedSandbox, | |
| 2769 | + | Agent: ag, | |
| 2770 | + | InitialCompactHandoff: currentCompactHandoff(), | |
| 2773 | 2771 | InitialSessionTitle: func() string { | |
| 2774 | 2772 | if sess == nil || sessionTitlePending { | |
| 2775 | 2773 | return "" | |
| @@ -2689,6 +2689,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2689 | autoSubagentsStatusToolAllowedForSession := autoSubagentsStatusToolAllowed(args) | 2689 | autoSubagentsStatusToolAllowedForSession := autoSubagentsStatusToolAllowed(args) | ||
| 2690 | autoSubagentsStopToolAllowedForSession := autoSubagentsStopToolAllowed(args) | 2690 | autoSubagentsStopToolAllowedForSession := autoSubagentsStopToolAllowed(args) | ||
| 2691 | autoSubagentsResumeToolAllowedForSession := autoSubagentsResumeToolAllowed(args) | 2691 | autoSubagentsResumeToolAllowedForSession := autoSubagentsResumeToolAllowed(args) | ||
| 2692 | + | autoSubagentsInterruptToolAllowedForSession := autoSubagentsInterruptToolAllowed(args) | |||
| 2692 | webSearchToolAllowedForInvocation := webSearchToolAllowedForSession(args) | 2693 | webSearchToolAllowedForInvocation := webSearchToolAllowedForSession(args) | ||
| 2693 | webSearchInvocationOverride := webSearchExplicitlyEnabledForSession(args) | 2694 | webSearchInvocationOverride := webSearchExplicitlyEnabledForSession(args) | ||
| 2694 | setWebSearchAvailable := func(available bool) { | 2695 | setWebSearchAvailable := func(available bool) { | ||
| @@ -2701,75 +2702,72 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2701 | } | 2702 | } | ||
| 2702 | 2703 | ||||
| 2703 | iv = modes.NewInteractive(modes.InteractiveConfig{ | 2704 | iv = modes.NewInteractive(modes.InteractiveConfig{ | ||
| 2704 | - | Terminal: term, | 2705 | + | Terminal: term, |
| 2705 | - | Theme: theme, | 2706 | + | Theme: theme, |
| 2706 | - | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | 2707 | + | InlineImagesEnabled: initialCfg.InlineImagesEnabled, |
| 2707 | - | TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled, | 2708 | + | TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled, |
| 2708 | - | TerminalTitleEnabled: initialCfg.TerminalTitleEnabled, | 2709 | + | TerminalTitleEnabled: initialCfg.TerminalTitleEnabled, |
| 2709 | - | AutoSubagentsEnabled: initialCfg.AutoSubagentsEnabled, | 2710 | + | AutoSubagentsEnabled: initialCfg.AutoSubagentsEnabled, |
| 2710 | - | PonytailEnabled: initialCfg.PonytailEnabled, | 2711 | + | PonytailEnabled: initialCfg.PonytailEnabled, |
| 2711 | - | WebSearchEnabled: initialCfg.WebSearchEnabled, | 2712 | + | WebSearchEnabled: initialCfg.WebSearchEnabled, |
| 2712 | - | WebSearchToolAllowed: &webSearchToolAllowedForInvocation, | 2713 | + | WebSearchToolAllowed: &webSearchToolAllowedForInvocation, |
| 2713 | - | WebSearchInvocationOverride: webSearchInvocationOverride, | 2714 | + | WebSearchInvocationOverride: webSearchInvocationOverride, |
| 2714 | - | AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession, | 2715 | + | AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession, |
| 2715 | - | AutoSubagentsStatusToolAllowed: &autoSubagentsStatusToolAllowedForSession, | 2716 | + | AutoSubagentsStatusToolAllowed: &autoSubagentsStatusToolAllowedForSession, |
| 2716 | - | AutoSubagentsStopToolAllowed: &autoSubagentsStopToolAllowedForSession, | 2717 | + | AutoSubagentsStopToolAllowed: &autoSubagentsStopToolAllowedForSession, |
| 2717 | - | AutoSubagentsResumeToolAllowed: &autoSubagentsResumeToolAllowedForSession, | 2718 | + | AutoSubagentsResumeToolAllowed: &autoSubagentsResumeToolAllowedForSession, |
| 2718 | - | FastMode: &fastMode, | 2719 | + | AutoSubagentsInterruptToolAllowed: &autoSubagentsInterruptToolAllowedForSession, |
| 2719 | - | LSPEnabled: initialCfg.LSPEnabled, | 2720 | + | FastMode: &fastMode, |
| 2720 | - | SubagentLSPEnabled: initialCfg.SubagentLSPEnabled, | 2721 | + | LSPEnabled: initialCfg.LSPEnabled, |
| 2721 | - | AutoCompactThreshold: initialCfg.AutoCompactThreshold, | 2722 | + | SubagentLSPEnabled: initialCfg.SubagentLSPEnabled, |
| 2722 | - | GoalMaxTokenBudget: initialCfg.Goals.MaxTokenBudget, | 2723 | + | AutoCompactThreshold: initialCfg.AutoCompactThreshold, |
| 2723 | - | JailByDefault: initialCfg.JailByDefault, | 2724 | + | GoalMaxTokenBudget: initialCfg.Goals.MaxTokenBudget, |
| 2724 | - | QuickModelShortcuts: quickModelShortcuts, | 2725 | + | JailByDefault: initialCfg.JailByDefault, |
| 2725 | - | ActiveModelProfile: initialCfg.ActiveModelProfile, | 2726 | + | QuickModelShortcuts: quickModelShortcuts, |
| 2726 | - | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | 2727 | + | ActiveModelProfile: initialCfg.ActiveModelProfile, |
| 2727 | - | RespectGitignore: initialCfg.RespectGitignore, | 2728 | + | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, |
| 2728 | - | CompactMode: initialCfg.CompactMode, | 2729 | + | RespectGitignore: initialCfg.RespectGitignore, |
| 2729 | - | TUIInputStyle: initialCfg.TUIInputStyle, | 2730 | + | CompactMode: initialCfg.CompactMode, |
| 2730 | - | TUIStatusPosition: initialCfg.TUIStatusPosition, | 2731 | + | TUIInputStyle: initialCfg.TUIInputStyle, |
| 2731 | - | TUIWorkingPosition: initialCfg.TUIWorkingPosition, | 2732 | + | TUIStatusPosition: initialCfg.TUIStatusPosition, |
| 2732 | - | ThemeName: initialCfg.Theme, | 2733 | + | TUIWorkingPosition: initialCfg.TUIWorkingPosition, |
| 2733 | - | EffectiveThemeName: themePreference.Effective, | 2734 | + | ThemeName: initialCfg.Theme, |
| 2734 | - | ThemeForced: themePreference.Forced, | 2735 | + | EffectiveThemeName: themePreference.Effective, |
| 2735 | - | TerminalProfile: theme.Terminal, | 2736 | + | ThemeForced: themePreference.Forced, |
| 2736 | - | FlatTools: initialCfg.FlatToolRender(), | 2737 | + | TerminalProfile: theme.Terminal, |
| 2737 | - | CompactUser: initialCfg.CompactUserInput(), | 2738 | + | FlatTools: initialCfg.FlatToolRender(), |
| 2738 | - | ExtensionThemes: extMgr.ThemeOptions, | 2739 | + | CompactUser: initialCfg.CompactUserInput(), |
| 2739 | - | AutoSubagentsSystemAddendum: ProactiveSubagentsSystemAddendumFor( | 2740 | + | ExtensionThemes: extMgr.ThemeOptions, |
| 2740 | - | autoSubagentsToolAllowedForSession, | 2741 | + | AutoSubagentsSystemAddendum: ProactiveSubagentsSystemAddendumFor(subagentActionsAllowed(args)), |
| 2741 | - | autoSubagentsStopToolAllowedForSession, | 2742 | + | OnDemandSubagentsSystemAddendum: OnDemandSubagentsSystemAddendum, |
| 2742 | - | autoSubagentsResumeToolAllowedForSession, | 2743 | + | SubagentsSystemAddendum: subagentsAddendum, |
| 2743 | - | ), | 2744 | + | SettingsStore: configSettingsStore{}, |
| 2744 | - | OnDemandSubagentsSystemAddendum: OnDemandSubagentsSystemAddendum, | 2745 | + | Model: r.Model, |
| 2745 | - | SubagentsSystemAddendum: subagentsAddendum, | 2746 | + | Provider: r.Provider, |
| 2746 | - | SettingsStore: configSettingsStore{}, | 2747 | + | AuthMethod: r.AuthMethod, |
| 2747 | - | Model: r.Model, | 2748 | + | BaseURL: r.BaseURL, |
| 2748 | - | Provider: r.Provider, | 2749 | + | Reasoning: r.Reasoning, |
| 2749 | - | AuthMethod: r.AuthMethod, | 2750 | + | OnReasoningChanged: runtime.SetReasoning, |
| 2750 | - | BaseURL: r.BaseURL, | 2751 | + | OnFastModeChanged: runtime.SetFastMode, |
| 2751 | - | Reasoning: r.Reasoning, | 2752 | + | SystemPrompt: r.SystemPrompt, |
| 2752 | - | OnReasoningChanged: runtime.SetReasoning, | 2753 | + | WritingGuidance: WritingGuidance(), |
| 2753 | - | OnFastModeChanged: runtime.SetFastMode, | 2754 | + | Tools: r.ToolRegistry, |
| 2754 | - | SystemPrompt: r.SystemPrompt, | 2755 | + | MaxSteps: r.MaxSteps, |
| 2755 | - | WritingGuidance: WritingGuidance(), | 2756 | + | CWD: r.CWD, |
| 2756 | - | Tools: r.ToolRegistry, | 2757 | + | StartupAgentName: args.AgentName, |
| 2757 | - | MaxSteps: r.MaxSteps, | 2758 | + | StartupContextPaths: instructionContextPaths(r.ContextFiles), |
| 2758 | - | CWD: r.CWD, | 2759 | + | StartupExtensionNames: startupExtensionNames(extMgr.All()), |
| 2759 | - | StartupAgentName: args.AgentName, | 2760 | + | StartupExtensionErrors: startupExtensionErrors, |
| 2760 | - | StartupContextPaths: instructionContextPaths(r.ContextFiles), | 2761 | + | StartupSkillNames: startupSkillNames(startupSkills), |
| 2761 | - | StartupExtensionNames: startupExtensionNames(extMgr.All()), | 2762 | + | ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup, |
| 2762 | - | StartupExtensionErrors: startupExtensionErrors, | 2763 | + | ZutHome: ZutHome(), |
| 2763 | - | StartupSkillNames: startupSkillNames(startupSkills), | 2764 | + | SessionsRoot: agentSessionsRoot(ZutHome(), args), |
| 2764 | - | ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup, | 2765 | + | SessionsDisabled: args.NoSess, |
| 2765 | - | ZutHome: ZutHome(), | 2766 | + | Version: version, |
| 2766 | - | SessionsRoot: agentSessionsRoot(ZutHome(), args), | 2767 | + | UpdateInfoChan: updateCh, |
| 2767 | - | SessionsDisabled: args.NoSess, | 2768 | + | Sandbox: sharedSandbox, |
| 2768 | - | Version: version, | 2769 | + | Agent: ag, |
| 2769 | - | UpdateInfoChan: updateCh, | 2770 | + | InitialCompactHandoff: currentCompactHandoff(), |
| 2770 | - | Sandbox: sharedSandbox, | |||
| 2771 | - | Agent: ag, | |||
| 2772 | - | InitialCompactHandoff: currentCompactHandoff(), | |||
| 2773 | InitialSessionTitle: func() string { | 2771 | InitialSessionTitle: func() string { | ||
| 2774 | if sess == nil || sessionTitlePending { | 2772 | if sess == nil || sessionTitlePending { | ||
| 2775 | return "" | 2773 | return "" | ||
M packages/agent/modes/interactive.go +6 -5
| @@ -94,11 +94,12 @@ type InteractiveConfig struct { | |||
| 94 | 94 | // the launch-time --no-tools/--tools policy excludes status queries. | |
| 95 | 95 | AutoSubagentsStatusToolAllowed *bool | |
| 96 | 96 | ||
| 97 | - | // AutoSubagentsStopToolAllowed and AutoSubagentsResumeToolAllowed are nil | |
| 98 | - | // for normal sessions and false when the launch-time tool policy excludes | |
| 99 | - | // manager lifecycle actions. | |
| 100 | - | AutoSubagentsStopToolAllowed *bool | |
| 101 | - | AutoSubagentsResumeToolAllowed *bool | |
| 97 | + | // AutoSubagentsStopToolAllowed, AutoSubagentsResumeToolAllowed, and | |
| 98 | + | // AutoSubagentsInterruptToolAllowed are nil for normal sessions and false | |
| 99 | + | // when the launch-time tool policy excludes manager lifecycle actions. | |
| 100 | + | AutoSubagentsStopToolAllowed *bool | |
| 101 | + | AutoSubagentsResumeToolAllowed *bool | |
| 102 | + | AutoSubagentsInterruptToolAllowed *bool | |
| 102 | 103 | ||
| 103 | 104 | // FastMode mirrors the active profile's or global persisted OpenAI | |
| 104 | 105 | // fast-mode flag at startup. nil/missing means disabled. Unsupported | |
| @@ -94,11 +94,12 @@ type InteractiveConfig struct { | |||||
| 94 | // the launch-time --no-tools/--tools policy excludes status queries. | 94 | // the launch-time --no-tools/--tools policy excludes status queries. | ||
| 95 | AutoSubagentsStatusToolAllowed *bool | 95 | AutoSubagentsStatusToolAllowed *bool | ||
| 96 | 96 | ||||
| 97 | - | // AutoSubagentsStopToolAllowed and AutoSubagentsResumeToolAllowed are nil | 97 | + | // AutoSubagentsStopToolAllowed, AutoSubagentsResumeToolAllowed, and |
| 98 | - | // for normal sessions and false when the launch-time tool policy excludes | 98 | + | // AutoSubagentsInterruptToolAllowed are nil for normal sessions and false |
| 99 | - | // manager lifecycle actions. | 99 | + | // when the launch-time tool policy excludes manager lifecycle actions. |
| 100 | - | AutoSubagentsStopToolAllowed *bool | 100 | + | AutoSubagentsStopToolAllowed *bool |
| 101 | - | AutoSubagentsResumeToolAllowed *bool | 101 | + | AutoSubagentsResumeToolAllowed *bool |
| 102 | + | AutoSubagentsInterruptToolAllowed *bool | |||
| 102 | 103 | ||||
| 103 | // FastMode mirrors the active profile's or global persisted OpenAI | 104 | // FastMode mirrors the active profile's or global persisted OpenAI | ||
| 104 | // fast-mode flag at startup. nil/missing means disabled. Unsupported | 105 | // fast-mode flag at startup. nil/missing means disabled. Unsupported | ||
M packages/agent/modes/interactive_resident_subagents.go +17 -1
| @@ -478,11 +478,18 @@ func autoSubagentsResumeToolAllowedConfig(cfg InteractiveConfig) bool { | |||
| 478 | 478 | } | |
| 479 | 479 | return autoSubagentsToolAllowedConfig(cfg) | |
| 480 | 480 | } | |
| 481 | + | func autoSubagentsInterruptToolAllowedConfig(cfg InteractiveConfig) bool { | |
| 482 | + | if cfg.AutoSubagentsInterruptToolAllowed != nil { | |
| 483 | + | return *cfg.AutoSubagentsInterruptToolAllowed | |
| 484 | + | } | |
| 485 | + | return autoSubagentsToolAllowedConfig(cfg) | |
| 486 | + | } | |
| 481 | 487 | func autoSubagentsAnyToolAllowedConfig(cfg InteractiveConfig) bool { | |
| 482 | 488 | return autoSubagentsToolAllowedConfig(cfg) || | |
| 483 | 489 | autoSubagentsStatusToolAllowedConfig(cfg) || | |
| 484 | 490 | autoSubagentsStopToolAllowedConfig(cfg) || | |
| 485 | - | autoSubagentsResumeToolAllowedConfig(cfg) | |
| 491 | + | autoSubagentsResumeToolAllowedConfig(cfg) || | |
| 492 | + | autoSubagentsInterruptToolAllowedConfig(cfg) | |
| 486 | 493 | } | |
| 487 | 494 | func (i *Interactive) autoSubagentsToolAllowed() bool { | |
| 488 | 495 | return autoSubagentsToolAllowedConfig(i.cfg) | |
| @@ -496,6 +503,9 @@ func (i *Interactive) autoSubagentsStopToolAllowed() bool { | |||
| 496 | 503 | func (i *Interactive) autoSubagentsResumeToolAllowed() bool { | |
| 497 | 504 | return autoSubagentsResumeToolAllowedConfig(i.cfg) | |
| 498 | 505 | } | |
| 506 | + | func (i *Interactive) autoSubagentsInterruptToolAllowed() bool { | |
| 507 | + | return autoSubagentsInterruptToolAllowedConfig(i.cfg) | |
| 508 | + | } | |
| 499 | 509 | func (i *Interactive) autoSubagentsEnabledLocked() bool { | |
| 500 | 510 | return i.cfg.AutoSubagentsEnabled != nil && *i.cfg.AutoSubagentsEnabled && i.autoSubagentsAvailable() | |
| 501 | 511 | } | |
| @@ -546,6 +556,12 @@ func (i *Interactive) applyAutoSubagentsTool() { | |||
| 546 | 556 | Enabled: func() bool { return true }, | |
| 547 | 557 | } | |
| 548 | 558 | } | |
| 559 | + | if i.autoSubagentsInterruptToolAllowed() { | |
| 560 | + | facade.Interrupt = &tools.SubagentInterruptTool{ | |
| 561 | + | ResidentManager: i.cfg.ResidentManager, | |
| 562 | + | Enabled: func() bool { return true }, | |
| 563 | + | } | |
| 564 | + | } | |
| 549 | 565 | next[facade.Name()] = facade | |
| 550 | 566 | } | |
| 551 | 567 | i.agent.SetTools(next) | |
| @@ -478,11 +478,18 @@ func autoSubagentsResumeToolAllowedConfig(cfg InteractiveConfig) bool { | |||||
| 478 | } | 478 | } | ||
| 479 | return autoSubagentsToolAllowedConfig(cfg) | 479 | return autoSubagentsToolAllowedConfig(cfg) | ||
| 480 | } | 480 | } | ||
| 481 | + | func autoSubagentsInterruptToolAllowedConfig(cfg InteractiveConfig) bool { | |||
| 482 | + | if cfg.AutoSubagentsInterruptToolAllowed != nil { | |||
| 483 | + | return *cfg.AutoSubagentsInterruptToolAllowed | |||
| 484 | + | } | |||
| 485 | + | return autoSubagentsToolAllowedConfig(cfg) | |||
| 486 | + | } | |||
| 481 | func autoSubagentsAnyToolAllowedConfig(cfg InteractiveConfig) bool { | 487 | func autoSubagentsAnyToolAllowedConfig(cfg InteractiveConfig) bool { | ||
| 482 | return autoSubagentsToolAllowedConfig(cfg) || | 488 | return autoSubagentsToolAllowedConfig(cfg) || | ||
| 483 | autoSubagentsStatusToolAllowedConfig(cfg) || | 489 | autoSubagentsStatusToolAllowedConfig(cfg) || | ||
| 484 | autoSubagentsStopToolAllowedConfig(cfg) || | 490 | autoSubagentsStopToolAllowedConfig(cfg) || | ||
| 485 | - | autoSubagentsResumeToolAllowedConfig(cfg) | 491 | + | autoSubagentsResumeToolAllowedConfig(cfg) || |
| 492 | + | autoSubagentsInterruptToolAllowedConfig(cfg) | |||
| 486 | } | 493 | } | ||
| 487 | func (i *Interactive) autoSubagentsToolAllowed() bool { | 494 | func (i *Interactive) autoSubagentsToolAllowed() bool { | ||
| 488 | return autoSubagentsToolAllowedConfig(i.cfg) | 495 | return autoSubagentsToolAllowedConfig(i.cfg) | ||
| @@ -496,6 +503,9 @@ func (i *Interactive) autoSubagentsStopToolAllowed() bool { | |||||
| 496 | func (i *Interactive) autoSubagentsResumeToolAllowed() bool { | 503 | func (i *Interactive) autoSubagentsResumeToolAllowed() bool { | ||
| 497 | return autoSubagentsResumeToolAllowedConfig(i.cfg) | 504 | return autoSubagentsResumeToolAllowedConfig(i.cfg) | ||
| 498 | } | 505 | } | ||
| 506 | + | func (i *Interactive) autoSubagentsInterruptToolAllowed() bool { | |||
| 507 | + | return autoSubagentsInterruptToolAllowedConfig(i.cfg) | |||
| 508 | + | } | |||
| 499 | func (i *Interactive) autoSubagentsEnabledLocked() bool { | 509 | func (i *Interactive) autoSubagentsEnabledLocked() bool { | ||
| 500 | return i.cfg.AutoSubagentsEnabled != nil && *i.cfg.AutoSubagentsEnabled && i.autoSubagentsAvailable() | 510 | return i.cfg.AutoSubagentsEnabled != nil && *i.cfg.AutoSubagentsEnabled && i.autoSubagentsAvailable() | ||
| 501 | } | 511 | } | ||
| @@ -546,6 +556,12 @@ func (i *Interactive) applyAutoSubagentsTool() { | |||||
| 546 | Enabled: func() bool { return true }, | 556 | Enabled: func() bool { return true }, | ||
| 547 | } | 557 | } | ||
| 548 | } | 558 | } | ||
| 559 | + | if i.autoSubagentsInterruptToolAllowed() { | |||
| 560 | + | facade.Interrupt = &tools.SubagentInterruptTool{ | |||
| 561 | + | ResidentManager: i.cfg.ResidentManager, | |||
| 562 | + | Enabled: func() bool { return true }, | |||
| 563 | + | } | |||
| 564 | + | } | |||
| 549 | next[facade.Name()] = facade | 565 | next[facade.Name()] = facade | ||
| 550 | } | 566 | } | ||
| 551 | i.agent.SetTools(next) | 567 | i.agent.SetTools(next) | ||
M packages/agent/modes/resident_subagent_indicator_test.go +12 -12
| @@ -44,11 +44,11 @@ func (t *residentIndicatorTerminal) Reset() { | |||
| 44 | 44 | } | |
| 45 | 45 | ||
| 46 | 46 | func TestInteractiveResidentSubagentActivityRendersAllActiveChildrenWhenHeightAllows(t *testing.T) { | |
| 47 | - | manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 48 | - | return func(ctx context.Context, _ string) error { | |
| 47 | + | manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 48 | + | return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error { | |
| 49 | 49 | <-ctx.Done() | |
| 50 | 50 | return ctx.Err() | |
| 51 | - | }, nil | |
| 51 | + | }), nil | |
| 52 | 52 | }) | |
| 53 | 53 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 54 | 54 | spawnRunningResidentIndicatorChildren(t, manager, 10) | |
| @@ -79,11 +79,11 @@ func TestInteractiveResidentSubagentActivityRendersAllActiveChildrenWhenHeightAl | |||
| 79 | 79 | } | |
| 80 | 80 | ||
| 81 | 81 | func TestInteractiveResidentSubagentActivitySummarizesBoundedPageUnderShortHeight(t *testing.T) { | |
| 82 | - | manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 83 | - | return func(ctx context.Context, _ string) error { | |
| 82 | + | manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 83 | + | return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error { | |
| 84 | 84 | <-ctx.Done() | |
| 85 | 85 | return ctx.Err() | |
| 86 | - | }, nil | |
| 86 | + | }), nil | |
| 87 | 87 | }) | |
| 88 | 88 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 89 | 89 | spawnRunningResidentIndicatorChildren(t, manager, 6) | |
| @@ -105,11 +105,11 @@ func TestInteractiveResidentSubagentActivitySummarizesBoundedPageUnderShortHeigh | |||
| 105 | 105 | } | |
| 106 | 106 | ||
| 107 | 107 | func TestInteractiveResidentSubagentActivityShowsAllActiveChildrenWhenShortHeightFits(t *testing.T) { | |
| 108 | - | manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 109 | - | return func(ctx context.Context, _ string) error { | |
| 108 | + | manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 109 | + | return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error { | |
| 110 | 110 | <-ctx.Done() | |
| 111 | 111 | return ctx.Err() | |
| 112 | - | }, nil | |
| 112 | + | }), nil | |
| 113 | 113 | }) | |
| 114 | 114 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 115 | 115 | spawnRunningResidentIndicatorChildren(t, manager, 2) | |
| @@ -175,11 +175,11 @@ func spawnResidentIndicatorChildren(t *testing.T, manager *subagents.ResidentMan | |||
| 175 | 175 | } | |
| 176 | 176 | ||
| 177 | 177 | func TestRenderResidentSubagentActivityPageReportsHiddenActiveChildren(t *testing.T) { | |
| 178 | - | manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 179 | - | return func(ctx context.Context, _ string) error { | |
| 178 | + | manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 179 | + | return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error { | |
| 180 | 180 | <-ctx.Done() | |
| 181 | 181 | return ctx.Err() | |
| 182 | - | }, nil | |
| 182 | + | }), nil | |
| 183 | 183 | }) | |
| 184 | 184 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 185 | 185 | spawnResidentIndicatorChildren(t, manager, 5) | |
| @@ -44,11 +44,11 @@ func (t *residentIndicatorTerminal) Reset() { | |||||
| 44 | } | 44 | } | ||
| 45 | 45 | ||||
| 46 | func TestInteractiveResidentSubagentActivityRendersAllActiveChildrenWhenHeightAllows(t *testing.T) { | 46 | func TestInteractiveResidentSubagentActivityRendersAllActiveChildrenWhenHeightAllows(t *testing.T) { | ||
| 47 | - | manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 47 | + | manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { |
| 48 | - | return func(ctx context.Context, _ string) error { | 48 | + | return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error { |
| 49 | <-ctx.Done() | 49 | <-ctx.Done() | ||
| 50 | return ctx.Err() | 50 | return ctx.Err() | ||
| 51 | - | }, nil | 51 | + | }), nil |
| 52 | }) | 52 | }) | ||
| 53 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | 53 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | ||
| 54 | spawnRunningResidentIndicatorChildren(t, manager, 10) | 54 | spawnRunningResidentIndicatorChildren(t, manager, 10) | ||
| @@ -79,11 +79,11 @@ func TestInteractiveResidentSubagentActivityRendersAllActiveChildrenWhenHeightAl | |||||
| 79 | } | 79 | } | ||
| 80 | 80 | ||||
| 81 | func TestInteractiveResidentSubagentActivitySummarizesBoundedPageUnderShortHeight(t *testing.T) { | 81 | func TestInteractiveResidentSubagentActivitySummarizesBoundedPageUnderShortHeight(t *testing.T) { | ||
| 82 | - | manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 82 | + | manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { |
| 83 | - | return func(ctx context.Context, _ string) error { | 83 | + | return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error { |
| 84 | <-ctx.Done() | 84 | <-ctx.Done() | ||
| 85 | return ctx.Err() | 85 | return ctx.Err() | ||
| 86 | - | }, nil | 86 | + | }), nil |
| 87 | }) | 87 | }) | ||
| 88 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | 88 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | ||
| 89 | spawnRunningResidentIndicatorChildren(t, manager, 6) | 89 | spawnRunningResidentIndicatorChildren(t, manager, 6) | ||
| @@ -105,11 +105,11 @@ func TestInteractiveResidentSubagentActivitySummarizesBoundedPageUnderShortHeigh | |||||
| 105 | } | 105 | } | ||
| 106 | 106 | ||||
| 107 | func TestInteractiveResidentSubagentActivityShowsAllActiveChildrenWhenShortHeightFits(t *testing.T) { | 107 | func TestInteractiveResidentSubagentActivityShowsAllActiveChildrenWhenShortHeightFits(t *testing.T) { | ||
| 108 | - | manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 108 | + | manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { |
| 109 | - | return func(ctx context.Context, _ string) error { | 109 | + | return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error { |
| 110 | <-ctx.Done() | 110 | <-ctx.Done() | ||
| 111 | return ctx.Err() | 111 | return ctx.Err() | ||
| 112 | - | }, nil | 112 | + | }), nil |
| 113 | }) | 113 | }) | ||
| 114 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | 114 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | ||
| 115 | spawnRunningResidentIndicatorChildren(t, manager, 2) | 115 | spawnRunningResidentIndicatorChildren(t, manager, 2) | ||
| @@ -175,11 +175,11 @@ func spawnResidentIndicatorChildren(t *testing.T, manager *subagents.ResidentMan | |||||
| 175 | } | 175 | } | ||
| 176 | 176 | ||||
| 177 | func TestRenderResidentSubagentActivityPageReportsHiddenActiveChildren(t *testing.T) { | 177 | func TestRenderResidentSubagentActivityPageReportsHiddenActiveChildren(t *testing.T) { | ||
| 178 | - | manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 178 | + | manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { |
| 179 | - | return func(ctx context.Context, _ string) error { | 179 | + | return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error { |
| 180 | <-ctx.Done() | 180 | <-ctx.Done() | ||
| 181 | return ctx.Err() | 181 | return ctx.Err() | ||
| 182 | - | }, nil | 182 | + | }), nil |
| 183 | }) | 183 | }) | ||
| 184 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | 184 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | ||
| 185 | spawnResidentIndicatorChildren(t, manager, 5) | 185 | spawnResidentIndicatorChildren(t, manager, 5) | ||
M packages/agent/modes/resident_subagent_tools_test.go +3 -2
| @@ -30,6 +30,7 @@ func TestApplyAutoSubagentsToolReplacesOneFacadeEntry(t *testing.T) { | |||
| 30 | 30 | AutoSubagentsStatusToolAllowed: &allowed, | |
| 31 | 31 | AutoSubagentsStopToolAllowed: &allowed, | |
| 32 | 32 | AutoSubagentsResumeToolAllowed: &resumeAllowed, | |
| 33 | + | // Interrupt is left unset, so it follows the spawn grant. | |
| 33 | 34 | }) | |
| 34 | 35 | ||
| 35 | 36 | interactive.applyAutoSubagentsTool() | |
| @@ -59,8 +60,8 @@ func TestApplyAutoSubagentsToolReplacesOneFacadeEntry(t *testing.T) { | |||
| 59 | 60 | if facade == preSeeded { | |
| 60 | 61 | t.Fatal("interactive refresh reused the pre-seeded facade instead of replacing it") | |
| 61 | 62 | } | |
| 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) | |
| 63 | + | if facade.Spawn == nil || facade.Status == nil || facade.Stop == nil || facade.Interrupt == nil { | |
| 64 | + | t.Fatalf("facade actions = spawn:%v status:%v stop:%v interrupt:%v", facade.Spawn != nil, facade.Status != nil, facade.Stop != nil, facade.Interrupt != nil) | |
| 64 | 65 | } | |
| 65 | 66 | if (facade.Resume != nil) != wantResume { | |
| 66 | 67 | t.Fatalf("facade resume = %v, want %v", facade.Resume != nil, wantResume) | |
| @@ -30,6 +30,7 @@ func TestApplyAutoSubagentsToolReplacesOneFacadeEntry(t *testing.T) { | |||||
| 30 | AutoSubagentsStatusToolAllowed: &allowed, | 30 | AutoSubagentsStatusToolAllowed: &allowed, | ||
| 31 | AutoSubagentsStopToolAllowed: &allowed, | 31 | AutoSubagentsStopToolAllowed: &allowed, | ||
| 32 | AutoSubagentsResumeToolAllowed: &resumeAllowed, | 32 | AutoSubagentsResumeToolAllowed: &resumeAllowed, | ||
| 33 | + | // Interrupt is left unset, so it follows the spawn grant. | |||
| 33 | }) | 34 | }) | ||
| 34 | 35 | ||||
| 35 | interactive.applyAutoSubagentsTool() | 36 | interactive.applyAutoSubagentsTool() | ||
| @@ -59,8 +60,8 @@ func TestApplyAutoSubagentsToolReplacesOneFacadeEntry(t *testing.T) { | |||||
| 59 | if facade == preSeeded { | 60 | if facade == preSeeded { | ||
| 60 | t.Fatal("interactive refresh reused the pre-seeded facade instead of replacing it") | 61 | t.Fatal("interactive refresh reused the pre-seeded facade instead of replacing it") | ||
| 61 | } | 62 | } | ||
| 62 | - | if facade.Spawn == nil || facade.Status == nil || facade.Stop == nil { | 63 | + | if facade.Spawn == nil || facade.Status == nil || facade.Stop == nil || facade.Interrupt == nil { |
| 63 | - | t.Fatalf("facade actions = spawn:%v status:%v stop:%v", facade.Spawn != nil, facade.Status != nil, facade.Stop != nil) | 64 | + | t.Fatalf("facade actions = spawn:%v status:%v stop:%v interrupt:%v", facade.Spawn != nil, facade.Status != nil, facade.Stop != nil, facade.Interrupt != nil) |
| 64 | } | 65 | } | ||
| 65 | if (facade.Resume != nil) != wantResume { | 66 | if (facade.Resume != nil) != wantResume { | ||
| 66 | t.Fatalf("facade resume = %v, want %v", facade.Resume != nil, wantResume) | 67 | t.Fatalf("facade resume = %v, want %v", facade.Resume != nil, wantResume) | ||
M packages/agent/modes/resident_subagents_dialog_test.go +3 -3
| @@ -32,12 +32,12 @@ func TestResidentSubagentListUsesHumanLabelTerminalStateAndRelativeTime(t *testi | |||
| 32 | 32 | ||
| 33 | 33 | func TestResidentSubagentsDialogShowsUsageMetadata(t *testing.T) { | |
| 34 | 34 | completed := make(chan struct{}, 2) | |
| 35 | - | manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 35 | + | manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 36 | 36 | journal.ConfigureUsage(272_000, true) | |
| 37 | - | return func(context.Context, string) error { | |
| 37 | + | return subagents.ResidentTurnRunner(func(context.Context, string) error { | |
| 38 | 38 | usage := provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525} | |
| 39 | 39 | return journal.RecordAgentEvent(core.EvUsage{Usage: usage, Cumulative: usage}) | |
| 40 | - | }, nil | |
| 40 | + | }), nil | |
| 41 | 41 | }) | |
| 42 | 42 | manager.SetCompletionObserver(func(subagents.ResidentCompletion) { completed <- struct{}{} }) | |
| 43 | 43 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| @@ -32,12 +32,12 @@ func TestResidentSubagentListUsesHumanLabelTerminalStateAndRelativeTime(t *testi | |||||
| 32 | 32 | ||||
| 33 | func TestResidentSubagentsDialogShowsUsageMetadata(t *testing.T) { | 33 | func TestResidentSubagentsDialogShowsUsageMetadata(t *testing.T) { | ||
| 34 | completed := make(chan struct{}, 2) | 34 | completed := make(chan struct{}, 2) | ||
| 35 | - | manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 35 | + | manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { |
| 36 | journal.ConfigureUsage(272_000, true) | 36 | journal.ConfigureUsage(272_000, true) | ||
| 37 | - | return func(context.Context, string) error { | 37 | + | return subagents.ResidentTurnRunner(func(context.Context, string) error { |
| 38 | usage := provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525} | 38 | usage := provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525} | ||
| 39 | return journal.RecordAgentEvent(core.EvUsage{Usage: usage, Cumulative: usage}) | 39 | return journal.RecordAgentEvent(core.EvUsage{Usage: usage, Cumulative: usage}) | ||
| 40 | - | }, nil | 40 | + | }), nil |
| 41 | }) | 41 | }) | ||
| 42 | manager.SetCompletionObserver(func(subagents.ResidentCompletion) { completed <- struct{}{} }) | 42 | manager.SetCompletionObserver(func(subagents.ResidentCompletion) { completed <- struct{}{} }) | ||
| 43 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | 43 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | ||
M packages/agent/modes/subagent_session_test.go +19 -19
| @@ -32,8 +32,8 @@ func TestResidentChildSessionBuildsSharedTranscriptView(t *testing.T) { | |||
| 32 | 32 | if err := journal.Close(); err != nil { | |
| 33 | 33 | t.Fatal(err) | |
| 34 | 34 | } | |
| 35 | - | manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 36 | - | return func(context.Context, string) error { return nil }, nil | |
| 35 | + | manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 36 | + | return subagents.ResidentTurnRunner(func(context.Context, string) error { return nil }), nil | |
| 37 | 37 | }) | |
| 38 | 38 | session := newResidentChildSession(manager, "child", tui.Dark) | |
| 39 | 39 | if err := session.LoadRecent(20); err != nil { | |
| @@ -52,11 +52,11 @@ func TestResidentChildSessionBuildsSharedTranscriptView(t *testing.T) { | |||
| 52 | 52 | func TestResidentChildSessionKeepsComposerUntilFollowUpIsAccepted(t *testing.T) { | |
| 53 | 53 | root := t.TempDir() | |
| 54 | 54 | runs := make(chan string, 2) | |
| 55 | - | manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 56 | - | return func(_ context.Context, prompt string) error { | |
| 55 | + | manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 56 | + | return subagents.ResidentTurnRunner(func(_ context.Context, prompt string) error { | |
| 57 | 57 | runs <- prompt | |
| 58 | 58 | return nil | |
| 59 | - | }, nil | |
| 59 | + | }), nil | |
| 60 | 60 | }) | |
| 61 | 61 | t.Cleanup(func() { | |
| 62 | 62 | if err := manager.Close(context.Background()); err != nil { | |
| @@ -227,8 +227,8 @@ func TestInteractiveResidentUpdatesRefreshOpenChildSession(t *testing.T) { | |||
| 227 | 227 | started := make(chan struct{}) | |
| 228 | 228 | stream := make(chan struct{}) | |
| 229 | 229 | release := make(chan struct{}) | |
| 230 | - | manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 231 | - | return func(context.Context, string) error { | |
| 230 | + | manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 231 | + | return subagents.ResidentTurnRunner(func(context.Context, string) error { | |
| 232 | 232 | close(started) | |
| 233 | 233 | <-stream | |
| 234 | 234 | if err := journal.RecordAgentEvent(core.EvTextDelta{Delta: "streamed without input"}); err != nil { | |
| @@ -236,7 +236,7 @@ func TestInteractiveResidentUpdatesRefreshOpenChildSession(t *testing.T) { | |||
| 236 | 236 | } | |
| 237 | 237 | <-release | |
| 238 | 238 | return nil | |
| 239 | - | }, nil | |
| 239 | + | }), nil | |
| 240 | 240 | }) | |
| 241 | 241 | t.Cleanup(func() { | |
| 242 | 242 | close(release) | |
| @@ -277,8 +277,8 @@ func TestInteractiveResidentHistoryUpdatesRefreshOpenChildSession(t *testing.T) | |||
| 277 | 277 | started := make(chan struct{}) | |
| 278 | 278 | persist := make(chan struct{}) | |
| 279 | 279 | release := make(chan struct{}) | |
| 280 | - | manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 281 | - | return func(context.Context, string) error { | |
| 280 | + | manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 281 | + | return subagents.ResidentTurnRunner(func(context.Context, string) error { | |
| 282 | 282 | close(started) | |
| 283 | 283 | <-persist | |
| 284 | 284 | if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "bash", Arguments: json.RawMessage(`{"command":"printf fresh"}`)}}}}); err != nil { | |
| @@ -289,7 +289,7 @@ func TestInteractiveResidentHistoryUpdatesRefreshOpenChildSession(t *testing.T) | |||
| 289 | 289 | } | |
| 290 | 290 | <-release | |
| 291 | 291 | return nil | |
| 292 | - | }, nil | |
| 292 | + | }), nil | |
| 293 | 293 | }) | |
| 294 | 294 | t.Cleanup(func() { | |
| 295 | 295 | close(release) | |
| @@ -328,8 +328,8 @@ func TestInteractiveResidentHistoryUpdateDoesNotBlockChildControlGoroutine(t *te | |||
| 328 | 328 | persist := make(chan struct{}) | |
| 329 | 329 | persisted := make(chan struct{}) | |
| 330 | 330 | release := make(chan struct{}) | |
| 331 | - | manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 332 | - | return func(context.Context, string) error { | |
| 331 | + | manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 332 | + | return subagents.ResidentTurnRunner(func(context.Context, string) error { | |
| 333 | 333 | close(started) | |
| 334 | 334 | <-persist | |
| 335 | 335 | if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "durable update"}}}}); err != nil { | |
| @@ -338,7 +338,7 @@ func TestInteractiveResidentHistoryUpdateDoesNotBlockChildControlGoroutine(t *te | |||
| 338 | 338 | close(persisted) | |
| 339 | 339 | <-release | |
| 340 | 340 | return nil | |
| 341 | - | }, nil | |
| 341 | + | }), nil | |
| 342 | 342 | }) | |
| 343 | 343 | t.Cleanup(func() { | |
| 344 | 344 | close(release) | |
| @@ -365,8 +365,8 @@ func TestInteractiveResidentHistoryUpdateDoesNotBlockChildControlGoroutine(t *te | |||
| 365 | 365 | } | |
| 366 | 366 | ||
| 367 | 367 | func TestInteractiveInputDownOpensResidentPickerAndEnterOpensLiveChild(t *testing.T) { | |
| 368 | - | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 369 | - | return func(context.Context, string) error { return nil }, nil | |
| 368 | + | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 369 | + | return subagents.ResidentTurnRunner(func(context.Context, string) error { return nil }), nil | |
| 370 | 370 | }) | |
| 371 | 371 | t.Cleanup(func() { | |
| 372 | 372 | if err := manager.Close(context.Background()); err != nil { | |
| @@ -399,12 +399,12 @@ func TestInteractiveInputDownOpensResidentPickerAndEnterOpensLiveChild(t *testin | |||
| 399 | 399 | func TestInteractiveResidentActivityDrivesIndependentAnimation(t *testing.T) { | |
| 400 | 400 | started := make(chan struct{}) | |
| 401 | 401 | release := make(chan struct{}) | |
| 402 | - | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 403 | - | return func(context.Context, string) error { | |
| 402 | + | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 403 | + | return subagents.ResidentTurnRunner(func(context.Context, string) error { | |
| 404 | 404 | close(started) | |
| 405 | 405 | <-release | |
| 406 | 406 | return nil | |
| 407 | - | }, nil | |
| 407 | + | }), nil | |
| 408 | 408 | }) | |
| 409 | 409 | t.Cleanup(func() { | |
| 410 | 410 | if err := manager.Close(context.Background()); err != nil { | |
| @@ -32,8 +32,8 @@ func TestResidentChildSessionBuildsSharedTranscriptView(t *testing.T) { | |||||
| 32 | if err := journal.Close(); err != nil { | 32 | if err := journal.Close(); err != nil { | ||
| 33 | t.Fatal(err) | 33 | t.Fatal(err) | ||
| 34 | } | 34 | } | ||
| 35 | - | manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 35 | + | manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { |
| 36 | - | return func(context.Context, string) error { return nil }, nil | 36 | + | return subagents.ResidentTurnRunner(func(context.Context, string) error { return nil }), nil |
| 37 | }) | 37 | }) | ||
| 38 | session := newResidentChildSession(manager, "child", tui.Dark) | 38 | session := newResidentChildSession(manager, "child", tui.Dark) | ||
| 39 | if err := session.LoadRecent(20); err != nil { | 39 | if err := session.LoadRecent(20); err != nil { | ||
| @@ -52,11 +52,11 @@ func TestResidentChildSessionBuildsSharedTranscriptView(t *testing.T) { | |||||
| 52 | func TestResidentChildSessionKeepsComposerUntilFollowUpIsAccepted(t *testing.T) { | 52 | func TestResidentChildSessionKeepsComposerUntilFollowUpIsAccepted(t *testing.T) { | ||
| 53 | root := t.TempDir() | 53 | root := t.TempDir() | ||
| 54 | runs := make(chan string, 2) | 54 | runs := make(chan string, 2) | ||
| 55 | - | manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 55 | + | manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { |
| 56 | - | return func(_ context.Context, prompt string) error { | 56 | + | return subagents.ResidentTurnRunner(func(_ context.Context, prompt string) error { |
| 57 | runs <- prompt | 57 | runs <- prompt | ||
| 58 | return nil | 58 | return nil | ||
| 59 | - | }, nil | 59 | + | }), nil |
| 60 | }) | 60 | }) | ||
| 61 | t.Cleanup(func() { | 61 | t.Cleanup(func() { | ||
| 62 | if err := manager.Close(context.Background()); err != nil { | 62 | if err := manager.Close(context.Background()); err != nil { | ||
| @@ -227,8 +227,8 @@ func TestInteractiveResidentUpdatesRefreshOpenChildSession(t *testing.T) { | |||||
| 227 | started := make(chan struct{}) | 227 | started := make(chan struct{}) | ||
| 228 | stream := make(chan struct{}) | 228 | stream := make(chan struct{}) | ||
| 229 | release := make(chan struct{}) | 229 | release := make(chan struct{}) | ||
| 230 | - | manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 230 | + | manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { |
| 231 | - | return func(context.Context, string) error { | 231 | + | return subagents.ResidentTurnRunner(func(context.Context, string) error { |
| 232 | close(started) | 232 | close(started) | ||
| 233 | <-stream | 233 | <-stream | ||
| 234 | if err := journal.RecordAgentEvent(core.EvTextDelta{Delta: "streamed without input"}); err != nil { | 234 | if err := journal.RecordAgentEvent(core.EvTextDelta{Delta: "streamed without input"}); err != nil { | ||
| @@ -236,7 +236,7 @@ func TestInteractiveResidentUpdatesRefreshOpenChildSession(t *testing.T) { | |||||
| 236 | } | 236 | } | ||
| 237 | <-release | 237 | <-release | ||
| 238 | return nil | 238 | return nil | ||
| 239 | - | }, nil | 239 | + | }), nil |
| 240 | }) | 240 | }) | ||
| 241 | t.Cleanup(func() { | 241 | t.Cleanup(func() { | ||
| 242 | close(release) | 242 | close(release) | ||
| @@ -277,8 +277,8 @@ func TestInteractiveResidentHistoryUpdatesRefreshOpenChildSession(t *testing.T) | |||||
| 277 | started := make(chan struct{}) | 277 | started := make(chan struct{}) | ||
| 278 | persist := make(chan struct{}) | 278 | persist := make(chan struct{}) | ||
| 279 | release := make(chan struct{}) | 279 | release := make(chan struct{}) | ||
| 280 | - | manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 280 | + | manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { |
| 281 | - | return func(context.Context, string) error { | 281 | + | return subagents.ResidentTurnRunner(func(context.Context, string) error { |
| 282 | close(started) | 282 | close(started) | ||
| 283 | <-persist | 283 | <-persist | ||
| 284 | if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "bash", Arguments: json.RawMessage(`{"command":"printf fresh"}`)}}}}); err != nil { | 284 | if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "bash", Arguments: json.RawMessage(`{"command":"printf fresh"}`)}}}}); err != nil { | ||
| @@ -289,7 +289,7 @@ func TestInteractiveResidentHistoryUpdatesRefreshOpenChildSession(t *testing.T) | |||||
| 289 | } | 289 | } | ||
| 290 | <-release | 290 | <-release | ||
| 291 | return nil | 291 | return nil | ||
| 292 | - | }, nil | 292 | + | }), nil |
| 293 | }) | 293 | }) | ||
| 294 | t.Cleanup(func() { | 294 | t.Cleanup(func() { | ||
| 295 | close(release) | 295 | close(release) | ||
| @@ -328,8 +328,8 @@ func TestInteractiveResidentHistoryUpdateDoesNotBlockChildControlGoroutine(t *te | |||||
| 328 | persist := make(chan struct{}) | 328 | persist := make(chan struct{}) | ||
| 329 | persisted := make(chan struct{}) | 329 | persisted := make(chan struct{}) | ||
| 330 | release := make(chan struct{}) | 330 | release := make(chan struct{}) | ||
| 331 | - | manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 331 | + | manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { |
| 332 | - | return func(context.Context, string) error { | 332 | + | return subagents.ResidentTurnRunner(func(context.Context, string) error { |
| 333 | close(started) | 333 | close(started) | ||
| 334 | <-persist | 334 | <-persist | ||
| 335 | if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "durable update"}}}}); err != nil { | 335 | if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "durable update"}}}}); err != nil { | ||
| @@ -338,7 +338,7 @@ func TestInteractiveResidentHistoryUpdateDoesNotBlockChildControlGoroutine(t *te | |||||
| 338 | close(persisted) | 338 | close(persisted) | ||
| 339 | <-release | 339 | <-release | ||
| 340 | return nil | 340 | return nil | ||
| 341 | - | }, nil | 341 | + | }), nil |
| 342 | }) | 342 | }) | ||
| 343 | t.Cleanup(func() { | 343 | t.Cleanup(func() { | ||
| 344 | close(release) | 344 | close(release) | ||
| @@ -365,8 +365,8 @@ func TestInteractiveResidentHistoryUpdateDoesNotBlockChildControlGoroutine(t *te | |||||
| 365 | } | 365 | } | ||
| 366 | 366 | ||||
| 367 | func TestInteractiveInputDownOpensResidentPickerAndEnterOpensLiveChild(t *testing.T) { | 367 | func TestInteractiveInputDownOpensResidentPickerAndEnterOpensLiveChild(t *testing.T) { | ||
| 368 | - | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 368 | + | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { |
| 369 | - | return func(context.Context, string) error { return nil }, nil | 369 | + | return subagents.ResidentTurnRunner(func(context.Context, string) error { return nil }), nil |
| 370 | }) | 370 | }) | ||
| 371 | t.Cleanup(func() { | 371 | t.Cleanup(func() { | ||
| 372 | if err := manager.Close(context.Background()); err != nil { | 372 | if err := manager.Close(context.Background()); err != nil { | ||
| @@ -399,12 +399,12 @@ func TestInteractiveInputDownOpensResidentPickerAndEnterOpensLiveChild(t *testin | |||||
| 399 | func TestInteractiveResidentActivityDrivesIndependentAnimation(t *testing.T) { | 399 | func TestInteractiveResidentActivityDrivesIndependentAnimation(t *testing.T) { | ||
| 400 | started := make(chan struct{}) | 400 | started := make(chan struct{}) | ||
| 401 | release := make(chan struct{}) | 401 | release := make(chan struct{}) | ||
| 402 | - | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 402 | + | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { |
| 403 | - | return func(context.Context, string) error { | 403 | + | return subagents.ResidentTurnRunner(func(context.Context, string) error { |
| 404 | close(started) | 404 | close(started) | ||
| 405 | <-release | 405 | <-release | ||
| 406 | return nil | 406 | return nil | ||
| 407 | - | }, nil | 407 | + | }), nil |
| 408 | }) | 408 | }) | ||
| 409 | t.Cleanup(func() { | 409 | t.Cleanup(func() { | ||
| 410 | if err := manager.Close(context.Background()); err != nil { | 410 | if err := manager.Close(context.Background()); err != nil { | ||
M packages/agent/modes/subagents_slash.go +7 -2
| @@ -77,11 +77,16 @@ func (i *Interactive) runResidentSubagents(ctx context.Context, args []string) { | |||
| 77 | 77 | i.subagentsStatus("", "/subagents "+sub+" <id> <prompt>: missing id or prompt") | |
| 78 | 78 | return | |
| 79 | 79 | } | |
| 80 | - | if err := i.cfg.ResidentManager.Resume(ctx, id, prompt); err != nil { | |
| 80 | + | outcome, err := i.cfg.ResidentManager.ResumeFollowUp(ctx, id, prompt, subagents.ResumeSteer, "", nil) | |
| 81 | + | if err != nil { | |
| 81 | 82 | i.subagentsStatus("", sub+": "+err.Error()) | |
| 82 | 83 | return | |
| 83 | 84 | } | |
| 84 | - | i.subagentsStatus("accepted follow-up for "+id, "") | |
| 85 | + | if outcome.Steered { | |
| 86 | + | i.subagentsStatus("steered follow-up into the running turn of "+id, "") | |
| 87 | + | } else { | |
| 88 | + | i.subagentsStatus("queued follow-up for "+id, "") | |
| 89 | + | } | |
| 85 | 90 | default: | |
| 86 | 91 | i.subagentsStatus("", "/subagents: resident child sessions support list / new / logs / result / kill / send / resume") | |
| 87 | 92 | } | |
| @@ -77,11 +77,16 @@ func (i *Interactive) runResidentSubagents(ctx context.Context, args []string) { | |||||
| 77 | i.subagentsStatus("", "/subagents "+sub+" <id> <prompt>: missing id or prompt") | 77 | i.subagentsStatus("", "/subagents "+sub+" <id> <prompt>: missing id or prompt") | ||
| 78 | return | 78 | return | ||
| 79 | } | 79 | } | ||
| 80 | - | if err := i.cfg.ResidentManager.Resume(ctx, id, prompt); err != nil { | 80 | + | outcome, err := i.cfg.ResidentManager.ResumeFollowUp(ctx, id, prompt, subagents.ResumeSteer, "", nil) |
| 81 | + | if err != nil { | |||
| 81 | i.subagentsStatus("", sub+": "+err.Error()) | 82 | i.subagentsStatus("", sub+": "+err.Error()) | ||
| 82 | return | 83 | return | ||
| 83 | } | 84 | } | ||
| 84 | - | i.subagentsStatus("accepted follow-up for "+id, "") | 85 | + | if outcome.Steered { |
| 86 | + | i.subagentsStatus("steered follow-up into the running turn of "+id, "") | |||
| 87 | + | } else { | |||
| 88 | + | i.subagentsStatus("queued follow-up for "+id, "") | |||
| 89 | + | } | |||
| 85 | default: | 90 | default: | ||
| 86 | i.subagentsStatus("", "/subagents: resident child sessions support list / new / logs / result / kill / send / resume") | 91 | i.subagentsStatus("", "/subagents: resident child sessions support list / new / logs / result / kill / send / resume") | ||
| 87 | } | 92 | } | ||
M packages/agent/settings_store.go +30 -16
| @@ -2,6 +2,7 @@ package agent | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "strings" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/zut/packages/agent/modes" | |
| 7 | 8 | "github.com/bnema/zut/packages/provider" | |
| @@ -339,34 +340,47 @@ const StrictOrchestratorDelegationUnavailableAddendum = `Delegation is unavailab | |||
| 339 | 340 | ||
| 340 | 341 | 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.` | |
| 341 | 342 | ||
| 343 | + | // SubagentActions records which subagent actions the launch-time tool policy | |
| 344 | + | // exposes, so prompt guidance names only actions the model can call. | |
| 345 | + | type SubagentActions struct { | |
| 346 | + | Spawn, Stop, Resume, Interrupt bool | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | func (a SubagentActions) lifecycle() bool { return a.Stop || a.Resume || a.Interrupt } | |
| 350 | + | ||
| 342 | 351 | // ProactiveSubagentsSystemAddendumFor returns the interactive collaboration | |
| 343 | 352 | // contract with guidance only for manager actions exposed at launch time. | |
| 344 | - | func ProactiveSubagentsSystemAddendumFor(spawnToolAllowed, stopToolAllowed, resumeToolAllowed bool) string { | |
| 353 | + | func ProactiveSubagentsSystemAddendumFor(actions SubagentActions) string { | |
| 345 | 354 | base := ProactiveSubagentsSystemAddendum | |
| 346 | - | if spawnToolAllowed { | |
| 355 | + | if actions.Spawn { | |
| 347 | 356 | base = ProactiveSubagentsRoutingAddendum + "\n\n" + base | |
| 348 | 357 | } | |
| 349 | - | return subagentsSystemAddendumFor(base, ProactiveSubagentsDelegationUnavailableAddendum, false, spawnToolAllowed, stopToolAllowed, resumeToolAllowed) | |
| 358 | + | return subagentsSystemAddendumFor(base, ProactiveSubagentsDelegationUnavailableAddendum, false, actions) | |
| 350 | 359 | } | |
| 351 | 360 | ||
| 352 | 361 | // StrictOrchestratorSystemAddendumFor returns the headless manager-only | |
| 353 | 362 | // contract with guidance only for manager actions exposed at launch time. | |
| 354 | - | func StrictOrchestratorSystemAddendumFor(spawnToolAllowed, stopToolAllowed, resumeToolAllowed bool) string { | |
| 355 | - | return subagentsSystemAddendumFor(StrictOrchestratorSystemAddendum, StrictOrchestratorDelegationUnavailableAddendum, true, spawnToolAllowed, stopToolAllowed, resumeToolAllowed) | |
| 363 | + | func StrictOrchestratorSystemAddendumFor(actions SubagentActions) string { | |
| 364 | + | return subagentsSystemAddendumFor(StrictOrchestratorSystemAddendum, StrictOrchestratorDelegationUnavailableAddendum, true, actions) | |
| 356 | 365 | } | |
| 357 | 366 | ||
| 358 | - | func subagentsSystemAddendumFor(base, unavailable string, strict, spawnToolAllowed, stopToolAllowed, resumeToolAllowed bool) string { | |
| 367 | + | func subagentsSystemAddendumFor(base, unavailable string, strict bool, actions SubagentActions) string { | |
| 359 | 368 | addendum := base + "\n\n" + subagentLifecycleAddendum | |
| 360 | - | switch { | |
| 361 | - | case stopToolAllowed && resumeToolAllowed: | |
| 362 | - | 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." | |
| 363 | - | case stopToolAllowed: | |
| 364 | - | addendum += "\n\nA manager lifecycle action is available: use the subagent tool's stop action to request termination of a stuck worker." | |
| 365 | - | case resumeToolAllowed: | |
| 366 | - | 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." | |
| 367 | - | } | |
| 368 | - | if !spawnToolAllowed { | |
| 369 | - | if stopToolAllowed || resumeToolAllowed { | |
| 369 | + | var lifecycle []string | |
| 370 | + | if actions.Resume { | |
| 371 | + | lifecycle = append(lifecycle, "use resume with an agent id and follow-up prompt to continue an idle worker, steer a running worker mid-turn, or restart a stopped worker with its existing session context") | |
| 372 | + | } | |
| 373 | + | if actions.Interrupt { | |
| 374 | + | lifecycle = append(lifecycle, "use interrupt to cancel only a worker's running turn while keeping its context, for example before asking it to wrap up") | |
| 375 | + | } | |
| 376 | + | if actions.Stop { | |
| 377 | + | lifecycle = append(lifecycle, "use stop to request termination of a stuck worker") | |
| 378 | + | } | |
| 379 | + | if len(lifecycle) != 0 { | |
| 380 | + | addendum += "\n\nSubagent manager lifecycle actions available: " + strings.Join(lifecycle, "; ") + "." | |
| 381 | + | } | |
| 382 | + | if !actions.Spawn { | |
| 383 | + | if actions.lifecycle() { | |
| 370 | 384 | addendum += "\n\nSpawning new workers is unavailable in this session. Use only the enabled manager lifecycle actions for existing workers." | |
| 371 | 385 | if strict { | |
| 372 | 386 | addendum += " Do not implement, debug, test, or review directly." | |
| @@ -2,6 +2,7 @@ package agent | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | + | "strings" | |||
| 5 | 6 | ||||
| 6 | "github.com/bnema/zut/packages/agent/modes" | 7 | "github.com/bnema/zut/packages/agent/modes" | ||
| 7 | "github.com/bnema/zut/packages/provider" | 8 | "github.com/bnema/zut/packages/provider" | ||
| @@ -339,34 +340,47 @@ const StrictOrchestratorDelegationUnavailableAddendum = `Delegation is unavailab | |||||
| 339 | 340 | ||||
| 340 | 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.` | 341 | 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.` | ||
| 341 | 342 | ||||
| 343 | + | // SubagentActions records which subagent actions the launch-time tool policy | |||
| 344 | + | // exposes, so prompt guidance names only actions the model can call. | |||
| 345 | + | type SubagentActions struct { | |||
| 346 | + | Spawn, Stop, Resume, Interrupt bool | |||
| 347 | + | } | |||
| 348 | + | ||||
| 349 | + | func (a SubagentActions) lifecycle() bool { return a.Stop || a.Resume || a.Interrupt } | |||
| 350 | + | ||||
| 342 | // ProactiveSubagentsSystemAddendumFor returns the interactive collaboration | 351 | // ProactiveSubagentsSystemAddendumFor returns the interactive collaboration | ||
| 343 | // contract with guidance only for manager actions exposed at launch time. | 352 | // contract with guidance only for manager actions exposed at launch time. | ||
| 344 | - | func ProactiveSubagentsSystemAddendumFor(spawnToolAllowed, stopToolAllowed, resumeToolAllowed bool) string { | 353 | + | func ProactiveSubagentsSystemAddendumFor(actions SubagentActions) string { |
| 345 | base := ProactiveSubagentsSystemAddendum | 354 | base := ProactiveSubagentsSystemAddendum | ||
| 346 | - | if spawnToolAllowed { | 355 | + | if actions.Spawn { |
| 347 | base = ProactiveSubagentsRoutingAddendum + "\n\n" + base | 356 | base = ProactiveSubagentsRoutingAddendum + "\n\n" + base | ||
| 348 | } | 357 | } | ||
| 349 | - | return subagentsSystemAddendumFor(base, ProactiveSubagentsDelegationUnavailableAddendum, false, spawnToolAllowed, stopToolAllowed, resumeToolAllowed) | 358 | + | return subagentsSystemAddendumFor(base, ProactiveSubagentsDelegationUnavailableAddendum, false, actions) |
| 350 | } | 359 | } | ||
| 351 | 360 | ||||
| 352 | // StrictOrchestratorSystemAddendumFor returns the headless manager-only | 361 | // StrictOrchestratorSystemAddendumFor returns the headless manager-only | ||
| 353 | // contract with guidance only for manager actions exposed at launch time. | 362 | // contract with guidance only for manager actions exposed at launch time. | ||
| 354 | - | func StrictOrchestratorSystemAddendumFor(spawnToolAllowed, stopToolAllowed, resumeToolAllowed bool) string { | 363 | + | func StrictOrchestratorSystemAddendumFor(actions SubagentActions) string { |
| 355 | - | return subagentsSystemAddendumFor(StrictOrchestratorSystemAddendum, StrictOrchestratorDelegationUnavailableAddendum, true, spawnToolAllowed, stopToolAllowed, resumeToolAllowed) | 364 | + | return subagentsSystemAddendumFor(StrictOrchestratorSystemAddendum, StrictOrchestratorDelegationUnavailableAddendum, true, actions) |
| 356 | } | 365 | } | ||
| 357 | 366 | ||||
| 358 | - | func subagentsSystemAddendumFor(base, unavailable string, strict, spawnToolAllowed, stopToolAllowed, resumeToolAllowed bool) string { | 367 | + | func subagentsSystemAddendumFor(base, unavailable string, strict bool, actions SubagentActions) string { |
| 359 | addendum := base + "\n\n" + subagentLifecycleAddendum | 368 | addendum := base + "\n\n" + subagentLifecycleAddendum | ||
| 360 | - | switch { | 369 | + | var lifecycle []string |
| 361 | - | case stopToolAllowed && resumeToolAllowed: | 370 | + | if actions.Resume { |
| 362 | - | 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." | 371 | + | lifecycle = append(lifecycle, "use resume with an agent id and follow-up prompt to continue an idle worker, steer a running worker mid-turn, or restart a stopped worker with its existing session context") |
| 363 | - | case stopToolAllowed: | 372 | + | } |
| 364 | - | addendum += "\n\nA manager lifecycle action is available: use the subagent tool's stop action to request termination of a stuck worker." | 373 | + | if actions.Interrupt { |
| 365 | - | case resumeToolAllowed: | 374 | + | lifecycle = append(lifecycle, "use interrupt to cancel only a worker's running turn while keeping its context, for example before asking it to wrap up") |
| 366 | - | 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." | 375 | + | } |
| 367 | - | } | 376 | + | if actions.Stop { |
| 368 | - | if !spawnToolAllowed { | 377 | + | lifecycle = append(lifecycle, "use stop to request termination of a stuck worker") |
| 369 | - | if stopToolAllowed || resumeToolAllowed { | 378 | + | } |
| 379 | + | if len(lifecycle) != 0 { | |||
| 380 | + | addendum += "\n\nSubagent manager lifecycle actions available: " + strings.Join(lifecycle, "; ") + "." | |||
| 381 | + | } | |||
| 382 | + | if !actions.Spawn { | |||
| 383 | + | if actions.lifecycle() { | |||
| 370 | addendum += "\n\nSpawning new workers is unavailable in this session. Use only the enabled manager lifecycle actions for existing workers." | 384 | addendum += "\n\nSpawning new workers is unavailable in this session. Use only the enabled manager lifecycle actions for existing workers." | ||
| 371 | if strict { | 385 | if strict { | ||
| 372 | addendum += " Do not implement, debug, test, or review directly." | 386 | addendum += " Do not implement, debug, test, or review directly." | ||
M packages/agent/settings_store_test.go +26 -22
| @@ -6,15 +6,16 @@ import ( | |||
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | 8 | func TestSubagentsSystemAddendaListOnlyEnabledLifecycleTools(t *testing.T) { | |
| 9 | - | // Substrings that identify the stop and resume action guidance. | |
| 9 | + | // Substrings that identify the lifecycle action guidance. | |
| 10 | 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" | |
| 11 | + | stopAction = "use stop to request termination of a stuck worker" | |
| 12 | + | resumeAction = "use resume with an agent id and follow-up prompt" | |
| 13 | + | interruptAction = "use interrupt to cancel only a worker's running turn" | |
| 14 | + | noSpawnNotice = "Spawning new workers is unavailable" | |
| 14 | 15 | ) | |
| 15 | 16 | builders := []struct { | |
| 16 | 17 | name string | |
| 17 | - | build func(bool, bool, bool) string | |
| 18 | + | build func(SubagentActions) string | |
| 18 | 19 | noSpawnFallback string | |
| 19 | 20 | }{ | |
| 20 | 21 | {name: "proactive", build: ProactiveSubagentsSystemAddendumFor, noSpawnFallback: "Continue non-delegated work locally."}, | |
| @@ -27,22 +28,25 @@ func TestSubagentsSystemAddendaListOnlyEnabledLifecycleTools(t *testing.T) { | |||
| 27 | 28 | spawn bool | |
| 28 | 29 | stop bool | |
| 29 | 30 | resume bool | |
| 31 | + | interrupt bool | |
| 30 | 32 | want []string | |
| 31 | 33 | unwanted []string | |
| 32 | 34 | available bool | |
| 33 | 35 | }{ | |
| 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 | + | {name: "none", spawn: true, unwanted: []string{stopAction, resumeAction, interruptAction}, available: true}, | |
| 37 | + | {name: "fully unavailable", unwanted: []string{stopAction, resumeAction, interruptAction}}, | |
| 38 | + | {name: "stop", spawn: true, stop: true, want: []string{stopAction}, unwanted: []string{resumeAction, interruptAction}, available: true}, | |
| 39 | + | {name: "resume", spawn: true, resume: true, want: []string{resumeAction}, unwanted: []string{stopAction, interruptAction}, available: true}, | |
| 40 | + | {name: "interrupt", spawn: true, interrupt: true, want: []string{interruptAction}, unwanted: []string{stopAction, resumeAction}, available: true}, | |
| 41 | + | {name: "all", spawn: true, stop: true, resume: true, interrupt: true, want: []string{stopAction, resumeAction, interruptAction}, available: true}, | |
| 42 | + | {name: "stop without spawn", stop: true, want: []string{stopAction, noSpawnNotice}, unwanted: []string{resumeAction, interruptAction}}, | |
| 43 | + | {name: "resume without spawn", resume: true, want: []string{resumeAction, noSpawnNotice}, unwanted: []string{stopAction, interruptAction}}, | |
| 44 | + | {name: "interrupt without spawn", interrupt: true, want: []string{interruptAction, noSpawnNotice}, unwanted: []string{stopAction, resumeAction}}, | |
| 45 | + | {name: "all without spawn", stop: true, resume: true, interrupt: true, want: []string{stopAction, resumeAction, interruptAction, noSpawnNotice}}, | |
| 42 | 46 | } { | |
| 43 | 47 | t.Run(tc.name, func(t *testing.T) { | |
| 44 | - | got := builder.build(tc.spawn, tc.stop, tc.resume) | |
| 45 | - | if !tc.spawn && (tc.stop || tc.resume) && !strings.Contains(got, builder.noSpawnFallback) { | |
| 48 | + | got := builder.build(SubagentActions{Spawn: tc.spawn, Stop: tc.stop, Resume: tc.resume, Interrupt: tc.interrupt}) | |
| 49 | + | if !tc.spawn && (tc.stop || tc.resume || tc.interrupt) && !strings.Contains(got, builder.noSpawnFallback) { | |
| 46 | 50 | t.Fatalf("addendum missing mode-specific no-spawn fallback %q:\n%s", builder.noSpawnFallback, got) | |
| 47 | 51 | } | |
| 48 | 52 | for _, want := range tc.want { | |
| @@ -65,7 +69,7 @@ func TestSubagentsSystemAddendaListOnlyEnabledLifecycleTools(t *testing.T) { | |||
| 65 | 69 | } | |
| 66 | 70 | ||
| 67 | 71 | func TestSubagentPoliciesSeparateProactiveAndStrictOwnership(t *testing.T) { | |
| 68 | - | proactive := ProactiveSubagentsSystemAddendumFor(true, false, false) | |
| 72 | + | proactive := ProactiveSubagentsSystemAddendumFor(SubagentActions{Spawn: true, Stop: false, Resume: false}) | |
| 69 | 73 | for _, want := range []string{"primary owner and implementer", "immediate next task you will perform locally", "If you cannot name useful non-overlapping local work, do not delegate", "Do not search, read, test, review, edit"} { | |
| 70 | 74 | if !strings.Contains(proactive, want) { | |
| 71 | 75 | t.Fatalf("proactive addendum missing %q:\n%s", want, proactive) | |
| @@ -75,7 +79,7 @@ func TestSubagentPoliciesSeparateProactiveAndStrictOwnership(t *testing.T) { | |||
| 75 | 79 | t.Fatalf("proactive addendum retained strict orchestrator contract:\n%s", proactive) | |
| 76 | 80 | } | |
| 77 | 81 | ||
| 78 | - | strict := StrictOrchestratorSystemAddendumFor(true, false, false) | |
| 82 | + | strict := StrictOrchestratorSystemAddendumFor(SubagentActions{Spawn: true, Stop: false, Resume: false}) | |
| 79 | 83 | for _, want := range []string{"not an implementer", "non-overlapping worker scopes", "Once a worker is active", "Only coordinate workers"} { | |
| 80 | 84 | if !strings.Contains(strict, want) { | |
| 81 | 85 | t.Fatalf("strict addendum missing %q:\n%s", want, strict) | |
| @@ -84,7 +88,7 @@ func TestSubagentPoliciesSeparateProactiveAndStrictOwnership(t *testing.T) { | |||
| 84 | 88 | } | |
| 85 | 89 | ||
| 86 | 90 | func TestProactiveAddendumRoutesBeforeWorkOnlyWhenSpawningIsAvailable(t *testing.T) { | |
| 87 | - | proactive := ProactiveSubagentsSystemAddendumFor(true, false, false) | |
| 91 | + | proactive := ProactiveSubagentsSystemAddendumFor(SubagentActions{Spawn: true, Stop: false, Resume: false}) | |
| 88 | 92 | for _, want := range []string{ | |
| 89 | 93 | "Route before you work:", | |
| 90 | 94 | "routing happens when the user states the request you are about to work on", | |
| @@ -110,7 +114,7 @@ func TestProactiveAddendumRoutesBeforeWorkOnlyWhenSpawningIsAvailable(t *testing | |||
| 110 | 114 | {name: "stop and resume", stop: true, resume: true}, | |
| 111 | 115 | } { | |
| 112 | 116 | t.Run(tc.name, func(t *testing.T) { | |
| 113 | - | got := ProactiveSubagentsSystemAddendumFor(false, tc.stop, tc.resume) | |
| 117 | + | got := ProactiveSubagentsSystemAddendumFor(SubagentActions{Spawn: false, Stop: tc.stop, Resume: tc.resume}) | |
| 114 | 118 | for _, unwanted := range []string{"Route before you work:", "[subagents_list]", "spawn a clearly described general worker"} { | |
| 115 | 119 | if strings.Contains(got, unwanted) { | |
| 116 | 120 | t.Fatalf("spawn-withheld addendum kept %q:\n%s", unwanted, got) | |
| @@ -133,18 +137,18 @@ func TestProactiveAddendumRoutesBeforeWorkOnlyWhenSpawningIsAvailable(t *testing | |||
| 133 | 137 | ||
| 134 | 138 | // Routing is the interactive collaboration step; the headless strict | |
| 135 | 139 | // contract divides scopes itself and must not inherit it. | |
| 136 | - | strict := StrictOrchestratorSystemAddendumFor(true, false, false) | |
| 140 | + | strict := StrictOrchestratorSystemAddendumFor(SubagentActions{Spawn: true, Stop: false, Resume: false}) | |
| 137 | 141 | if strings.Contains(strict, "Route before you work:") || strings.Contains(strict, "[subagents_list]") { | |
| 138 | 142 | t.Fatalf("strict orchestrator inherited interactive routing guidance:\n%s", strict) | |
| 139 | 143 | } | |
| 140 | 144 | } | |
| 141 | 145 | ||
| 142 | 146 | func TestSubagentPoliciesHandleUnavailableDelegationByMode(t *testing.T) { | |
| 143 | - | proactive := ProactiveSubagentsSystemAddendumFor(false, false, false) | |
| 147 | + | proactive := ProactiveSubagentsSystemAddendumFor(SubagentActions{Spawn: false, Stop: false, Resume: false}) | |
| 144 | 148 | if !strings.Contains(proactive, "Continue the user's task locally") || strings.Contains(proactive, "report this limitation") { | |
| 145 | 149 | t.Fatalf("proactive unavailable guidance blocks local work:\n%s", proactive) | |
| 146 | 150 | } | |
| 147 | - | strict := StrictOrchestratorSystemAddendumFor(false, false, false) | |
| 151 | + | strict := StrictOrchestratorSystemAddendumFor(SubagentActions{Spawn: false, Stop: false, Resume: false}) | |
| 148 | 152 | if !strings.Contains(strict, "report this limitation") || !strings.Contains(strict, "rather than implementing") { | |
| 149 | 153 | t.Fatalf("strict unavailable guidance permits local implementation:\n%s", strict) | |
| 150 | 154 | } | |
| @@ -6,15 +6,16 @@ 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. | 9 | + | // Substrings that identify the lifecycle action guidance. |
| 10 | const ( | 10 | const ( | ||
| 11 | - | stopAction = "stop action to request termination of a stuck worker" | 11 | + | stopAction = "use stop to request termination of a stuck worker" |
| 12 | - | resumeAction = "resume action with an agent id and follow-up prompt" | 12 | + | resumeAction = "use resume with an agent id and follow-up prompt" |
| 13 | - | noSpawnNotice = "Spawning new workers is unavailable" | 13 | + | interruptAction = "use interrupt to cancel only a worker's running turn" |
| 14 | + | noSpawnNotice = "Spawning new workers is unavailable" | |||
| 14 | ) | 15 | ) | ||
| 15 | builders := []struct { | 16 | builders := []struct { | ||
| 16 | name string | 17 | name string | ||
| 17 | - | build func(bool, bool, bool) string | 18 | + | build func(SubagentActions) string |
| 18 | noSpawnFallback string | 19 | noSpawnFallback string | ||
| 19 | }{ | 20 | }{ | ||
| 20 | {name: "proactive", build: ProactiveSubagentsSystemAddendumFor, noSpawnFallback: "Continue non-delegated work locally."}, | 21 | {name: "proactive", build: ProactiveSubagentsSystemAddendumFor, noSpawnFallback: "Continue non-delegated work locally."}, | ||
| @@ -27,22 +28,25 @@ func TestSubagentsSystemAddendaListOnlyEnabledLifecycleTools(t *testing.T) { | |||||
| 27 | spawn bool | 28 | spawn bool | ||
| 28 | stop bool | 29 | stop bool | ||
| 29 | resume bool | 30 | resume bool | ||
| 31 | + | interrupt bool | |||
| 30 | want []string | 32 | want []string | ||
| 31 | unwanted []string | 33 | unwanted []string | ||
| 32 | available bool | 34 | available bool | ||
| 33 | }{ | 35 | }{ | ||
| 34 | - | {name: "none", spawn: true, unwanted: []string{stopAction, resumeAction}, available: true}, | 36 | + | {name: "none", spawn: true, unwanted: []string{stopAction, resumeAction, interruptAction}, available: true}, |
| 35 | - | {name: "fully unavailable", unwanted: []string{stopAction, resumeAction}}, | 37 | + | {name: "fully unavailable", unwanted: []string{stopAction, resumeAction, interruptAction}}, |
| 36 | - | {name: "stop", spawn: true, stop: true, want: []string{stopAction}, unwanted: []string{resumeAction}, available: true}, | 38 | + | {name: "stop", spawn: true, stop: true, want: []string{stopAction}, unwanted: []string{resumeAction, interruptAction}, available: true}, |
| 37 | - | {name: "resume", spawn: true, resume: true, want: []string{resumeAction}, unwanted: []string{stopAction}, available: true}, | 39 | + | {name: "resume", spawn: true, resume: true, want: []string{resumeAction}, unwanted: []string{stopAction, interruptAction}, available: true}, |
| 38 | - | {name: "both", spawn: true, stop: true, resume: true, want: []string{stopAction, resumeAction}, available: true}, | 40 | + | {name: "interrupt", spawn: true, interrupt: true, want: []string{interruptAction}, unwanted: []string{stopAction, resumeAction}, available: true}, |
| 39 | - | {name: "stop without spawn", stop: true, want: []string{stopAction, noSpawnNotice}, unwanted: []string{resumeAction}}, | 41 | + | {name: "all", spawn: true, stop: true, resume: true, interrupt: true, want: []string{stopAction, resumeAction, interruptAction}, available: true}, |
| 40 | - | {name: "resume without spawn", resume: true, want: []string{resumeAction, noSpawnNotice}, unwanted: []string{stopAction}}, | 42 | + | {name: "stop without spawn", stop: true, want: []string{stopAction, noSpawnNotice}, unwanted: []string{resumeAction, interruptAction}}, |
| 41 | - | {name: "both without spawn", stop: true, resume: true, want: []string{stopAction, resumeAction, noSpawnNotice}}, | 43 | + | {name: "resume without spawn", resume: true, want: []string{resumeAction, noSpawnNotice}, unwanted: []string{stopAction, interruptAction}}, |
| 44 | + | {name: "interrupt without spawn", interrupt: true, want: []string{interruptAction, noSpawnNotice}, unwanted: []string{stopAction, resumeAction}}, | |||
| 45 | + | {name: "all without spawn", stop: true, resume: true, interrupt: true, want: []string{stopAction, resumeAction, interruptAction, noSpawnNotice}}, | |||
| 42 | } { | 46 | } { | ||
| 43 | t.Run(tc.name, func(t *testing.T) { | 47 | t.Run(tc.name, func(t *testing.T) { | ||
| 44 | - | got := builder.build(tc.spawn, tc.stop, tc.resume) | 48 | + | got := builder.build(SubagentActions{Spawn: tc.spawn, Stop: tc.stop, Resume: tc.resume, Interrupt: tc.interrupt}) |
| 45 | - | if !tc.spawn && (tc.stop || tc.resume) && !strings.Contains(got, builder.noSpawnFallback) { | 49 | + | if !tc.spawn && (tc.stop || tc.resume || tc.interrupt) && !strings.Contains(got, builder.noSpawnFallback) { |
| 46 | t.Fatalf("addendum missing mode-specific no-spawn fallback %q:\n%s", builder.noSpawnFallback, got) | 50 | t.Fatalf("addendum missing mode-specific no-spawn fallback %q:\n%s", builder.noSpawnFallback, got) | ||
| 47 | } | 51 | } | ||
| 48 | for _, want := range tc.want { | 52 | for _, want := range tc.want { | ||
| @@ -65,7 +69,7 @@ func TestSubagentsSystemAddendaListOnlyEnabledLifecycleTools(t *testing.T) { | |||||
| 65 | } | 69 | } | ||
| 66 | 70 | ||||
| 67 | func TestSubagentPoliciesSeparateProactiveAndStrictOwnership(t *testing.T) { | 71 | func TestSubagentPoliciesSeparateProactiveAndStrictOwnership(t *testing.T) { | ||
| 68 | - | proactive := ProactiveSubagentsSystemAddendumFor(true, false, false) | 72 | + | proactive := ProactiveSubagentsSystemAddendumFor(SubagentActions{Spawn: true, Stop: false, Resume: false}) |
| 69 | for _, want := range []string{"primary owner and implementer", "immediate next task you will perform locally", "If you cannot name useful non-overlapping local work, do not delegate", "Do not search, read, test, review, edit"} { | 73 | for _, want := range []string{"primary owner and implementer", "immediate next task you will perform locally", "If you cannot name useful non-overlapping local work, do not delegate", "Do not search, read, test, review, edit"} { | ||
| 70 | if !strings.Contains(proactive, want) { | 74 | if !strings.Contains(proactive, want) { | ||
| 71 | t.Fatalf("proactive addendum missing %q:\n%s", want, proactive) | 75 | t.Fatalf("proactive addendum missing %q:\n%s", want, proactive) | ||
| @@ -75,7 +79,7 @@ func TestSubagentPoliciesSeparateProactiveAndStrictOwnership(t *testing.T) { | |||||
| 75 | t.Fatalf("proactive addendum retained strict orchestrator contract:\n%s", proactive) | 79 | t.Fatalf("proactive addendum retained strict orchestrator contract:\n%s", proactive) | ||
| 76 | } | 80 | } | ||
| 77 | 81 | ||||
| 78 | - | strict := StrictOrchestratorSystemAddendumFor(true, false, false) | 82 | + | strict := StrictOrchestratorSystemAddendumFor(SubagentActions{Spawn: true, Stop: false, Resume: false}) |
| 79 | for _, want := range []string{"not an implementer", "non-overlapping worker scopes", "Once a worker is active", "Only coordinate workers"} { | 83 | for _, want := range []string{"not an implementer", "non-overlapping worker scopes", "Once a worker is active", "Only coordinate workers"} { | ||
| 80 | if !strings.Contains(strict, want) { | 84 | if !strings.Contains(strict, want) { | ||
| 81 | t.Fatalf("strict addendum missing %q:\n%s", want, strict) | 85 | t.Fatalf("strict addendum missing %q:\n%s", want, strict) | ||
| @@ -84,7 +88,7 @@ func TestSubagentPoliciesSeparateProactiveAndStrictOwnership(t *testing.T) { | |||||
| 84 | } | 88 | } | ||
| 85 | 89 | ||||
| 86 | func TestProactiveAddendumRoutesBeforeWorkOnlyWhenSpawningIsAvailable(t *testing.T) { | 90 | func TestProactiveAddendumRoutesBeforeWorkOnlyWhenSpawningIsAvailable(t *testing.T) { | ||
| 87 | - | proactive := ProactiveSubagentsSystemAddendumFor(true, false, false) | 91 | + | proactive := ProactiveSubagentsSystemAddendumFor(SubagentActions{Spawn: true, Stop: false, Resume: false}) |
| 88 | for _, want := range []string{ | 92 | for _, want := range []string{ | ||
| 89 | "Route before you work:", | 93 | "Route before you work:", | ||
| 90 | "routing happens when the user states the request you are about to work on", | 94 | "routing happens when the user states the request you are about to work on", | ||
| @@ -110,7 +114,7 @@ func TestProactiveAddendumRoutesBeforeWorkOnlyWhenSpawningIsAvailable(t *testing | |||||
| 110 | {name: "stop and resume", stop: true, resume: true}, | 114 | {name: "stop and resume", stop: true, resume: true}, | ||
| 111 | } { | 115 | } { | ||
| 112 | t.Run(tc.name, func(t *testing.T) { | 116 | t.Run(tc.name, func(t *testing.T) { | ||
| 113 | - | got := ProactiveSubagentsSystemAddendumFor(false, tc.stop, tc.resume) | 117 | + | got := ProactiveSubagentsSystemAddendumFor(SubagentActions{Spawn: false, Stop: tc.stop, Resume: tc.resume}) |
| 114 | for _, unwanted := range []string{"Route before you work:", "[subagents_list]", "spawn a clearly described general worker"} { | 118 | for _, unwanted := range []string{"Route before you work:", "[subagents_list]", "spawn a clearly described general worker"} { | ||
| 115 | if strings.Contains(got, unwanted) { | 119 | if strings.Contains(got, unwanted) { | ||
| 116 | t.Fatalf("spawn-withheld addendum kept %q:\n%s", unwanted, got) | 120 | t.Fatalf("spawn-withheld addendum kept %q:\n%s", unwanted, got) | ||
| @@ -133,18 +137,18 @@ func TestProactiveAddendumRoutesBeforeWorkOnlyWhenSpawningIsAvailable(t *testing | |||||
| 133 | 137 | ||||
| 134 | // Routing is the interactive collaboration step; the headless strict | 138 | // Routing is the interactive collaboration step; the headless strict | ||
| 135 | // contract divides scopes itself and must not inherit it. | 139 | // contract divides scopes itself and must not inherit it. | ||
| 136 | - | strict := StrictOrchestratorSystemAddendumFor(true, false, false) | 140 | + | strict := StrictOrchestratorSystemAddendumFor(SubagentActions{Spawn: true, Stop: false, Resume: false}) |
| 137 | if strings.Contains(strict, "Route before you work:") || strings.Contains(strict, "[subagents_list]") { | 141 | if strings.Contains(strict, "Route before you work:") || strings.Contains(strict, "[subagents_list]") { | ||
| 138 | t.Fatalf("strict orchestrator inherited interactive routing guidance:\n%s", strict) | 142 | t.Fatalf("strict orchestrator inherited interactive routing guidance:\n%s", strict) | ||
| 139 | } | 143 | } | ||
| 140 | } | 144 | } | ||
| 141 | 145 | ||||
| 142 | func TestSubagentPoliciesHandleUnavailableDelegationByMode(t *testing.T) { | 146 | func TestSubagentPoliciesHandleUnavailableDelegationByMode(t *testing.T) { | ||
| 143 | - | proactive := ProactiveSubagentsSystemAddendumFor(false, false, false) | 147 | + | proactive := ProactiveSubagentsSystemAddendumFor(SubagentActions{Spawn: false, Stop: false, Resume: false}) |
| 144 | if !strings.Contains(proactive, "Continue the user's task locally") || strings.Contains(proactive, "report this limitation") { | 148 | if !strings.Contains(proactive, "Continue the user's task locally") || strings.Contains(proactive, "report this limitation") { | ||
| 145 | t.Fatalf("proactive unavailable guidance blocks local work:\n%s", proactive) | 149 | t.Fatalf("proactive unavailable guidance blocks local work:\n%s", proactive) | ||
| 146 | } | 150 | } | ||
| 147 | - | strict := StrictOrchestratorSystemAddendumFor(false, false, false) | 151 | + | strict := StrictOrchestratorSystemAddendumFor(SubagentActions{Spawn: false, Stop: false, Resume: false}) |
| 148 | if !strings.Contains(strict, "report this limitation") || !strings.Contains(strict, "rather than implementing") { | 152 | if !strings.Contains(strict, "report this limitation") || !strings.Contains(strict, "rather than implementing") { | ||
| 149 | t.Fatalf("strict unavailable guidance permits local implementation:\n%s", strict) | 153 | t.Fatalf("strict unavailable guidance permits local implementation:\n%s", strict) | ||
| 150 | } | 154 | } | ||
M packages/agent/subagent_factory.go +66 -7
| @@ -13,11 +13,65 @@ import ( | |||
| 13 | 13 | "github.com/bnema/zut/packages/provider" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | + | // residentAgentRuntime runs a child's turns on one core.Agent and lets the | |
| 17 | + | // manager steer the running turn through the agent's queued-message boundary. | |
| 18 | + | type residentAgentRuntime struct { | |
| 19 | + | agent *core.Agent | |
| 20 | + | run func(context.Context, string) error | |
| 21 | + | ||
| 22 | + | mu sync.Mutex | |
| 23 | + | running bool | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (r *residentAgentRuntime) Run(ctx context.Context, prompt string) error { | |
| 27 | + | r.mu.Lock() | |
| 28 | + | r.running = true | |
| 29 | + | r.mu.Unlock() | |
| 30 | + | err := r.run(ctx, prompt) | |
| 31 | + | for { | |
| 32 | + | // A follow-up steered after the agent's last queue check would | |
| 33 | + | // otherwise be silently left behind by a successful turn. Closing | |
| 34 | + | // the steer window under the lock makes delivery all-or-nothing. | |
| 35 | + | // On error or cancellation the queue is left for the child to | |
| 36 | + | // drain and report as undelivered. The loop ends because each | |
| 37 | + | // continuation drains the queue at its first step, and Steer only | |
| 38 | + | // refills it on an explicit manager call. | |
| 39 | + | r.mu.Lock() | |
| 40 | + | if err != nil || ctx.Err() != nil || r.agent.QueuedMessageCount() == 0 { | |
| 41 | + | r.running = false | |
| 42 | + | r.mu.Unlock() | |
| 43 | + | return err | |
| 44 | + | } | |
| 45 | + | r.mu.Unlock() | |
| 46 | + | err = r.run(ctx, "") | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func (r *residentAgentRuntime) Steer(prompt string) bool { | |
| 51 | + | r.mu.Lock() | |
| 52 | + | defer r.mu.Unlock() | |
| 53 | + | if !r.running { | |
| 54 | + | return false | |
| 55 | + | } | |
| 56 | + | return r.agent.QueueMessage(prompt, nil) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func (r *residentAgentRuntime) PendingSteers() int { return r.agent.QueuedMessageCount() } | |
| 60 | + | ||
| 61 | + | func (r *residentAgentRuntime) DrainSteers() []string { | |
| 62 | + | drained := r.agent.DrainQueuedMessages() | |
| 63 | + | out := make([]string, 0, len(drained)) | |
| 64 | + | for _, message := range drained { | |
| 65 | + | out = append(out, message.Text) | |
| 66 | + | } | |
| 67 | + | return out | |
| 68 | + | } | |
| 69 | + | ||
| 16 | 70 | // newResidentChildRunner is the host-owned construction boundary for one | |
| 17 | 71 | // resident child. It resolves a fresh provider client and a fresh core.Agent | |
| 18 | 72 | // exactly once for the child's durable session; no subprocess configuration or | |
| 19 | 73 | // credential transfer is involved. | |
| 20 | - | func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 74 | + | func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 21 | 75 | if strings.TrimSpace(spec.SessionID) == "" { | |
| 22 | 76 | return nil, fmt.Errorf("resident child %q has no session identity", spec.ID) | |
| 23 | 77 | } | |
| @@ -81,10 +135,9 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||
| 81 | 135 | } | |
| 82 | 136 | } | |
| 83 | 137 | limit := agent.MaxSteps | |
| 84 | - | return func(ctx context.Context, prompt string) error { | |
| 85 | - | if ctx == nil { | |
| 86 | - | ctx = context.Background() | |
| 87 | - | } | |
| 138 | + | // run executes one accepted prompt, or continues the transcript to deliver | |
| 139 | + | // late steered follow-ups when prompt is empty. | |
| 140 | + | run := func(ctx context.Context, prompt string) error { | |
| 88 | 141 | turnCtx, cancel := context.WithCancel(ctx) | |
| 89 | 142 | defer cancel() | |
| 90 | 143 | var journalErr error | |
| @@ -115,7 +168,12 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||
| 115 | 168 | journalMu.Unlock() | |
| 116 | 169 | } | |
| 117 | 170 | } | |
| 118 | - | err := agent.Prompt(turnCtx, prompt, nil, sink) | |
| 171 | + | var err error | |
| 172 | + | if prompt == "" { | |
| 173 | + | err = agent.Continue(turnCtx, sink) | |
| 174 | + | } else { | |
| 175 | + | err = agent.Prompt(turnCtx, prompt, nil, sink) | |
| 176 | + | } | |
| 119 | 177 | if journalFailure := checkJournal(); journalFailure != nil { | |
| 120 | 178 | return journalFailure | |
| 121 | 179 | } | |
| @@ -126,7 +184,8 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||
| 126 | 184 | } | |
| 127 | 185 | } | |
| 128 | 186 | return err | |
| 129 | - | }, nil | |
| 187 | + | } | |
| 188 | + | return &residentAgentRuntime{agent: agent, run: run}, nil | |
| 130 | 189 | } | |
| 131 | 190 | ||
| 132 | 191 | // A compaction recovery never rewrites a transcript with nothing worth | |
| @@ -13,11 +13,65 @@ import ( | |||||
| 13 | "github.com/bnema/zut/packages/provider" | 13 | "github.com/bnema/zut/packages/provider" | ||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | + | // residentAgentRuntime runs a child's turns on one core.Agent and lets the | |||
| 17 | + | // manager steer the running turn through the agent's queued-message boundary. | |||
| 18 | + | type residentAgentRuntime struct { | |||
| 19 | + | agent *core.Agent | |||
| 20 | + | run func(context.Context, string) error | |||
| 21 | + | ||||
| 22 | + | mu sync.Mutex | |||
| 23 | + | running bool | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func (r *residentAgentRuntime) Run(ctx context.Context, prompt string) error { | |||
| 27 | + | r.mu.Lock() | |||
| 28 | + | r.running = true | |||
| 29 | + | r.mu.Unlock() | |||
| 30 | + | err := r.run(ctx, prompt) | |||
| 31 | + | for { | |||
| 32 | + | // A follow-up steered after the agent's last queue check would | |||
| 33 | + | // otherwise be silently left behind by a successful turn. Closing | |||
| 34 | + | // the steer window under the lock makes delivery all-or-nothing. | |||
| 35 | + | // On error or cancellation the queue is left for the child to | |||
| 36 | + | // drain and report as undelivered. The loop ends because each | |||
| 37 | + | // continuation drains the queue at its first step, and Steer only | |||
| 38 | + | // refills it on an explicit manager call. | |||
| 39 | + | r.mu.Lock() | |||
| 40 | + | if err != nil || ctx.Err() != nil || r.agent.QueuedMessageCount() == 0 { | |||
| 41 | + | r.running = false | |||
| 42 | + | r.mu.Unlock() | |||
| 43 | + | return err | |||
| 44 | + | } | |||
| 45 | + | r.mu.Unlock() | |||
| 46 | + | err = r.run(ctx, "") | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func (r *residentAgentRuntime) Steer(prompt string) bool { | |||
| 51 | + | r.mu.Lock() | |||
| 52 | + | defer r.mu.Unlock() | |||
| 53 | + | if !r.running { | |||
| 54 | + | return false | |||
| 55 | + | } | |||
| 56 | + | return r.agent.QueueMessage(prompt, nil) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func (r *residentAgentRuntime) PendingSteers() int { return r.agent.QueuedMessageCount() } | |||
| 60 | + | ||||
| 61 | + | func (r *residentAgentRuntime) DrainSteers() []string { | |||
| 62 | + | drained := r.agent.DrainQueuedMessages() | |||
| 63 | + | out := make([]string, 0, len(drained)) | |||
| 64 | + | for _, message := range drained { | |||
| 65 | + | out = append(out, message.Text) | |||
| 66 | + | } | |||
| 67 | + | return out | |||
| 68 | + | } | |||
| 69 | + | ||||
| 16 | // newResidentChildRunner is the host-owned construction boundary for one | 70 | // newResidentChildRunner is the host-owned construction boundary for one | ||
| 17 | // resident child. It resolves a fresh provider client and a fresh core.Agent | 71 | // resident child. It resolves a fresh provider client and a fresh core.Agent | ||
| 18 | // exactly once for the child's durable session; no subprocess configuration or | 72 | // exactly once for the child's durable session; no subprocess configuration or | ||
| 19 | // credential transfer is involved. | 73 | // credential transfer is involved. | ||
| 20 | - | func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 74 | + | func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { |
| 21 | if strings.TrimSpace(spec.SessionID) == "" { | 75 | if strings.TrimSpace(spec.SessionID) == "" { | ||
| 22 | return nil, fmt.Errorf("resident child %q has no session identity", spec.ID) | 76 | return nil, fmt.Errorf("resident child %q has no session identity", spec.ID) | ||
| 23 | } | 77 | } | ||
| @@ -81,10 +135,9 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||||
| 81 | } | 135 | } | ||
| 82 | } | 136 | } | ||
| 83 | limit := agent.MaxSteps | 137 | limit := agent.MaxSteps | ||
| 84 | - | return func(ctx context.Context, prompt string) error { | 138 | + | // run executes one accepted prompt, or continues the transcript to deliver |
| 85 | - | if ctx == nil { | 139 | + | // late steered follow-ups when prompt is empty. |
| 86 | - | ctx = context.Background() | 140 | + | run := func(ctx context.Context, prompt string) error { |
| 87 | - | } | |||
| 88 | turnCtx, cancel := context.WithCancel(ctx) | 141 | turnCtx, cancel := context.WithCancel(ctx) | ||
| 89 | defer cancel() | 142 | defer cancel() | ||
| 90 | var journalErr error | 143 | var journalErr error | ||
| @@ -115,7 +168,12 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||||
| 115 | journalMu.Unlock() | 168 | journalMu.Unlock() | ||
| 116 | } | 169 | } | ||
| 117 | } | 170 | } | ||
| 118 | - | err := agent.Prompt(turnCtx, prompt, nil, sink) | 171 | + | var err error |
| 172 | + | if prompt == "" { | |||
| 173 | + | err = agent.Continue(turnCtx, sink) | |||
| 174 | + | } else { | |||
| 175 | + | err = agent.Prompt(turnCtx, prompt, nil, sink) | |||
| 176 | + | } | |||
| 119 | if journalFailure := checkJournal(); journalFailure != nil { | 177 | if journalFailure := checkJournal(); journalFailure != nil { | ||
| 120 | return journalFailure | 178 | return journalFailure | ||
| 121 | } | 179 | } | ||
| @@ -126,7 +184,8 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||||
| 126 | } | 184 | } | ||
| 127 | } | 185 | } | ||
| 128 | return err | 186 | return err | ||
| 129 | - | }, nil | 187 | + | } |
| 188 | + | return &residentAgentRuntime{agent: agent, run: run}, nil | |||
| 130 | } | 189 | } | ||
| 131 | 190 | ||||
| 132 | // A compaction recovery never rewrites a transcript with nothing worth | 191 | // A compaction recovery never rewrites a transcript with nothing worth | ||
M packages/agent/subagent_factory_test.go +21 -21
| @@ -27,7 +27,7 @@ func TestResidentChildRunnerWithoutStepLimitRunsPastFormerCap(t *testing.T) { | |||
| 27 | 27 | Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL, | |
| 28 | 28 | CWD: t.TempDir(), NoContextFiles: true, NoSkill: true, DisableRepetitionGuard: true, | |
| 29 | 29 | }) | |
| 30 | - | if err := runner(t.Context(), "review"); err != nil { | |
| 30 | + | if err := runner.Run(t.Context(), "review"); err != nil { | |
| 31 | 31 | t.Fatalf("runner error = %v, want success past the former 50-step cap", err) | |
| 32 | 32 | } | |
| 33 | 33 | if got := requests.Load(); got != 52 { | |
| @@ -41,7 +41,7 @@ func TestResidentChildRunnerEnforcesExplicitParentStepLimit(t *testing.T) { | |||
| 41 | 41 | Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL, | |
| 42 | 42 | MaxSteps: 3, CWD: t.TempDir(), NoContextFiles: true, NoSkill: true, | |
| 43 | 43 | }) | |
| 44 | - | err := runner(t.Context(), "review") | |
| 44 | + | err := runner.Run(t.Context(), "review") | |
| 45 | 45 | if !errors.Is(err, core.ErrMaxSteps) || !strings.Contains(err.Error(), "3") { | |
| 46 | 46 | t.Fatalf("runner error = %v, want step limit 3", err) | |
| 47 | 47 | } | |
| @@ -83,7 +83,7 @@ func residentToolLoopServer(t *testing.T, toolCalls int) (*httptest.Server, *ato | |||
| 83 | 83 | ||
| 84 | 84 | // newResidentChildTestRunner builds one journaled resident child runner with | |
| 85 | 85 | // its own accepted spec. | |
| 86 | - | func newResidentChildTestRunner(t *testing.T, childID string, args Args) (subagents.ResidentTurnRunner, *subagents.ResidentJournal, subagents.ResidentChildSpec) { | |
| 86 | + | func newResidentChildTestRunner(t *testing.T, childID string, args Args) (subagents.ResidentRuntime, *subagents.ResidentJournal, subagents.ResidentChildSpec) { | |
| 87 | 87 | t.Helper() | |
| 88 | 88 | spec := subagents.ResidentChildSpec{ | |
| 89 | 89 | ID: childID, SessionID: childID + "-session", Provider: "openai", Model: "gpt-4o", | |
| @@ -97,11 +97,11 @@ func newResidentChildTestRunner(t *testing.T, childID string, args Args) (subage | |||
| 97 | 97 | if err := journal.Accept(spec, "review"); err != nil { | |
| 98 | 98 | t.Fatal(err) | |
| 99 | 99 | } | |
| 100 | - | runner, err := newResidentChildRunner(args, spec, journal) | |
| 100 | + | runtime, err := newResidentChildRunner(args, spec, journal) | |
| 101 | 101 | if err != nil { | |
| 102 | 102 | t.Fatal(err) | |
| 103 | 103 | } | |
| 104 | - | return runner, journal, spec | |
| 104 | + | return runtime, journal, spec | |
| 105 | 105 | } | |
| 106 | 106 | ||
| 107 | 107 | func TestResidentChildRunnerInjectsContextReminderIntoNextRequest(t *testing.T) { | |
| @@ -159,10 +159,10 @@ func TestResidentChildRunnerInjectsContextReminderIntoNextRequest(t *testing.T) | |||
| 159 | 159 | if err != nil { | |
| 160 | 160 | t.Fatal(err) | |
| 161 | 161 | } | |
| 162 | - | if err := runner(t.Context(), "review"); err != nil { | |
| 162 | + | if err := runner.Run(t.Context(), "review"); err != nil { | |
| 163 | 163 | t.Fatal(err) | |
| 164 | 164 | } | |
| 165 | - | if err := runner(t.Context(), "follow up"); err != nil { | |
| 165 | + | if err := runner.Run(t.Context(), "follow up"); err != nil { | |
| 166 | 166 | t.Fatal(err) | |
| 167 | 167 | } | |
| 168 | 168 | if got := requests.Load(); got != 2 { | |
| @@ -353,7 +353,7 @@ func TestResidentChildRunnerKeepsToolsAcrossTurns(t *testing.T) { | |||
| 353 | 353 | defer server.Close() | |
| 354 | 354 | ||
| 355 | 355 | runner, journal := newResidentTestRunner(t, server.URL) | |
| 356 | - | if err := runner(t.Context(), "review"); err != nil { | |
| 356 | + | if err := runner.Run(t.Context(), "review"); err != nil { | |
| 357 | 357 | t.Fatal(err) | |
| 358 | 358 | } | |
| 359 | 359 | if got := requests.Load(); got != 2 { | |
| @@ -384,7 +384,7 @@ func TestResidentChildRunnerRunsHighUsageFollowUpWithoutBudget(t *testing.T) { | |||
| 384 | 384 | defer server.Close() | |
| 385 | 385 | ||
| 386 | 386 | runner, journal := newResidentTestRunner(t, server.URL) | |
| 387 | - | if err := runner(t.Context(), "review"); err != nil { | |
| 387 | + | if err := runner.Run(t.Context(), "review"); err != nil { | |
| 388 | 388 | t.Fatalf("runner error = %v, want terminal result", err) | |
| 389 | 389 | } | |
| 390 | 390 | if got := requests.Load(); got != 1 { | |
| @@ -394,7 +394,7 @@ func TestResidentChildRunnerRunsHighUsageFollowUpWithoutBudget(t *testing.T) { | |||
| 394 | 394 | t.Fatalf("latest summary = %q", summary) | |
| 395 | 395 | } | |
| 396 | 396 | // Without a cumulative budget, a high-usage follow-up runs normally. | |
| 397 | - | if err := runner(t.Context(), "continue"); err != nil { | |
| 397 | + | if err := runner.Run(t.Context(), "continue"); err != nil { | |
| 398 | 398 | t.Fatalf("follow-up error = %v, want success", err) | |
| 399 | 399 | } | |
| 400 | 400 | if got := requests.Load(); got != 2 { | |
| @@ -429,7 +429,7 @@ func TestResidentChildRunnerResumesWithRetainedHistory(t *testing.T) { | |||
| 429 | 429 | })) | |
| 430 | 430 | defer server.Close() | |
| 431 | 431 | cwd := t.TempDir() | |
| 432 | - | manager := subagents.NewResidentManager(t.TempDir(), func(spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 432 | + | manager := subagents.NewResidentManager(t.TempDir(), func(spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 433 | 433 | return newResidentChildRunner(Args{Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL, CWD: cwd, NoContextFiles: true, NoSkill: true}, spec, journal) | |
| 434 | 434 | }) | |
| 435 | 435 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| @@ -460,7 +460,7 @@ func TestResidentChildRunnerResumesWithRetainedHistory(t *testing.T) { | |||
| 460 | 460 | } | |
| 461 | 461 | } | |
| 462 | 462 | ||
| 463 | - | func newResidentTestRunner(t *testing.T, baseURL string) (subagents.ResidentTurnRunner, *subagents.ResidentJournal) { | |
| 463 | + | func newResidentTestRunner(t *testing.T, baseURL string) (subagents.ResidentRuntime, *subagents.ResidentJournal) { | |
| 464 | 464 | t.Helper() | |
| 465 | 465 | runner, journal, _ := newResidentChildTestRunner(t, "resident-child", Args{ | |
| 466 | 466 | Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: baseURL, | |
| @@ -672,7 +672,7 @@ func TestResidentChildRunnerRecoversFromContextOverflowOnce(t *testing.T) { | |||
| 672 | 672 | if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil { | |
| 673 | 673 | t.Fatal(err) | |
| 674 | 674 | } | |
| 675 | - | runErr := runner(t.Context(), "review") | |
| 675 | + | runErr := runner.Run(t.Context(), "review") | |
| 676 | 676 | if err := journal.RecordTurnFinished(spec, "turn-1", runErr); err != nil { | |
| 677 | 677 | t.Fatal(err) | |
| 678 | 678 | } | |
| @@ -728,7 +728,7 @@ func TestResidentChildRunnerDoesNotRecoverTwicePerTurn(t *testing.T) { | |||
| 728 | 728 | script := &residentOverflowScript{toolCallRequests: 2, secondOverflow: true, finalText: "unreached"} | |
| 729 | 729 | server := script.start(t) | |
| 730 | 730 | runner, journal, _ := newResidentChildTestRunner(t, "double-overflow", residentArguments(t, server.URL)) | |
| 731 | - | err := runner(t.Context(), "review") | |
| 731 | + | err := runner.Run(t.Context(), "review") | |
| 732 | 732 | if err == nil || !provider.IsContextOverflowError(err) { | |
| 733 | 733 | t.Fatalf("runner error = %v, want the second overflow to stay terminal", err) | |
| 734 | 734 | } | |
| @@ -746,7 +746,7 @@ func TestResidentChildRunnerDoesNotReplenishExplicitStepLimit(t *testing.T) { | |||
| 746 | 746 | args := residentArguments(t, server.URL) | |
| 747 | 747 | args.MaxSteps = 4 | |
| 748 | 748 | runner, journal, _ := newResidentChildTestRunner(t, "limited-overflow", args) | |
| 749 | - | err := runner(t.Context(), "review") | |
| 749 | + | err := runner.Run(t.Context(), "review") | |
| 750 | 750 | if err == nil || !provider.IsContextOverflowError(err) { | |
| 751 | 751 | t.Fatalf("runner error = %v, want the overflow", err) | |
| 752 | 752 | } | |
| @@ -791,7 +791,7 @@ func TestResidentChildRunnerFailsTurnWhenCheckpointCannotPersist(t *testing.T) { | |||
| 791 | 791 | if err != nil { | |
| 792 | 792 | t.Fatal(err) | |
| 793 | 793 | } | |
| 794 | - | if err := runner(t.Context(), "review"); err == nil { | |
| 794 | + | if err := runner.Run(t.Context(), "review"); err == nil { | |
| 795 | 795 | t.Fatal("runner recovered without persisting its checkpoint") | |
| 796 | 796 | } | |
| 797 | 797 | if got := script.requests.Load(); got != 4 { | |
| @@ -815,7 +815,7 @@ func TestResidentChildRunnerFailsTurnWhenCheckpointCannotPersist(t *testing.T) { | |||
| 815 | 815 | if err != nil { | |
| 816 | 816 | t.Fatal(err) | |
| 817 | 817 | } | |
| 818 | - | if err := runner(t.Context(), "verify"); err != nil { | |
| 818 | + | if err := runner.Run(t.Context(), "verify"); err != nil { | |
| 819 | 819 | t.Fatalf("follow-up error = %v", err) | |
| 820 | 820 | } | |
| 821 | 821 | bodies := script.capturedBodies() | |
| @@ -891,7 +891,7 @@ func TestResidentChildRunnerIgnoresCompactionUsageInReminder(t *testing.T) { | |||
| 891 | 891 | } | |
| 892 | 892 | server := script.start(t) | |
| 893 | 893 | runner, _, _ := newResidentChildTestRunner(t, "usage-child", residentArguments(t, server.URL)) | |
| 894 | - | if err := runner(t.Context(), "review"); err != nil { | |
| 894 | + | if err := runner.Run(t.Context(), "review"); err != nil { | |
| 895 | 895 | t.Fatalf("runner error = %v, want one recovered turn", err) | |
| 896 | 896 | } | |
| 897 | 897 | bodies := script.capturedBodies() | |
| @@ -936,7 +936,7 @@ func TestResidentChildRunnerKeepsHostContextOutOfCheckpoint(t *testing.T) { | |||
| 936 | 936 | } | |
| 937 | 937 | server := script.start(t) | |
| 938 | 938 | runner, journal, _ := newResidentChildTestRunner(t, "checkpoint-context", residentArguments(t, server.URL)) | |
| 939 | - | if err := runner(t.Context(), "review"); err != nil { | |
| 939 | + | if err := runner.Run(t.Context(), "review"); err != nil { | |
| 940 | 940 | t.Fatalf("runner error = %v, want one recovered turn", err) | |
| 941 | 941 | } | |
| 942 | 942 | messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir()) | |
| @@ -964,7 +964,7 @@ func TestResidentChildRunnerKeepsRemainingStepAllowanceAcrossRecovery(t *testing | |||
| 964 | 964 | args := residentArguments(t, server.URL) | |
| 965 | 965 | args.MaxSteps = 5 | |
| 966 | 966 | runner, journal, _ := newResidentChildTestRunner(t, "partial-allowance", args) | |
| 967 | - | err := runner(t.Context(), "review") | |
| 967 | + | err := runner.Run(t.Context(), "review") | |
| 968 | 968 | if !errors.Is(err, core.ErrMaxSteps) { | |
| 969 | 969 | t.Fatalf("runner error = %v, want the continuation to exhaust the remaining allowance", err) | |
| 970 | 970 | } | |
| @@ -982,7 +982,7 @@ func TestResidentChildRunnerDoesNotCompactWithoutHistory(t *testing.T) { | |||
| 982 | 982 | script := &residentOverflowScript{toolCallRequests: 0, finalText: "unreached"} | |
| 983 | 983 | server := script.start(t) | |
| 984 | 984 | runner, journal, _ := newResidentChildTestRunner(t, "short-overflow", residentArguments(t, server.URL)) | |
| 985 | - | err := runner(t.Context(), "review") | |
| 985 | + | err := runner.Run(t.Context(), "review") | |
| 986 | 986 | if err == nil || !provider.IsContextOverflowError(err) || !strings.Contains(err.Error(), "context_length_exceeded") { | |
| 987 | 987 | t.Fatalf("runner error = %v, want the original overflow", err) | |
| 988 | 988 | } | |
| @@ -27,7 +27,7 @@ func TestResidentChildRunnerWithoutStepLimitRunsPastFormerCap(t *testing.T) { | |||||
| 27 | Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL, | 27 | Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL, | ||
| 28 | CWD: t.TempDir(), NoContextFiles: true, NoSkill: true, DisableRepetitionGuard: true, | 28 | CWD: t.TempDir(), NoContextFiles: true, NoSkill: true, DisableRepetitionGuard: true, | ||
| 29 | }) | 29 | }) | ||
| 30 | - | if err := runner(t.Context(), "review"); err != nil { | 30 | + | if err := runner.Run(t.Context(), "review"); err != nil { |
| 31 | t.Fatalf("runner error = %v, want success past the former 50-step cap", err) | 31 | t.Fatalf("runner error = %v, want success past the former 50-step cap", err) | ||
| 32 | } | 32 | } | ||
| 33 | if got := requests.Load(); got != 52 { | 33 | if got := requests.Load(); got != 52 { | ||
| @@ -41,7 +41,7 @@ func TestResidentChildRunnerEnforcesExplicitParentStepLimit(t *testing.T) { | |||||
| 41 | Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL, | 41 | Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL, | ||
| 42 | MaxSteps: 3, CWD: t.TempDir(), NoContextFiles: true, NoSkill: true, | 42 | MaxSteps: 3, CWD: t.TempDir(), NoContextFiles: true, NoSkill: true, | ||
| 43 | }) | 43 | }) | ||
| 44 | - | err := runner(t.Context(), "review") | 44 | + | err := runner.Run(t.Context(), "review") |
| 45 | if !errors.Is(err, core.ErrMaxSteps) || !strings.Contains(err.Error(), "3") { | 45 | if !errors.Is(err, core.ErrMaxSteps) || !strings.Contains(err.Error(), "3") { | ||
| 46 | t.Fatalf("runner error = %v, want step limit 3", err) | 46 | t.Fatalf("runner error = %v, want step limit 3", err) | ||
| 47 | } | 47 | } | ||
| @@ -83,7 +83,7 @@ func residentToolLoopServer(t *testing.T, toolCalls int) (*httptest.Server, *ato | |||||
| 83 | 83 | ||||
| 84 | // newResidentChildTestRunner builds one journaled resident child runner with | 84 | // newResidentChildTestRunner builds one journaled resident child runner with | ||
| 85 | // its own accepted spec. | 85 | // its own accepted spec. | ||
| 86 | - | func newResidentChildTestRunner(t *testing.T, childID string, args Args) (subagents.ResidentTurnRunner, *subagents.ResidentJournal, subagents.ResidentChildSpec) { | 86 | + | func newResidentChildTestRunner(t *testing.T, childID string, args Args) (subagents.ResidentRuntime, *subagents.ResidentJournal, subagents.ResidentChildSpec) { |
| 87 | t.Helper() | 87 | t.Helper() | ||
| 88 | spec := subagents.ResidentChildSpec{ | 88 | spec := subagents.ResidentChildSpec{ | ||
| 89 | ID: childID, SessionID: childID + "-session", Provider: "openai", Model: "gpt-4o", | 89 | ID: childID, SessionID: childID + "-session", Provider: "openai", Model: "gpt-4o", | ||
| @@ -97,11 +97,11 @@ func newResidentChildTestRunner(t *testing.T, childID string, args Args) (subage | |||||
| 97 | if err := journal.Accept(spec, "review"); err != nil { | 97 | if err := journal.Accept(spec, "review"); err != nil { | ||
| 98 | t.Fatal(err) | 98 | t.Fatal(err) | ||
| 99 | } | 99 | } | ||
| 100 | - | runner, err := newResidentChildRunner(args, spec, journal) | 100 | + | runtime, err := newResidentChildRunner(args, spec, journal) |
| 101 | if err != nil { | 101 | if err != nil { | ||
| 102 | t.Fatal(err) | 102 | t.Fatal(err) | ||
| 103 | } | 103 | } | ||
| 104 | - | return runner, journal, spec | 104 | + | return runtime, journal, spec |
| 105 | } | 105 | } | ||
| 106 | 106 | ||||
| 107 | func TestResidentChildRunnerInjectsContextReminderIntoNextRequest(t *testing.T) { | 107 | func TestResidentChildRunnerInjectsContextReminderIntoNextRequest(t *testing.T) { | ||
| @@ -159,10 +159,10 @@ func TestResidentChildRunnerInjectsContextReminderIntoNextRequest(t *testing.T) | |||||
| 159 | if err != nil { | 159 | if err != nil { | ||
| 160 | t.Fatal(err) | 160 | t.Fatal(err) | ||
| 161 | } | 161 | } | ||
| 162 | - | if err := runner(t.Context(), "review"); err != nil { | 162 | + | if err := runner.Run(t.Context(), "review"); err != nil { |
| 163 | t.Fatal(err) | 163 | t.Fatal(err) | ||
| 164 | } | 164 | } | ||
| 165 | - | if err := runner(t.Context(), "follow up"); err != nil { | 165 | + | if err := runner.Run(t.Context(), "follow up"); err != nil { |
| 166 | t.Fatal(err) | 166 | t.Fatal(err) | ||
| 167 | } | 167 | } | ||
| 168 | if got := requests.Load(); got != 2 { | 168 | if got := requests.Load(); got != 2 { | ||
| @@ -353,7 +353,7 @@ func TestResidentChildRunnerKeepsToolsAcrossTurns(t *testing.T) { | |||||
| 353 | defer server.Close() | 353 | defer server.Close() | ||
| 354 | 354 | ||||
| 355 | runner, journal := newResidentTestRunner(t, server.URL) | 355 | runner, journal := newResidentTestRunner(t, server.URL) | ||
| 356 | - | if err := runner(t.Context(), "review"); err != nil { | 356 | + | if err := runner.Run(t.Context(), "review"); err != nil { |
| 357 | t.Fatal(err) | 357 | t.Fatal(err) | ||
| 358 | } | 358 | } | ||
| 359 | if got := requests.Load(); got != 2 { | 359 | if got := requests.Load(); got != 2 { | ||
| @@ -384,7 +384,7 @@ func TestResidentChildRunnerRunsHighUsageFollowUpWithoutBudget(t *testing.T) { | |||||
| 384 | defer server.Close() | 384 | defer server.Close() | ||
| 385 | 385 | ||||
| 386 | runner, journal := newResidentTestRunner(t, server.URL) | 386 | runner, journal := newResidentTestRunner(t, server.URL) | ||
| 387 | - | if err := runner(t.Context(), "review"); err != nil { | 387 | + | if err := runner.Run(t.Context(), "review"); err != nil { |
| 388 | t.Fatalf("runner error = %v, want terminal result", err) | 388 | t.Fatalf("runner error = %v, want terminal result", err) | ||
| 389 | } | 389 | } | ||
| 390 | if got := requests.Load(); got != 1 { | 390 | if got := requests.Load(); got != 1 { | ||
| @@ -394,7 +394,7 @@ func TestResidentChildRunnerRunsHighUsageFollowUpWithoutBudget(t *testing.T) { | |||||
| 394 | t.Fatalf("latest summary = %q", summary) | 394 | t.Fatalf("latest summary = %q", summary) | ||
| 395 | } | 395 | } | ||
| 396 | // Without a cumulative budget, a high-usage follow-up runs normally. | 396 | // Without a cumulative budget, a high-usage follow-up runs normally. | ||
| 397 | - | if err := runner(t.Context(), "continue"); err != nil { | 397 | + | if err := runner.Run(t.Context(), "continue"); err != nil { |
| 398 | t.Fatalf("follow-up error = %v, want success", err) | 398 | t.Fatalf("follow-up error = %v, want success", err) | ||
| 399 | } | 399 | } | ||
| 400 | if got := requests.Load(); got != 2 { | 400 | if got := requests.Load(); got != 2 { | ||
| @@ -429,7 +429,7 @@ func TestResidentChildRunnerResumesWithRetainedHistory(t *testing.T) { | |||||
| 429 | })) | 429 | })) | ||
| 430 | defer server.Close() | 430 | defer server.Close() | ||
| 431 | cwd := t.TempDir() | 431 | cwd := t.TempDir() | ||
| 432 | - | manager := subagents.NewResidentManager(t.TempDir(), func(spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 432 | + | manager := subagents.NewResidentManager(t.TempDir(), func(spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { |
| 433 | return newResidentChildRunner(Args{Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL, CWD: cwd, NoContextFiles: true, NoSkill: true}, spec, journal) | 433 | return newResidentChildRunner(Args{Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL, CWD: cwd, NoContextFiles: true, NoSkill: true}, spec, journal) | ||
| 434 | }) | 434 | }) | ||
| 435 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | 435 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | ||
| @@ -460,7 +460,7 @@ func TestResidentChildRunnerResumesWithRetainedHistory(t *testing.T) { | |||||
| 460 | } | 460 | } | ||
| 461 | } | 461 | } | ||
| 462 | 462 | ||||
| 463 | - | func newResidentTestRunner(t *testing.T, baseURL string) (subagents.ResidentTurnRunner, *subagents.ResidentJournal) { | 463 | + | func newResidentTestRunner(t *testing.T, baseURL string) (subagents.ResidentRuntime, *subagents.ResidentJournal) { |
| 464 | t.Helper() | 464 | t.Helper() | ||
| 465 | runner, journal, _ := newResidentChildTestRunner(t, "resident-child", Args{ | 465 | runner, journal, _ := newResidentChildTestRunner(t, "resident-child", Args{ | ||
| 466 | Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: baseURL, | 466 | Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: baseURL, | ||
| @@ -672,7 +672,7 @@ func TestResidentChildRunnerRecoversFromContextOverflowOnce(t *testing.T) { | |||||
| 672 | if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil { | 672 | if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil { | ||
| 673 | t.Fatal(err) | 673 | t.Fatal(err) | ||
| 674 | } | 674 | } | ||
| 675 | - | runErr := runner(t.Context(), "review") | 675 | + | runErr := runner.Run(t.Context(), "review") |
| 676 | if err := journal.RecordTurnFinished(spec, "turn-1", runErr); err != nil { | 676 | if err := journal.RecordTurnFinished(spec, "turn-1", runErr); err != nil { | ||
| 677 | t.Fatal(err) | 677 | t.Fatal(err) | ||
| 678 | } | 678 | } | ||
| @@ -728,7 +728,7 @@ func TestResidentChildRunnerDoesNotRecoverTwicePerTurn(t *testing.T) { | |||||
| 728 | script := &residentOverflowScript{toolCallRequests: 2, secondOverflow: true, finalText: "unreached"} | 728 | script := &residentOverflowScript{toolCallRequests: 2, secondOverflow: true, finalText: "unreached"} | ||
| 729 | server := script.start(t) | 729 | server := script.start(t) | ||
| 730 | runner, journal, _ := newResidentChildTestRunner(t, "double-overflow", residentArguments(t, server.URL)) | 730 | runner, journal, _ := newResidentChildTestRunner(t, "double-overflow", residentArguments(t, server.URL)) | ||
| 731 | - | err := runner(t.Context(), "review") | 731 | + | err := runner.Run(t.Context(), "review") |
| 732 | if err == nil || !provider.IsContextOverflowError(err) { | 732 | if err == nil || !provider.IsContextOverflowError(err) { | ||
| 733 | t.Fatalf("runner error = %v, want the second overflow to stay terminal", err) | 733 | t.Fatalf("runner error = %v, want the second overflow to stay terminal", err) | ||
| 734 | } | 734 | } | ||
| @@ -746,7 +746,7 @@ func TestResidentChildRunnerDoesNotReplenishExplicitStepLimit(t *testing.T) { | |||||
| 746 | args := residentArguments(t, server.URL) | 746 | args := residentArguments(t, server.URL) | ||
| 747 | args.MaxSteps = 4 | 747 | args.MaxSteps = 4 | ||
| 748 | runner, journal, _ := newResidentChildTestRunner(t, "limited-overflow", args) | 748 | runner, journal, _ := newResidentChildTestRunner(t, "limited-overflow", args) | ||
| 749 | - | err := runner(t.Context(), "review") | 749 | + | err := runner.Run(t.Context(), "review") |
| 750 | if err == nil || !provider.IsContextOverflowError(err) { | 750 | if err == nil || !provider.IsContextOverflowError(err) { | ||
| 751 | t.Fatalf("runner error = %v, want the overflow", err) | 751 | t.Fatalf("runner error = %v, want the overflow", err) | ||
| 752 | } | 752 | } | ||
| @@ -791,7 +791,7 @@ func TestResidentChildRunnerFailsTurnWhenCheckpointCannotPersist(t *testing.T) { | |||||
| 791 | if err != nil { | 791 | if err != nil { | ||
| 792 | t.Fatal(err) | 792 | t.Fatal(err) | ||
| 793 | } | 793 | } | ||
| 794 | - | if err := runner(t.Context(), "review"); err == nil { | 794 | + | if err := runner.Run(t.Context(), "review"); err == nil { |
| 795 | t.Fatal("runner recovered without persisting its checkpoint") | 795 | t.Fatal("runner recovered without persisting its checkpoint") | ||
| 796 | } | 796 | } | ||
| 797 | if got := script.requests.Load(); got != 4 { | 797 | if got := script.requests.Load(); got != 4 { | ||
| @@ -815,7 +815,7 @@ func TestResidentChildRunnerFailsTurnWhenCheckpointCannotPersist(t *testing.T) { | |||||
| 815 | if err != nil { | 815 | if err != nil { | ||
| 816 | t.Fatal(err) | 816 | t.Fatal(err) | ||
| 817 | } | 817 | } | ||
| 818 | - | if err := runner(t.Context(), "verify"); err != nil { | 818 | + | if err := runner.Run(t.Context(), "verify"); err != nil { |
| 819 | t.Fatalf("follow-up error = %v", err) | 819 | t.Fatalf("follow-up error = %v", err) | ||
| 820 | } | 820 | } | ||
| 821 | bodies := script.capturedBodies() | 821 | bodies := script.capturedBodies() | ||
| @@ -891,7 +891,7 @@ func TestResidentChildRunnerIgnoresCompactionUsageInReminder(t *testing.T) { | |||||
| 891 | } | 891 | } | ||
| 892 | server := script.start(t) | 892 | server := script.start(t) | ||
| 893 | runner, _, _ := newResidentChildTestRunner(t, "usage-child", residentArguments(t, server.URL)) | 893 | runner, _, _ := newResidentChildTestRunner(t, "usage-child", residentArguments(t, server.URL)) | ||
| 894 | - | if err := runner(t.Context(), "review"); err != nil { | 894 | + | if err := runner.Run(t.Context(), "review"); err != nil { |
| 895 | t.Fatalf("runner error = %v, want one recovered turn", err) | 895 | t.Fatalf("runner error = %v, want one recovered turn", err) | ||
| 896 | } | 896 | } | ||
| 897 | bodies := script.capturedBodies() | 897 | bodies := script.capturedBodies() | ||
| @@ -936,7 +936,7 @@ func TestResidentChildRunnerKeepsHostContextOutOfCheckpoint(t *testing.T) { | |||||
| 936 | } | 936 | } | ||
| 937 | server := script.start(t) | 937 | server := script.start(t) | ||
| 938 | runner, journal, _ := newResidentChildTestRunner(t, "checkpoint-context", residentArguments(t, server.URL)) | 938 | runner, journal, _ := newResidentChildTestRunner(t, "checkpoint-context", residentArguments(t, server.URL)) | ||
| 939 | - | if err := runner(t.Context(), "review"); err != nil { | 939 | + | if err := runner.Run(t.Context(), "review"); err != nil { |
| 940 | t.Fatalf("runner error = %v, want one recovered turn", err) | 940 | t.Fatalf("runner error = %v, want one recovered turn", err) | ||
| 941 | } | 941 | } | ||
| 942 | messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir()) | 942 | messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir()) | ||
| @@ -964,7 +964,7 @@ func TestResidentChildRunnerKeepsRemainingStepAllowanceAcrossRecovery(t *testing | |||||
| 964 | args := residentArguments(t, server.URL) | 964 | args := residentArguments(t, server.URL) | ||
| 965 | args.MaxSteps = 5 | 965 | args.MaxSteps = 5 | ||
| 966 | runner, journal, _ := newResidentChildTestRunner(t, "partial-allowance", args) | 966 | runner, journal, _ := newResidentChildTestRunner(t, "partial-allowance", args) | ||
| 967 | - | err := runner(t.Context(), "review") | 967 | + | err := runner.Run(t.Context(), "review") |
| 968 | if !errors.Is(err, core.ErrMaxSteps) { | 968 | if !errors.Is(err, core.ErrMaxSteps) { | ||
| 969 | t.Fatalf("runner error = %v, want the continuation to exhaust the remaining allowance", err) | 969 | t.Fatalf("runner error = %v, want the continuation to exhaust the remaining allowance", err) | ||
| 970 | } | 970 | } | ||
| @@ -982,7 +982,7 @@ func TestResidentChildRunnerDoesNotCompactWithoutHistory(t *testing.T) { | |||||
| 982 | script := &residentOverflowScript{toolCallRequests: 0, finalText: "unreached"} | 982 | script := &residentOverflowScript{toolCallRequests: 0, finalText: "unreached"} | ||
| 983 | server := script.start(t) | 983 | server := script.start(t) | ||
| 984 | runner, journal, _ := newResidentChildTestRunner(t, "short-overflow", residentArguments(t, server.URL)) | 984 | runner, journal, _ := newResidentChildTestRunner(t, "short-overflow", residentArguments(t, server.URL)) | ||
| 985 | - | err := runner(t.Context(), "review") | 985 | + | err := runner.Run(t.Context(), "review") |
| 986 | if err == nil || !provider.IsContextOverflowError(err) || !strings.Contains(err.Error(), "context_length_exceeded") { | 986 | if err == nil || !provider.IsContextOverflowError(err) || !strings.Contains(err.Error(), "context_length_exceeded") { | ||
| 987 | t.Fatalf("runner error = %v, want the original overflow", err) | 987 | t.Fatalf("runner error = %v, want the original overflow", err) | ||
| 988 | } | 988 | } | ||
M packages/agent/subagent_runtime.go +7 -1
| @@ -137,7 +137,7 @@ func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime { | |||
| 137 | 137 | root = filepath.Join(ZutHome(), "subagents") | |
| 138 | 138 | rt.root = root | |
| 139 | 139 | } | |
| 140 | - | rt.resident = subagents.NewResidentManagerWithPolicy(root, cfg.Policy, func(spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 140 | + | rt.resident = subagents.NewResidentManagerWithPolicy(root, cfg.Policy, func(spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 141 | 141 | return newResidentChildRunner(residentChildArgs(rt.args, rt.credentialProvider, spec), spec, journal) | |
| 142 | 142 | }) | |
| 143 | 143 | rt.resident.SetCompletionObserver(cfg.ResidentCompletion) | |
| @@ -492,6 +492,12 @@ func (rt *subagentRuntime) InjectTools(reg core.Registry) core.Registry { | |||
| 492 | 492 | Enabled: func() bool { return true }, | |
| 493 | 493 | } | |
| 494 | 494 | } | |
| 495 | + | if autoSubagentsInterruptToolAllowed(rt.args) { | |
| 496 | + | facade.Interrupt = &tools.SubagentInterruptTool{ | |
| 497 | + | ResidentManager: rt.residentManagerForTools(), | |
| 498 | + | Enabled: func() bool { return true }, | |
| 499 | + | } | |
| 500 | + | } | |
| 495 | 501 | reg[facade.Name()] = facade | |
| 496 | 502 | return reg | |
| 497 | 503 | } | |
| @@ -137,7 +137,7 @@ func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime { | |||||
| 137 | root = filepath.Join(ZutHome(), "subagents") | 137 | root = filepath.Join(ZutHome(), "subagents") | ||
| 138 | rt.root = root | 138 | rt.root = root | ||
| 139 | } | 139 | } | ||
| 140 | - | rt.resident = subagents.NewResidentManagerWithPolicy(root, cfg.Policy, func(spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 140 | + | rt.resident = subagents.NewResidentManagerWithPolicy(root, cfg.Policy, func(spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { |
| 141 | return newResidentChildRunner(residentChildArgs(rt.args, rt.credentialProvider, spec), spec, journal) | 141 | return newResidentChildRunner(residentChildArgs(rt.args, rt.credentialProvider, spec), spec, journal) | ||
| 142 | }) | 142 | }) | ||
| 143 | rt.resident.SetCompletionObserver(cfg.ResidentCompletion) | 143 | rt.resident.SetCompletionObserver(cfg.ResidentCompletion) | ||
| @@ -492,6 +492,12 @@ func (rt *subagentRuntime) InjectTools(reg core.Registry) core.Registry { | |||||
| 492 | Enabled: func() bool { return true }, | 492 | Enabled: func() bool { return true }, | ||
| 493 | } | 493 | } | ||
| 494 | } | 494 | } | ||
| 495 | + | if autoSubagentsInterruptToolAllowed(rt.args) { | |||
| 496 | + | facade.Interrupt = &tools.SubagentInterruptTool{ | |||
| 497 | + | ResidentManager: rt.residentManagerForTools(), | |||
| 498 | + | Enabled: func() bool { return true }, | |||
| 499 | + | } | |||
| 500 | + | } | |||
| 495 | reg[facade.Name()] = facade | 501 | reg[facade.Name()] = facade | ||
| 496 | return reg | 502 | return reg | ||
| 497 | } | 503 | } | ||
M packages/agent/subagent_runtime_test.go +12 -6
| @@ -354,17 +354,23 @@ func TestInjectToolsRegistersOneSubagentFacade(t *testing.T) { | |||
| 354 | 354 | return Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true} | |
| 355 | 355 | } | |
| 356 | 356 | cases := []struct { | |
| 357 | - | name string | |
| 358 | - | args Args | |
| 359 | - | spawn, status, stop, resume bool | |
| 357 | + | name string | |
| 358 | + | args Args | |
| 359 | + | spawn, status, stop, resume, interrupt bool | |
| 360 | 360 | }{ | |
| 361 | - | {name: "default grants every action", args: cwdArgs(), spawn: true, status: true, stop: true, resume: true}, | |
| 361 | + | {name: "default grants every action", args: cwdArgs(), spawn: true, status: true, stop: true, resume: true, interrupt: true}, | |
| 362 | 362 | {name: "resume suffix grants resume only", args: func() Args { | |
| 363 | 363 | args := cwdArgs() | |
| 364 | 364 | args.ToolsSet = true | |
| 365 | 365 | args.Tools = []string{"subagent:resume"} | |
| 366 | 366 | return args | |
| 367 | 367 | }(), resume: true}, | |
| 368 | + | {name: "interrupt suffix grants interrupt only", args: func() Args { | |
| 369 | + | args := cwdArgs() | |
| 370 | + | args.ToolsSet = true | |
| 371 | + | args.Tools = []string{"subagent:interrupt"} | |
| 372 | + | return args | |
| 373 | + | }(), interrupt: true}, | |
| 368 | 374 | } | |
| 369 | 375 | legacy := []string{"subagent_spawn", "subagent_status", "subagent_stop", "subagent_resume"} | |
| 370 | 376 | for _, tc := range cases { | |
| @@ -388,8 +394,8 @@ func TestInjectToolsRegistersOneSubagentFacade(t *testing.T) { | |||
| 388 | 394 | if !ok { | |
| 389 | 395 | t.Fatalf("registry entry %q = %T, want *tools.SubagentTool", tools.SubagentToolName, tool) | |
| 390 | 396 | } | |
| 391 | - | got := [4]bool{facade.Spawn != nil, facade.Status != nil, facade.Stop != nil, facade.Resume != nil} | |
| 392 | - | want := [4]bool{tc.spawn, tc.status, tc.stop, tc.resume} | |
| 397 | + | got := [5]bool{facade.Spawn != nil, facade.Status != nil, facade.Stop != nil, facade.Resume != nil, facade.Interrupt != nil} | |
| 398 | + | want := [5]bool{tc.spawn, tc.status, tc.stop, tc.resume, tc.interrupt} | |
| 393 | 399 | if got != want { | |
| 394 | 400 | t.Fatalf("facade actions = %v, want %v", got, want) | |
| 395 | 401 | } | |
| @@ -354,17 +354,23 @@ func TestInjectToolsRegistersOneSubagentFacade(t *testing.T) { | |||||
| 354 | return Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true} | 354 | return Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true} | ||
| 355 | } | 355 | } | ||
| 356 | cases := []struct { | 356 | cases := []struct { | ||
| 357 | - | name string | 357 | + | name string |
| 358 | - | args Args | 358 | + | args Args |
| 359 | - | spawn, status, stop, resume bool | 359 | + | spawn, status, stop, resume, interrupt bool |
| 360 | }{ | 360 | }{ | ||
| 361 | - | {name: "default grants every action", args: cwdArgs(), spawn: true, status: true, stop: true, resume: true}, | 361 | + | {name: "default grants every action", args: cwdArgs(), spawn: true, status: true, stop: true, resume: true, interrupt: true}, |
| 362 | {name: "resume suffix grants resume only", args: func() Args { | 362 | {name: "resume suffix grants resume only", args: func() Args { | ||
| 363 | args := cwdArgs() | 363 | args := cwdArgs() | ||
| 364 | args.ToolsSet = true | 364 | args.ToolsSet = true | ||
| 365 | args.Tools = []string{"subagent:resume"} | 365 | args.Tools = []string{"subagent:resume"} | ||
| 366 | return args | 366 | return args | ||
| 367 | }(), resume: true}, | 367 | }(), resume: true}, | ||
| 368 | + | {name: "interrupt suffix grants interrupt only", args: func() Args { | |||
| 369 | + | args := cwdArgs() | |||
| 370 | + | args.ToolsSet = true | |||
| 371 | + | args.Tools = []string{"subagent:interrupt"} | |||
| 372 | + | return args | |||
| 373 | + | }(), interrupt: true}, | |||
| 368 | } | 374 | } | ||
| 369 | legacy := []string{"subagent_spawn", "subagent_status", "subagent_stop", "subagent_resume"} | 375 | legacy := []string{"subagent_spawn", "subagent_status", "subagent_stop", "subagent_resume"} | ||
| 370 | for _, tc := range cases { | 376 | for _, tc := range cases { | ||
| @@ -388,8 +394,8 @@ func TestInjectToolsRegistersOneSubagentFacade(t *testing.T) { | |||||
| 388 | if !ok { | 394 | if !ok { | ||
| 389 | t.Fatalf("registry entry %q = %T, want *tools.SubagentTool", tools.SubagentToolName, tool) | 395 | t.Fatalf("registry entry %q = %T, want *tools.SubagentTool", tools.SubagentToolName, tool) | ||
| 390 | } | 396 | } | ||
| 391 | - | got := [4]bool{facade.Spawn != nil, facade.Status != nil, facade.Stop != nil, facade.Resume != nil} | 397 | + | got := [5]bool{facade.Spawn != nil, facade.Status != nil, facade.Stop != nil, facade.Resume != nil, facade.Interrupt != nil} |
| 392 | - | want := [4]bool{tc.spawn, tc.status, tc.stop, tc.resume} | 398 | + | want := [5]bool{tc.spawn, tc.status, tc.stop, tc.resume, tc.interrupt} |
| 393 | if got != want { | 399 | if got != want { | ||
| 394 | t.Fatalf("facade actions = %v, want %v", got, want) | 400 | t.Fatalf("facade actions = %v, want %v", got, want) | ||
| 395 | } | 401 | } | ||
A packages/agent/subagent_steer_test.go +104 -0
| @@ -0,0 +1,104 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "net/http" | |
| 6 | + | "net/http/httptest" | |
| 7 | + | "strings" | |
| 8 | + | "sync" | |
| 9 | + | "sync/atomic" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/zut/packages/agent/subagents" | |
| 14 | + | "github.com/bnema/zut/packages/provider" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // A follow-up steered while the child waits on the provider reaches the next | |
| 18 | + | // model request of the same turn and is persisted as a user message, so the | |
| 19 | + | // child reads it without waiting for its turn to end (issue 226). | |
| 20 | + | func TestResidentAgentRuntimeDeliversSteerMidTurn(t *testing.T) { | |
| 21 | + | firstRequest := make(chan struct{}) | |
| 22 | + | releaseFirst := make(chan struct{}) | |
| 23 | + | var requests atomic.Int32 | |
| 24 | + | var mu sync.Mutex | |
| 25 | + | var bodies []string | |
| 26 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 27 | + | number := requests.Add(1) | |
| 28 | + | var body map[string]any | |
| 29 | + | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { | |
| 30 | + | t.Errorf("decode request: %v", err) | |
| 31 | + | } | |
| 32 | + | encoded, _ := json.Marshal(body["messages"]) | |
| 33 | + | mu.Lock() | |
| 34 | + | bodies = append(bodies, string(encoded)) | |
| 35 | + | mu.Unlock() | |
| 36 | + | w.Header().Set("content-type", "text/event-stream") | |
| 37 | + | if number == 1 { | |
| 38 | + | close(firstRequest) | |
| 39 | + | <-releaseFirst | |
| 40 | + | writeOpenAIChunk(t, w, map[string]any{ | |
| 41 | + | "choices": []any{map[string]any{"index": 0, "delta": map[string]any{"tool_calls": []any{map[string]any{ | |
| 42 | + | "index": 0, "id": "call-1", "type": "function", | |
| 43 | + | "function": map[string]any{"name": "read", "arguments": `{"path":"missing.txt"}`}, | |
| 44 | + | }}}, "finish_reason": "tool_calls"}}, | |
| 45 | + | }) | |
| 46 | + | return | |
| 47 | + | } | |
| 48 | + | writeOpenAIChunk(t, w, map[string]any{ | |
| 49 | + | "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": "wrapped up"}, "finish_reason": "stop"}}, | |
| 50 | + | }) | |
| 51 | + | })) | |
| 52 | + | defer server.Close() | |
| 53 | + | ||
| 54 | + | runtime, journal, _ := newResidentChildTestRunner(t, "steer-child", Args{ | |
| 55 | + | Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL, | |
| 56 | + | CWD: t.TempDir(), NoContextFiles: true, NoSkill: true, | |
| 57 | + | }) | |
| 58 | + | if runtime.Steer("too early") { | |
| 59 | + | t.Fatal("Steer accepted a follow-up with no running turn") | |
| 60 | + | } | |
| 61 | + | done := make(chan error, 1) | |
| 62 | + | go func() { done <- runtime.Run(t.Context(), "investigate") }() | |
| 63 | + | select { | |
| 64 | + | case <-firstRequest: | |
| 65 | + | case <-time.After(5 * time.Second): | |
| 66 | + | t.Fatal("first provider request did not arrive") | |
| 67 | + | } | |
| 68 | + | if !runtime.Steer("stop investigating and report now") { | |
| 69 | + | t.Fatal("Steer rejected a follow-up during a running turn") | |
| 70 | + | } | |
| 71 | + | if got := runtime.PendingSteers(); got != 1 { | |
| 72 | + | t.Fatalf("pending steers = %d, want 1", got) | |
| 73 | + | } | |
| 74 | + | close(releaseFirst) | |
| 75 | + | if err := <-done; err != nil { | |
| 76 | + | t.Fatalf("Run: %v", err) | |
| 77 | + | } | |
| 78 | + | if got := runtime.PendingSteers(); got != 0 { | |
| 79 | + | t.Fatalf("pending steers after turn = %d, want 0", got) | |
| 80 | + | } | |
| 81 | + | mu.Lock() | |
| 82 | + | defer mu.Unlock() | |
| 83 | + | if len(bodies) != 2 || !strings.Contains(bodies[1], "stop investigating and report now") { | |
| 84 | + | t.Fatalf("provider requests = %#v, want the steer in the second request", bodies) | |
| 85 | + | } | |
| 86 | + | messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir()) | |
| 87 | + | if err != nil { | |
| 88 | + | t.Fatal(err) | |
| 89 | + | } | |
| 90 | + | var users []string | |
| 91 | + | for _, message := range messages { | |
| 92 | + | if message.Role != provider.RoleUser { | |
| 93 | + | continue | |
| 94 | + | } | |
| 95 | + | for _, content := range message.Content { | |
| 96 | + | if text, ok := content.(provider.TextBlock); ok { | |
| 97 | + | users = append(users, text.Text) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | if len(users) != 2 || users[1] != "stop investigating and report now" { | |
| 102 | + | t.Fatalf("journaled user messages = %#v", users) | |
| 103 | + | } | |
| 104 | + | } | |
| @@ -0,0 +1,104 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "net/http" | |||
| 6 | + | "net/http/httptest" | |||
| 7 | + | "strings" | |||
| 8 | + | "sync" | |||
| 9 | + | "sync/atomic" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/zut/packages/agent/subagents" | |||
| 14 | + | "github.com/bnema/zut/packages/provider" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // A follow-up steered while the child waits on the provider reaches the next | |||
| 18 | + | // model request of the same turn and is persisted as a user message, so the | |||
| 19 | + | // child reads it without waiting for its turn to end (issue 226). | |||
| 20 | + | func TestResidentAgentRuntimeDeliversSteerMidTurn(t *testing.T) { | |||
| 21 | + | firstRequest := make(chan struct{}) | |||
| 22 | + | releaseFirst := make(chan struct{}) | |||
| 23 | + | var requests atomic.Int32 | |||
| 24 | + | var mu sync.Mutex | |||
| 25 | + | var bodies []string | |||
| 26 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 27 | + | number := requests.Add(1) | |||
| 28 | + | var body map[string]any | |||
| 29 | + | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { | |||
| 30 | + | t.Errorf("decode request: %v", err) | |||
| 31 | + | } | |||
| 32 | + | encoded, _ := json.Marshal(body["messages"]) | |||
| 33 | + | mu.Lock() | |||
| 34 | + | bodies = append(bodies, string(encoded)) | |||
| 35 | + | mu.Unlock() | |||
| 36 | + | w.Header().Set("content-type", "text/event-stream") | |||
| 37 | + | if number == 1 { | |||
| 38 | + | close(firstRequest) | |||
| 39 | + | <-releaseFirst | |||
| 40 | + | writeOpenAIChunk(t, w, map[string]any{ | |||
| 41 | + | "choices": []any{map[string]any{"index": 0, "delta": map[string]any{"tool_calls": []any{map[string]any{ | |||
| 42 | + | "index": 0, "id": "call-1", "type": "function", | |||
| 43 | + | "function": map[string]any{"name": "read", "arguments": `{"path":"missing.txt"}`}, | |||
| 44 | + | }}}, "finish_reason": "tool_calls"}}, | |||
| 45 | + | }) | |||
| 46 | + | return | |||
| 47 | + | } | |||
| 48 | + | writeOpenAIChunk(t, w, map[string]any{ | |||
| 49 | + | "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": "wrapped up"}, "finish_reason": "stop"}}, | |||
| 50 | + | }) | |||
| 51 | + | })) | |||
| 52 | + | defer server.Close() | |||
| 53 | + | ||||
| 54 | + | runtime, journal, _ := newResidentChildTestRunner(t, "steer-child", Args{ | |||
| 55 | + | Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL, | |||
| 56 | + | CWD: t.TempDir(), NoContextFiles: true, NoSkill: true, | |||
| 57 | + | }) | |||
| 58 | + | if runtime.Steer("too early") { | |||
| 59 | + | t.Fatal("Steer accepted a follow-up with no running turn") | |||
| 60 | + | } | |||
| 61 | + | done := make(chan error, 1) | |||
| 62 | + | go func() { done <- runtime.Run(t.Context(), "investigate") }() | |||
| 63 | + | select { | |||
| 64 | + | case <-firstRequest: | |||
| 65 | + | case <-time.After(5 * time.Second): | |||
| 66 | + | t.Fatal("first provider request did not arrive") | |||
| 67 | + | } | |||
| 68 | + | if !runtime.Steer("stop investigating and report now") { | |||
| 69 | + | t.Fatal("Steer rejected a follow-up during a running turn") | |||
| 70 | + | } | |||
| 71 | + | if got := runtime.PendingSteers(); got != 1 { | |||
| 72 | + | t.Fatalf("pending steers = %d, want 1", got) | |||
| 73 | + | } | |||
| 74 | + | close(releaseFirst) | |||
| 75 | + | if err := <-done; err != nil { | |||
| 76 | + | t.Fatalf("Run: %v", err) | |||
| 77 | + | } | |||
| 78 | + | if got := runtime.PendingSteers(); got != 0 { | |||
| 79 | + | t.Fatalf("pending steers after turn = %d, want 0", got) | |||
| 80 | + | } | |||
| 81 | + | mu.Lock() | |||
| 82 | + | defer mu.Unlock() | |||
| 83 | + | if len(bodies) != 2 || !strings.Contains(bodies[1], "stop investigating and report now") { | |||
| 84 | + | t.Fatalf("provider requests = %#v, want the steer in the second request", bodies) | |||
| 85 | + | } | |||
| 86 | + | messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir()) | |||
| 87 | + | if err != nil { | |||
| 88 | + | t.Fatal(err) | |||
| 89 | + | } | |||
| 90 | + | var users []string | |||
| 91 | + | for _, message := range messages { | |||
| 92 | + | if message.Role != provider.RoleUser { | |||
| 93 | + | continue | |||
| 94 | + | } | |||
| 95 | + | for _, content := range message.Content { | |||
| 96 | + | if text, ok := content.(provider.TextBlock); ok { | |||
| 97 | + | users = append(users, text.Text) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | if len(users) != 2 || users[1] != "stop investigating and report now" { | |||
| 102 | + | t.Fatalf("journaled user messages = %#v", users) | |||
| 103 | + | } | |||
| 104 | + | } | |||
M packages/agent/subagent_tool_policy_test.go +15 -12
| @@ -11,18 +11,20 @@ import ( | |||
| 11 | 11 | // and every pre-existing disable path still disables all of them. | |
| 12 | 12 | func TestSubagentActionAllowedMatrix(t *testing.T) { | |
| 13 | 13 | cases := []struct { | |
| 14 | - | name string | |
| 15 | - | args Args | |
| 16 | - | spawn bool | |
| 17 | - | status bool | |
| 18 | - | stop bool | |
| 19 | - | resume bool | |
| 14 | + | name string | |
| 15 | + | args Args | |
| 16 | + | spawn bool | |
| 17 | + | status bool | |
| 18 | + | stop bool | |
| 19 | + | resume bool | |
| 20 | + | interrupt bool | |
| 20 | 21 | }{ | |
| 21 | - | {name: "default grants every action", args: Args{}, spawn: true, status: true, stop: true, resume: true}, | |
| 22 | + | {name: "default grants every action", args: Args{}, spawn: true, status: true, stop: true, resume: true, interrupt: true}, | |
| 22 | 23 | {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}, | |
| 24 | + | {name: "bare subagent grants every action", args: Args{ToolsSet: true, Tools: []string{"subagent"}}, spawn: true, status: true, stop: true, resume: true, interrupt: true}, | |
| 25 | + | {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, interrupt: true}, | |
| 25 | 26 | {name: "one action suffix grants one action", args: Args{ToolsSet: true, Tools: []string{"subagent:resume"}}, resume: true}, | |
| 27 | + | {name: "interrupt suffix grants interrupt only", args: Args{ToolsSet: true, Tools: []string{"subagent:interrupt"}}, interrupt: true}, | |
| 26 | 28 | {name: "two action suffixes grant two actions", args: Args{ToolsSet: true, Tools: []string{"subagent:spawn", "subagent:stop"}}, spawn: true, stop: true}, | |
| 27 | 29 | {name: "unknown action suffix denies all", args: Args{ToolsSet: true, Tools: []string{"subagent:bogus"}}}, | |
| 28 | 30 | {name: "empty allowlist denies all", args: Args{ToolsSet: true, Tools: []string{}}}, | |
| @@ -31,17 +33,18 @@ func TestSubagentActionAllowedMatrix(t *testing.T) { | |||
| 31 | 33 | } | |
| 32 | 34 | for _, tc := range cases { | |
| 33 | 35 | t.Run(tc.name, func(t *testing.T) { | |
| 34 | - | got := [4]bool{ | |
| 36 | + | got := [5]bool{ | |
| 35 | 37 | autoSubagentsToolAllowed(tc.args), | |
| 36 | 38 | autoSubagentsStatusToolAllowed(tc.args), | |
| 37 | 39 | autoSubagentsStopToolAllowed(tc.args), | |
| 38 | 40 | autoSubagentsResumeToolAllowed(tc.args), | |
| 41 | + | autoSubagentsInterruptToolAllowed(tc.args), | |
| 39 | 42 | } | |
| 40 | - | want := [4]bool{tc.spawn, tc.status, tc.stop, tc.resume} | |
| 43 | + | want := [5]bool{tc.spawn, tc.status, tc.stop, tc.resume, tc.interrupt} | |
| 41 | 44 | if got != want { | |
| 42 | 45 | t.Fatalf("actions = %v, want %v (args %+v)", got, want, tc.args) | |
| 43 | 46 | } | |
| 44 | - | wantAny := tc.spawn || tc.status || tc.stop || tc.resume | |
| 47 | + | wantAny := tc.spawn || tc.status || tc.stop || tc.resume || tc.interrupt | |
| 45 | 48 | if any := autoSubagentsAnyToolAllowed(tc.args); any != wantAny { | |
| 46 | 49 | t.Fatalf("autoSubagentsAnyToolAllowed = %v, want %v (args %+v)", any, wantAny, tc.args) | |
| 47 | 50 | } | |
| @@ -11,18 +11,20 @@ import ( | |||||
| 11 | // and every pre-existing disable path still disables all of them. | 11 | // and every pre-existing disable path still disables all of them. | ||
| 12 | func TestSubagentActionAllowedMatrix(t *testing.T) { | 12 | func TestSubagentActionAllowedMatrix(t *testing.T) { | ||
| 13 | cases := []struct { | 13 | cases := []struct { | ||
| 14 | - | name string | 14 | + | name string |
| 15 | - | args Args | 15 | + | args Args |
| 16 | - | spawn bool | 16 | + | spawn bool |
| 17 | - | status bool | 17 | + | status bool |
| 18 | - | stop bool | 18 | + | stop bool |
| 19 | - | resume bool | 19 | + | resume bool |
| 20 | + | interrupt bool | |||
| 20 | }{ | 21 | }{ | ||
| 21 | - | {name: "default grants every action", args: Args{}, spawn: true, status: true, stop: true, resume: true}, | 22 | + | {name: "default grants every action", args: Args{}, spawn: true, status: true, stop: true, resume: true, interrupt: true}, |
| 22 | {name: "unrelated allowlist denies all", args: Args{ToolsSet: true, Tools: []string{"read"}}}, | 23 | {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 grants every action", args: Args{ToolsSet: true, Tools: []string{"subagent"}}, spawn: true, status: true, stop: true, resume: true, interrupt: 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: "bare subagent wins beside a suffix", args: Args{ToolsSet: true, Tools: []string{"read", "subagent:resume", "subagent"}}, spawn: true, status: true, stop: true, resume: true, interrupt: true}, |
| 25 | {name: "one action suffix grants one action", args: Args{ToolsSet: true, Tools: []string{"subagent:resume"}}, resume: true}, | 26 | {name: "one action suffix grants one action", args: Args{ToolsSet: true, Tools: []string{"subagent:resume"}}, resume: true}, | ||
| 27 | + | {name: "interrupt suffix grants interrupt only", args: Args{ToolsSet: true, Tools: []string{"subagent:interrupt"}}, interrupt: true}, | |||
| 26 | {name: "two action suffixes grant two actions", args: Args{ToolsSet: true, Tools: []string{"subagent:spawn", "subagent:stop"}}, spawn: true, stop: true}, | 28 | {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"}}}, | 29 | {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{}}}, | 30 | {name: "empty allowlist denies all", args: Args{ToolsSet: true, Tools: []string{}}}, | ||
| @@ -31,17 +33,18 @@ func TestSubagentActionAllowedMatrix(t *testing.T) { | |||||
| 31 | } | 33 | } | ||
| 32 | for _, tc := range cases { | 34 | for _, tc := range cases { | ||
| 33 | t.Run(tc.name, func(t *testing.T) { | 35 | t.Run(tc.name, func(t *testing.T) { | ||
| 34 | - | got := [4]bool{ | 36 | + | got := [5]bool{ |
| 35 | autoSubagentsToolAllowed(tc.args), | 37 | autoSubagentsToolAllowed(tc.args), | ||
| 36 | autoSubagentsStatusToolAllowed(tc.args), | 38 | autoSubagentsStatusToolAllowed(tc.args), | ||
| 37 | autoSubagentsStopToolAllowed(tc.args), | 39 | autoSubagentsStopToolAllowed(tc.args), | ||
| 38 | autoSubagentsResumeToolAllowed(tc.args), | 40 | autoSubagentsResumeToolAllowed(tc.args), | ||
| 41 | + | autoSubagentsInterruptToolAllowed(tc.args), | |||
| 39 | } | 42 | } | ||
| 40 | - | want := [4]bool{tc.spawn, tc.status, tc.stop, tc.resume} | 43 | + | want := [5]bool{tc.spawn, tc.status, tc.stop, tc.resume, tc.interrupt} |
| 41 | if got != want { | 44 | if got != want { | ||
| 42 | t.Fatalf("actions = %v, want %v (args %+v)", got, want, tc.args) | 45 | t.Fatalf("actions = %v, want %v (args %+v)", got, want, tc.args) | ||
| 43 | } | 46 | } | ||
| 44 | - | wantAny := tc.spawn || tc.status || tc.stop || tc.resume | 47 | + | wantAny := tc.spawn || tc.status || tc.stop || tc.resume || tc.interrupt |
| 45 | if any := autoSubagentsAnyToolAllowed(tc.args); any != wantAny { | 48 | if any := autoSubagentsAnyToolAllowed(tc.args); any != wantAny { | ||
| 46 | t.Fatalf("autoSubagentsAnyToolAllowed = %v, want %v (args %+v)", any, wantAny, tc.args) | 49 | t.Fatalf("autoSubagentsAnyToolAllowed = %v, want %v (args %+v)", any, wantAny, tc.args) | ||
| 47 | } | 50 | } | ||
M packages/agent/subagents/completion.go +46 -1
| @@ -16,12 +16,31 @@ type Completion struct { | |||
| 16 | 16 | Task string | |
| 17 | 17 | Error string | |
| 18 | 18 | Summary string | |
| 19 | + | // Undelivered lists follow-ups steered into the turn that the child never | |
| 20 | + | // read because the turn ended first. | |
| 21 | + | Undelivered []string | |
| 19 | 22 | } | |
| 20 | 23 | ||
| 24 | + | // CompletionDropped is the status of a queued follow-up that was discarded | |
| 25 | + | // before the child ever started it. | |
| 26 | + | const CompletionDropped = "dropped" | |
| 27 | + | ||
| 28 | + | // maxUndeliveredPreview bounds each undelivered follow-up in a parent update. | |
| 29 | + | const maxUndeliveredPreview = 120 | |
| 30 | + | ||
| 21 | 31 | // Completion projects a typed resident outcome into the parent notification | |
| 22 | 32 | // shared by interactive and orchestrated modes. | |
| 23 | 33 | func (c ResidentCompletion) Completion() Completion { | |
| 24 | - | result := Completion{AgentID: c.ChildID, TurnID: c.TurnID, Status: string(ResidentCompleted), Task: c.Task, Summary: c.Summary} | |
| 34 | + | result := Completion{AgentID: c.ChildID, TurnID: c.TurnID, Status: string(ResidentCompleted), Task: c.Task, Summary: c.Summary, Undelivered: append([]string(nil), c.Undelivered...)} | |
| 35 | + | if c.NotStarted { | |
| 36 | + | result.Status = CompletionDropped | |
| 37 | + | // Cancellation is the expected reason; any other error, such as a | |
| 38 | + | // journal failure, explains why the follow-up could not run. | |
| 39 | + | if c.Err != nil && !errors.Is(c.Err, context.Canceled) { | |
| 40 | + | result.Error = c.Err.Error() | |
| 41 | + | } | |
| 42 | + | return result | |
| 43 | + | } | |
| 25 | 44 | if c.Err != nil { | |
| 26 | 45 | result.Status, result.Error = string(ResidentFailed), c.Err.Error() | |
| 27 | 46 | if errors.Is(c.Err, context.Canceled) { | |
| @@ -161,12 +180,28 @@ func FormatCompletionUpdateWithActive(batch []Completion, activeAgentIDs []strin | |||
| 161 | 180 | b.WriteString("[auto-subagents update]\n") | |
| 162 | 181 | for _, completion := range batch { | |
| 163 | 182 | fmt.Fprintf(&b, "- %s: %s", completion.AgentID, completion.Status) | |
| 183 | + | if completion.Status == CompletionDropped { | |
| 184 | + | // The child never read this follow-up. Echoing it as a task would | |
| 185 | + | // read as work the child attempted. | |
| 186 | + | fmt.Fprintf(&b, " (queued follow-up never started: %s)", undeliveredPreview(completion.Task)) | |
| 187 | + | if completion.Error != "" { | |
| 188 | + | fmt.Fprintf(&b, " (%s)", completion.Error) | |
| 189 | + | } | |
| 190 | + | b.WriteByte('\n') | |
| 191 | + | continue | |
| 192 | + | } | |
| 164 | 193 | if completion.Error != "" { | |
| 165 | 194 | fmt.Fprintf(&b, " (%s)", completion.Error) | |
| 166 | 195 | } | |
| 167 | 196 | if completion.Task != "" { | |
| 168 | 197 | fmt.Fprintf(&b, " — %s", completion.Task) | |
| 169 | 198 | } | |
| 199 | + | if len(completion.Undelivered) != 0 { | |
| 200 | + | fmt.Fprintf(&b, "\n undelivered: %d follow-up(s) the child never read:", len(completion.Undelivered)) | |
| 201 | + | for _, text := range completion.Undelivered { | |
| 202 | + | fmt.Fprintf(&b, "\n - %s", undeliveredPreview(text)) | |
| 203 | + | } | |
| 204 | + | } | |
| 170 | 205 | if completion.Summary != "" { | |
| 171 | 206 | label := "final" | |
| 172 | 207 | if completion.Status != string(ResidentCompleted) { | |
| @@ -188,3 +223,13 @@ func FormatCompletionUpdateWithActive(batch []Completion, activeAgentIDs []strin | |||
| 188 | 223 | } | |
| 189 | 224 | return strings.TrimSpace(b.String()) | |
| 190 | 225 | } | |
| 226 | + | ||
| 227 | + | // undeliveredPreview renders one follow-up on a single bounded line. | |
| 228 | + | func undeliveredPreview(text string) string { | |
| 229 | + | text = strings.Join(strings.Fields(text), " ") | |
| 230 | + | runes := []rune(text) | |
| 231 | + | if len(runes) <= maxUndeliveredPreview { | |
| 232 | + | return text | |
| 233 | + | } | |
| 234 | + | return strings.TrimSpace(string(runes[:maxUndeliveredPreview-1])) + "…" | |
| 235 | + | } | |
| @@ -16,12 +16,31 @@ type Completion struct { | |||||
| 16 | Task string | 16 | Task string | ||
| 17 | Error string | 17 | Error string | ||
| 18 | Summary string | 18 | Summary string | ||
| 19 | + | // Undelivered lists follow-ups steered into the turn that the child never | |||
| 20 | + | // read because the turn ended first. | |||
| 21 | + | Undelivered []string | |||
| 19 | } | 22 | } | ||
| 20 | 23 | ||||
| 24 | + | // CompletionDropped is the status of a queued follow-up that was discarded | |||
| 25 | + | // before the child ever started it. | |||
| 26 | + | const CompletionDropped = "dropped" | |||
| 27 | + | ||||
| 28 | + | // maxUndeliveredPreview bounds each undelivered follow-up in a parent update. | |||
| 29 | + | const maxUndeliveredPreview = 120 | |||
| 30 | + | ||||
| 21 | // Completion projects a typed resident outcome into the parent notification | 31 | // Completion projects a typed resident outcome into the parent notification | ||
| 22 | // shared by interactive and orchestrated modes. | 32 | // shared by interactive and orchestrated modes. | ||
| 23 | func (c ResidentCompletion) Completion() Completion { | 33 | func (c ResidentCompletion) Completion() Completion { | ||
| 24 | - | result := Completion{AgentID: c.ChildID, TurnID: c.TurnID, Status: string(ResidentCompleted), Task: c.Task, Summary: c.Summary} | 34 | + | result := Completion{AgentID: c.ChildID, TurnID: c.TurnID, Status: string(ResidentCompleted), Task: c.Task, Summary: c.Summary, Undelivered: append([]string(nil), c.Undelivered...)} |
| 35 | + | if c.NotStarted { | |||
| 36 | + | result.Status = CompletionDropped | |||
| 37 | + | // Cancellation is the expected reason; any other error, such as a | |||
| 38 | + | // journal failure, explains why the follow-up could not run. | |||
| 39 | + | if c.Err != nil && !errors.Is(c.Err, context.Canceled) { | |||
| 40 | + | result.Error = c.Err.Error() | |||
| 41 | + | } | |||
| 42 | + | return result | |||
| 43 | + | } | |||
| 25 | if c.Err != nil { | 44 | if c.Err != nil { | ||
| 26 | result.Status, result.Error = string(ResidentFailed), c.Err.Error() | 45 | result.Status, result.Error = string(ResidentFailed), c.Err.Error() | ||
| 27 | if errors.Is(c.Err, context.Canceled) { | 46 | if errors.Is(c.Err, context.Canceled) { | ||
| @@ -161,12 +180,28 @@ func FormatCompletionUpdateWithActive(batch []Completion, activeAgentIDs []strin | |||||
| 161 | b.WriteString("[auto-subagents update]\n") | 180 | b.WriteString("[auto-subagents update]\n") | ||
| 162 | for _, completion := range batch { | 181 | for _, completion := range batch { | ||
| 163 | fmt.Fprintf(&b, "- %s: %s", completion.AgentID, completion.Status) | 182 | fmt.Fprintf(&b, "- %s: %s", completion.AgentID, completion.Status) | ||
| 183 | + | if completion.Status == CompletionDropped { | |||
| 184 | + | // The child never read this follow-up. Echoing it as a task would | |||
| 185 | + | // read as work the child attempted. | |||
| 186 | + | fmt.Fprintf(&b, " (queued follow-up never started: %s)", undeliveredPreview(completion.Task)) | |||
| 187 | + | if completion.Error != "" { | |||
| 188 | + | fmt.Fprintf(&b, " (%s)", completion.Error) | |||
| 189 | + | } | |||
| 190 | + | b.WriteByte('\n') | |||
| 191 | + | continue | |||
| 192 | + | } | |||
| 164 | if completion.Error != "" { | 193 | if completion.Error != "" { | ||
| 165 | fmt.Fprintf(&b, " (%s)", completion.Error) | 194 | fmt.Fprintf(&b, " (%s)", completion.Error) | ||
| 166 | } | 195 | } | ||
| 167 | if completion.Task != "" { | 196 | if completion.Task != "" { | ||
| 168 | fmt.Fprintf(&b, " — %s", completion.Task) | 197 | fmt.Fprintf(&b, " — %s", completion.Task) | ||
| 169 | } | 198 | } | ||
| 199 | + | if len(completion.Undelivered) != 0 { | |||
| 200 | + | fmt.Fprintf(&b, "\n undelivered: %d follow-up(s) the child never read:", len(completion.Undelivered)) | |||
| 201 | + | for _, text := range completion.Undelivered { | |||
| 202 | + | fmt.Fprintf(&b, "\n - %s", undeliveredPreview(text)) | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 170 | if completion.Summary != "" { | 205 | if completion.Summary != "" { | ||
| 171 | label := "final" | 206 | label := "final" | ||
| 172 | if completion.Status != string(ResidentCompleted) { | 207 | if completion.Status != string(ResidentCompleted) { | ||
| @@ -188,3 +223,13 @@ func FormatCompletionUpdateWithActive(batch []Completion, activeAgentIDs []strin | |||||
| 188 | } | 223 | } | ||
| 189 | return strings.TrimSpace(b.String()) | 224 | return strings.TrimSpace(b.String()) | ||
| 190 | } | 225 | } | ||
| 226 | + | ||||
| 227 | + | // undeliveredPreview renders one follow-up on a single bounded line. | |||
| 228 | + | func undeliveredPreview(text string) string { | |||
| 229 | + | text = strings.Join(strings.Fields(text), " ") | |||
| 230 | + | runes := []rune(text) | |||
| 231 | + | if len(runes) <= maxUndeliveredPreview { | |||
| 232 | + | return text | |||
| 233 | + | } | |||
| 234 | + | return strings.TrimSpace(string(runes[:maxUndeliveredPreview-1])) + "…" | |||
| 235 | + | } | |||
M packages/agent/subagents/completion_test.go +17 -1
| @@ -3,6 +3,7 @@ package subagents | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | + | "reflect" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "testing" | |
| 8 | 9 | ) | |
| @@ -38,6 +39,21 @@ func TestResidentCompletionProjection(t *testing.T) { | |||
| 38 | 39 | } | |
| 39 | 40 | } | |
| 40 | 41 | ||
| 42 | + | // A dropped follow-up hides expected cancellation but keeps any other cause. | |
| 43 | + | func TestDroppedCompletionKeepsNonCancellationError(t *testing.T) { | |
| 44 | + | canceled := (ResidentCompletion{ChildID: "c", TurnID: "t", Task: "next", Err: context.Canceled, NotStarted: true}).Completion() | |
| 45 | + | if canceled.Status != CompletionDropped || canceled.Error != "" { | |
| 46 | + | t.Fatalf("canceled drop = %#v", canceled) | |
| 47 | + | } | |
| 48 | + | failed := (ResidentCompletion{ChildID: "c", TurnID: "t", Task: "next", Err: errors.New("journal full"), NotStarted: true}).Completion() | |
| 49 | + | if failed.Status != CompletionDropped || failed.Error != "journal full" { | |
| 50 | + | t.Fatalf("failed drop = %#v", failed) | |
| 51 | + | } | |
| 52 | + | if text := FormatCompletionUpdate([]Completion{failed}, ""); !strings.Contains(text, "dropped (queued follow-up never started: next) (journal full)") { | |
| 53 | + | t.Fatalf("update = %q", text) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 41 | 57 | func TestFormatCompletionUpdateIncludesFinalSummary(t *testing.T) { | |
| 42 | 58 | got := FormatCompletionUpdate([]Completion{{AgentID: "child", Status: "completed", Task: "review", Summary: "found the regression"}}, "") | |
| 43 | 59 | if !strings.Contains(got, "final: found the regression") { | |
| @@ -85,7 +101,7 @@ func TestCompletionTrackerWaitReadyDoesNotWaitForSiblings(t *testing.T) { | |||
| 85 | 101 | ctx, cancel := context.WithCancel(context.Background()) | |
| 86 | 102 | cancel() | |
| 87 | 103 | got, err := tracker.WaitReady(ctx) | |
| 88 | - | if err != nil || len(got) != 1 || got[0] != want { | |
| 104 | + | if err != nil || len(got) != 1 || !reflect.DeepEqual(got[0], want) { | |
| 89 | 105 | t.Fatalf("WaitReady = %#v, %v, want ready result despite pending sibling", got, err) | |
| 90 | 106 | } | |
| 91 | 107 | if _, err := tracker.WaitReady(ctx); !errors.Is(err, context.Canceled) { | |
| @@ -3,6 +3,7 @@ package subagents | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | + | "reflect" | |||
| 6 | "strings" | 7 | "strings" | ||
| 7 | "testing" | 8 | "testing" | ||
| 8 | ) | 9 | ) | ||
| @@ -38,6 +39,21 @@ func TestResidentCompletionProjection(t *testing.T) { | |||||
| 38 | } | 39 | } | ||
| 39 | } | 40 | } | ||
| 40 | 41 | ||||
| 42 | + | // A dropped follow-up hides expected cancellation but keeps any other cause. | |||
| 43 | + | func TestDroppedCompletionKeepsNonCancellationError(t *testing.T) { | |||
| 44 | + | canceled := (ResidentCompletion{ChildID: "c", TurnID: "t", Task: "next", Err: context.Canceled, NotStarted: true}).Completion() | |||
| 45 | + | if canceled.Status != CompletionDropped || canceled.Error != "" { | |||
| 46 | + | t.Fatalf("canceled drop = %#v", canceled) | |||
| 47 | + | } | |||
| 48 | + | failed := (ResidentCompletion{ChildID: "c", TurnID: "t", Task: "next", Err: errors.New("journal full"), NotStarted: true}).Completion() | |||
| 49 | + | if failed.Status != CompletionDropped || failed.Error != "journal full" { | |||
| 50 | + | t.Fatalf("failed drop = %#v", failed) | |||
| 51 | + | } | |||
| 52 | + | if text := FormatCompletionUpdate([]Completion{failed}, ""); !strings.Contains(text, "dropped (queued follow-up never started: next) (journal full)") { | |||
| 53 | + | t.Fatalf("update = %q", text) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 41 | func TestFormatCompletionUpdateIncludesFinalSummary(t *testing.T) { | 57 | func TestFormatCompletionUpdateIncludesFinalSummary(t *testing.T) { | ||
| 42 | got := FormatCompletionUpdate([]Completion{{AgentID: "child", Status: "completed", Task: "review", Summary: "found the regression"}}, "") | 58 | got := FormatCompletionUpdate([]Completion{{AgentID: "child", Status: "completed", Task: "review", Summary: "found the regression"}}, "") | ||
| 43 | if !strings.Contains(got, "final: found the regression") { | 59 | if !strings.Contains(got, "final: found the regression") { | ||
| @@ -85,7 +101,7 @@ func TestCompletionTrackerWaitReadyDoesNotWaitForSiblings(t *testing.T) { | |||||
| 85 | ctx, cancel := context.WithCancel(context.Background()) | 101 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 86 | cancel() | 102 | cancel() | ||
| 87 | got, err := tracker.WaitReady(ctx) | 103 | got, err := tracker.WaitReady(ctx) | ||
| 88 | - | if err != nil || len(got) != 1 || got[0] != want { | 104 | + | if err != nil || len(got) != 1 || !reflect.DeepEqual(got[0], want) { |
| 89 | t.Fatalf("WaitReady = %#v, %v, want ready result despite pending sibling", got, err) | 105 | t.Fatalf("WaitReady = %#v, %v, want ready result despite pending sibling", got, err) | ||
| 90 | } | 106 | } | ||
| 91 | if _, err := tracker.WaitReady(ctx); !errors.Is(err, context.Canceled) { | 107 | if _, err := tracker.WaitReady(ctx); !errors.Is(err, context.Canceled) { | ||
M packages/agent/subagents/resident_cancellation_test.go +2 -2
| @@ -11,8 +11,8 @@ func TestResidentCancellationPreservesInterruptedState(t *testing.T) { | |||
| 11 | 11 | for _, turnErr := range []error{context.Canceled, errors.Join(context.Canceled, errors.New("worker failed"))} { | |
| 12 | 12 | t.Run(turnErr.Error(), func(t *testing.T) { | |
| 13 | 13 | root := t.TempDir() | |
| 14 | - | manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | |
| 15 | - | return func(context.Context, string) error { return turnErr }, nil | |
| 14 | + | manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { | |
| 15 | + | return ResidentTurnRunner(func(context.Context, string) error { return turnErr }), nil | |
| 16 | 16 | }) | |
| 17 | 17 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 18 | 18 | spec := ResidentChildSpec{ID: "canceled", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test", Required: true} | |
| @@ -11,8 +11,8 @@ func TestResidentCancellationPreservesInterruptedState(t *testing.T) { | |||||
| 11 | for _, turnErr := range []error{context.Canceled, errors.Join(context.Canceled, errors.New("worker failed"))} { | 11 | for _, turnErr := range []error{context.Canceled, errors.Join(context.Canceled, errors.New("worker failed"))} { | ||
| 12 | t.Run(turnErr.Error(), func(t *testing.T) { | 12 | t.Run(turnErr.Error(), func(t *testing.T) { | ||
| 13 | root := t.TempDir() | 13 | root := t.TempDir() | ||
| 14 | - | manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | 14 | + | manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { |
| 15 | - | return func(context.Context, string) error { return turnErr }, nil | 15 | + | return ResidentTurnRunner(func(context.Context, string) error { return turnErr }), nil |
| 16 | }) | 16 | }) | ||
| 17 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | 17 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | ||
| 18 | spec := ResidentChildSpec{ID: "canceled", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test", Required: true} | 18 | spec := ResidentChildSpec{ID: "canceled", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test", Required: true} | ||
M packages/agent/subagents/resident_child.go +191 -32
| @@ -12,16 +12,59 @@ import ( | |||
| 12 | 12 | "github.com/google/uuid" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | - | // ResidentTurnRunner executes one already-accepted prompt. The child owns the | |
| 16 | - | // ordering and cancellation boundary; implementations own agent/provider work. | |
| 15 | + | // ResidentRuntime owns one child's agent. The child owns turn ordering and | |
| 16 | + | // cancellation; the runtime owns agent/provider work. | |
| 17 | + | // | |
| 18 | + | // Run executes one accepted turn. Steer injects a follow-up into the turn Run | |
| 19 | + | // is executing and reports false when no turn can still receive it; a true | |
| 20 | + | // result guarantees Run delivers the follow-up before it returns successfully. | |
| 21 | + | // PendingSteers counts follow-ups not yet delivered, and DrainSteers removes | |
| 22 | + | // and returns them after a turn ended without delivering them. | |
| 23 | + | type ResidentRuntime interface { | |
| 24 | + | Run(ctx context.Context, prompt string) error | |
| 25 | + | Steer(prompt string) bool | |
| 26 | + | PendingSteers() int | |
| 27 | + | DrainSteers() []string | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // ResidentTurnRunner is a runtime that runs turns but cannot be steered, so | |
| 31 | + | // every follow-up becomes a separate queued turn. | |
| 17 | 32 | type ResidentTurnRunner func(context.Context, string) error | |
| 18 | 33 | ||
| 34 | + | func (r ResidentTurnRunner) Run(ctx context.Context, prompt string) error { return r(ctx, prompt) } | |
| 35 | + | func (ResidentTurnRunner) Steer(string) bool { return false } | |
| 36 | + | func (ResidentTurnRunner) PendingSteers() int { return 0 } | |
| 37 | + | func (ResidentTurnRunner) DrainSteers() []string { return nil } | |
| 38 | + | ||
| 19 | 39 | type residentPrompt struct { | |
| 20 | 40 | turnID string | |
| 21 | 41 | prompt string | |
| 22 | 42 | ack chan error | |
| 23 | 43 | } | |
| 24 | 44 | ||
| 45 | + | type residentControlKind int | |
| 46 | + | ||
| 47 | + | const ( | |
| 48 | + | residentControlSteer residentControlKind = iota + 1 | |
| 49 | + | residentControlInterrupt | |
| 50 | + | ) | |
| 51 | + | ||
| 52 | + | // residentControl is a request handled by the control goroutine, so it is | |
| 53 | + | // ordered with turn start and completion. | |
| 54 | + | type residentControl struct { | |
| 55 | + | kind residentControlKind | |
| 56 | + | prompt string | |
| 57 | + | // onSteered runs on the control goroutine before the active turn can | |
| 58 | + | // report its completion, so a caller can subscribe without a race. | |
| 59 | + | onSteered func(turnID string) | |
| 60 | + | reply chan residentControlReply | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | type residentControlReply struct { | |
| 64 | + | ok bool | |
| 65 | + | turnID string | |
| 66 | + | } | |
| 67 | + | ||
| 25 | 68 | type residentTurnResult struct { | |
| 26 | 69 | turnID string | |
| 27 | 70 | err error | |
| @@ -34,13 +77,19 @@ type ResidentCompletion struct { | |||
| 34 | 77 | Task string | |
| 35 | 78 | Err error | |
| 36 | 79 | Summary string | |
| 80 | + | // NotStarted marks a queued follow-up that was dropped before the child | |
| 81 | + | // ever ran it, for example because the child was stopped or interrupted. | |
| 82 | + | NotStarted bool | |
| 83 | + | // Undelivered lists follow-ups steered into this turn that the child never | |
| 84 | + | // read because the turn ended first. | |
| 85 | + | Undelivered []string | |
| 37 | 86 | } | |
| 38 | 87 | ||
| 39 | 88 | // ResidentChild serializes prompt execution through one control goroutine. | |
| 40 | 89 | // No mutex is held while a runner performs provider or tool I/O. When a | |
| 41 | 90 | // journal is configured, every state boundary is committed by that goroutine. | |
| 42 | 91 | type ResidentChild struct { | |
| 43 | - | runner ResidentTurnRunner | |
| 92 | + | runtime ResidentRuntime | |
| 44 | 93 | journal *ResidentJournal | |
| 45 | 94 | workspace WorkspaceHandle | |
| 46 | 95 | spec ResidentChildSpec | |
| @@ -48,11 +97,12 @@ type ResidentChild struct { | |||
| 48 | 97 | onUpdate func(historyChanged bool) | |
| 49 | 98 | onStateChange func(ResidentState) | |
| 50 | 99 | ||
| 51 | - | ctx context.Context | |
| 52 | - | cancel context.CancelFunc | |
| 53 | - | inbox chan residentPrompt | |
| 54 | - | done chan struct{} | |
| 55 | - | once sync.Once | |
| 100 | + | ctx context.Context | |
| 101 | + | cancel context.CancelFunc | |
| 102 | + | inbox chan residentPrompt | |
| 103 | + | control chan residentControl | |
| 104 | + | done chan struct{} | |
| 105 | + | once sync.Once | |
| 56 | 106 | ||
| 57 | 107 | mu sync.RWMutex | |
| 58 | 108 | state ResidentState | |
| @@ -61,39 +111,41 @@ type ResidentChild struct { | |||
| 61 | 111 | activityUpdatedAt time.Time | |
| 62 | 112 | live *residentLiveProjection | |
| 63 | 113 | cleanupWorkspace bool | |
| 114 | + | queuedTurns int | |
| 64 | 115 | } | |
| 65 | 116 | ||
| 66 | - | func NewResidentChild(runner ResidentTurnRunner) *ResidentChild { | |
| 67 | - | return newResidentChild(ResidentChildSpec{}, nil, runner) | |
| 117 | + | func NewResidentChild(runtime ResidentRuntime) *ResidentChild { | |
| 118 | + | return newResidentChild(ResidentChildSpec{}, nil, runtime) | |
| 68 | 119 | } | |
| 69 | 120 | ||
| 70 | - | func newJournaledResidentChild(spec ResidentChildSpec, journal *ResidentJournal, runner ResidentTurnRunner, onCompletion func(ResidentCompletion)) *ResidentChild { | |
| 71 | - | child := newResidentChildWithWorkspace(spec, journal, nil, runner) | |
| 121 | + | func newJournaledResidentChild(spec ResidentChildSpec, journal *ResidentJournal, runtime ResidentRuntime, onCompletion func(ResidentCompletion)) *ResidentChild { | |
| 122 | + | child := newResidentChildWithWorkspace(spec, journal, nil, runtime) | |
| 72 | 123 | child.onCompletion = onCompletion | |
| 73 | 124 | return child | |
| 74 | 125 | } | |
| 75 | 126 | ||
| 76 | - | func newJournaledResidentChildWithWorkspace(spec ResidentChildSpec, journal *ResidentJournal, workspace WorkspaceHandle, runner ResidentTurnRunner, onCompletion func(ResidentCompletion)) *ResidentChild { | |
| 77 | - | child := newResidentChildWithWorkspace(spec, journal, workspace, runner) | |
| 127 | + | func newJournaledResidentChildWithWorkspace(spec ResidentChildSpec, journal *ResidentJournal, workspace WorkspaceHandle, runtime ResidentRuntime, onCompletion func(ResidentCompletion)) *ResidentChild { | |
| 128 | + | child := newResidentChildWithWorkspace(spec, journal, workspace, runtime) | |
| 78 | 129 | child.onCompletion = onCompletion | |
| 79 | 130 | return child | |
| 80 | 131 | } | |
| 81 | 132 | ||
| 82 | - | func newResidentChild(spec ResidentChildSpec, journal *ResidentJournal, runner ResidentTurnRunner) *ResidentChild { | |
| 83 | - | return newResidentChildWithWorkspace(spec, journal, nil, runner) | |
| 133 | + | func newResidentChild(spec ResidentChildSpec, journal *ResidentJournal, runtime ResidentRuntime) *ResidentChild { | |
| 134 | + | return newResidentChildWithWorkspace(spec, journal, nil, runtime) | |
| 84 | 135 | } | |
| 85 | 136 | ||
| 86 | - | func newResidentChildWithWorkspace(spec ResidentChildSpec, journal *ResidentJournal, workspace WorkspaceHandle, runner ResidentTurnRunner) *ResidentChild { | |
| 137 | + | func newResidentChildWithWorkspace(spec ResidentChildSpec, journal *ResidentJournal, workspace WorkspaceHandle, runtime ResidentRuntime) *ResidentChild { | |
| 87 | 138 | ctx, cancel := context.WithCancel(context.Background()) | |
| 88 | 139 | now := time.Now().UTC() | |
| 89 | 140 | child := &ResidentChild{ | |
| 90 | - | runner: runner, | |
| 141 | + | runtime: runtime, | |
| 91 | 142 | journal: journal, | |
| 92 | 143 | workspace: workspace, | |
| 93 | 144 | spec: spec, | |
| 94 | 145 | ctx: ctx, | |
| 95 | 146 | cancel: cancel, | |
| 96 | 147 | inbox: make(chan residentPrompt), | |
| 148 | + | control: make(chan residentControl), | |
| 97 | 149 | done: make(chan struct{}), | |
| 98 | 150 | state: ResidentQueued, | |
| 99 | 151 | stateUpdatedAt: now, | |
| @@ -183,6 +235,27 @@ func (c *ResidentChild) ActivityUpdatedAt() time.Time { | |||
| 183 | 235 | return c.activityUpdatedAt | |
| 184 | 236 | } | |
| 185 | 237 | ||
| 238 | + | // PendingFollowUps counts follow-ups the child has accepted but not yet read: | |
| 239 | + | // queued turns plus messages steered into the running turn. | |
| 240 | + | func (c *ResidentChild) PendingFollowUps() int { | |
| 241 | + | if c == nil { | |
| 242 | + | return 0 | |
| 243 | + | } | |
| 244 | + | c.mu.RLock() | |
| 245 | + | queued := c.queuedTurns | |
| 246 | + | c.mu.RUnlock() | |
| 247 | + | if c.runtime != nil { | |
| 248 | + | queued += c.runtime.PendingSteers() | |
| 249 | + | } | |
| 250 | + | return queued | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func (c *ResidentChild) setQueuedTurns(n int) { | |
| 254 | + | c.mu.Lock() | |
| 255 | + | c.queuedTurns = n | |
| 256 | + | c.mu.Unlock() | |
| 257 | + | } | |
| 258 | + | ||
| 186 | 259 | func (c *ResidentChild) setState(state ResidentState) { | |
| 187 | 260 | c.mu.Lock() | |
| 188 | 261 | now := time.Now().UTC() | |
| @@ -270,8 +343,42 @@ func (c *ResidentChild) enqueue(ctx context.Context, request residentPrompt) err | |||
| 270 | 343 | return err | |
| 271 | 344 | } | |
| 272 | 345 | ||
| 346 | + | // steer injects a follow-up into the running turn. It reports false, leaving | |
| 347 | + | // the child unchanged, when no turn is executing or earlier follow-ups are | |
| 348 | + | // still queued; the caller must then queue the follow-up as a new turn. | |
| 349 | + | func (c *ResidentChild) steer(ctx context.Context, prompt string, onSteered func(turnID string)) (bool, string, error) { | |
| 350 | + | reply, err := c.sendControl(ctx, residentControl{kind: residentControlSteer, prompt: strings.TrimSpace(prompt), onSteered: onSteered}) | |
| 351 | + | return reply.ok, reply.turnID, err | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | // interruptTurn cancels only the running turn. The child stays alive with its | |
| 355 | + | // transcript, and queued follow-ups are dropped. It reports false when no turn | |
| 356 | + | // is running. | |
| 357 | + | func (c *ResidentChild) interruptTurn(ctx context.Context) (bool, error) { | |
| 358 | + | reply, err := c.sendControl(ctx, residentControl{kind: residentControlInterrupt}) | |
| 359 | + | return reply.ok, err | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | func (c *ResidentChild) sendControl(ctx context.Context, request residentControl) (residentControlReply, error) { | |
| 363 | + | if c == nil || c.runtime == nil { | |
| 364 | + | return residentControlReply{}, errors.New("resident child: no runtime") | |
| 365 | + | } | |
| 366 | + | if ctx == nil { | |
| 367 | + | ctx = context.Background() | |
| 368 | + | } | |
| 369 | + | request.reply = make(chan residentControlReply, 1) | |
| 370 | + | select { | |
| 371 | + | case <-ctx.Done(): | |
| 372 | + | return residentControlReply{}, ctx.Err() | |
| 373 | + | case <-c.done: | |
| 374 | + | return residentControlReply{}, errors.New("resident child: closed") | |
| 375 | + | case c.control <- request: | |
| 376 | + | } | |
| 377 | + | return <-request.reply, nil | |
| 378 | + | } | |
| 379 | + | ||
| 273 | 380 | func (c *ResidentChild) enqueueAccepted(ctx context.Context, request residentPrompt) (bool, error) { | |
| 274 | - | if c == nil || c.runner == nil { | |
| 381 | + | if c == nil || c.runtime == nil { | |
| 275 | 382 | return false, errors.New("resident child: no runner") | |
| 276 | 383 | } | |
| 277 | 384 | if ctx == nil { | |
| @@ -336,10 +443,28 @@ func (c *ResidentChild) run() { | |||
| 336 | 443 | ||
| 337 | 444 | var queue []residentPrompt | |
| 338 | 445 | var interruptedPrompts []residentPrompt | |
| 446 | + | var droppedPrompts []residentPrompt | |
| 339 | 447 | results := make(chan residentTurnResult, 1) | |
| 340 | 448 | var active residentPrompt | |
| 341 | - | running, interrupted := false, false | |
| 449 | + | turnCancel := context.CancelFunc(func() {}) | |
| 450 | + | defer func() { turnCancel() }() | |
| 451 | + | running, interrupted, turnInterrupted := false, false, false | |
| 452 | + | // dropQueued reports queued follow-ups that never started. Each was | |
| 453 | + | // accepted and tracked, so each still gets exactly one completion, marked | |
| 454 | + | // NotStarted so the parent does not mistake it for interrupted work. | |
| 455 | + | dropQueued := func(err error) { | |
| 456 | + | for _, pending := range queue { | |
| 457 | + | if c.journal != nil { | |
| 458 | + | _ = c.journal.RecordTurnInterrupted(c.spec, pending.turnID) | |
| 459 | + | } | |
| 460 | + | if c.onCompletion != nil { | |
| 461 | + | c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: pending.turnID, Task: pending.prompt, Err: err, NotStarted: true}) | |
| 462 | + | } | |
| 463 | + | } | |
| 464 | + | queue = nil | |
| 465 | + | } | |
| 342 | 466 | for { | |
| 467 | + | c.setQueuedTurns(len(queue)) | |
| 343 | 468 | if !interrupted && !running && len(queue) > 0 { | |
| 344 | 469 | active, queue = queue[0], queue[1:] | |
| 345 | 470 | if c.journal != nil { | |
| @@ -358,10 +483,12 @@ func (c *ResidentChild) run() { | |||
| 358 | 483 | } | |
| 359 | 484 | } | |
| 360 | 485 | c.startTurn(active.turnID) | |
| 361 | - | running = true | |
| 362 | - | go func(request residentPrompt) { | |
| 363 | - | results <- residentTurnResult{turnID: request.turnID, err: c.runner(c.ctx, request.prompt)} | |
| 364 | - | }(active) | |
| 486 | + | running, turnInterrupted = true, false | |
| 487 | + | turnCtx, cancelTurn := context.WithCancel(c.ctx) | |
| 488 | + | turnCancel = cancelTurn | |
| 489 | + | go func(ctx context.Context, request residentPrompt) { | |
| 490 | + | results <- residentTurnResult{turnID: request.turnID, err: c.runtime.Run(ctx, request.prompt)} | |
| 491 | + | }(turnCtx, active) | |
| 365 | 492 | } | |
| 366 | 493 | ||
| 367 | 494 | if interrupted && !running { | |
| @@ -369,7 +496,10 @@ func (c *ResidentChild) run() { | |||
| 369 | 496 | c.setState(ResidentInterrupted) | |
| 370 | 497 | if c.onCompletion != nil { | |
| 371 | 498 | for _, prompt := range interruptedPrompts { | |
| 372 | - | c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: prompt.turnID, Task: prompt.prompt, Err: context.Canceled}) | |
| 499 | + | c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: prompt.turnID, Task: prompt.prompt, Err: context.Canceled, Undelivered: c.runtime.DrainSteers()}) | |
| 500 | + | } | |
| 501 | + | for _, prompt := range droppedPrompts { | |
| 502 | + | c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: prompt.turnID, Task: prompt.prompt, Err: context.Canceled, NotStarted: true}) | |
| 373 | 503 | } | |
| 374 | 504 | } | |
| 375 | 505 | return | |
| @@ -392,12 +522,38 @@ func (c *ResidentChild) run() { | |||
| 392 | 522 | } | |
| 393 | 523 | } | |
| 394 | 524 | for _, pending := range queue { | |
| 395 | - | interruptedPrompts = append(interruptedPrompts, pending) | |
| 525 | + | droppedPrompts = append(droppedPrompts, pending) | |
| 396 | 526 | if c.journal != nil { | |
| 397 | 527 | _ = c.journal.RecordTurnInterrupted(c.spec, pending.turnID) | |
| 398 | 528 | } | |
| 399 | 529 | } | |
| 400 | 530 | queue = nil | |
| 531 | + | c.setQueuedTurns(0) | |
| 532 | + | case request := <-c.control: | |
| 533 | + | switch request.kind { | |
| 534 | + | case residentControlSteer: | |
| 535 | + | // Steering past queued turns would reorder follow-ups, and an | |
| 536 | + | // interrupted turn can no longer read new input. | |
| 537 | + | if running && !interrupted && !turnInterrupted && len(queue) == 0 && request.prompt != "" && c.runtime.Steer(request.prompt) { | |
| 538 | + | if request.onSteered != nil { | |
| 539 | + | request.onSteered(active.turnID) | |
| 540 | + | } | |
| 541 | + | c.notifyUpdate(false) | |
| 542 | + | request.reply <- residentControlReply{ok: true, turnID: active.turnID} | |
| 543 | + | } else { | |
| 544 | + | request.reply <- residentControlReply{} | |
| 545 | + | } | |
| 546 | + | case residentControlInterrupt: | |
| 547 | + | if !running || interrupted || turnInterrupted { | |
| 548 | + | request.reply <- residentControlReply{} | |
| 549 | + | continue | |
| 550 | + | } | |
| 551 | + | turnInterrupted = true | |
| 552 | + | turnCancel() | |
| 553 | + | dropQueued(context.Canceled) | |
| 554 | + | c.setQueuedTurns(0) | |
| 555 | + | request.reply <- residentControlReply{ok: true, turnID: active.turnID} | |
| 556 | + | } | |
| 401 | 557 | case request := <-c.inbox: | |
| 402 | 558 | if interrupted { | |
| 403 | 559 | request.ack <- errors.New("resident child: closed") | |
| @@ -407,10 +563,17 @@ func (c *ResidentChild) run() { | |||
| 407 | 563 | request.ack <- nil | |
| 408 | 564 | case result := <-results: | |
| 409 | 565 | running = false | |
| 566 | + | turnCancel() | |
| 410 | 567 | if interrupted { | |
| 411 | 568 | continue | |
| 412 | 569 | } | |
| 413 | 570 | terminalErr := result.err | |
| 571 | + | if turnInterrupted && terminalErr == nil { | |
| 572 | + | // The turn finished before cancellation reached it; the | |
| 573 | + | // manager still asked for an interruption. | |
| 574 | + | terminalErr = context.Canceled | |
| 575 | + | } | |
| 576 | + | undelivered := c.runtime.DrainSteers() | |
| 414 | 577 | var capture *WorkspaceCapture | |
| 415 | 578 | if (terminalErr == nil || !errors.Is(terminalErr, context.Canceled)) && c.workspace != nil && c.workspace.Mode() == WorkspaceWorktree { | |
| 416 | 579 | captured, err := c.workspace.Capture(c.ctx) | |
| @@ -441,14 +604,10 @@ func (c *ResidentChild) run() { | |||
| 441 | 604 | c.live.Finish(state) | |
| 442 | 605 | c.setState(state) | |
| 443 | 606 | if c.onCompletion != nil { | |
| 444 | - | c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: result.turnID, Task: active.prompt, Err: terminalErr, Summary: summary}) | |
| 445 | - | if persistenceFailed { | |
| 446 | - | for _, pending := range queue { | |
| 447 | - | c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: pending.turnID, Task: pending.prompt, Err: terminalErr}) | |
| 448 | - | } | |
| 449 | - | } | |
| 607 | + | c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: result.turnID, Task: active.prompt, Err: terminalErr, Summary: summary, Undelivered: undelivered}) | |
| 450 | 608 | } | |
| 451 | 609 | if persistenceFailed { | |
| 610 | + | dropQueued(terminalErr) | |
| 452 | 611 | return | |
| 453 | 612 | } | |
| 454 | 613 | } | |
| @@ -12,16 +12,59 @@ import ( | |||||
| 12 | "github.com/google/uuid" | 12 | "github.com/google/uuid" | ||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | - | // ResidentTurnRunner executes one already-accepted prompt. The child owns the | 15 | + | // ResidentRuntime owns one child's agent. The child owns turn ordering and |
| 16 | - | // ordering and cancellation boundary; implementations own agent/provider work. | 16 | + | // cancellation; the runtime owns agent/provider work. |
| 17 | + | // | |||
| 18 | + | // Run executes one accepted turn. Steer injects a follow-up into the turn Run | |||
| 19 | + | // is executing and reports false when no turn can still receive it; a true | |||
| 20 | + | // result guarantees Run delivers the follow-up before it returns successfully. | |||
| 21 | + | // PendingSteers counts follow-ups not yet delivered, and DrainSteers removes | |||
| 22 | + | // and returns them after a turn ended without delivering them. | |||
| 23 | + | type ResidentRuntime interface { | |||
| 24 | + | Run(ctx context.Context, prompt string) error | |||
| 25 | + | Steer(prompt string) bool | |||
| 26 | + | PendingSteers() int | |||
| 27 | + | DrainSteers() []string | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // ResidentTurnRunner is a runtime that runs turns but cannot be steered, so | |||
| 31 | + | // every follow-up becomes a separate queued turn. | |||
| 17 | type ResidentTurnRunner func(context.Context, string) error | 32 | type ResidentTurnRunner func(context.Context, string) error | ||
| 18 | 33 | ||||
| 34 | + | func (r ResidentTurnRunner) Run(ctx context.Context, prompt string) error { return r(ctx, prompt) } | |||
| 35 | + | func (ResidentTurnRunner) Steer(string) bool { return false } | |||
| 36 | + | func (ResidentTurnRunner) PendingSteers() int { return 0 } | |||
| 37 | + | func (ResidentTurnRunner) DrainSteers() []string { return nil } | |||
| 38 | + | ||||
| 19 | type residentPrompt struct { | 39 | type residentPrompt struct { | ||
| 20 | turnID string | 40 | turnID string | ||
| 21 | prompt string | 41 | prompt string | ||
| 22 | ack chan error | 42 | ack chan error | ||
| 23 | } | 43 | } | ||
| 24 | 44 | ||||
| 45 | + | type residentControlKind int | |||
| 46 | + | ||||
| 47 | + | const ( | |||
| 48 | + | residentControlSteer residentControlKind = iota + 1 | |||
| 49 | + | residentControlInterrupt | |||
| 50 | + | ) | |||
| 51 | + | ||||
| 52 | + | // residentControl is a request handled by the control goroutine, so it is | |||
| 53 | + | // ordered with turn start and completion. | |||
| 54 | + | type residentControl struct { | |||
| 55 | + | kind residentControlKind | |||
| 56 | + | prompt string | |||
| 57 | + | // onSteered runs on the control goroutine before the active turn can | |||
| 58 | + | // report its completion, so a caller can subscribe without a race. | |||
| 59 | + | onSteered func(turnID string) | |||
| 60 | + | reply chan residentControlReply | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | type residentControlReply struct { | |||
| 64 | + | ok bool | |||
| 65 | + | turnID string | |||
| 66 | + | } | |||
| 67 | + | ||||
| 25 | type residentTurnResult struct { | 68 | type residentTurnResult struct { | ||
| 26 | turnID string | 69 | turnID string | ||
| 27 | err error | 70 | err error | ||
| @@ -34,13 +77,19 @@ type ResidentCompletion struct { | |||||
| 34 | Task string | 77 | Task string | ||
| 35 | Err error | 78 | Err error | ||
| 36 | Summary string | 79 | Summary string | ||
| 80 | + | // NotStarted marks a queued follow-up that was dropped before the child | |||
| 81 | + | // ever ran it, for example because the child was stopped or interrupted. | |||
| 82 | + | NotStarted bool | |||
| 83 | + | // Undelivered lists follow-ups steered into this turn that the child never | |||
| 84 | + | // read because the turn ended first. | |||
| 85 | + | Undelivered []string | |||
| 37 | } | 86 | } | ||
| 38 | 87 | ||||
| 39 | // ResidentChild serializes prompt execution through one control goroutine. | 88 | // ResidentChild serializes prompt execution through one control goroutine. | ||
| 40 | // No mutex is held while a runner performs provider or tool I/O. When a | 89 | // No mutex is held while a runner performs provider or tool I/O. When a | ||
| 41 | // journal is configured, every state boundary is committed by that goroutine. | 90 | // journal is configured, every state boundary is committed by that goroutine. | ||
| 42 | type ResidentChild struct { | 91 | type ResidentChild struct { | ||
| 43 | - | runner ResidentTurnRunner | 92 | + | runtime ResidentRuntime |
| 44 | journal *ResidentJournal | 93 | journal *ResidentJournal | ||
| 45 | workspace WorkspaceHandle | 94 | workspace WorkspaceHandle | ||
| 46 | spec ResidentChildSpec | 95 | spec ResidentChildSpec | ||
| @@ -48,11 +97,12 @@ type ResidentChild struct { | |||||
| 48 | onUpdate func(historyChanged bool) | 97 | onUpdate func(historyChanged bool) | ||
| 49 | onStateChange func(ResidentState) | 98 | onStateChange func(ResidentState) | ||
| 50 | 99 | ||||
| 51 | - | ctx context.Context | 100 | + | ctx context.Context |
| 52 | - | cancel context.CancelFunc | 101 | + | cancel context.CancelFunc |
| 53 | - | inbox chan residentPrompt | 102 | + | inbox chan residentPrompt |
| 54 | - | done chan struct{} | 103 | + | control chan residentControl |
| 55 | - | once sync.Once | 104 | + | done chan struct{} |
| 105 | + | once sync.Once | |||
| 56 | 106 | ||||
| 57 | mu sync.RWMutex | 107 | mu sync.RWMutex | ||
| 58 | state ResidentState | 108 | state ResidentState | ||
| @@ -61,39 +111,41 @@ type ResidentChild struct { | |||||
| 61 | activityUpdatedAt time.Time | 111 | activityUpdatedAt time.Time | ||
| 62 | live *residentLiveProjection | 112 | live *residentLiveProjection | ||
| 63 | cleanupWorkspace bool | 113 | cleanupWorkspace bool | ||
| 114 | + | queuedTurns int | |||
| 64 | } | 115 | } | ||
| 65 | 116 | ||||
| 66 | - | func NewResidentChild(runner ResidentTurnRunner) *ResidentChild { | 117 | + | func NewResidentChild(runtime ResidentRuntime) *ResidentChild { |
| 67 | - | return newResidentChild(ResidentChildSpec{}, nil, runner) | 118 | + | return newResidentChild(ResidentChildSpec{}, nil, runtime) |
| 68 | } | 119 | } | ||
| 69 | 120 | ||||
| 70 | - | func newJournaledResidentChild(spec ResidentChildSpec, journal *ResidentJournal, runner ResidentTurnRunner, onCompletion func(ResidentCompletion)) *ResidentChild { | 121 | + | func newJournaledResidentChild(spec ResidentChildSpec, journal *ResidentJournal, runtime ResidentRuntime, onCompletion func(ResidentCompletion)) *ResidentChild { |
| 71 | - | child := newResidentChildWithWorkspace(spec, journal, nil, runner) | 122 | + | child := newResidentChildWithWorkspace(spec, journal, nil, runtime) |
| 72 | child.onCompletion = onCompletion | 123 | child.onCompletion = onCompletion | ||
| 73 | return child | 124 | return child | ||
| 74 | } | 125 | } | ||
| 75 | 126 | ||||
| 76 | - | func newJournaledResidentChildWithWorkspace(spec ResidentChildSpec, journal *ResidentJournal, workspace WorkspaceHandle, runner ResidentTurnRunner, onCompletion func(ResidentCompletion)) *ResidentChild { | 127 | + | func newJournaledResidentChildWithWorkspace(spec ResidentChildSpec, journal *ResidentJournal, workspace WorkspaceHandle, runtime ResidentRuntime, onCompletion func(ResidentCompletion)) *ResidentChild { |
| 77 | - | child := newResidentChildWithWorkspace(spec, journal, workspace, runner) | 128 | + | child := newResidentChildWithWorkspace(spec, journal, workspace, runtime) |
| 78 | child.onCompletion = onCompletion | 129 | child.onCompletion = onCompletion | ||
| 79 | return child | 130 | return child | ||
| 80 | } | 131 | } | ||
| 81 | 132 | ||||
| 82 | - | func newResidentChild(spec ResidentChildSpec, journal *ResidentJournal, runner ResidentTurnRunner) *ResidentChild { | 133 | + | func newResidentChild(spec ResidentChildSpec, journal *ResidentJournal, runtime ResidentRuntime) *ResidentChild { |
| 83 | - | return newResidentChildWithWorkspace(spec, journal, nil, runner) | 134 | + | return newResidentChildWithWorkspace(spec, journal, nil, runtime) |
| 84 | } | 135 | } | ||
| 85 | 136 | ||||
| 86 | - | func newResidentChildWithWorkspace(spec ResidentChildSpec, journal *ResidentJournal, workspace WorkspaceHandle, runner ResidentTurnRunner) *ResidentChild { | 137 | + | func newResidentChildWithWorkspace(spec ResidentChildSpec, journal *ResidentJournal, workspace WorkspaceHandle, runtime ResidentRuntime) *ResidentChild { |
| 87 | ctx, cancel := context.WithCancel(context.Background()) | 138 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 88 | now := time.Now().UTC() | 139 | now := time.Now().UTC() | ||
| 89 | child := &ResidentChild{ | 140 | child := &ResidentChild{ | ||
| 90 | - | runner: runner, | 141 | + | runtime: runtime, |
| 91 | journal: journal, | 142 | journal: journal, | ||
| 92 | workspace: workspace, | 143 | workspace: workspace, | ||
| 93 | spec: spec, | 144 | spec: spec, | ||
| 94 | ctx: ctx, | 145 | ctx: ctx, | ||
| 95 | cancel: cancel, | 146 | cancel: cancel, | ||
| 96 | inbox: make(chan residentPrompt), | 147 | inbox: make(chan residentPrompt), | ||
| 148 | + | control: make(chan residentControl), | |||
| 97 | done: make(chan struct{}), | 149 | done: make(chan struct{}), | ||
| 98 | state: ResidentQueued, | 150 | state: ResidentQueued, | ||
| 99 | stateUpdatedAt: now, | 151 | stateUpdatedAt: now, | ||
| @@ -183,6 +235,27 @@ func (c *ResidentChild) ActivityUpdatedAt() time.Time { | |||||
| 183 | return c.activityUpdatedAt | 235 | return c.activityUpdatedAt | ||
| 184 | } | 236 | } | ||
| 185 | 237 | ||||
| 238 | + | // PendingFollowUps counts follow-ups the child has accepted but not yet read: | |||
| 239 | + | // queued turns plus messages steered into the running turn. | |||
| 240 | + | func (c *ResidentChild) PendingFollowUps() int { | |||
| 241 | + | if c == nil { | |||
| 242 | + | return 0 | |||
| 243 | + | } | |||
| 244 | + | c.mu.RLock() | |||
| 245 | + | queued := c.queuedTurns | |||
| 246 | + | c.mu.RUnlock() | |||
| 247 | + | if c.runtime != nil { | |||
| 248 | + | queued += c.runtime.PendingSteers() | |||
| 249 | + | } | |||
| 250 | + | return queued | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | func (c *ResidentChild) setQueuedTurns(n int) { | |||
| 254 | + | c.mu.Lock() | |||
| 255 | + | c.queuedTurns = n | |||
| 256 | + | c.mu.Unlock() | |||
| 257 | + | } | |||
| 258 | + | ||||
| 186 | func (c *ResidentChild) setState(state ResidentState) { | 259 | func (c *ResidentChild) setState(state ResidentState) { | ||
| 187 | c.mu.Lock() | 260 | c.mu.Lock() | ||
| 188 | now := time.Now().UTC() | 261 | now := time.Now().UTC() | ||
| @@ -270,8 +343,42 @@ func (c *ResidentChild) enqueue(ctx context.Context, request residentPrompt) err | |||||
| 270 | return err | 343 | return err | ||
| 271 | } | 344 | } | ||
| 272 | 345 | ||||
| 346 | + | // steer injects a follow-up into the running turn. It reports false, leaving | |||
| 347 | + | // the child unchanged, when no turn is executing or earlier follow-ups are | |||
| 348 | + | // still queued; the caller must then queue the follow-up as a new turn. | |||
| 349 | + | func (c *ResidentChild) steer(ctx context.Context, prompt string, onSteered func(turnID string)) (bool, string, error) { | |||
| 350 | + | reply, err := c.sendControl(ctx, residentControl{kind: residentControlSteer, prompt: strings.TrimSpace(prompt), onSteered: onSteered}) | |||
| 351 | + | return reply.ok, reply.turnID, err | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | // interruptTurn cancels only the running turn. The child stays alive with its | |||
| 355 | + | // transcript, and queued follow-ups are dropped. It reports false when no turn | |||
| 356 | + | // is running. | |||
| 357 | + | func (c *ResidentChild) interruptTurn(ctx context.Context) (bool, error) { | |||
| 358 | + | reply, err := c.sendControl(ctx, residentControl{kind: residentControlInterrupt}) | |||
| 359 | + | return reply.ok, err | |||
| 360 | + | } | |||
| 361 | + | ||||
| 362 | + | func (c *ResidentChild) sendControl(ctx context.Context, request residentControl) (residentControlReply, error) { | |||
| 363 | + | if c == nil || c.runtime == nil { | |||
| 364 | + | return residentControlReply{}, errors.New("resident child: no runtime") | |||
| 365 | + | } | |||
| 366 | + | if ctx == nil { | |||
| 367 | + | ctx = context.Background() | |||
| 368 | + | } | |||
| 369 | + | request.reply = make(chan residentControlReply, 1) | |||
| 370 | + | select { | |||
| 371 | + | case <-ctx.Done(): | |||
| 372 | + | return residentControlReply{}, ctx.Err() | |||
| 373 | + | case <-c.done: | |||
| 374 | + | return residentControlReply{}, errors.New("resident child: closed") | |||
| 375 | + | case c.control <- request: | |||
| 376 | + | } | |||
| 377 | + | return <-request.reply, nil | |||
| 378 | + | } | |||
| 379 | + | ||||
| 273 | func (c *ResidentChild) enqueueAccepted(ctx context.Context, request residentPrompt) (bool, error) { | 380 | func (c *ResidentChild) enqueueAccepted(ctx context.Context, request residentPrompt) (bool, error) { | ||
| 274 | - | if c == nil || c.runner == nil { | 381 | + | if c == nil || c.runtime == nil { |
| 275 | return false, errors.New("resident child: no runner") | 382 | return false, errors.New("resident child: no runner") | ||
| 276 | } | 383 | } | ||
| 277 | if ctx == nil { | 384 | if ctx == nil { | ||
| @@ -336,10 +443,28 @@ func (c *ResidentChild) run() { | |||||
| 336 | 443 | ||||
| 337 | var queue []residentPrompt | 444 | var queue []residentPrompt | ||
| 338 | var interruptedPrompts []residentPrompt | 445 | var interruptedPrompts []residentPrompt | ||
| 446 | + | var droppedPrompts []residentPrompt | |||
| 339 | results := make(chan residentTurnResult, 1) | 447 | results := make(chan residentTurnResult, 1) | ||
| 340 | var active residentPrompt | 448 | var active residentPrompt | ||
| 341 | - | running, interrupted := false, false | 449 | + | turnCancel := context.CancelFunc(func() {}) |
| 450 | + | defer func() { turnCancel() }() | |||
| 451 | + | running, interrupted, turnInterrupted := false, false, false | |||
| 452 | + | // dropQueued reports queued follow-ups that never started. Each was | |||
| 453 | + | // accepted and tracked, so each still gets exactly one completion, marked | |||
| 454 | + | // NotStarted so the parent does not mistake it for interrupted work. | |||
| 455 | + | dropQueued := func(err error) { | |||
| 456 | + | for _, pending := range queue { | |||
| 457 | + | if c.journal != nil { | |||
| 458 | + | _ = c.journal.RecordTurnInterrupted(c.spec, pending.turnID) | |||
| 459 | + | } | |||
| 460 | + | if c.onCompletion != nil { | |||
| 461 | + | c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: pending.turnID, Task: pending.prompt, Err: err, NotStarted: true}) | |||
| 462 | + | } | |||
| 463 | + | } | |||
| 464 | + | queue = nil | |||
| 465 | + | } | |||
| 342 | for { | 466 | for { | ||
| 467 | + | c.setQueuedTurns(len(queue)) | |||
| 343 | if !interrupted && !running && len(queue) > 0 { | 468 | if !interrupted && !running && len(queue) > 0 { | ||
| 344 | active, queue = queue[0], queue[1:] | 469 | active, queue = queue[0], queue[1:] | ||
| 345 | if c.journal != nil { | 470 | if c.journal != nil { | ||
| @@ -358,10 +483,12 @@ func (c *ResidentChild) run() { | |||||
| 358 | } | 483 | } | ||
| 359 | } | 484 | } | ||
| 360 | c.startTurn(active.turnID) | 485 | c.startTurn(active.turnID) | ||
| 361 | - | running = true | 486 | + | running, turnInterrupted = true, false |
| 362 | - | go func(request residentPrompt) { | 487 | + | turnCtx, cancelTurn := context.WithCancel(c.ctx) |
| 363 | - | results <- residentTurnResult{turnID: request.turnID, err: c.runner(c.ctx, request.prompt)} | 488 | + | turnCancel = cancelTurn |
| 364 | - | }(active) | 489 | + | go func(ctx context.Context, request residentPrompt) { |
| 490 | + | results <- residentTurnResult{turnID: request.turnID, err: c.runtime.Run(ctx, request.prompt)} | |||
| 491 | + | }(turnCtx, active) | |||
| 365 | } | 492 | } | ||
| 366 | 493 | ||||
| 367 | if interrupted && !running { | 494 | if interrupted && !running { | ||
| @@ -369,7 +496,10 @@ func (c *ResidentChild) run() { | |||||
| 369 | c.setState(ResidentInterrupted) | 496 | c.setState(ResidentInterrupted) | ||
| 370 | if c.onCompletion != nil { | 497 | if c.onCompletion != nil { | ||
| 371 | for _, prompt := range interruptedPrompts { | 498 | for _, prompt := range interruptedPrompts { | ||
| 372 | - | c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: prompt.turnID, Task: prompt.prompt, Err: context.Canceled}) | 499 | + | c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: prompt.turnID, Task: prompt.prompt, Err: context.Canceled, Undelivered: c.runtime.DrainSteers()}) |
| 500 | + | } | |||
| 501 | + | for _, prompt := range droppedPrompts { | |||
| 502 | + | c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: prompt.turnID, Task: prompt.prompt, Err: context.Canceled, NotStarted: true}) | |||
| 373 | } | 503 | } | ||
| 374 | } | 504 | } | ||
| 375 | return | 505 | return | ||
| @@ -392,12 +522,38 @@ func (c *ResidentChild) run() { | |||||
| 392 | } | 522 | } | ||
| 393 | } | 523 | } | ||
| 394 | for _, pending := range queue { | 524 | for _, pending := range queue { | ||
| 395 | - | interruptedPrompts = append(interruptedPrompts, pending) | 525 | + | droppedPrompts = append(droppedPrompts, pending) |
| 396 | if c.journal != nil { | 526 | if c.journal != nil { | ||
| 397 | _ = c.journal.RecordTurnInterrupted(c.spec, pending.turnID) | 527 | _ = c.journal.RecordTurnInterrupted(c.spec, pending.turnID) | ||
| 398 | } | 528 | } | ||
| 399 | } | 529 | } | ||
| 400 | queue = nil | 530 | queue = nil | ||
| 531 | + | c.setQueuedTurns(0) | |||
| 532 | + | case request := <-c.control: | |||
| 533 | + | switch request.kind { | |||
| 534 | + | case residentControlSteer: | |||
| 535 | + | // Steering past queued turns would reorder follow-ups, and an | |||
| 536 | + | // interrupted turn can no longer read new input. | |||
| 537 | + | if running && !interrupted && !turnInterrupted && len(queue) == 0 && request.prompt != "" && c.runtime.Steer(request.prompt) { | |||
| 538 | + | if request.onSteered != nil { | |||
| 539 | + | request.onSteered(active.turnID) | |||
| 540 | + | } | |||
| 541 | + | c.notifyUpdate(false) | |||
| 542 | + | request.reply <- residentControlReply{ok: true, turnID: active.turnID} | |||
| 543 | + | } else { | |||
| 544 | + | request.reply <- residentControlReply{} | |||
| 545 | + | } | |||
| 546 | + | case residentControlInterrupt: | |||
| 547 | + | if !running || interrupted || turnInterrupted { | |||
| 548 | + | request.reply <- residentControlReply{} | |||
| 549 | + | continue | |||
| 550 | + | } | |||
| 551 | + | turnInterrupted = true | |||
| 552 | + | turnCancel() | |||
| 553 | + | dropQueued(context.Canceled) | |||
| 554 | + | c.setQueuedTurns(0) | |||
| 555 | + | request.reply <- residentControlReply{ok: true, turnID: active.turnID} | |||
| 556 | + | } | |||
| 401 | case request := <-c.inbox: | 557 | case request := <-c.inbox: | ||
| 402 | if interrupted { | 558 | if interrupted { | ||
| 403 | request.ack <- errors.New("resident child: closed") | 559 | request.ack <- errors.New("resident child: closed") | ||
| @@ -407,10 +563,17 @@ func (c *ResidentChild) run() { | |||||
| 407 | request.ack <- nil | 563 | request.ack <- nil | ||
| 408 | case result := <-results: | 564 | case result := <-results: | ||
| 409 | running = false | 565 | running = false | ||
| 566 | + | turnCancel() | |||
| 410 | if interrupted { | 567 | if interrupted { | ||
| 411 | continue | 568 | continue | ||
| 412 | } | 569 | } | ||
| 413 | terminalErr := result.err | 570 | terminalErr := result.err | ||
| 571 | + | if turnInterrupted && terminalErr == nil { | |||
| 572 | + | // The turn finished before cancellation reached it; the | |||
| 573 | + | // manager still asked for an interruption. | |||
| 574 | + | terminalErr = context.Canceled | |||
| 575 | + | } | |||
| 576 | + | undelivered := c.runtime.DrainSteers() | |||
| 414 | var capture *WorkspaceCapture | 577 | var capture *WorkspaceCapture | ||
| 415 | if (terminalErr == nil || !errors.Is(terminalErr, context.Canceled)) && c.workspace != nil && c.workspace.Mode() == WorkspaceWorktree { | 578 | if (terminalErr == nil || !errors.Is(terminalErr, context.Canceled)) && c.workspace != nil && c.workspace.Mode() == WorkspaceWorktree { | ||
| 416 | captured, err := c.workspace.Capture(c.ctx) | 579 | captured, err := c.workspace.Capture(c.ctx) | ||
| @@ -441,14 +604,10 @@ func (c *ResidentChild) run() { | |||||
| 441 | c.live.Finish(state) | 604 | c.live.Finish(state) | ||
| 442 | c.setState(state) | 605 | c.setState(state) | ||
| 443 | if c.onCompletion != nil { | 606 | if c.onCompletion != nil { | ||
| 444 | - | c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: result.turnID, Task: active.prompt, Err: terminalErr, Summary: summary}) | 607 | + | c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: result.turnID, Task: active.prompt, Err: terminalErr, Summary: summary, Undelivered: undelivered}) |
| 445 | - | if persistenceFailed { | |||
| 446 | - | for _, pending := range queue { | |||
| 447 | - | c.onCompletion(ResidentCompletion{ChildID: c.spec.ID, TurnID: pending.turnID, Task: pending.prompt, Err: terminalErr}) | |||
| 448 | - | } | |||
| 449 | - | } | |||
| 450 | } | 608 | } | ||
| 451 | if persistenceFailed { | 609 | if persistenceFailed { | ||
| 610 | + | dropQueued(terminalErr) | |||
| 452 | return | 611 | return | ||
| 453 | } | 612 | } | ||
| 454 | } | 613 | } | ||
M packages/agent/subagents/resident_child_test.go +5 -5
| @@ -14,7 +14,7 @@ func TestJournaledResidentChildReceivesLiveJournalEvents(t *testing.T) { | |||
| 14 | 14 | if err != nil { | |
| 15 | 15 | t.Fatal(err) | |
| 16 | 16 | } | |
| 17 | - | child := newJournaledResidentChild(ResidentChildSpec{}, journal, func(context.Context, string) error { return nil }, nil) | |
| 17 | + | child := newJournaledResidentChild(ResidentChildSpec{}, journal, ResidentTurnRunner(func(context.Context, string) error { return nil }), nil) | |
| 18 | 18 | defer child.Close(context.Background()) | |
| 19 | 19 | oldActivity := time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC) | |
| 20 | 20 | child.mu.Lock() | |
| @@ -36,7 +36,7 @@ func TestResidentChildRunsAcceptedPromptsFIFOWithOneActiveTurn(t *testing.T) { | |||
| 36 | 36 | started := make(chan string, 2) | |
| 37 | 37 | releaseFirst := make(chan struct{}) | |
| 38 | 38 | completed := make(chan string, 2) | |
| 39 | - | child := NewResidentChild(func(ctx context.Context, prompt string) error { | |
| 39 | + | child := NewResidentChild(ResidentTurnRunner(func(ctx context.Context, prompt string) error { | |
| 40 | 40 | started <- prompt | |
| 41 | 41 | if prompt == "first" { | |
| 42 | 42 | select { | |
| @@ -47,7 +47,7 @@ func TestResidentChildRunsAcceptedPromptsFIFOWithOneActiveTurn(t *testing.T) { | |||
| 47 | 47 | } | |
| 48 | 48 | completed <- prompt | |
| 49 | 49 | return nil | |
| 50 | - | }) | |
| 50 | + | })) | |
| 51 | 51 | defer child.Close(context.Background()) | |
| 52 | 52 | ||
| 53 | 53 | if err := child.Resume(context.Background(), "first"); err != nil { | |
| @@ -95,10 +95,10 @@ func TestResidentChildReportsAndPreservesFailureWhenTurnStartCannotPersist(t *te | |||
| 95 | 95 | child := newJournaledResidentChild( | |
| 96 | 96 | ResidentChildSpec{ID: "start-persistence-failure"}, | |
| 97 | 97 | journal, | |
| 98 | - | func(context.Context, string) error { | |
| 98 | + | ResidentTurnRunner(func(context.Context, string) error { | |
| 99 | 99 | run <- struct{}{} | |
| 100 | 100 | return nil | |
| 101 | - | }, | |
| 101 | + | }), | |
| 102 | 102 | func(completion ResidentCompletion) { completed <- completion }, | |
| 103 | 103 | ) | |
| 104 | 104 | ||
| @@ -14,7 +14,7 @@ func TestJournaledResidentChildReceivesLiveJournalEvents(t *testing.T) { | |||||
| 14 | if err != nil { | 14 | if err != nil { | ||
| 15 | t.Fatal(err) | 15 | t.Fatal(err) | ||
| 16 | } | 16 | } | ||
| 17 | - | child := newJournaledResidentChild(ResidentChildSpec{}, journal, func(context.Context, string) error { return nil }, nil) | 17 | + | child := newJournaledResidentChild(ResidentChildSpec{}, journal, ResidentTurnRunner(func(context.Context, string) error { return nil }), nil) |
| 18 | defer child.Close(context.Background()) | 18 | defer child.Close(context.Background()) | ||
| 19 | oldActivity := time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC) | 19 | oldActivity := time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC) | ||
| 20 | child.mu.Lock() | 20 | child.mu.Lock() | ||
| @@ -36,7 +36,7 @@ func TestResidentChildRunsAcceptedPromptsFIFOWithOneActiveTurn(t *testing.T) { | |||||
| 36 | started := make(chan string, 2) | 36 | started := make(chan string, 2) | ||
| 37 | releaseFirst := make(chan struct{}) | 37 | releaseFirst := make(chan struct{}) | ||
| 38 | completed := make(chan string, 2) | 38 | completed := make(chan string, 2) | ||
| 39 | - | child := NewResidentChild(func(ctx context.Context, prompt string) error { | 39 | + | child := NewResidentChild(ResidentTurnRunner(func(ctx context.Context, prompt string) error { |
| 40 | started <- prompt | 40 | started <- prompt | ||
| 41 | if prompt == "first" { | 41 | if prompt == "first" { | ||
| 42 | select { | 42 | select { | ||
| @@ -47,7 +47,7 @@ func TestResidentChildRunsAcceptedPromptsFIFOWithOneActiveTurn(t *testing.T) { | |||||
| 47 | } | 47 | } | ||
| 48 | completed <- prompt | 48 | completed <- prompt | ||
| 49 | return nil | 49 | return nil | ||
| 50 | - | }) | 50 | + | })) |
| 51 | defer child.Close(context.Background()) | 51 | defer child.Close(context.Background()) | ||
| 52 | 52 | ||||
| 53 | if err := child.Resume(context.Background(), "first"); err != nil { | 53 | if err := child.Resume(context.Background(), "first"); err != nil { | ||
| @@ -95,10 +95,10 @@ func TestResidentChildReportsAndPreservesFailureWhenTurnStartCannotPersist(t *te | |||||
| 95 | child := newJournaledResidentChild( | 95 | child := newJournaledResidentChild( | ||
| 96 | ResidentChildSpec{ID: "start-persistence-failure"}, | 96 | ResidentChildSpec{ID: "start-persistence-failure"}, | ||
| 97 | journal, | 97 | journal, | ||
| 98 | - | func(context.Context, string) error { | 98 | + | ResidentTurnRunner(func(context.Context, string) error { |
| 99 | run <- struct{}{} | 99 | run <- struct{}{} | ||
| 100 | return nil | 100 | return nil | ||
| 101 | - | }, | 101 | + | }), |
| 102 | func(completion ResidentCompletion) { completed <- completion }, | 102 | func(completion ResidentCompletion) { completed <- completion }, | ||
| 103 | ) | 103 | ) | ||
| 104 | 104 | ||||
M packages/agent/subagents/resident_failure_recovery_test.go +3 -3
| @@ -28,8 +28,8 @@ func TestResidentFailureRecoveryKeepsRequiredUnmetUntilResume(t *testing.T) { | |||
| 28 | 28 | spec.WorkspaceMode, spec.WorkspaceCapture = WorkspaceWorktree, CapturePatch | |
| 29 | 29 | } | |
| 30 | 30 | turnErr := errors.New("turn failed") | |
| 31 | - | factory := func(spec ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) { | |
| 32 | - | return func(_ context.Context, prompt string) error { | |
| 31 | + | factory := func(spec ResidentChildSpec, journal *ResidentJournal) (ResidentRuntime, error) { | |
| 32 | + | return ResidentTurnRunner(func(_ context.Context, prompt string) error { | |
| 33 | 33 | if prompt == "continue remaining work" { | |
| 34 | 34 | messages, err := ReadResidentTranscriptMessages(journal.Dir()) | |
| 35 | 35 | if err != nil || len(messages) < 3 { | |
| @@ -66,7 +66,7 @@ func TestResidentFailureRecoveryKeepsRequiredUnmetUntilResume(t *testing.T) { | |||
| 66 | 66 | } | |
| 67 | 67 | } | |
| 68 | 68 | return turnErr | |
| 69 | - | }, nil | |
| 69 | + | }), nil | |
| 70 | 70 | } | |
| 71 | 71 | manager := NewResidentManager(root, factory) | |
| 72 | 72 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| @@ -28,8 +28,8 @@ func TestResidentFailureRecoveryKeepsRequiredUnmetUntilResume(t *testing.T) { | |||||
| 28 | spec.WorkspaceMode, spec.WorkspaceCapture = WorkspaceWorktree, CapturePatch | 28 | spec.WorkspaceMode, spec.WorkspaceCapture = WorkspaceWorktree, CapturePatch | ||
| 29 | } | 29 | } | ||
| 30 | turnErr := errors.New("turn failed") | 30 | turnErr := errors.New("turn failed") | ||
| 31 | - | factory := func(spec ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) { | 31 | + | factory := func(spec ResidentChildSpec, journal *ResidentJournal) (ResidentRuntime, error) { |
| 32 | - | return func(_ context.Context, prompt string) error { | 32 | + | return ResidentTurnRunner(func(_ context.Context, prompt string) error { |
| 33 | if prompt == "continue remaining work" { | 33 | if prompt == "continue remaining work" { | ||
| 34 | messages, err := ReadResidentTranscriptMessages(journal.Dir()) | 34 | messages, err := ReadResidentTranscriptMessages(journal.Dir()) | ||
| 35 | if err != nil || len(messages) < 3 { | 35 | if err != nil || len(messages) < 3 { | ||
| @@ -66,7 +66,7 @@ func TestResidentFailureRecoveryKeepsRequiredUnmetUntilResume(t *testing.T) { | |||||
| 66 | } | 66 | } | ||
| 67 | } | 67 | } | ||
| 68 | return turnErr | 68 | return turnErr | ||
| 69 | - | }, nil | 69 | + | }), nil |
| 70 | } | 70 | } | ||
| 71 | manager := NewResidentManager(root, factory) | 71 | manager := NewResidentManager(root, factory) | ||
| 72 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | 72 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | ||
M packages/agent/subagents/resident_history_test.go +2 -2
| @@ -261,8 +261,8 @@ func TestReadResidentHistoryPageCursorSurvivesJournalAppend(t *testing.T) { | |||
| 261 | 261 | } | |
| 262 | 262 | ||
| 263 | 263 | func TestResidentManagerHistoryRejectsPathTraversal(t *testing.T) { | |
| 264 | - | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | |
| 265 | - | return func(context.Context, string) error { return nil }, nil | |
| 264 | + | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { | |
| 265 | + | return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil | |
| 266 | 266 | }) | |
| 267 | 267 | if _, err := manager.History("../escape", 10); err == nil { | |
| 268 | 268 | t.Fatal("History accepted traversal") | |
| @@ -261,8 +261,8 @@ func TestReadResidentHistoryPageCursorSurvivesJournalAppend(t *testing.T) { | |||||
| 261 | } | 261 | } | ||
| 262 | 262 | ||||
| 263 | func TestResidentManagerHistoryRejectsPathTraversal(t *testing.T) { | 263 | func TestResidentManagerHistoryRejectsPathTraversal(t *testing.T) { | ||
| 264 | - | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | 264 | + | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { |
| 265 | - | return func(context.Context, string) error { return nil }, nil | 265 | + | return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil |
| 266 | }) | 266 | }) | ||
| 267 | if _, err := manager.History("../escape", 10); err == nil { | 267 | if _, err := manager.History("../escape", 10); err == nil { | ||
| 268 | t.Fatal("History accepted traversal") | 268 | t.Fatal("History accepted traversal") | ||
M packages/agent/subagents/resident_journal_test.go +2 -2
| @@ -722,8 +722,8 @@ func TestReconcileResidentJournalTranslatesV2BudgetHistory(t *testing.T) { | |||
| 722 | 722 | if err != nil { | |
| 723 | 723 | t.Fatal(err) | |
| 724 | 724 | } | |
| 725 | - | factory := func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | |
| 726 | - | return func(context.Context, string) error { return nil }, nil | |
| 725 | + | factory := func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { | |
| 726 | + | return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil | |
| 727 | 727 | } | |
| 728 | 728 | manager := NewResidentManager(root, factory) | |
| 729 | 729 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| @@ -722,8 +722,8 @@ func TestReconcileResidentJournalTranslatesV2BudgetHistory(t *testing.T) { | |||||
| 722 | if err != nil { | 722 | if err != nil { | ||
| 723 | t.Fatal(err) | 723 | t.Fatal(err) | ||
| 724 | } | 724 | } | ||
| 725 | - | factory := func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | 725 | + | factory := func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { |
| 726 | - | return func(context.Context, string) error { return nil }, nil | 726 | + | return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil |
| 727 | } | 727 | } | ||
| 728 | manager := NewResidentManager(root, factory) | 728 | manager := NewResidentManager(root, factory) | ||
| 729 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | 729 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | ||
M packages/agent/subagents/resident_manager.go +98 -9
| @@ -16,9 +16,9 @@ import ( | |||
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | 18 | // ResidentFactory is the host-owned construction boundary. It receives only | |
| 19 | - | // an already-resolved non-secret specification and returns an in-process turn | |
| 20 | - | // runner; it never starts another zut binary. | |
| 21 | - | type ResidentFactory func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) | |
| 19 | + | // an already-resolved non-secret specification and returns an in-process | |
| 20 | + | // runtime; it never starts another zut binary. | |
| 21 | + | type ResidentFactory func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) | |
| 22 | 22 | ||
| 23 | 23 | type ResidentManager struct { | |
| 24 | 24 | root string | |
| @@ -216,6 +216,7 @@ type ResidentSnapshot struct { | |||
| 216 | 216 | TurnStartedAt time.Time | |
| 217 | 217 | ActivityUpdatedAt time.Time | |
| 218 | 218 | WaitingForModel bool | |
| 219 | + | PendingFollowUps int | |
| 219 | 220 | OwnedElsewhere bool | |
| 220 | 221 | Usage provider.Usage | |
| 221 | 222 | ContextUsed int | |
| @@ -232,7 +233,7 @@ func residentSnapshot(child *ResidentChild) ResidentSnapshot { | |||
| 232 | 233 | Provider: child.spec.Provider, Model: child.spec.Model, | |
| 233 | 234 | WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required, | |
| 234 | 235 | UpdatedAt: child.StateUpdatedAt(), TurnStartedAt: child.TurnStartedAt(), | |
| 235 | - | ActivityUpdatedAt: child.ActivityUpdatedAt(), WaitingForModel: live.WaitingForModel, | |
| 236 | + | ActivityUpdatedAt: child.ActivityUpdatedAt(), WaitingForModel: live.WaitingForModel, PendingFollowUps: child.PendingFollowUps(), | |
| 236 | 237 | Usage: live.Usage, ContextUsed: live.ContextUsed, ContextMax: live.ContextMax, Subscription: live.Subscription} | |
| 237 | 238 | } | |
| 238 | 239 | ||
| @@ -378,7 +379,20 @@ func (m *ResidentManager) Spawn(ctx context.Context, spec ResidentChildSpec, tas | |||
| 378 | 379 | return child, nil | |
| 379 | 380 | } | |
| 380 | 381 | ||
| 381 | - | func (m *ResidentManager) scheduledRunner(childID string, runner ResidentTurnRunner) ResidentTurnRunner { | |
| 382 | + | // scheduledRuntime gates each turn on a global scheduler slot. Steering needs | |
| 383 | + | // no slot: it only reaches a turn that already holds one. | |
| 384 | + | type scheduledRuntime struct { | |
| 385 | + | ResidentRuntime | |
| 386 | + | run ResidentTurnRunner | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | func (r scheduledRuntime) Run(ctx context.Context, prompt string) error { return r.run(ctx, prompt) } | |
| 390 | + | ||
| 391 | + | func (m *ResidentManager) scheduledRunner(childID string, runtime ResidentRuntime) ResidentRuntime { | |
| 392 | + | return scheduledRuntime{ResidentRuntime: runtime, run: m.scheduledRun(childID, runtime)} | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | func (m *ResidentManager) scheduledRun(childID string, runtime ResidentRuntime) ResidentTurnRunner { | |
| 382 | 396 | return func(ctx context.Context, prompt string) error { | |
| 383 | 397 | if ctx == nil { | |
| 384 | 398 | ctx = context.Background() | |
| @@ -418,7 +432,7 @@ func (m *ResidentManager) scheduledRunner(childID string, runner ResidentTurnRun | |||
| 418 | 432 | _ = m.scheduler.Release(childID) | |
| 419 | 433 | m.dispatch() | |
| 420 | 434 | }() | |
| 421 | - | return runner(ctx, prompt) | |
| 435 | + | return runtime.Run(ctx, prompt) | |
| 422 | 436 | } | |
| 423 | 437 | } | |
| 424 | 438 | ||
| @@ -439,15 +453,69 @@ func (m *ResidentManager) dispatch() { | |||
| 439 | 453 | // resident child. A disk-only child is rebuilt only for this explicit new | |
| 440 | 454 | // prompt; reconciliation itself never constructs or replays one. | |
| 441 | 455 | func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) error { | |
| 442 | - | return m.ResumeWithTurn(ctx, childID, prompt, uuid.NewString()) | |
| 456 | + | _, err := m.ResumeFollowUp(ctx, childID, prompt, ResumeSteer, uuid.NewString(), nil) | |
| 457 | + | return err | |
| 443 | 458 | } | |
| 444 | 459 | ||
| 445 | - | // ResumeWithTurn is Resume with a caller-owned turn ID. The ID names the | |
| 460 | + | // ResumeMode selects how a follow-up reaches a child that is running a turn. | |
| 461 | + | type ResumeMode string | |
| 462 | + | ||
| 463 | + | const ( | |
| 464 | + | // ResumeSteer injects the follow-up into the running turn at its next | |
| 465 | + | // tool or model boundary. An idle child starts a new turn instead. | |
| 466 | + | ResumeSteer ResumeMode = "steer" | |
| 467 | + | // ResumeQueue runs the follow-up as a separate turn after the current one. | |
| 468 | + | ResumeQueue ResumeMode = "queue" | |
| 469 | + | ) | |
| 470 | + | ||
| 471 | + | // ResumeOutcome identifies the turn that will answer a follow-up. | |
| 472 | + | type ResumeOutcome struct { | |
| 473 | + | // TurnID is the new turn for a queued follow-up, or the running turn | |
| 474 | + | // that received a steered one. | |
| 475 | + | TurnID string | |
| 476 | + | Steered bool | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | // ResumeFollowUp delivers a follow-up by mode. A steered follow-up joins the | |
| 480 | + | // running turn, so it creates no new turn and no new completion. onSteered, when | |
| 481 | + | // set, runs before that turn can complete so a caller can watch its completion | |
| 482 | + | // without racing it. When steering is impossible, the follow-up is queued as a | |
| 483 | + | // new turn named turnID. | |
| 484 | + | func (m *ResidentManager) ResumeFollowUp(ctx context.Context, childID, prompt string, mode ResumeMode, turnID string, onSteered func(turnID string)) (ResumeOutcome, error) { | |
| 485 | + | if mode == "" { | |
| 486 | + | mode = ResumeSteer | |
| 487 | + | } | |
| 488 | + | if mode != ResumeSteer && mode != ResumeQueue { | |
| 489 | + | return ResumeOutcome{}, fmt.Errorf("resident manager: unknown resume mode %q", mode) | |
| 490 | + | } | |
| 491 | + | if mode == ResumeSteer && m != nil && strings.TrimSpace(prompt) != "" { | |
| 492 | + | m.lifecycleMu.Lock() | |
| 493 | + | child := m.Get(strings.TrimSpace(childID)) | |
| 494 | + | if child != nil { | |
| 495 | + | steered, activeTurn, err := child.steer(ctx, prompt, onSteered) | |
| 496 | + | if err == nil && steered { | |
| 497 | + | m.lifecycleMu.Unlock() | |
| 498 | + | m.notifyUpdate(child.spec.ID, false) | |
| 499 | + | return ResumeOutcome{TurnID: activeTurn, Steered: true}, nil | |
| 500 | + | } | |
| 501 | + | } | |
| 502 | + | m.lifecycleMu.Unlock() | |
| 503 | + | } | |
| 504 | + | if strings.TrimSpace(turnID) == "" { | |
| 505 | + | turnID = uuid.NewString() | |
| 506 | + | } | |
| 507 | + | if err := m.resumeWithTurn(ctx, childID, prompt, turnID); err != nil { | |
| 508 | + | return ResumeOutcome{}, err | |
| 509 | + | } | |
| 510 | + | return ResumeOutcome{TurnID: turnID}, nil | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | // resumeWithTurn queues a follow-up turn with a caller-owned ID. The ID names the | |
| 446 | 514 | // accepted follow-up turn in its completion, so a caller that must observe the | |
| 447 | 515 | // outcome can subscribe with WatchCompletion before the turn can finish. The | |
| 448 | 516 | // ID must be unique per child: replay rejects a duplicate turn.accepted record, | |
| 449 | 517 | // so reusing one makes the journal unreplayable. | |
| 450 | - | func (m *ResidentManager) ResumeWithTurn(ctx context.Context, childID, prompt, turnID string) error { | |
| 518 | + | func (m *ResidentManager) resumeWithTurn(ctx context.Context, childID, prompt, turnID string) error { | |
| 451 | 519 | if strings.TrimSpace(turnID) == "" { | |
| 452 | 520 | turnID = uuid.NewString() | |
| 453 | 521 | } | |
| @@ -928,6 +996,27 @@ func (m *ResidentManager) UnmetRequired() []ResidentSnapshot { | |||
| 928 | 996 | return result | |
| 929 | 997 | } | |
| 930 | 998 | ||
| 999 | + | // Interrupt cancels only a live child's running turn and drops its queued | |
| 1000 | + | // follow-ups. The child stays live with its transcript, so a later resume | |
| 1001 | + | // continues with full context. It reports false when no turn is running. | |
| 1002 | + | func (m *ResidentManager) Interrupt(ctx context.Context, childID string) (bool, error) { | |
| 1003 | + | if m == nil { | |
| 1004 | + | return false, errors.New("resident manager: unavailable") | |
| 1005 | + | } | |
| 1006 | + | m.lifecycleMu.Lock() | |
| 1007 | + | defer m.lifecycleMu.Unlock() | |
| 1008 | + | child := m.Get(strings.TrimSpace(childID)) | |
| 1009 | + | if child == nil { | |
| 1010 | + | return false, errors.New("resident manager: child is not live") | |
| 1011 | + | } | |
| 1012 | + | interrupted, err := child.interruptTurn(ctx) | |
| 1013 | + | if err != nil { | |
| 1014 | + | return false, err | |
| 1015 | + | } | |
| 1016 | + | m.notifyUpdate(child.spec.ID, false) | |
| 1017 | + | return interrupted, nil | |
| 1018 | + | } | |
| 1019 | + | ||
| 931 | 1020 | // Stop cancels one live child and waits for its control loop. A disk-only | |
| 932 | 1021 | // interrupted child must be explicitly resumed by host construction instead. | |
| 933 | 1022 | func (m *ResidentManager) Stop(ctx context.Context, childID string) error { | |
| @@ -16,9 +16,9 @@ import ( | |||||
| 16 | ) | 16 | ) | ||
| 17 | 17 | ||||
| 18 | // ResidentFactory is the host-owned construction boundary. It receives only | 18 | // ResidentFactory is the host-owned construction boundary. It receives only | ||
| 19 | - | // an already-resolved non-secret specification and returns an in-process turn | 19 | + | // an already-resolved non-secret specification and returns an in-process |
| 20 | - | // runner; it never starts another zut binary. | 20 | + | // runtime; it never starts another zut binary. |
| 21 | - | type ResidentFactory func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) | 21 | + | type ResidentFactory func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) |
| 22 | 22 | ||||
| 23 | type ResidentManager struct { | 23 | type ResidentManager struct { | ||
| 24 | root string | 24 | root string | ||
| @@ -216,6 +216,7 @@ type ResidentSnapshot struct { | |||||
| 216 | TurnStartedAt time.Time | 216 | TurnStartedAt time.Time | ||
| 217 | ActivityUpdatedAt time.Time | 217 | ActivityUpdatedAt time.Time | ||
| 218 | WaitingForModel bool | 218 | WaitingForModel bool | ||
| 219 | + | PendingFollowUps int | |||
| 219 | OwnedElsewhere bool | 220 | OwnedElsewhere bool | ||
| 220 | Usage provider.Usage | 221 | Usage provider.Usage | ||
| 221 | ContextUsed int | 222 | ContextUsed int | ||
| @@ -232,7 +233,7 @@ func residentSnapshot(child *ResidentChild) ResidentSnapshot { | |||||
| 232 | Provider: child.spec.Provider, Model: child.spec.Model, | 233 | Provider: child.spec.Provider, Model: child.spec.Model, | ||
| 233 | WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required, | 234 | WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required, | ||
| 234 | UpdatedAt: child.StateUpdatedAt(), TurnStartedAt: child.TurnStartedAt(), | 235 | UpdatedAt: child.StateUpdatedAt(), TurnStartedAt: child.TurnStartedAt(), | ||
| 235 | - | ActivityUpdatedAt: child.ActivityUpdatedAt(), WaitingForModel: live.WaitingForModel, | 236 | + | ActivityUpdatedAt: child.ActivityUpdatedAt(), WaitingForModel: live.WaitingForModel, PendingFollowUps: child.PendingFollowUps(), |
| 236 | Usage: live.Usage, ContextUsed: live.ContextUsed, ContextMax: live.ContextMax, Subscription: live.Subscription} | 237 | Usage: live.Usage, ContextUsed: live.ContextUsed, ContextMax: live.ContextMax, Subscription: live.Subscription} | ||
| 237 | } | 238 | } | ||
| 238 | 239 | ||||
| @@ -378,7 +379,20 @@ func (m *ResidentManager) Spawn(ctx context.Context, spec ResidentChildSpec, tas | |||||
| 378 | return child, nil | 379 | return child, nil | ||
| 379 | } | 380 | } | ||
| 380 | 381 | ||||
| 381 | - | func (m *ResidentManager) scheduledRunner(childID string, runner ResidentTurnRunner) ResidentTurnRunner { | 382 | + | // scheduledRuntime gates each turn on a global scheduler slot. Steering needs |
| 383 | + | // no slot: it only reaches a turn that already holds one. | |||
| 384 | + | type scheduledRuntime struct { | |||
| 385 | + | ResidentRuntime | |||
| 386 | + | run ResidentTurnRunner | |||
| 387 | + | } | |||
| 388 | + | ||||
| 389 | + | func (r scheduledRuntime) Run(ctx context.Context, prompt string) error { return r.run(ctx, prompt) } | |||
| 390 | + | ||||
| 391 | + | func (m *ResidentManager) scheduledRunner(childID string, runtime ResidentRuntime) ResidentRuntime { | |||
| 392 | + | return scheduledRuntime{ResidentRuntime: runtime, run: m.scheduledRun(childID, runtime)} | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | func (m *ResidentManager) scheduledRun(childID string, runtime ResidentRuntime) ResidentTurnRunner { | |||
| 382 | return func(ctx context.Context, prompt string) error { | 396 | return func(ctx context.Context, prompt string) error { | ||
| 383 | if ctx == nil { | 397 | if ctx == nil { | ||
| 384 | ctx = context.Background() | 398 | ctx = context.Background() | ||
| @@ -418,7 +432,7 @@ func (m *ResidentManager) scheduledRunner(childID string, runner ResidentTurnRun | |||||
| 418 | _ = m.scheduler.Release(childID) | 432 | _ = m.scheduler.Release(childID) | ||
| 419 | m.dispatch() | 433 | m.dispatch() | ||
| 420 | }() | 434 | }() | ||
| 421 | - | return runner(ctx, prompt) | 435 | + | return runtime.Run(ctx, prompt) |
| 422 | } | 436 | } | ||
| 423 | } | 437 | } | ||
| 424 | 438 | ||||
| @@ -439,15 +453,69 @@ func (m *ResidentManager) dispatch() { | |||||
| 439 | // resident child. A disk-only child is rebuilt only for this explicit new | 453 | // resident child. A disk-only child is rebuilt only for this explicit new | ||
| 440 | // prompt; reconciliation itself never constructs or replays one. | 454 | // prompt; reconciliation itself never constructs or replays one. | ||
| 441 | func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) error { | 455 | func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) error { | ||
| 442 | - | return m.ResumeWithTurn(ctx, childID, prompt, uuid.NewString()) | 456 | + | _, err := m.ResumeFollowUp(ctx, childID, prompt, ResumeSteer, uuid.NewString(), nil) |
| 457 | + | return err | |||
| 443 | } | 458 | } | ||
| 444 | 459 | ||||
| 445 | - | // ResumeWithTurn is Resume with a caller-owned turn ID. The ID names the | 460 | + | // ResumeMode selects how a follow-up reaches a child that is running a turn. |
| 461 | + | type ResumeMode string | |||
| 462 | + | ||||
| 463 | + | const ( | |||
| 464 | + | // ResumeSteer injects the follow-up into the running turn at its next | |||
| 465 | + | // tool or model boundary. An idle child starts a new turn instead. | |||
| 466 | + | ResumeSteer ResumeMode = "steer" | |||
| 467 | + | // ResumeQueue runs the follow-up as a separate turn after the current one. | |||
| 468 | + | ResumeQueue ResumeMode = "queue" | |||
| 469 | + | ) | |||
| 470 | + | ||||
| 471 | + | // ResumeOutcome identifies the turn that will answer a follow-up. | |||
| 472 | + | type ResumeOutcome struct { | |||
| 473 | + | // TurnID is the new turn for a queued follow-up, or the running turn | |||
| 474 | + | // that received a steered one. | |||
| 475 | + | TurnID string | |||
| 476 | + | Steered bool | |||
| 477 | + | } | |||
| 478 | + | ||||
| 479 | + | // ResumeFollowUp delivers a follow-up by mode. A steered follow-up joins the | |||
| 480 | + | // running turn, so it creates no new turn and no new completion. onSteered, when | |||
| 481 | + | // set, runs before that turn can complete so a caller can watch its completion | |||
| 482 | + | // without racing it. When steering is impossible, the follow-up is queued as a | |||
| 483 | + | // new turn named turnID. | |||
| 484 | + | func (m *ResidentManager) ResumeFollowUp(ctx context.Context, childID, prompt string, mode ResumeMode, turnID string, onSteered func(turnID string)) (ResumeOutcome, error) { | |||
| 485 | + | if mode == "" { | |||
| 486 | + | mode = ResumeSteer | |||
| 487 | + | } | |||
| 488 | + | if mode != ResumeSteer && mode != ResumeQueue { | |||
| 489 | + | return ResumeOutcome{}, fmt.Errorf("resident manager: unknown resume mode %q", mode) | |||
| 490 | + | } | |||
| 491 | + | if mode == ResumeSteer && m != nil && strings.TrimSpace(prompt) != "" { | |||
| 492 | + | m.lifecycleMu.Lock() | |||
| 493 | + | child := m.Get(strings.TrimSpace(childID)) | |||
| 494 | + | if child != nil { | |||
| 495 | + | steered, activeTurn, err := child.steer(ctx, prompt, onSteered) | |||
| 496 | + | if err == nil && steered { | |||
| 497 | + | m.lifecycleMu.Unlock() | |||
| 498 | + | m.notifyUpdate(child.spec.ID, false) | |||
| 499 | + | return ResumeOutcome{TurnID: activeTurn, Steered: true}, nil | |||
| 500 | + | } | |||
| 501 | + | } | |||
| 502 | + | m.lifecycleMu.Unlock() | |||
| 503 | + | } | |||
| 504 | + | if strings.TrimSpace(turnID) == "" { | |||
| 505 | + | turnID = uuid.NewString() | |||
| 506 | + | } | |||
| 507 | + | if err := m.resumeWithTurn(ctx, childID, prompt, turnID); err != nil { | |||
| 508 | + | return ResumeOutcome{}, err | |||
| 509 | + | } | |||
| 510 | + | return ResumeOutcome{TurnID: turnID}, nil | |||
| 511 | + | } | |||
| 512 | + | ||||
| 513 | + | // resumeWithTurn queues a follow-up turn with a caller-owned ID. The ID names the | |||
| 446 | // accepted follow-up turn in its completion, so a caller that must observe the | 514 | // 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 | 515 | // 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, | 516 | // ID must be unique per child: replay rejects a duplicate turn.accepted record, | ||
| 449 | // so reusing one makes the journal unreplayable. | 517 | // so reusing one makes the journal unreplayable. | ||
| 450 | - | func (m *ResidentManager) ResumeWithTurn(ctx context.Context, childID, prompt, turnID string) error { | 518 | + | func (m *ResidentManager) resumeWithTurn(ctx context.Context, childID, prompt, turnID string) error { |
| 451 | if strings.TrimSpace(turnID) == "" { | 519 | if strings.TrimSpace(turnID) == "" { | ||
| 452 | turnID = uuid.NewString() | 520 | turnID = uuid.NewString() | ||
| 453 | } | 521 | } | ||
| @@ -928,6 +996,27 @@ func (m *ResidentManager) UnmetRequired() []ResidentSnapshot { | |||||
| 928 | return result | 996 | return result | ||
| 929 | } | 997 | } | ||
| 930 | 998 | ||||
| 999 | + | // Interrupt cancels only a live child's running turn and drops its queued | |||
| 1000 | + | // follow-ups. The child stays live with its transcript, so a later resume | |||
| 1001 | + | // continues with full context. It reports false when no turn is running. | |||
| 1002 | + | func (m *ResidentManager) Interrupt(ctx context.Context, childID string) (bool, error) { | |||
| 1003 | + | if m == nil { | |||
| 1004 | + | return false, errors.New("resident manager: unavailable") | |||
| 1005 | + | } | |||
| 1006 | + | m.lifecycleMu.Lock() | |||
| 1007 | + | defer m.lifecycleMu.Unlock() | |||
| 1008 | + | child := m.Get(strings.TrimSpace(childID)) | |||
| 1009 | + | if child == nil { | |||
| 1010 | + | return false, errors.New("resident manager: child is not live") | |||
| 1011 | + | } | |||
| 1012 | + | interrupted, err := child.interruptTurn(ctx) | |||
| 1013 | + | if err != nil { | |||
| 1014 | + | return false, err | |||
| 1015 | + | } | |||
| 1016 | + | m.notifyUpdate(child.spec.ID, false) | |||
| 1017 | + | return interrupted, nil | |||
| 1018 | + | } | |||
| 1019 | + | ||||
| 931 | // Stop cancels one live child and waits for its control loop. A disk-only | 1020 | // Stop cancels one live child and waits for its control loop. A disk-only | ||
| 932 | // interrupted child must be explicitly resumed by host construction instead. | 1021 | // interrupted child must be explicitly resumed by host construction instead. | ||
| 933 | func (m *ResidentManager) Stop(ctx context.Context, childID string) error { | 1022 | func (m *ResidentManager) Stop(ctx context.Context, childID string) error { | ||
M packages/agent/subagents/resident_manager_test.go +69 -69
| @@ -20,8 +20,8 @@ import ( | |||
| 20 | 20 | ||
| 21 | 21 | func TestResidentManagerSpawnsJournaledInProcessChild(t *testing.T) { | |
| 22 | 22 | run := make(chan string, 1) | |
| 23 | - | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | |
| 24 | - | return func(_ context.Context, prompt string) error { run <- prompt; return nil }, nil | |
| 23 | + | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { | |
| 24 | + | return ResidentTurnRunner(func(_ context.Context, prompt string) error { run <- prompt; return nil }), nil | |
| 25 | 25 | }) | |
| 26 | 26 | defer manager.Close(context.Background()) | |
| 27 | 27 | ||
| @@ -57,10 +57,10 @@ func TestResidentManagerSpawnsJournaledInProcessChild(t *testing.T) { | |||
| 57 | 57 | } | |
| 58 | 58 | ||
| 59 | 59 | func TestResidentManagerCompletedAnswerCanResume(t *testing.T) { | |
| 60 | - | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) { | |
| 61 | - | return func(context.Context, string) error { | |
| 60 | + | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentRuntime, error) { | |
| 61 | + | return ResidentTurnRunner(func(context.Context, string) error { | |
| 62 | 62 | return journal.RecordAgentEvent(core.EvUsage{Cumulative: provider.Usage{InputTokens: 100}}) | |
| 63 | - | }, nil | |
| 63 | + | }), nil | |
| 64 | 64 | }) | |
| 65 | 65 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 66 | 66 | spec := ResidentChildSpec{ID: "completed-child", SessionID: "completed-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5"} | |
| @@ -82,11 +82,11 @@ func TestResidentManagerCompletedAnswerCanResume(t *testing.T) { | |||
| 82 | 82 | } | |
| 83 | 83 | } | |
| 84 | 84 | ||
| 85 | - | // ResumeWithTurn lets a caller subscribe to the exact follow-up turn before it | |
| 85 | + | // resumeWithTurn lets a caller subscribe to the exact follow-up turn before it | |
| 86 | 86 | // can complete. | |
| 87 | 87 | func TestResidentManagerResumeWithTurnNamesTheAcceptedTurn(t *testing.T) { | |
| 88 | - | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, _ *ResidentJournal) (ResidentTurnRunner, error) { | |
| 89 | - | return func(context.Context, string) error { return nil }, nil | |
| 88 | + | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) { | |
| 89 | + | return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil | |
| 90 | 90 | }) | |
| 91 | 91 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 92 | 92 | initial, cancelInitial := manager.WatchCompletion("turn-child", "initial-turn") | |
| @@ -101,8 +101,8 @@ func TestResidentManagerResumeWithTurnNamesTheAcceptedTurn(t *testing.T) { | |||
| 101 | 101 | } | |
| 102 | 102 | followUp, cancelFollowUp := manager.WatchCompletion("turn-child", "follow-up-turn") | |
| 103 | 103 | defer cancelFollowUp() | |
| 104 | - | if err := manager.ResumeWithTurn(context.Background(), "turn-child", "continue", "follow-up-turn"); err != nil { | |
| 105 | - | t.Fatalf("ResumeWithTurn: %v", err) | |
| 104 | + | if err := manager.resumeWithTurn(context.Background(), "turn-child", "continue", "follow-up-turn"); err != nil { | |
| 105 | + | t.Fatalf("resumeWithTurn: %v", err) | |
| 106 | 106 | } | |
| 107 | 107 | select { | |
| 108 | 108 | case completion := <-followUp: | |
| @@ -121,8 +121,8 @@ func TestResidentManagerResumeWithTurnNamesTheAcceptedTurn(t *testing.T) { | |||
| 121 | 121 | // the completion is still addressable and the journal stays replayable. | |
| 122 | 122 | func TestResidentManagerResumeWithTurnGeneratesIDForBlank(t *testing.T) { | |
| 123 | 123 | completions := make(chan ResidentCompletion, 4) | |
| 124 | - | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, _ *ResidentJournal) (ResidentTurnRunner, error) { | |
| 125 | - | return func(context.Context, string) error { return nil }, nil | |
| 124 | + | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) { | |
| 125 | + | return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil | |
| 126 | 126 | }) | |
| 127 | 127 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 128 | 128 | initial, cancelInitial := manager.WatchCompletion("blank-turn-child", "initial-turn") | |
| @@ -136,8 +136,8 @@ func TestResidentManagerResumeWithTurnGeneratesIDForBlank(t *testing.T) { | |||
| 136 | 136 | t.Fatal("initial turn did not complete") | |
| 137 | 137 | } | |
| 138 | 138 | manager.SetCompletionObserver(func(completion ResidentCompletion) { completions <- completion }) | |
| 139 | - | if err := manager.ResumeWithTurn(context.Background(), "blank-turn-child", "continue", " "); err != nil { | |
| 140 | - | t.Fatalf("ResumeWithTurn: %v", err) | |
| 139 | + | if err := manager.resumeWithTurn(context.Background(), "blank-turn-child", "continue", " "); err != nil { | |
| 140 | + | t.Fatalf("resumeWithTurn: %v", err) | |
| 141 | 141 | } | |
| 142 | 142 | select { | |
| 143 | 143 | case completion := <-completions: | |
| @@ -151,10 +151,10 @@ func TestResidentManagerResumeWithTurnGeneratesIDForBlank(t *testing.T) { | |||
| 151 | 151 | ||
| 152 | 152 | func TestResidentManagerCompletionCarriesFinalSummary(t *testing.T) { | |
| 153 | 153 | completed := make(chan ResidentCompletion, 1) | |
| 154 | - | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) { | |
| 155 | - | return func(context.Context, string) error { | |
| 154 | + | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentRuntime, error) { | |
| 155 | + | return ResidentTurnRunner(func(context.Context, string) error { | |
| 156 | 156 | return journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "the actual child answer"}}}}) | |
| 157 | - | }, nil | |
| 157 | + | }), nil | |
| 158 | 158 | }) | |
| 159 | 159 | manager.SetCompletionObserver(func(completion ResidentCompletion) { completed <- completion }) | |
| 160 | 160 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| @@ -179,13 +179,13 @@ func TestResidentManagerReportsQueuedTurnsWhenFinalResultCannotPersist(t *testin | |||
| 179 | 179 | release := make(chan struct{}) | |
| 180 | 180 | completed := make(chan ResidentCompletion, 2) | |
| 181 | 181 | var journal *ResidentJournal | |
| 182 | - | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, childJournal *ResidentJournal) (ResidentTurnRunner, error) { | |
| 182 | + | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, childJournal *ResidentJournal) (ResidentRuntime, error) { | |
| 183 | 183 | journal = childJournal | |
| 184 | - | return func(context.Context, string) error { | |
| 184 | + | return ResidentTurnRunner(func(context.Context, string) error { | |
| 185 | 185 | close(started) | |
| 186 | 186 | <-release | |
| 187 | 187 | return nil | |
| 188 | - | }, nil | |
| 188 | + | }), nil | |
| 189 | 189 | }) | |
| 190 | 190 | manager.SetCompletionObserver(func(completion ResidentCompletion) { completed <- completion }) | |
| 191 | 191 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| @@ -226,12 +226,12 @@ func TestResidentManagerReportsQueuedTurnsWhenFinalResultCannotPersist(t *testin | |||
| 226 | 226 | func TestResidentManagerReportsAcceptedInterruptedTurn(t *testing.T) { | |
| 227 | 227 | started := make(chan struct{}) | |
| 228 | 228 | completed := make(chan ResidentCompletion, 1) | |
| 229 | - | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | |
| 230 | - | return func(ctx context.Context, _ string) error { | |
| 229 | + | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { | |
| 230 | + | return ResidentTurnRunner(func(ctx context.Context, _ string) error { | |
| 231 | 231 | close(started) | |
| 232 | 232 | <-ctx.Done() | |
| 233 | 233 | return ctx.Err() | |
| 234 | - | }, nil | |
| 234 | + | }), nil | |
| 235 | 235 | }) | |
| 236 | 236 | manager.SetCompletionObserver(func(completion ResidentCompletion) { completed <- completion }) | |
| 237 | 237 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| @@ -261,12 +261,12 @@ func TestResidentManagerReportsAcceptedInterruptedTurn(t *testing.T) { | |||
| 261 | 261 | func TestResidentManagerLiveReturnsChildSnapshot(t *testing.T) { | |
| 262 | 262 | started := make(chan struct{}) | |
| 263 | 263 | release := make(chan struct{}) | |
| 264 | - | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | |
| 265 | - | return func(context.Context, string) error { | |
| 264 | + | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { | |
| 265 | + | return ResidentTurnRunner(func(context.Context, string) error { | |
| 266 | 266 | close(started) | |
| 267 | 267 | <-release | |
| 268 | 268 | return nil | |
| 269 | - | }, nil | |
| 269 | + | }), nil | |
| 270 | 270 | }) | |
| 271 | 271 | defer manager.Close(context.Background()) | |
| 272 | 272 | defer close(release) | |
| @@ -294,8 +294,8 @@ func TestResidentManagerHistoryObserverSkipsTransientStreamEvents(t *testing.T) | |||
| 294 | 294 | persist := make(chan struct{}) | |
| 295 | 295 | release := make(chan struct{}) | |
| 296 | 296 | historyUpdates := make(chan string, 1) | |
| 297 | - | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) { | |
| 298 | - | return func(context.Context, string) error { | |
| 297 | + | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentRuntime, error) { | |
| 298 | + | return ResidentTurnRunner(func(context.Context, string) error { | |
| 299 | 299 | close(started) | |
| 300 | 300 | if err := journal.RecordAgentEvent(core.EvTextDelta{Delta: "streaming"}); err != nil { | |
| 301 | 301 | return err | |
| @@ -307,7 +307,7 @@ func TestResidentManagerHistoryObserverSkipsTransientStreamEvents(t *testing.T) | |||
| 307 | 307 | } | |
| 308 | 308 | <-release | |
| 309 | 309 | return nil | |
| 310 | - | }, nil | |
| 310 | + | }), nil | |
| 311 | 311 | }) | |
| 312 | 312 | manager.SetHistoryUpdateObserver(func(childID string) { historyUpdates <- childID }) | |
| 313 | 313 | t.Cleanup(func() { | |
| @@ -341,12 +341,12 @@ func TestResidentManagerActivityObserverTracksRunningChildrenWithoutPolling(t *t | |||
| 341 | 341 | started := make(chan struct{}) | |
| 342 | 342 | release := make(chan struct{}) | |
| 343 | 343 | activity := make(chan bool, 2) | |
| 344 | - | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | |
| 345 | - | return func(context.Context, string) error { | |
| 344 | + | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { | |
| 345 | + | return ResidentTurnRunner(func(context.Context, string) error { | |
| 346 | 346 | close(started) | |
| 347 | 347 | <-release | |
| 348 | 348 | return nil | |
| 349 | - | }, nil | |
| 349 | + | }), nil | |
| 350 | 350 | }) | |
| 351 | 351 | manager.SetActivityObserver(func(active bool) { activity <- active }) | |
| 352 | 352 | t.Cleanup(func() { | |
| @@ -387,7 +387,7 @@ func TestResidentManagerActivityObserverTracksRunningChildrenWithoutPolling(t *t | |||
| 387 | 387 | ||
| 388 | 388 | func TestResidentManagerRecordsFactoryFailureAfterAcceptance(t *testing.T) { | |
| 389 | 389 | root := t.TempDir() | |
| 390 | - | manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | |
| 390 | + | manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { | |
| 391 | 391 | return nil, errors.New("synthetic factory failure") | |
| 392 | 392 | }) | |
| 393 | 393 | spec := ResidentChildSpec{ID: "factory-failure", SessionID: "child-session", Provider: "openai", Model: "gpt-5"} | |
| @@ -412,7 +412,7 @@ func TestResidentManagerReconcileIgnoresLegacyAgentsContainer(t *testing.T) { | |||
| 412 | 412 | if err := os.WriteFile(filepath.Join(legacyChild, "events.jsonl"), []byte("legacy\n"), 0o600); err != nil { | |
| 413 | 413 | t.Fatal(err) | |
| 414 | 414 | } | |
| 415 | - | manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | |
| 415 | + | manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { | |
| 416 | 416 | return nil, nil | |
| 417 | 417 | }) | |
| 418 | 418 | if errs := manager.Reconcile(); len(errs) != 0 { | |
| @@ -425,15 +425,15 @@ func TestResidentManagerReconcileIgnoresLegacyAgentsContainer(t *testing.T) { | |||
| 425 | 425 | ||
| 426 | 426 | func TestResidentManagerResumeRebuildsStoppedChild(t *testing.T) { | |
| 427 | 427 | runs := make(chan string, 2) | |
| 428 | - | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | |
| 429 | - | return func(ctx context.Context, prompt string) error { | |
| 428 | + | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { | |
| 429 | + | return ResidentTurnRunner(func(ctx context.Context, prompt string) error { | |
| 430 | 430 | runs <- prompt | |
| 431 | 431 | if prompt == "initial" { | |
| 432 | 432 | <-ctx.Done() | |
| 433 | 433 | return ctx.Err() | |
| 434 | 434 | } | |
| 435 | 435 | return nil | |
| 436 | - | }, nil | |
| 436 | + | }), nil | |
| 437 | 437 | }) | |
| 438 | 438 | defer manager.Close(context.Background()) | |
| 439 | 439 | spec := ResidentChildSpec{ID: "stopped-child", SessionID: "child-session", Provider: "openai", Model: "gpt-5", Required: true} | |
| @@ -470,11 +470,11 @@ func TestResidentManagerPreparesWorkspaceBeforeAcceptance(t *testing.T) { | |||
| 470 | 470 | t.Fatalf("workspace request = %#v", req) | |
| 471 | 471 | } | |
| 472 | 472 | return SharedWorkspace{Root: req.RepositoryRoot}.Prepare(context.Background(), req) | |
| 473 | - | }, func(spec ResidentChildSpec, _ *ResidentJournal) (ResidentTurnRunner, error) { | |
| 473 | + | }, func(spec ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) { | |
| 474 | 474 | if !prepared || spec.Workspace != "/repo" { | |
| 475 | 475 | t.Fatalf("factory spec = %#v, prepared=%t", spec, prepared) | |
| 476 | 476 | } | |
| 477 | - | return func(context.Context, string) error { return nil }, nil | |
| 477 | + | return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil | |
| 478 | 478 | }) | |
| 479 | 479 | defer manager.Close(context.Background()) | |
| 480 | 480 | if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "workspace-child", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo"}, "task"); err != nil { | |
| @@ -487,8 +487,8 @@ func TestResidentManagerRejectsWorkspaceOutsideAllowedRootsBeforeAcceptance(t *t | |||
| 487 | 487 | manager := newResidentManager(t.TempDir(), SubagentPolicy{AllowedRoots: []string{t.TempDir()}}, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) { | |
| 488 | 488 | prepared = true | |
| 489 | 489 | return nil, nil | |
| 490 | - | }, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | |
| 491 | - | return func(context.Context, string) error { return nil }, nil | |
| 490 | + | }, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { | |
| 491 | + | return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil | |
| 492 | 492 | }) | |
| 493 | 493 | outside := t.TempDir() | |
| 494 | 494 | if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "outside-root", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: outside}, "task"); err == nil { | |
| @@ -504,8 +504,8 @@ func TestResidentManagerCapturesSuccessfulWorktreeBeforeCleanup(t *testing.T) { | |||
| 504 | 504 | workspace := &testResidentWorkspace{dir: "/isolated", mode: WorkspaceWorktree, capture: WorkspaceCapture{Patch: []byte("patch"), ChangedFiles: []string{"changed.go"}}} | |
| 505 | 505 | manager := NewResidentManagerWithWorkspace(root, 0, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) { | |
| 506 | 506 | return workspace, nil | |
| 507 | - | }, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | |
| 508 | - | return func(context.Context, string) error { return nil }, nil | |
| 507 | + | }, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { | |
| 508 | + | return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil | |
| 509 | 509 | }) | |
| 510 | 510 | defer manager.Close(context.Background()) | |
| 511 | 511 | if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "capture-child", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo", WorkspaceMode: WorkspaceWorktree}, "task"); err != nil { | |
| @@ -545,8 +545,8 @@ func TestResidentManagerCapturesFailedWorktreePatch(t *testing.T) { | |||
| 545 | 545 | workspace := &testResidentWorkspace{dir: "/isolated", mode: WorkspaceWorktree, capture: WorkspaceCapture{Patch: []byte("partial patch"), ChangedFiles: []string{"changed.go"}}} | |
| 546 | 546 | manager := NewResidentManagerWithWorkspace(root, 0, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) { | |
| 547 | 547 | return workspace, nil | |
| 548 | - | }, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | |
| 549 | - | return func(context.Context, string) error { return errors.New("turn failed") }, nil | |
| 548 | + | }, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { | |
| 549 | + | return ResidentTurnRunner(func(context.Context, string) error { return errors.New("turn failed") }), nil | |
| 550 | 550 | }) | |
| 551 | 551 | defer manager.Close(context.Background()) | |
| 552 | 552 | if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "failed-capture", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo", WorkspaceMode: WorkspaceWorktree}, "task"); err != nil { | |
| @@ -582,8 +582,8 @@ func TestResidentManagerCapturesFailedWorktreePatch(t *testing.T) { | |||
| 582 | 582 | ||
| 583 | 583 | func TestResidentManagerRetainsFailedWorktree(t *testing.T) { | |
| 584 | 584 | workspace := &testResidentWorkspace{dir: "/isolated", mode: WorkspaceWorktree} | |
| 585 | - | manager := NewResidentManagerWithWorkspace(t.TempDir(), 0, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) { return workspace, nil }, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | |
| 586 | - | return func(context.Context, string) error { return errors.New("turn failed") }, nil | |
| 585 | + | manager := NewResidentManagerWithWorkspace(t.TempDir(), 0, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) { return workspace, nil }, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { | |
| 586 | + | return ResidentTurnRunner(func(context.Context, string) error { return errors.New("turn failed") }), nil | |
| 587 | 587 | }) | |
| 588 | 588 | defer manager.Close(context.Background()) | |
| 589 | 589 | if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "failed-worktree", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo", WorkspaceMode: WorkspaceWorktree}, "task"); err != nil { | |
| @@ -608,10 +608,10 @@ func TestResidentManagerCapturesRealWorktreePatch(t *testing.T) { | |||
| 608 | 608 | t.Skip("git is required") | |
| 609 | 609 | } | |
| 610 | 610 | root, repo := t.TempDir(), initTestRepo(t) | |
| 611 | - | manager := NewResidentManager(root, func(spec ResidentChildSpec, _ *ResidentJournal) (ResidentTurnRunner, error) { | |
| 612 | - | return func(context.Context, string) error { | |
| 611 | + | manager := NewResidentManager(root, func(spec ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) { | |
| 612 | + | return ResidentTurnRunner(func(context.Context, string) error { | |
| 613 | 613 | return os.WriteFile(filepath.Join(spec.Workspace, "README.md"), []byte("resident change\n"), 0o600) | |
| 614 | - | }, nil | |
| 614 | + | }), nil | |
| 615 | 615 | }) | |
| 616 | 616 | defer manager.Close(context.Background()) | |
| 617 | 617 | child, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "git-worktree", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: repo, WorkspaceMode: WorkspaceWorktree, WorkspaceBase: "HEAD", WorkspaceCapture: CapturePatch}, "task") | |
| @@ -673,8 +673,8 @@ func (w *testResidentWorkspace) Cleanup(context.Context) error { w.cleaned.Store | |||
| 673 | 673 | ||
| 674 | 674 | func TestResidentManagerRejectsDuplicateBeforeSecondJournalAcceptance(t *testing.T) { | |
| 675 | 675 | root := t.TempDir() | |
| 676 | - | manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | |
| 677 | - | return func(context.Context, string) error { return nil }, nil | |
| 676 | + | manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { | |
| 677 | + | return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil | |
| 678 | 678 | }) | |
| 679 | 679 | defer manager.Close(context.Background()) | |
| 680 | 680 | spec := ResidentChildSpec{ID: "duplicate", SessionID: "child-session", Provider: "openai", Model: "gpt-5"} | |
| @@ -794,9 +794,9 @@ func TestResidentManagerReconcileMakesInterruptedJournalDiscoverableWithoutRepla | |||
| 794 | 794 | t.Fatal(err) | |
| 795 | 795 | } | |
| 796 | 796 | runs := 0 | |
| 797 | - | manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | |
| 797 | + | manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { | |
| 798 | 798 | runs++ | |
| 799 | - | return func(context.Context, string) error { return nil }, nil | |
| 799 | + | return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil | |
| 800 | 800 | }) | |
| 801 | 801 | if errs := manager.Reconcile(); len(errs) != 0 { | |
| 802 | 802 | t.Fatalf("Reconcile errors = %v", errs) | |
| @@ -881,8 +881,8 @@ func TestResidentManagerReconcileDoesNotMutateForeignProcessJournal(t *testing.T | |||
| 881 | 881 | t.Fatal(err) | |
| 882 | 882 | } | |
| 883 | 883 | ||
| 884 | - | observer := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | |
| 885 | - | return func(context.Context, string) error { return nil }, nil | |
| 884 | + | observer := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { | |
| 885 | + | return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil | |
| 886 | 886 | }) | |
| 887 | 887 | if errs := observer.Reconcile(); len(errs) != 0 { | |
| 888 | 888 | t.Fatalf("Reconcile errors = %v", errs) | |
| @@ -944,11 +944,11 @@ func TestResidentManagerExplicitlyResumesRecoveredChildWithoutReplayingTask(t *t | |||
| 944 | 944 | t.Fatal(err) | |
| 945 | 945 | } | |
| 946 | 946 | runs := make(chan string, 1) | |
| 947 | - | manager := NewResidentManager(root, func(got ResidentChildSpec, _ *ResidentJournal) (ResidentTurnRunner, error) { | |
| 947 | + | manager := NewResidentManager(root, func(got ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) { | |
| 948 | 948 | if got.SessionID != spec.SessionID { | |
| 949 | 949 | t.Fatalf("session = %q, want %q", got.SessionID, spec.SessionID) | |
| 950 | 950 | } | |
| 951 | - | return func(_ context.Context, prompt string) error { runs <- prompt; return nil }, nil | |
| 951 | + | return ResidentTurnRunner(func(_ context.Context, prompt string) error { runs <- prompt; return nil }), nil | |
| 952 | 952 | }) | |
| 953 | 953 | defer manager.Close(context.Background()) | |
| 954 | 954 | if errs := manager.Reconcile(); len(errs) != 0 { | |
| @@ -989,8 +989,8 @@ func TestResidentManagerExplicitResumeReusesInterruptedWorktree(t *testing.T) { | |||
| 989 | 989 | t.Fatalf("resume workspace request = %#v", req) | |
| 990 | 990 | } | |
| 991 | 991 | return workspace, nil | |
| 992 | - | }, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | |
| 993 | - | return func(context.Context, string) error { return nil }, nil | |
| 992 | + | }, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { | |
| 993 | + | return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil | |
| 994 | 994 | }) | |
| 995 | 995 | defer manager.Close(context.Background()) | |
| 996 | 996 | if errs := manager.Reconcile(); len(errs) != 0 { | |
| @@ -1004,14 +1004,14 @@ func TestResidentManagerExplicitResumeReusesInterruptedWorktree(t *testing.T) { | |||
| 1004 | 1004 | func TestResidentManagerAppliesPositiveQueueTimeoutDurably(t *testing.T) { | |
| 1005 | 1005 | started := make(chan string, 2) | |
| 1006 | 1006 | release := make(chan struct{}) | |
| 1007 | - | manager := NewResidentManagerWithPolicy(t.TempDir(), SubagentPolicy{MaxConcurrent: 1, QueueTimeout: 20 * time.Millisecond}, func(spec ResidentChildSpec, _ *ResidentJournal) (ResidentTurnRunner, error) { | |
| 1008 | - | return func(_ context.Context, _ string) error { | |
| 1007 | + | manager := NewResidentManagerWithPolicy(t.TempDir(), SubagentPolicy{MaxConcurrent: 1, QueueTimeout: 20 * time.Millisecond}, func(spec ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) { | |
| 1008 | + | return ResidentTurnRunner(func(_ context.Context, _ string) error { | |
| 1009 | 1009 | started <- spec.ID | |
| 1010 | 1010 | if spec.ID == "first" { | |
| 1011 | 1011 | <-release | |
| 1012 | 1012 | } | |
| 1013 | 1013 | return nil | |
| 1014 | - | }, nil | |
| 1014 | + | }), nil | |
| 1015 | 1015 | }) | |
| 1016 | 1016 | defer manager.Close(context.Background()) | |
| 1017 | 1017 | if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "first", SessionID: "first", Provider: "openai", Model: "gpt-5"}, "first"); err != nil { | |
| @@ -1048,8 +1048,8 @@ func TestResidentManagerAppliesPositiveQueueTimeoutDurably(t *testing.T) { | |||
| 1048 | 1048 | } | |
| 1049 | 1049 | ||
| 1050 | 1050 | func TestResidentManagerNotifiesAcceptedObserverForFollowUps(t *testing.T) { | |
| 1051 | - | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | |
| 1052 | - | return func(context.Context, string) error { return nil }, nil | |
| 1051 | + | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { | |
| 1052 | + | return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil | |
| 1053 | 1053 | }) | |
| 1054 | 1054 | defer manager.Close(context.Background()) | |
| 1055 | 1055 | accepted := make(chan string, 2) | |
| @@ -1082,7 +1082,7 @@ func TestResidentManagerReconcileReplacesStaleRecoveredSnapshots(t *testing.T) { | |||
| 1082 | 1082 | if err := journal.Close(); err != nil { | |
| 1083 | 1083 | t.Fatal(err) | |
| 1084 | 1084 | } | |
| 1085 | - | manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { return nil, nil }) | |
| 1085 | + | manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { return nil, nil }) | |
| 1086 | 1086 | if errs := manager.Reconcile(); len(errs) != 0 || len(manager.Snapshot()) != 1 { | |
| 1087 | 1087 | t.Fatalf("first Reconcile = %v, snapshots=%#v", errs, manager.Snapshot()) | |
| 1088 | 1088 | } | |
| @@ -1097,8 +1097,8 @@ func TestResidentManagerReconcileReplacesStaleRecoveredSnapshots(t *testing.T) { | |||
| 1097 | 1097 | func TestResidentManagerLimitsConcurrentTurnsGlobally(t *testing.T) { | |
| 1098 | 1098 | started := make(chan string, DefaultResidentConcurrency+1) | |
| 1099 | 1099 | release := make(chan struct{}) | |
| 1100 | - | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | |
| 1101 | - | return func(ctx context.Context, prompt string) error { | |
| 1100 | + | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { | |
| 1101 | + | return ResidentTurnRunner(func(ctx context.Context, prompt string) error { | |
| 1102 | 1102 | started <- prompt | |
| 1103 | 1103 | select { | |
| 1104 | 1104 | case <-release: | |
| @@ -1106,7 +1106,7 @@ func TestResidentManagerLimitsConcurrentTurnsGlobally(t *testing.T) { | |||
| 1106 | 1106 | case <-ctx.Done(): | |
| 1107 | 1107 | return ctx.Err() | |
| 1108 | 1108 | } | |
| 1109 | - | }, nil | |
| 1109 | + | }), nil | |
| 1110 | 1110 | }) | |
| 1111 | 1111 | defer manager.Close(context.Background()) | |
| 1112 | 1112 | ||
| @@ -20,8 +20,8 @@ import ( | |||||
| 20 | 20 | ||||
| 21 | func TestResidentManagerSpawnsJournaledInProcessChild(t *testing.T) { | 21 | func TestResidentManagerSpawnsJournaledInProcessChild(t *testing.T) { | ||
| 22 | run := make(chan string, 1) | 22 | run := make(chan string, 1) | ||
| 23 | - | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | 23 | + | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { |
| 24 | - | return func(_ context.Context, prompt string) error { run <- prompt; return nil }, nil | 24 | + | return ResidentTurnRunner(func(_ context.Context, prompt string) error { run <- prompt; return nil }), nil |
| 25 | }) | 25 | }) | ||
| 26 | defer manager.Close(context.Background()) | 26 | defer manager.Close(context.Background()) | ||
| 27 | 27 | ||||
| @@ -57,10 +57,10 @@ func TestResidentManagerSpawnsJournaledInProcessChild(t *testing.T) { | |||||
| 57 | } | 57 | } | ||
| 58 | 58 | ||||
| 59 | func TestResidentManagerCompletedAnswerCanResume(t *testing.T) { | 59 | func TestResidentManagerCompletedAnswerCanResume(t *testing.T) { | ||
| 60 | - | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) { | 60 | + | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentRuntime, error) { |
| 61 | - | return func(context.Context, string) error { | 61 | + | return ResidentTurnRunner(func(context.Context, string) error { |
| 62 | return journal.RecordAgentEvent(core.EvUsage{Cumulative: provider.Usage{InputTokens: 100}}) | 62 | return journal.RecordAgentEvent(core.EvUsage{Cumulative: provider.Usage{InputTokens: 100}}) | ||
| 63 | - | }, nil | 63 | + | }), nil |
| 64 | }) | 64 | }) | ||
| 65 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | 65 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | ||
| 66 | spec := ResidentChildSpec{ID: "completed-child", SessionID: "completed-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5"} | 66 | spec := ResidentChildSpec{ID: "completed-child", SessionID: "completed-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5"} | ||
| @@ -82,11 +82,11 @@ func TestResidentManagerCompletedAnswerCanResume(t *testing.T) { | |||||
| 82 | } | 82 | } | ||
| 83 | } | 83 | } | ||
| 84 | 84 | ||||
| 85 | - | // ResumeWithTurn lets a caller subscribe to the exact follow-up turn before it | 85 | + | // resumeWithTurn lets a caller subscribe to the exact follow-up turn before it |
| 86 | // can complete. | 86 | // can complete. | ||
| 87 | func TestResidentManagerResumeWithTurnNamesTheAcceptedTurn(t *testing.T) { | 87 | func TestResidentManagerResumeWithTurnNamesTheAcceptedTurn(t *testing.T) { | ||
| 88 | - | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, _ *ResidentJournal) (ResidentTurnRunner, error) { | 88 | + | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) { |
| 89 | - | return func(context.Context, string) error { return nil }, nil | 89 | + | return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil |
| 90 | }) | 90 | }) | ||
| 91 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | 91 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | ||
| 92 | initial, cancelInitial := manager.WatchCompletion("turn-child", "initial-turn") | 92 | initial, cancelInitial := manager.WatchCompletion("turn-child", "initial-turn") | ||
| @@ -101,8 +101,8 @@ func TestResidentManagerResumeWithTurnNamesTheAcceptedTurn(t *testing.T) { | |||||
| 101 | } | 101 | } | ||
| 102 | followUp, cancelFollowUp := manager.WatchCompletion("turn-child", "follow-up-turn") | 102 | followUp, cancelFollowUp := manager.WatchCompletion("turn-child", "follow-up-turn") | ||
| 103 | defer cancelFollowUp() | 103 | defer cancelFollowUp() | ||
| 104 | - | if err := manager.ResumeWithTurn(context.Background(), "turn-child", "continue", "follow-up-turn"); err != nil { | 104 | + | if err := manager.resumeWithTurn(context.Background(), "turn-child", "continue", "follow-up-turn"); err != nil { |
| 105 | - | t.Fatalf("ResumeWithTurn: %v", err) | 105 | + | t.Fatalf("resumeWithTurn: %v", err) |
| 106 | } | 106 | } | ||
| 107 | select { | 107 | select { | ||
| 108 | case completion := <-followUp: | 108 | case completion := <-followUp: | ||
| @@ -121,8 +121,8 @@ func TestResidentManagerResumeWithTurnNamesTheAcceptedTurn(t *testing.T) { | |||||
| 121 | // the completion is still addressable and the journal stays replayable. | 121 | // the completion is still addressable and the journal stays replayable. | ||
| 122 | func TestResidentManagerResumeWithTurnGeneratesIDForBlank(t *testing.T) { | 122 | func TestResidentManagerResumeWithTurnGeneratesIDForBlank(t *testing.T) { | ||
| 123 | completions := make(chan ResidentCompletion, 4) | 123 | completions := make(chan ResidentCompletion, 4) | ||
| 124 | - | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, _ *ResidentJournal) (ResidentTurnRunner, error) { | 124 | + | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) { |
| 125 | - | return func(context.Context, string) error { return nil }, nil | 125 | + | return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil |
| 126 | }) | 126 | }) | ||
| 127 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | 127 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | ||
| 128 | initial, cancelInitial := manager.WatchCompletion("blank-turn-child", "initial-turn") | 128 | initial, cancelInitial := manager.WatchCompletion("blank-turn-child", "initial-turn") | ||
| @@ -136,8 +136,8 @@ func TestResidentManagerResumeWithTurnGeneratesIDForBlank(t *testing.T) { | |||||
| 136 | t.Fatal("initial turn did not complete") | 136 | t.Fatal("initial turn did not complete") | ||
| 137 | } | 137 | } | ||
| 138 | manager.SetCompletionObserver(func(completion ResidentCompletion) { completions <- completion }) | 138 | manager.SetCompletionObserver(func(completion ResidentCompletion) { completions <- completion }) | ||
| 139 | - | if err := manager.ResumeWithTurn(context.Background(), "blank-turn-child", "continue", " "); err != nil { | 139 | + | if err := manager.resumeWithTurn(context.Background(), "blank-turn-child", "continue", " "); err != nil { |
| 140 | - | t.Fatalf("ResumeWithTurn: %v", err) | 140 | + | t.Fatalf("resumeWithTurn: %v", err) |
| 141 | } | 141 | } | ||
| 142 | select { | 142 | select { | ||
| 143 | case completion := <-completions: | 143 | case completion := <-completions: | ||
| @@ -151,10 +151,10 @@ func TestResidentManagerResumeWithTurnGeneratesIDForBlank(t *testing.T) { | |||||
| 151 | 151 | ||||
| 152 | func TestResidentManagerCompletionCarriesFinalSummary(t *testing.T) { | 152 | func TestResidentManagerCompletionCarriesFinalSummary(t *testing.T) { | ||
| 153 | completed := make(chan ResidentCompletion, 1) | 153 | completed := make(chan ResidentCompletion, 1) | ||
| 154 | - | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) { | 154 | + | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentRuntime, error) { |
| 155 | - | return func(context.Context, string) error { | 155 | + | return ResidentTurnRunner(func(context.Context, string) error { |
| 156 | return journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "the actual child answer"}}}}) | 156 | return journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "the actual child answer"}}}}) | ||
| 157 | - | }, nil | 157 | + | }), nil |
| 158 | }) | 158 | }) | ||
| 159 | manager.SetCompletionObserver(func(completion ResidentCompletion) { completed <- completion }) | 159 | manager.SetCompletionObserver(func(completion ResidentCompletion) { completed <- completion }) | ||
| 160 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | 160 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | ||
| @@ -179,13 +179,13 @@ func TestResidentManagerReportsQueuedTurnsWhenFinalResultCannotPersist(t *testin | |||||
| 179 | release := make(chan struct{}) | 179 | release := make(chan struct{}) | ||
| 180 | completed := make(chan ResidentCompletion, 2) | 180 | completed := make(chan ResidentCompletion, 2) | ||
| 181 | var journal *ResidentJournal | 181 | var journal *ResidentJournal | ||
| 182 | - | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, childJournal *ResidentJournal) (ResidentTurnRunner, error) { | 182 | + | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, childJournal *ResidentJournal) (ResidentRuntime, error) { |
| 183 | journal = childJournal | 183 | journal = childJournal | ||
| 184 | - | return func(context.Context, string) error { | 184 | + | return ResidentTurnRunner(func(context.Context, string) error { |
| 185 | close(started) | 185 | close(started) | ||
| 186 | <-release | 186 | <-release | ||
| 187 | return nil | 187 | return nil | ||
| 188 | - | }, nil | 188 | + | }), nil |
| 189 | }) | 189 | }) | ||
| 190 | manager.SetCompletionObserver(func(completion ResidentCompletion) { completed <- completion }) | 190 | manager.SetCompletionObserver(func(completion ResidentCompletion) { completed <- completion }) | ||
| 191 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | 191 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | ||
| @@ -226,12 +226,12 @@ func TestResidentManagerReportsQueuedTurnsWhenFinalResultCannotPersist(t *testin | |||||
| 226 | func TestResidentManagerReportsAcceptedInterruptedTurn(t *testing.T) { | 226 | func TestResidentManagerReportsAcceptedInterruptedTurn(t *testing.T) { | ||
| 227 | started := make(chan struct{}) | 227 | started := make(chan struct{}) | ||
| 228 | completed := make(chan ResidentCompletion, 1) | 228 | completed := make(chan ResidentCompletion, 1) | ||
| 229 | - | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | 229 | + | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { |
| 230 | - | return func(ctx context.Context, _ string) error { | 230 | + | return ResidentTurnRunner(func(ctx context.Context, _ string) error { |
| 231 | close(started) | 231 | close(started) | ||
| 232 | <-ctx.Done() | 232 | <-ctx.Done() | ||
| 233 | return ctx.Err() | 233 | return ctx.Err() | ||
| 234 | - | }, nil | 234 | + | }), nil |
| 235 | }) | 235 | }) | ||
| 236 | manager.SetCompletionObserver(func(completion ResidentCompletion) { completed <- completion }) | 236 | manager.SetCompletionObserver(func(completion ResidentCompletion) { completed <- completion }) | ||
| 237 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | 237 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | ||
| @@ -261,12 +261,12 @@ func TestResidentManagerReportsAcceptedInterruptedTurn(t *testing.T) { | |||||
| 261 | func TestResidentManagerLiveReturnsChildSnapshot(t *testing.T) { | 261 | func TestResidentManagerLiveReturnsChildSnapshot(t *testing.T) { | ||
| 262 | started := make(chan struct{}) | 262 | started := make(chan struct{}) | ||
| 263 | release := make(chan struct{}) | 263 | release := make(chan struct{}) | ||
| 264 | - | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | 264 | + | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { |
| 265 | - | return func(context.Context, string) error { | 265 | + | return ResidentTurnRunner(func(context.Context, string) error { |
| 266 | close(started) | 266 | close(started) | ||
| 267 | <-release | 267 | <-release | ||
| 268 | return nil | 268 | return nil | ||
| 269 | - | }, nil | 269 | + | }), nil |
| 270 | }) | 270 | }) | ||
| 271 | defer manager.Close(context.Background()) | 271 | defer manager.Close(context.Background()) | ||
| 272 | defer close(release) | 272 | defer close(release) | ||
| @@ -294,8 +294,8 @@ func TestResidentManagerHistoryObserverSkipsTransientStreamEvents(t *testing.T) | |||||
| 294 | persist := make(chan struct{}) | 294 | persist := make(chan struct{}) | ||
| 295 | release := make(chan struct{}) | 295 | release := make(chan struct{}) | ||
| 296 | historyUpdates := make(chan string, 1) | 296 | historyUpdates := make(chan string, 1) | ||
| 297 | - | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentTurnRunner, error) { | 297 | + | manager := NewResidentManager(t.TempDir(), func(_ ResidentChildSpec, journal *ResidentJournal) (ResidentRuntime, error) { |
| 298 | - | return func(context.Context, string) error { | 298 | + | return ResidentTurnRunner(func(context.Context, string) error { |
| 299 | close(started) | 299 | close(started) | ||
| 300 | if err := journal.RecordAgentEvent(core.EvTextDelta{Delta: "streaming"}); err != nil { | 300 | if err := journal.RecordAgentEvent(core.EvTextDelta{Delta: "streaming"}); err != nil { | ||
| 301 | return err | 301 | return err | ||
| @@ -307,7 +307,7 @@ func TestResidentManagerHistoryObserverSkipsTransientStreamEvents(t *testing.T) | |||||
| 307 | } | 307 | } | ||
| 308 | <-release | 308 | <-release | ||
| 309 | return nil | 309 | return nil | ||
| 310 | - | }, nil | 310 | + | }), nil |
| 311 | }) | 311 | }) | ||
| 312 | manager.SetHistoryUpdateObserver(func(childID string) { historyUpdates <- childID }) | 312 | manager.SetHistoryUpdateObserver(func(childID string) { historyUpdates <- childID }) | ||
| 313 | t.Cleanup(func() { | 313 | t.Cleanup(func() { | ||
| @@ -341,12 +341,12 @@ func TestResidentManagerActivityObserverTracksRunningChildrenWithoutPolling(t *t | |||||
| 341 | started := make(chan struct{}) | 341 | started := make(chan struct{}) | ||
| 342 | release := make(chan struct{}) | 342 | release := make(chan struct{}) | ||
| 343 | activity := make(chan bool, 2) | 343 | activity := make(chan bool, 2) | ||
| 344 | - | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | 344 | + | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { |
| 345 | - | return func(context.Context, string) error { | 345 | + | return ResidentTurnRunner(func(context.Context, string) error { |
| 346 | close(started) | 346 | close(started) | ||
| 347 | <-release | 347 | <-release | ||
| 348 | return nil | 348 | return nil | ||
| 349 | - | }, nil | 349 | + | }), nil |
| 350 | }) | 350 | }) | ||
| 351 | manager.SetActivityObserver(func(active bool) { activity <- active }) | 351 | manager.SetActivityObserver(func(active bool) { activity <- active }) | ||
| 352 | t.Cleanup(func() { | 352 | t.Cleanup(func() { | ||
| @@ -387,7 +387,7 @@ func TestResidentManagerActivityObserverTracksRunningChildrenWithoutPolling(t *t | |||||
| 387 | 387 | ||||
| 388 | func TestResidentManagerRecordsFactoryFailureAfterAcceptance(t *testing.T) { | 388 | func TestResidentManagerRecordsFactoryFailureAfterAcceptance(t *testing.T) { | ||
| 389 | root := t.TempDir() | 389 | root := t.TempDir() | ||
| 390 | - | manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | 390 | + | manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { |
| 391 | return nil, errors.New("synthetic factory failure") | 391 | return nil, errors.New("synthetic factory failure") | ||
| 392 | }) | 392 | }) | ||
| 393 | spec := ResidentChildSpec{ID: "factory-failure", SessionID: "child-session", Provider: "openai", Model: "gpt-5"} | 393 | spec := ResidentChildSpec{ID: "factory-failure", SessionID: "child-session", Provider: "openai", Model: "gpt-5"} | ||
| @@ -412,7 +412,7 @@ func TestResidentManagerReconcileIgnoresLegacyAgentsContainer(t *testing.T) { | |||||
| 412 | if err := os.WriteFile(filepath.Join(legacyChild, "events.jsonl"), []byte("legacy\n"), 0o600); err != nil { | 412 | if err := os.WriteFile(filepath.Join(legacyChild, "events.jsonl"), []byte("legacy\n"), 0o600); err != nil { | ||
| 413 | t.Fatal(err) | 413 | t.Fatal(err) | ||
| 414 | } | 414 | } | ||
| 415 | - | manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | 415 | + | manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { |
| 416 | return nil, nil | 416 | return nil, nil | ||
| 417 | }) | 417 | }) | ||
| 418 | if errs := manager.Reconcile(); len(errs) != 0 { | 418 | if errs := manager.Reconcile(); len(errs) != 0 { | ||
| @@ -425,15 +425,15 @@ func TestResidentManagerReconcileIgnoresLegacyAgentsContainer(t *testing.T) { | |||||
| 425 | 425 | ||||
| 426 | func TestResidentManagerResumeRebuildsStoppedChild(t *testing.T) { | 426 | func TestResidentManagerResumeRebuildsStoppedChild(t *testing.T) { | ||
| 427 | runs := make(chan string, 2) | 427 | runs := make(chan string, 2) | ||
| 428 | - | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | 428 | + | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { |
| 429 | - | return func(ctx context.Context, prompt string) error { | 429 | + | return ResidentTurnRunner(func(ctx context.Context, prompt string) error { |
| 430 | runs <- prompt | 430 | runs <- prompt | ||
| 431 | if prompt == "initial" { | 431 | if prompt == "initial" { | ||
| 432 | <-ctx.Done() | 432 | <-ctx.Done() | ||
| 433 | return ctx.Err() | 433 | return ctx.Err() | ||
| 434 | } | 434 | } | ||
| 435 | return nil | 435 | return nil | ||
| 436 | - | }, nil | 436 | + | }), nil |
| 437 | }) | 437 | }) | ||
| 438 | defer manager.Close(context.Background()) | 438 | defer manager.Close(context.Background()) | ||
| 439 | spec := ResidentChildSpec{ID: "stopped-child", SessionID: "child-session", Provider: "openai", Model: "gpt-5", Required: true} | 439 | spec := ResidentChildSpec{ID: "stopped-child", SessionID: "child-session", Provider: "openai", Model: "gpt-5", Required: true} | ||
| @@ -470,11 +470,11 @@ func TestResidentManagerPreparesWorkspaceBeforeAcceptance(t *testing.T) { | |||||
| 470 | t.Fatalf("workspace request = %#v", req) | 470 | t.Fatalf("workspace request = %#v", req) | ||
| 471 | } | 471 | } | ||
| 472 | return SharedWorkspace{Root: req.RepositoryRoot}.Prepare(context.Background(), req) | 472 | return SharedWorkspace{Root: req.RepositoryRoot}.Prepare(context.Background(), req) | ||
| 473 | - | }, func(spec ResidentChildSpec, _ *ResidentJournal) (ResidentTurnRunner, error) { | 473 | + | }, func(spec ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) { |
| 474 | if !prepared || spec.Workspace != "/repo" { | 474 | if !prepared || spec.Workspace != "/repo" { | ||
| 475 | t.Fatalf("factory spec = %#v, prepared=%t", spec, prepared) | 475 | t.Fatalf("factory spec = %#v, prepared=%t", spec, prepared) | ||
| 476 | } | 476 | } | ||
| 477 | - | return func(context.Context, string) error { return nil }, nil | 477 | + | return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil |
| 478 | }) | 478 | }) | ||
| 479 | defer manager.Close(context.Background()) | 479 | defer manager.Close(context.Background()) | ||
| 480 | if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "workspace-child", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo"}, "task"); err != nil { | 480 | if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "workspace-child", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo"}, "task"); err != nil { | ||
| @@ -487,8 +487,8 @@ func TestResidentManagerRejectsWorkspaceOutsideAllowedRootsBeforeAcceptance(t *t | |||||
| 487 | manager := newResidentManager(t.TempDir(), SubagentPolicy{AllowedRoots: []string{t.TempDir()}}, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) { | 487 | manager := newResidentManager(t.TempDir(), SubagentPolicy{AllowedRoots: []string{t.TempDir()}}, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) { | ||
| 488 | prepared = true | 488 | prepared = true | ||
| 489 | return nil, nil | 489 | return nil, nil | ||
| 490 | - | }, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | 490 | + | }, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { |
| 491 | - | return func(context.Context, string) error { return nil }, nil | 491 | + | return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil |
| 492 | }) | 492 | }) | ||
| 493 | outside := t.TempDir() | 493 | outside := t.TempDir() | ||
| 494 | if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "outside-root", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: outside}, "task"); err == nil { | 494 | if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "outside-root", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: outside}, "task"); err == nil { | ||
| @@ -504,8 +504,8 @@ func TestResidentManagerCapturesSuccessfulWorktreeBeforeCleanup(t *testing.T) { | |||||
| 504 | workspace := &testResidentWorkspace{dir: "/isolated", mode: WorkspaceWorktree, capture: WorkspaceCapture{Patch: []byte("patch"), ChangedFiles: []string{"changed.go"}}} | 504 | workspace := &testResidentWorkspace{dir: "/isolated", mode: WorkspaceWorktree, capture: WorkspaceCapture{Patch: []byte("patch"), ChangedFiles: []string{"changed.go"}}} | ||
| 505 | manager := NewResidentManagerWithWorkspace(root, 0, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) { | 505 | manager := NewResidentManagerWithWorkspace(root, 0, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) { | ||
| 506 | return workspace, nil | 506 | return workspace, nil | ||
| 507 | - | }, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | 507 | + | }, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { |
| 508 | - | return func(context.Context, string) error { return nil }, nil | 508 | + | return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil |
| 509 | }) | 509 | }) | ||
| 510 | defer manager.Close(context.Background()) | 510 | defer manager.Close(context.Background()) | ||
| 511 | if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "capture-child", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo", WorkspaceMode: WorkspaceWorktree}, "task"); err != nil { | 511 | if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "capture-child", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo", WorkspaceMode: WorkspaceWorktree}, "task"); err != nil { | ||
| @@ -545,8 +545,8 @@ func TestResidentManagerCapturesFailedWorktreePatch(t *testing.T) { | |||||
| 545 | workspace := &testResidentWorkspace{dir: "/isolated", mode: WorkspaceWorktree, capture: WorkspaceCapture{Patch: []byte("partial patch"), ChangedFiles: []string{"changed.go"}}} | 545 | workspace := &testResidentWorkspace{dir: "/isolated", mode: WorkspaceWorktree, capture: WorkspaceCapture{Patch: []byte("partial patch"), ChangedFiles: []string{"changed.go"}}} | ||
| 546 | manager := NewResidentManagerWithWorkspace(root, 0, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) { | 546 | manager := NewResidentManagerWithWorkspace(root, 0, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) { | ||
| 547 | return workspace, nil | 547 | return workspace, nil | ||
| 548 | - | }, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | 548 | + | }, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { |
| 549 | - | return func(context.Context, string) error { return errors.New("turn failed") }, nil | 549 | + | return ResidentTurnRunner(func(context.Context, string) error { return errors.New("turn failed") }), nil |
| 550 | }) | 550 | }) | ||
| 551 | defer manager.Close(context.Background()) | 551 | defer manager.Close(context.Background()) | ||
| 552 | if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "failed-capture", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo", WorkspaceMode: WorkspaceWorktree}, "task"); err != nil { | 552 | if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "failed-capture", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo", WorkspaceMode: WorkspaceWorktree}, "task"); err != nil { | ||
| @@ -582,8 +582,8 @@ func TestResidentManagerCapturesFailedWorktreePatch(t *testing.T) { | |||||
| 582 | 582 | ||||
| 583 | func TestResidentManagerRetainsFailedWorktree(t *testing.T) { | 583 | func TestResidentManagerRetainsFailedWorktree(t *testing.T) { | ||
| 584 | workspace := &testResidentWorkspace{dir: "/isolated", mode: WorkspaceWorktree} | 584 | workspace := &testResidentWorkspace{dir: "/isolated", mode: WorkspaceWorktree} | ||
| 585 | - | manager := NewResidentManagerWithWorkspace(t.TempDir(), 0, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) { return workspace, nil }, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | 585 | + | manager := NewResidentManagerWithWorkspace(t.TempDir(), 0, func(context.Context, WorkspaceRequest) (WorkspaceHandle, error) { return workspace, nil }, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { |
| 586 | - | return func(context.Context, string) error { return errors.New("turn failed") }, nil | 586 | + | return ResidentTurnRunner(func(context.Context, string) error { return errors.New("turn failed") }), nil |
| 587 | }) | 587 | }) | ||
| 588 | defer manager.Close(context.Background()) | 588 | defer manager.Close(context.Background()) | ||
| 589 | if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "failed-worktree", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo", WorkspaceMode: WorkspaceWorktree}, "task"); err != nil { | 589 | if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "failed-worktree", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: "/repo", WorkspaceMode: WorkspaceWorktree}, "task"); err != nil { | ||
| @@ -608,10 +608,10 @@ func TestResidentManagerCapturesRealWorktreePatch(t *testing.T) { | |||||
| 608 | t.Skip("git is required") | 608 | t.Skip("git is required") | ||
| 609 | } | 609 | } | ||
| 610 | root, repo := t.TempDir(), initTestRepo(t) | 610 | root, repo := t.TempDir(), initTestRepo(t) | ||
| 611 | - | manager := NewResidentManager(root, func(spec ResidentChildSpec, _ *ResidentJournal) (ResidentTurnRunner, error) { | 611 | + | manager := NewResidentManager(root, func(spec ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) { |
| 612 | - | return func(context.Context, string) error { | 612 | + | return ResidentTurnRunner(func(context.Context, string) error { |
| 613 | return os.WriteFile(filepath.Join(spec.Workspace, "README.md"), []byte("resident change\n"), 0o600) | 613 | return os.WriteFile(filepath.Join(spec.Workspace, "README.md"), []byte("resident change\n"), 0o600) | ||
| 614 | - | }, nil | 614 | + | }), nil |
| 615 | }) | 615 | }) | ||
| 616 | defer manager.Close(context.Background()) | 616 | defer manager.Close(context.Background()) | ||
| 617 | child, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "git-worktree", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: repo, WorkspaceMode: WorkspaceWorktree, WorkspaceBase: "HEAD", WorkspaceCapture: CapturePatch}, "task") | 617 | child, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "git-worktree", SessionID: "child-session", Provider: "openai", Model: "gpt-5", RepositoryRoot: repo, WorkspaceMode: WorkspaceWorktree, WorkspaceBase: "HEAD", WorkspaceCapture: CapturePatch}, "task") | ||
| @@ -673,8 +673,8 @@ func (w *testResidentWorkspace) Cleanup(context.Context) error { w.cleaned.Store | |||||
| 673 | 673 | ||||
| 674 | func TestResidentManagerRejectsDuplicateBeforeSecondJournalAcceptance(t *testing.T) { | 674 | func TestResidentManagerRejectsDuplicateBeforeSecondJournalAcceptance(t *testing.T) { | ||
| 675 | root := t.TempDir() | 675 | root := t.TempDir() | ||
| 676 | - | manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | 676 | + | manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { |
| 677 | - | return func(context.Context, string) error { return nil }, nil | 677 | + | return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil |
| 678 | }) | 678 | }) | ||
| 679 | defer manager.Close(context.Background()) | 679 | defer manager.Close(context.Background()) | ||
| 680 | spec := ResidentChildSpec{ID: "duplicate", SessionID: "child-session", Provider: "openai", Model: "gpt-5"} | 680 | spec := ResidentChildSpec{ID: "duplicate", SessionID: "child-session", Provider: "openai", Model: "gpt-5"} | ||
| @@ -794,9 +794,9 @@ func TestResidentManagerReconcileMakesInterruptedJournalDiscoverableWithoutRepla | |||||
| 794 | t.Fatal(err) | 794 | t.Fatal(err) | ||
| 795 | } | 795 | } | ||
| 796 | runs := 0 | 796 | runs := 0 | ||
| 797 | - | manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | 797 | + | manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { |
| 798 | runs++ | 798 | runs++ | ||
| 799 | - | return func(context.Context, string) error { return nil }, nil | 799 | + | return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil |
| 800 | }) | 800 | }) | ||
| 801 | if errs := manager.Reconcile(); len(errs) != 0 { | 801 | if errs := manager.Reconcile(); len(errs) != 0 { | ||
| 802 | t.Fatalf("Reconcile errors = %v", errs) | 802 | t.Fatalf("Reconcile errors = %v", errs) | ||
| @@ -881,8 +881,8 @@ func TestResidentManagerReconcileDoesNotMutateForeignProcessJournal(t *testing.T | |||||
| 881 | t.Fatal(err) | 881 | t.Fatal(err) | ||
| 882 | } | 882 | } | ||
| 883 | 883 | ||||
| 884 | - | observer := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | 884 | + | observer := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { |
| 885 | - | return func(context.Context, string) error { return nil }, nil | 885 | + | return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil |
| 886 | }) | 886 | }) | ||
| 887 | if errs := observer.Reconcile(); len(errs) != 0 { | 887 | if errs := observer.Reconcile(); len(errs) != 0 { | ||
| 888 | t.Fatalf("Reconcile errors = %v", errs) | 888 | t.Fatalf("Reconcile errors = %v", errs) | ||
| @@ -944,11 +944,11 @@ func TestResidentManagerExplicitlyResumesRecoveredChildWithoutReplayingTask(t *t | |||||
| 944 | t.Fatal(err) | 944 | t.Fatal(err) | ||
| 945 | } | 945 | } | ||
| 946 | runs := make(chan string, 1) | 946 | runs := make(chan string, 1) | ||
| 947 | - | manager := NewResidentManager(root, func(got ResidentChildSpec, _ *ResidentJournal) (ResidentTurnRunner, error) { | 947 | + | manager := NewResidentManager(root, func(got ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) { |
| 948 | if got.SessionID != spec.SessionID { | 948 | if got.SessionID != spec.SessionID { | ||
| 949 | t.Fatalf("session = %q, want %q", got.SessionID, spec.SessionID) | 949 | t.Fatalf("session = %q, want %q", got.SessionID, spec.SessionID) | ||
| 950 | } | 950 | } | ||
| 951 | - | return func(_ context.Context, prompt string) error { runs <- prompt; return nil }, nil | 951 | + | return ResidentTurnRunner(func(_ context.Context, prompt string) error { runs <- prompt; return nil }), nil |
| 952 | }) | 952 | }) | ||
| 953 | defer manager.Close(context.Background()) | 953 | defer manager.Close(context.Background()) | ||
| 954 | if errs := manager.Reconcile(); len(errs) != 0 { | 954 | if errs := manager.Reconcile(); len(errs) != 0 { | ||
| @@ -989,8 +989,8 @@ func TestResidentManagerExplicitResumeReusesInterruptedWorktree(t *testing.T) { | |||||
| 989 | t.Fatalf("resume workspace request = %#v", req) | 989 | t.Fatalf("resume workspace request = %#v", req) | ||
| 990 | } | 990 | } | ||
| 991 | return workspace, nil | 991 | return workspace, nil | ||
| 992 | - | }, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | 992 | + | }, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { |
| 993 | - | return func(context.Context, string) error { return nil }, nil | 993 | + | return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil |
| 994 | }) | 994 | }) | ||
| 995 | defer manager.Close(context.Background()) | 995 | defer manager.Close(context.Background()) | ||
| 996 | if errs := manager.Reconcile(); len(errs) != 0 { | 996 | if errs := manager.Reconcile(); len(errs) != 0 { | ||
| @@ -1004,14 +1004,14 @@ func TestResidentManagerExplicitResumeReusesInterruptedWorktree(t *testing.T) { | |||||
| 1004 | func TestResidentManagerAppliesPositiveQueueTimeoutDurably(t *testing.T) { | 1004 | func TestResidentManagerAppliesPositiveQueueTimeoutDurably(t *testing.T) { | ||
| 1005 | started := make(chan string, 2) | 1005 | started := make(chan string, 2) | ||
| 1006 | release := make(chan struct{}) | 1006 | release := make(chan struct{}) | ||
| 1007 | - | manager := NewResidentManagerWithPolicy(t.TempDir(), SubagentPolicy{MaxConcurrent: 1, QueueTimeout: 20 * time.Millisecond}, func(spec ResidentChildSpec, _ *ResidentJournal) (ResidentTurnRunner, error) { | 1007 | + | manager := NewResidentManagerWithPolicy(t.TempDir(), SubagentPolicy{MaxConcurrent: 1, QueueTimeout: 20 * time.Millisecond}, func(spec ResidentChildSpec, _ *ResidentJournal) (ResidentRuntime, error) { |
| 1008 | - | return func(_ context.Context, _ string) error { | 1008 | + | return ResidentTurnRunner(func(_ context.Context, _ string) error { |
| 1009 | started <- spec.ID | 1009 | started <- spec.ID | ||
| 1010 | if spec.ID == "first" { | 1010 | if spec.ID == "first" { | ||
| 1011 | <-release | 1011 | <-release | ||
| 1012 | } | 1012 | } | ||
| 1013 | return nil | 1013 | return nil | ||
| 1014 | - | }, nil | 1014 | + | }), nil |
| 1015 | }) | 1015 | }) | ||
| 1016 | defer manager.Close(context.Background()) | 1016 | defer manager.Close(context.Background()) | ||
| 1017 | if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "first", SessionID: "first", Provider: "openai", Model: "gpt-5"}, "first"); err != nil { | 1017 | if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "first", SessionID: "first", Provider: "openai", Model: "gpt-5"}, "first"); err != nil { | ||
| @@ -1048,8 +1048,8 @@ func TestResidentManagerAppliesPositiveQueueTimeoutDurably(t *testing.T) { | |||||
| 1048 | } | 1048 | } | ||
| 1049 | 1049 | ||||
| 1050 | func TestResidentManagerNotifiesAcceptedObserverForFollowUps(t *testing.T) { | 1050 | func TestResidentManagerNotifiesAcceptedObserverForFollowUps(t *testing.T) { | ||
| 1051 | - | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | 1051 | + | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { |
| 1052 | - | return func(context.Context, string) error { return nil }, nil | 1052 | + | return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil |
| 1053 | }) | 1053 | }) | ||
| 1054 | defer manager.Close(context.Background()) | 1054 | defer manager.Close(context.Background()) | ||
| 1055 | accepted := make(chan string, 2) | 1055 | accepted := make(chan string, 2) | ||
| @@ -1082,7 +1082,7 @@ func TestResidentManagerReconcileReplacesStaleRecoveredSnapshots(t *testing.T) { | |||||
| 1082 | if err := journal.Close(); err != nil { | 1082 | if err := journal.Close(); err != nil { | ||
| 1083 | t.Fatal(err) | 1083 | t.Fatal(err) | ||
| 1084 | } | 1084 | } | ||
| 1085 | - | manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { return nil, nil }) | 1085 | + | manager := NewResidentManager(root, func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { return nil, nil }) |
| 1086 | if errs := manager.Reconcile(); len(errs) != 0 || len(manager.Snapshot()) != 1 { | 1086 | if errs := manager.Reconcile(); len(errs) != 0 || len(manager.Snapshot()) != 1 { | ||
| 1087 | t.Fatalf("first Reconcile = %v, snapshots=%#v", errs, manager.Snapshot()) | 1087 | t.Fatalf("first Reconcile = %v, snapshots=%#v", errs, manager.Snapshot()) | ||
| 1088 | } | 1088 | } | ||
| @@ -1097,8 +1097,8 @@ func TestResidentManagerReconcileReplacesStaleRecoveredSnapshots(t *testing.T) { | |||||
| 1097 | func TestResidentManagerLimitsConcurrentTurnsGlobally(t *testing.T) { | 1097 | func TestResidentManagerLimitsConcurrentTurnsGlobally(t *testing.T) { | ||
| 1098 | started := make(chan string, DefaultResidentConcurrency+1) | 1098 | started := make(chan string, DefaultResidentConcurrency+1) | ||
| 1099 | release := make(chan struct{}) | 1099 | release := make(chan struct{}) | ||
| 1100 | - | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | 1100 | + | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { |
| 1101 | - | return func(ctx context.Context, prompt string) error { | 1101 | + | return ResidentTurnRunner(func(ctx context.Context, prompt string) error { |
| 1102 | started <- prompt | 1102 | started <- prompt | ||
| 1103 | select { | 1103 | select { | ||
| 1104 | case <-release: | 1104 | case <-release: | ||
| @@ -1106,7 +1106,7 @@ func TestResidentManagerLimitsConcurrentTurnsGlobally(t *testing.T) { | |||||
| 1106 | case <-ctx.Done(): | 1106 | case <-ctx.Done(): | ||
| 1107 | return ctx.Err() | 1107 | return ctx.Err() | ||
| 1108 | } | 1108 | } | ||
| 1109 | - | }, nil | 1109 | + | }), nil |
| 1110 | }) | 1110 | }) | ||
| 1111 | defer manager.Close(context.Background()) | 1111 | defer manager.Close(context.Background()) | ||
| 1112 | 1112 | ||||
M packages/agent/subagents/resident_snapshot_test.go +2 -2
| @@ -8,8 +8,8 @@ import ( | |||
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | func TestResidentManagerSnapshotPageAndLookupAreBounded(t *testing.T) { | |
| 11 | - | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | |
| 12 | - | return func(context.Context, string) error { return nil }, nil | |
| 11 | + | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { | |
| 12 | + | return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil | |
| 13 | 13 | }) | |
| 14 | 14 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 15 | 15 | for i := 0; i < 5; i++ { | |
| @@ -8,8 +8,8 @@ import ( | |||||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | func TestResidentManagerSnapshotPageAndLookupAreBounded(t *testing.T) { | 10 | func TestResidentManagerSnapshotPageAndLookupAreBounded(t *testing.T) { | ||
| 11 | - | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) { | 11 | + | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { |
| 12 | - | return func(context.Context, string) error { return nil }, nil | 12 | + | return ResidentTurnRunner(func(context.Context, string) error { return nil }), nil |
| 13 | }) | 13 | }) | ||
| 14 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | 14 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | ||
| 15 | for i := 0; i < 5; i++ { | 15 | for i := 0; i < 5; i++ { | ||
A packages/agent/subagents/resident_steer_test.go +325 -0
| @@ -0,0 +1,325 @@ | |||
| 1 | + | package subagents | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "strings" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // steerableRuntime is a ResidentRuntime whose turn blocks until released or | |
| 12 | + | // canceled, recording steered follow-ups it would deliver at the next boundary. | |
| 13 | + | type steerableRuntime struct { | |
| 14 | + | started chan string | |
| 15 | + | release chan struct{} | |
| 16 | + | ||
| 17 | + | mu sync.Mutex | |
| 18 | + | running bool | |
| 19 | + | pending []string | |
| 20 | + | delivered []string | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func newSteerableRuntime() *steerableRuntime { | |
| 24 | + | return &steerableRuntime{started: make(chan string, 4), release: make(chan struct{})} | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func (r *steerableRuntime) Run(ctx context.Context, prompt string) error { | |
| 28 | + | r.mu.Lock() | |
| 29 | + | r.running = true | |
| 30 | + | r.mu.Unlock() | |
| 31 | + | defer func() { | |
| 32 | + | r.mu.Lock() | |
| 33 | + | r.running = false | |
| 34 | + | r.mu.Unlock() | |
| 35 | + | }() | |
| 36 | + | r.started <- prompt | |
| 37 | + | select { | |
| 38 | + | case <-ctx.Done(): | |
| 39 | + | return ctx.Err() | |
| 40 | + | case <-r.release: | |
| 41 | + | } | |
| 42 | + | r.mu.Lock() | |
| 43 | + | r.delivered = append(r.delivered, r.pending...) | |
| 44 | + | r.pending = nil | |
| 45 | + | r.mu.Unlock() | |
| 46 | + | return nil | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func (r *steerableRuntime) Steer(prompt string) bool { | |
| 50 | + | r.mu.Lock() | |
| 51 | + | defer r.mu.Unlock() | |
| 52 | + | if !r.running { | |
| 53 | + | return false | |
| 54 | + | } | |
| 55 | + | r.pending = append(r.pending, prompt) | |
| 56 | + | return true | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func (r *steerableRuntime) PendingSteers() int { | |
| 60 | + | r.mu.Lock() | |
| 61 | + | defer r.mu.Unlock() | |
| 62 | + | return len(r.pending) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func (r *steerableRuntime) DrainSteers() []string { | |
| 66 | + | r.mu.Lock() | |
| 67 | + | defer r.mu.Unlock() | |
| 68 | + | out := r.pending | |
| 69 | + | r.pending = nil | |
| 70 | + | return out | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func (r *steerableRuntime) awaitStart(t *testing.T) string { | |
| 74 | + | t.Helper() | |
| 75 | + | select { | |
| 76 | + | case prompt := <-r.started: | |
| 77 | + | return prompt | |
| 78 | + | case <-time.After(5 * time.Second): | |
| 79 | + | t.Fatal("turn did not start") | |
| 80 | + | return "" | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func newSteerTestManager(t *testing.T, runtime ResidentRuntime) (*ResidentManager, chan ResidentCompletion) { | |
| 85 | + | t.Helper() | |
| 86 | + | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { return runtime, nil }) | |
| 87 | + | completions := make(chan ResidentCompletion, 8) | |
| 88 | + | manager.SetCompletionObserver(func(c ResidentCompletion) { completions <- c }) | |
| 89 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 90 | + | return manager, completions | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func drainCompletions(t *testing.T, completions chan ResidentCompletion, want int) []ResidentCompletion { | |
| 94 | + | t.Helper() | |
| 95 | + | var got []ResidentCompletion | |
| 96 | + | for len(got) < want { | |
| 97 | + | select { | |
| 98 | + | case c := <-completions: | |
| 99 | + | got = append(got, c) | |
| 100 | + | case <-time.After(5 * time.Second): | |
| 101 | + | t.Fatalf("got %d completions, want %d: %#v", len(got), want, got) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | select { | |
| 105 | + | case extra := <-completions: | |
| 106 | + | t.Fatalf("unexpected extra completion %#v", extra) | |
| 107 | + | case <-time.After(50 * time.Millisecond): | |
| 108 | + | } | |
| 109 | + | return got | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // Issue 226: stopping a child must not echo an undelivered queued follow-up | |
| 113 | + | // back to the parent as if it were an interrupted task. | |
| 114 | + | func TestResidentStopReportsQueuedFollowUpAsDroppedNotInterrupted(t *testing.T) { | |
| 115 | + | runtime := ResidentTurnRunner(func(ctx context.Context, _ string) error { <-ctx.Done(); return ctx.Err() }) | |
| 116 | + | started := make(chan struct{}, 1) | |
| 117 | + | manager, completions := newSteerTestManager(t, ResidentTurnRunner(func(ctx context.Context, prompt string) error { | |
| 118 | + | started <- struct{}{} | |
| 119 | + | return runtime(ctx, prompt) | |
| 120 | + | })) | |
| 121 | + | spec := ResidentChildSpec{ID: "stop-echo", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"} | |
| 122 | + | if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil { | |
| 123 | + | t.Fatal(err) | |
| 124 | + | } | |
| 125 | + | <-started | |
| 126 | + | if _, err := manager.ResumeFollowUp(t.Context(), spec.ID, "stop and report now", ResumeQueue, "follow-up", nil); err != nil { | |
| 127 | + | t.Fatal(err) | |
| 128 | + | } | |
| 129 | + | if err := manager.Stop(t.Context(), spec.ID); err != nil { | |
| 130 | + | t.Fatal(err) | |
| 131 | + | } | |
| 132 | + | got := drainCompletions(t, completions, 2) | |
| 133 | + | byTurn := map[string]Completion{} | |
| 134 | + | for _, c := range got { | |
| 135 | + | byTurn[c.TurnID] = c.Completion() | |
| 136 | + | } | |
| 137 | + | if byTurn["initial"].Status != string(ResidentInterrupted) { | |
| 138 | + | t.Fatalf("initial completion = %#v", byTurn["initial"]) | |
| 139 | + | } | |
| 140 | + | if byTurn["follow-up"].Status != CompletionDropped { | |
| 141 | + | t.Fatalf("follow-up completion = %#v, want dropped", byTurn["follow-up"]) | |
| 142 | + | } | |
| 143 | + | update := FormatCompletionUpdate([]Completion{byTurn["initial"], byTurn["follow-up"]}, "") | |
| 144 | + | if strings.Contains(update, "interrupted — stop and report now") || !strings.Contains(update, "dropped (queued follow-up never started: stop and report now)") { | |
| 145 | + | t.Fatalf("update = %q", update) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // A steered follow-up joins the running turn: no new turn, no extra | |
| 150 | + | // completion, and the running runtime receives it. | |
| 151 | + | func TestResidentResumeSteersRunningTurn(t *testing.T) { | |
| 152 | + | runtime := newSteerableRuntime() | |
| 153 | + | manager, completions := newSteerTestManager(t, runtime) | |
| 154 | + | spec := ResidentChildSpec{ID: "steered", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"} | |
| 155 | + | if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil { | |
| 156 | + | t.Fatal(err) | |
| 157 | + | } | |
| 158 | + | runtime.awaitStart(t) | |
| 159 | + | var watched string | |
| 160 | + | outcome, err := manager.ResumeFollowUp(t.Context(), spec.ID, "wrap up now", ResumeSteer, "unused", func(turnID string) { watched = turnID }) | |
| 161 | + | if err != nil { | |
| 162 | + | t.Fatal(err) | |
| 163 | + | } | |
| 164 | + | if !outcome.Steered || outcome.TurnID != "initial" || watched != "initial" { | |
| 165 | + | t.Fatalf("outcome = %#v, watched = %q", outcome, watched) | |
| 166 | + | } | |
| 167 | + | if snapshot, _ := manager.SnapshotFor(spec.ID); snapshot.PendingFollowUps != 1 { | |
| 168 | + | t.Fatalf("pending follow-ups = %d, want 1", snapshot.PendingFollowUps) | |
| 169 | + | } | |
| 170 | + | close(runtime.release) | |
| 171 | + | got := drainCompletions(t, completions, 1) | |
| 172 | + | if got[0].TurnID != "initial" || got[0].Err != nil || len(got[0].Undelivered) != 0 { | |
| 173 | + | t.Fatalf("completion = %#v", got[0]) | |
| 174 | + | } | |
| 175 | + | if len(runtime.delivered) != 1 || runtime.delivered[0] != "wrap up now" { | |
| 176 | + | t.Fatalf("delivered = %#v", runtime.delivered) | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // An idle child cannot be steered, so steer mode falls back to a new turn. | |
| 181 | + | func TestResidentResumeSteerFallsBackToNewTurnWhenIdle(t *testing.T) { | |
| 182 | + | runtime := newSteerableRuntime() | |
| 183 | + | close(runtime.release) | |
| 184 | + | manager, completions := newSteerTestManager(t, runtime) | |
| 185 | + | spec := ResidentChildSpec{ID: "idle", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"} | |
| 186 | + | if _, err := manager.Spawn(t.Context(), spec, "task"); err != nil { | |
| 187 | + | t.Fatal(err) | |
| 188 | + | } | |
| 189 | + | drainCompletions(t, completions, 1) | |
| 190 | + | outcome, err := manager.ResumeFollowUp(t.Context(), spec.ID, "next", ResumeSteer, "second", nil) | |
| 191 | + | if err != nil || outcome.Steered || outcome.TurnID != "second" { | |
| 192 | + | t.Fatalf("outcome = %#v, err = %v", outcome, err) | |
| 193 | + | } | |
| 194 | + | if got := drainCompletions(t, completions, 1); got[0].TurnID != "second" { | |
| 195 | + | t.Fatalf("completion = %#v", got[0]) | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // Stopping a child with a steered follow-up it never read reports that | |
| 200 | + | // follow-up as undelivered on the interrupted turn. | |
| 201 | + | func TestResidentStopReportsUndeliveredSteer(t *testing.T) { | |
| 202 | + | runtime := newSteerableRuntime() | |
| 203 | + | manager, completions := newSteerTestManager(t, runtime) | |
| 204 | + | spec := ResidentChildSpec{ID: "undelivered", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"} | |
| 205 | + | if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil { | |
| 206 | + | t.Fatal(err) | |
| 207 | + | } | |
| 208 | + | runtime.awaitStart(t) | |
| 209 | + | if _, err := manager.ResumeFollowUp(t.Context(), spec.ID, "stop and report now", ResumeSteer, "", nil); err != nil { | |
| 210 | + | t.Fatal(err) | |
| 211 | + | } | |
| 212 | + | if err := manager.Stop(t.Context(), spec.ID); err != nil { | |
| 213 | + | t.Fatal(err) | |
| 214 | + | } | |
| 215 | + | got := drainCompletions(t, completions, 1) | |
| 216 | + | completion := got[0].Completion() | |
| 217 | + | if completion.Status != string(ResidentInterrupted) || len(completion.Undelivered) != 1 || completion.Undelivered[0] != "stop and report now" { | |
| 218 | + | t.Fatalf("completion = %#v", completion) | |
| 219 | + | } | |
| 220 | + | if update := FormatCompletionUpdate([]Completion{completion}, ""); !strings.Contains(update, "undelivered: 1 follow-up(s) the child never read:\n - stop and report now") { | |
| 221 | + | t.Fatalf("update = %q", update) | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | // Interrupt cancels only the running turn; the child stays live and a later | |
| 226 | + | // resume runs as a new turn with the same runtime. | |
| 227 | + | func TestResidentInterruptKeepsChildResumable(t *testing.T) { | |
| 228 | + | runtime := newSteerableRuntime() | |
| 229 | + | manager, completions := newSteerTestManager(t, runtime) | |
| 230 | + | spec := ResidentChildSpec{ID: "interrupted", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test", Required: true} | |
| 231 | + | if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil { | |
| 232 | + | t.Fatal(err) | |
| 233 | + | } | |
| 234 | + | runtime.awaitStart(t) | |
| 235 | + | if _, err := manager.ResumeFollowUp(t.Context(), spec.ID, "queued work", ResumeQueue, "queued", nil); err != nil { | |
| 236 | + | t.Fatal(err) | |
| 237 | + | } | |
| 238 | + | interrupted, err := manager.Interrupt(t.Context(), spec.ID) | |
| 239 | + | if err != nil || !interrupted { | |
| 240 | + | t.Fatalf("Interrupt = %t, %v", interrupted, err) | |
| 241 | + | } | |
| 242 | + | got := drainCompletions(t, completions, 2) | |
| 243 | + | byTurn := map[string]Completion{} | |
| 244 | + | for _, c := range got { | |
| 245 | + | byTurn[c.TurnID] = c.Completion() | |
| 246 | + | } | |
| 247 | + | if byTurn["initial"].Status != string(ResidentInterrupted) || byTurn["queued"].Status != CompletionDropped { | |
| 248 | + | t.Fatalf("completions = %#v", byTurn) | |
| 249 | + | } | |
| 250 | + | if manager.Get(spec.ID) == nil { | |
| 251 | + | t.Fatal("interrupt removed the live child") | |
| 252 | + | } | |
| 253 | + | if again, err := manager.Interrupt(t.Context(), spec.ID); err != nil || again { | |
| 254 | + | t.Fatalf("second Interrupt = %t, %v, want no running turn", again, err) | |
| 255 | + | } | |
| 256 | + | close(runtime.release) | |
| 257 | + | outcome, err := manager.ResumeFollowUp(t.Context(), spec.ID, "wrap up with what you have", ResumeSteer, "wrap-up", nil) | |
| 258 | + | if err != nil || outcome.Steered { | |
| 259 | + | t.Fatalf("resume after interrupt = %#v, %v", outcome, err) | |
| 260 | + | } | |
| 261 | + | if prompt := runtime.awaitStart(t); prompt != "wrap up with what you have" { | |
| 262 | + | t.Fatalf("resumed prompt = %q", prompt) | |
| 263 | + | } | |
| 264 | + | if got := drainCompletions(t, completions, 1); got[0].TurnID != "wrap-up" || got[0].Err != nil { | |
| 265 | + | t.Fatalf("wrap-up completion = %#v", got[0]) | |
| 266 | + | } | |
| 267 | + | if len(manager.UnmetRequired()) != 0 { | |
| 268 | + | t.Fatal("successful wrap-up left required work unmet") | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | // An interrupt can be accepted just before the turn finishes on its own. The | |
| 273 | + | // turn must still report interrupted exactly once, and a late interrupt on the | |
| 274 | + | // now-idle child must be refused. | |
| 275 | + | func TestResidentInterruptRacingNaturalCompletion(t *testing.T) { | |
| 276 | + | started, release := make(chan struct{}, 1), make(chan struct{}) | |
| 277 | + | // This runtime ignores cancellation, so its turn ends successfully after | |
| 278 | + | // the interrupt was already accepted. | |
| 279 | + | runtime := ResidentTurnRunner(func(context.Context, string) error { | |
| 280 | + | started <- struct{}{} | |
| 281 | + | <-release | |
| 282 | + | return nil | |
| 283 | + | }) | |
| 284 | + | manager, completions := newSteerTestManager(t, runtime) | |
| 285 | + | spec := ResidentChildSpec{ID: "racing", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"} | |
| 286 | + | if _, err := manager.Spawn(t.Context(), spec, "work"); err != nil { | |
| 287 | + | t.Fatal(err) | |
| 288 | + | } | |
| 289 | + | <-started | |
| 290 | + | if ok, err := manager.Interrupt(t.Context(), spec.ID); err != nil || !ok { | |
| 291 | + | t.Fatalf("Interrupt = %t, %v", ok, err) | |
| 292 | + | } | |
| 293 | + | close(release) | |
| 294 | + | got := drainCompletions(t, completions, 1) | |
| 295 | + | if got[0].TurnID != "initial" || got[0].Completion().Status != string(ResidentInterrupted) { | |
| 296 | + | t.Fatalf("completion = %#v", got[0]) | |
| 297 | + | } | |
| 298 | + | if ok, err := manager.Interrupt(t.Context(), spec.ID); err != nil || ok { | |
| 299 | + | t.Fatalf("late Interrupt = %t, %v, want refused on idle child", ok, err) | |
| 300 | + | } | |
| 301 | + | if manager.Get(spec.ID) == nil { | |
| 302 | + | t.Fatal("interrupt removed the live child") | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | // Steering must not jump ahead of a queued follow-up. | |
| 307 | + | func TestResidentSteerQueuesBehindPendingTurns(t *testing.T) { | |
| 308 | + | runtime := newSteerableRuntime() | |
| 309 | + | manager, _ := newSteerTestManager(t, runtime) | |
| 310 | + | spec := ResidentChildSpec{ID: "ordered", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"} | |
| 311 | + | if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil { | |
| 312 | + | t.Fatal(err) | |
| 313 | + | } | |
| 314 | + | runtime.awaitStart(t) | |
| 315 | + | if _, err := manager.ResumeFollowUp(t.Context(), spec.ID, "first", ResumeQueue, "first", nil); err != nil { | |
| 316 | + | t.Fatal(err) | |
| 317 | + | } | |
| 318 | + | outcome, err := manager.ResumeFollowUp(t.Context(), spec.ID, "second", ResumeSteer, "second", nil) | |
| 319 | + | if err != nil || outcome.Steered || outcome.TurnID != "second" { | |
| 320 | + | t.Fatalf("outcome = %#v, err = %v", outcome, err) | |
| 321 | + | } | |
| 322 | + | if snapshot, _ := manager.SnapshotFor(spec.ID); snapshot.PendingFollowUps != 2 { | |
| 323 | + | t.Fatalf("pending follow-ups = %d, want 2 queued turns", snapshot.PendingFollowUps) | |
| 324 | + | } | |
| 325 | + | } | |
| @@ -0,0 +1,325 @@ | |||||
| 1 | + | package subagents | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "strings" | |||
| 6 | + | "sync" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // steerableRuntime is a ResidentRuntime whose turn blocks until released or | |||
| 12 | + | // canceled, recording steered follow-ups it would deliver at the next boundary. | |||
| 13 | + | type steerableRuntime struct { | |||
| 14 | + | started chan string | |||
| 15 | + | release chan struct{} | |||
| 16 | + | ||||
| 17 | + | mu sync.Mutex | |||
| 18 | + | running bool | |||
| 19 | + | pending []string | |||
| 20 | + | delivered []string | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func newSteerableRuntime() *steerableRuntime { | |||
| 24 | + | return &steerableRuntime{started: make(chan string, 4), release: make(chan struct{})} | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func (r *steerableRuntime) Run(ctx context.Context, prompt string) error { | |||
| 28 | + | r.mu.Lock() | |||
| 29 | + | r.running = true | |||
| 30 | + | r.mu.Unlock() | |||
| 31 | + | defer func() { | |||
| 32 | + | r.mu.Lock() | |||
| 33 | + | r.running = false | |||
| 34 | + | r.mu.Unlock() | |||
| 35 | + | }() | |||
| 36 | + | r.started <- prompt | |||
| 37 | + | select { | |||
| 38 | + | case <-ctx.Done(): | |||
| 39 | + | return ctx.Err() | |||
| 40 | + | case <-r.release: | |||
| 41 | + | } | |||
| 42 | + | r.mu.Lock() | |||
| 43 | + | r.delivered = append(r.delivered, r.pending...) | |||
| 44 | + | r.pending = nil | |||
| 45 | + | r.mu.Unlock() | |||
| 46 | + | return nil | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func (r *steerableRuntime) Steer(prompt string) bool { | |||
| 50 | + | r.mu.Lock() | |||
| 51 | + | defer r.mu.Unlock() | |||
| 52 | + | if !r.running { | |||
| 53 | + | return false | |||
| 54 | + | } | |||
| 55 | + | r.pending = append(r.pending, prompt) | |||
| 56 | + | return true | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func (r *steerableRuntime) PendingSteers() int { | |||
| 60 | + | r.mu.Lock() | |||
| 61 | + | defer r.mu.Unlock() | |||
| 62 | + | return len(r.pending) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func (r *steerableRuntime) DrainSteers() []string { | |||
| 66 | + | r.mu.Lock() | |||
| 67 | + | defer r.mu.Unlock() | |||
| 68 | + | out := r.pending | |||
| 69 | + | r.pending = nil | |||
| 70 | + | return out | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func (r *steerableRuntime) awaitStart(t *testing.T) string { | |||
| 74 | + | t.Helper() | |||
| 75 | + | select { | |||
| 76 | + | case prompt := <-r.started: | |||
| 77 | + | return prompt | |||
| 78 | + | case <-time.After(5 * time.Second): | |||
| 79 | + | t.Fatal("turn did not start") | |||
| 80 | + | return "" | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func newSteerTestManager(t *testing.T, runtime ResidentRuntime) (*ResidentManager, chan ResidentCompletion) { | |||
| 85 | + | t.Helper() | |||
| 86 | + | manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentRuntime, error) { return runtime, nil }) | |||
| 87 | + | completions := make(chan ResidentCompletion, 8) | |||
| 88 | + | manager.SetCompletionObserver(func(c ResidentCompletion) { completions <- c }) | |||
| 89 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |||
| 90 | + | return manager, completions | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func drainCompletions(t *testing.T, completions chan ResidentCompletion, want int) []ResidentCompletion { | |||
| 94 | + | t.Helper() | |||
| 95 | + | var got []ResidentCompletion | |||
| 96 | + | for len(got) < want { | |||
| 97 | + | select { | |||
| 98 | + | case c := <-completions: | |||
| 99 | + | got = append(got, c) | |||
| 100 | + | case <-time.After(5 * time.Second): | |||
| 101 | + | t.Fatalf("got %d completions, want %d: %#v", len(got), want, got) | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | select { | |||
| 105 | + | case extra := <-completions: | |||
| 106 | + | t.Fatalf("unexpected extra completion %#v", extra) | |||
| 107 | + | case <-time.After(50 * time.Millisecond): | |||
| 108 | + | } | |||
| 109 | + | return got | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // Issue 226: stopping a child must not echo an undelivered queued follow-up | |||
| 113 | + | // back to the parent as if it were an interrupted task. | |||
| 114 | + | func TestResidentStopReportsQueuedFollowUpAsDroppedNotInterrupted(t *testing.T) { | |||
| 115 | + | runtime := ResidentTurnRunner(func(ctx context.Context, _ string) error { <-ctx.Done(); return ctx.Err() }) | |||
| 116 | + | started := make(chan struct{}, 1) | |||
| 117 | + | manager, completions := newSteerTestManager(t, ResidentTurnRunner(func(ctx context.Context, prompt string) error { | |||
| 118 | + | started <- struct{}{} | |||
| 119 | + | return runtime(ctx, prompt) | |||
| 120 | + | })) | |||
| 121 | + | spec := ResidentChildSpec{ID: "stop-echo", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"} | |||
| 122 | + | if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil { | |||
| 123 | + | t.Fatal(err) | |||
| 124 | + | } | |||
| 125 | + | <-started | |||
| 126 | + | if _, err := manager.ResumeFollowUp(t.Context(), spec.ID, "stop and report now", ResumeQueue, "follow-up", nil); err != nil { | |||
| 127 | + | t.Fatal(err) | |||
| 128 | + | } | |||
| 129 | + | if err := manager.Stop(t.Context(), spec.ID); err != nil { | |||
| 130 | + | t.Fatal(err) | |||
| 131 | + | } | |||
| 132 | + | got := drainCompletions(t, completions, 2) | |||
| 133 | + | byTurn := map[string]Completion{} | |||
| 134 | + | for _, c := range got { | |||
| 135 | + | byTurn[c.TurnID] = c.Completion() | |||
| 136 | + | } | |||
| 137 | + | if byTurn["initial"].Status != string(ResidentInterrupted) { | |||
| 138 | + | t.Fatalf("initial completion = %#v", byTurn["initial"]) | |||
| 139 | + | } | |||
| 140 | + | if byTurn["follow-up"].Status != CompletionDropped { | |||
| 141 | + | t.Fatalf("follow-up completion = %#v, want dropped", byTurn["follow-up"]) | |||
| 142 | + | } | |||
| 143 | + | update := FormatCompletionUpdate([]Completion{byTurn["initial"], byTurn["follow-up"]}, "") | |||
| 144 | + | if strings.Contains(update, "interrupted — stop and report now") || !strings.Contains(update, "dropped (queued follow-up never started: stop and report now)") { | |||
| 145 | + | t.Fatalf("update = %q", update) | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // A steered follow-up joins the running turn: no new turn, no extra | |||
| 150 | + | // completion, and the running runtime receives it. | |||
| 151 | + | func TestResidentResumeSteersRunningTurn(t *testing.T) { | |||
| 152 | + | runtime := newSteerableRuntime() | |||
| 153 | + | manager, completions := newSteerTestManager(t, runtime) | |||
| 154 | + | spec := ResidentChildSpec{ID: "steered", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"} | |||
| 155 | + | if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil { | |||
| 156 | + | t.Fatal(err) | |||
| 157 | + | } | |||
| 158 | + | runtime.awaitStart(t) | |||
| 159 | + | var watched string | |||
| 160 | + | outcome, err := manager.ResumeFollowUp(t.Context(), spec.ID, "wrap up now", ResumeSteer, "unused", func(turnID string) { watched = turnID }) | |||
| 161 | + | if err != nil { | |||
| 162 | + | t.Fatal(err) | |||
| 163 | + | } | |||
| 164 | + | if !outcome.Steered || outcome.TurnID != "initial" || watched != "initial" { | |||
| 165 | + | t.Fatalf("outcome = %#v, watched = %q", outcome, watched) | |||
| 166 | + | } | |||
| 167 | + | if snapshot, _ := manager.SnapshotFor(spec.ID); snapshot.PendingFollowUps != 1 { | |||
| 168 | + | t.Fatalf("pending follow-ups = %d, want 1", snapshot.PendingFollowUps) | |||
| 169 | + | } | |||
| 170 | + | close(runtime.release) | |||
| 171 | + | got := drainCompletions(t, completions, 1) | |||
| 172 | + | if got[0].TurnID != "initial" || got[0].Err != nil || len(got[0].Undelivered) != 0 { | |||
| 173 | + | t.Fatalf("completion = %#v", got[0]) | |||
| 174 | + | } | |||
| 175 | + | if len(runtime.delivered) != 1 || runtime.delivered[0] != "wrap up now" { | |||
| 176 | + | t.Fatalf("delivered = %#v", runtime.delivered) | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // An idle child cannot be steered, so steer mode falls back to a new turn. | |||
| 181 | + | func TestResidentResumeSteerFallsBackToNewTurnWhenIdle(t *testing.T) { | |||
| 182 | + | runtime := newSteerableRuntime() | |||
| 183 | + | close(runtime.release) | |||
| 184 | + | manager, completions := newSteerTestManager(t, runtime) | |||
| 185 | + | spec := ResidentChildSpec{ID: "idle", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"} | |||
| 186 | + | if _, err := manager.Spawn(t.Context(), spec, "task"); err != nil { | |||
| 187 | + | t.Fatal(err) | |||
| 188 | + | } | |||
| 189 | + | drainCompletions(t, completions, 1) | |||
| 190 | + | outcome, err := manager.ResumeFollowUp(t.Context(), spec.ID, "next", ResumeSteer, "second", nil) | |||
| 191 | + | if err != nil || outcome.Steered || outcome.TurnID != "second" { | |||
| 192 | + | t.Fatalf("outcome = %#v, err = %v", outcome, err) | |||
| 193 | + | } | |||
| 194 | + | if got := drainCompletions(t, completions, 1); got[0].TurnID != "second" { | |||
| 195 | + | t.Fatalf("completion = %#v", got[0]) | |||
| 196 | + | } | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | // Stopping a child with a steered follow-up it never read reports that | |||
| 200 | + | // follow-up as undelivered on the interrupted turn. | |||
| 201 | + | func TestResidentStopReportsUndeliveredSteer(t *testing.T) { | |||
| 202 | + | runtime := newSteerableRuntime() | |||
| 203 | + | manager, completions := newSteerTestManager(t, runtime) | |||
| 204 | + | spec := ResidentChildSpec{ID: "undelivered", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"} | |||
| 205 | + | if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil { | |||
| 206 | + | t.Fatal(err) | |||
| 207 | + | } | |||
| 208 | + | runtime.awaitStart(t) | |||
| 209 | + | if _, err := manager.ResumeFollowUp(t.Context(), spec.ID, "stop and report now", ResumeSteer, "", nil); err != nil { | |||
| 210 | + | t.Fatal(err) | |||
| 211 | + | } | |||
| 212 | + | if err := manager.Stop(t.Context(), spec.ID); err != nil { | |||
| 213 | + | t.Fatal(err) | |||
| 214 | + | } | |||
| 215 | + | got := drainCompletions(t, completions, 1) | |||
| 216 | + | completion := got[0].Completion() | |||
| 217 | + | if completion.Status != string(ResidentInterrupted) || len(completion.Undelivered) != 1 || completion.Undelivered[0] != "stop and report now" { | |||
| 218 | + | t.Fatalf("completion = %#v", completion) | |||
| 219 | + | } | |||
| 220 | + | if update := FormatCompletionUpdate([]Completion{completion}, ""); !strings.Contains(update, "undelivered: 1 follow-up(s) the child never read:\n - stop and report now") { | |||
| 221 | + | t.Fatalf("update = %q", update) | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | // Interrupt cancels only the running turn; the child stays live and a later | |||
| 226 | + | // resume runs as a new turn with the same runtime. | |||
| 227 | + | func TestResidentInterruptKeepsChildResumable(t *testing.T) { | |||
| 228 | + | runtime := newSteerableRuntime() | |||
| 229 | + | manager, completions := newSteerTestManager(t, runtime) | |||
| 230 | + | spec := ResidentChildSpec{ID: "interrupted", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test", Required: true} | |||
| 231 | + | if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil { | |||
| 232 | + | t.Fatal(err) | |||
| 233 | + | } | |||
| 234 | + | runtime.awaitStart(t) | |||
| 235 | + | if _, err := manager.ResumeFollowUp(t.Context(), spec.ID, "queued work", ResumeQueue, "queued", nil); err != nil { | |||
| 236 | + | t.Fatal(err) | |||
| 237 | + | } | |||
| 238 | + | interrupted, err := manager.Interrupt(t.Context(), spec.ID) | |||
| 239 | + | if err != nil || !interrupted { | |||
| 240 | + | t.Fatalf("Interrupt = %t, %v", interrupted, err) | |||
| 241 | + | } | |||
| 242 | + | got := drainCompletions(t, completions, 2) | |||
| 243 | + | byTurn := map[string]Completion{} | |||
| 244 | + | for _, c := range got { | |||
| 245 | + | byTurn[c.TurnID] = c.Completion() | |||
| 246 | + | } | |||
| 247 | + | if byTurn["initial"].Status != string(ResidentInterrupted) || byTurn["queued"].Status != CompletionDropped { | |||
| 248 | + | t.Fatalf("completions = %#v", byTurn) | |||
| 249 | + | } | |||
| 250 | + | if manager.Get(spec.ID) == nil { | |||
| 251 | + | t.Fatal("interrupt removed the live child") | |||
| 252 | + | } | |||
| 253 | + | if again, err := manager.Interrupt(t.Context(), spec.ID); err != nil || again { | |||
| 254 | + | t.Fatalf("second Interrupt = %t, %v, want no running turn", again, err) | |||
| 255 | + | } | |||
| 256 | + | close(runtime.release) | |||
| 257 | + | outcome, err := manager.ResumeFollowUp(t.Context(), spec.ID, "wrap up with what you have", ResumeSteer, "wrap-up", nil) | |||
| 258 | + | if err != nil || outcome.Steered { | |||
| 259 | + | t.Fatalf("resume after interrupt = %#v, %v", outcome, err) | |||
| 260 | + | } | |||
| 261 | + | if prompt := runtime.awaitStart(t); prompt != "wrap up with what you have" { | |||
| 262 | + | t.Fatalf("resumed prompt = %q", prompt) | |||
| 263 | + | } | |||
| 264 | + | if got := drainCompletions(t, completions, 1); got[0].TurnID != "wrap-up" || got[0].Err != nil { | |||
| 265 | + | t.Fatalf("wrap-up completion = %#v", got[0]) | |||
| 266 | + | } | |||
| 267 | + | if len(manager.UnmetRequired()) != 0 { | |||
| 268 | + | t.Fatal("successful wrap-up left required work unmet") | |||
| 269 | + | } | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | // An interrupt can be accepted just before the turn finishes on its own. The | |||
| 273 | + | // turn must still report interrupted exactly once, and a late interrupt on the | |||
| 274 | + | // now-idle child must be refused. | |||
| 275 | + | func TestResidentInterruptRacingNaturalCompletion(t *testing.T) { | |||
| 276 | + | started, release := make(chan struct{}, 1), make(chan struct{}) | |||
| 277 | + | // This runtime ignores cancellation, so its turn ends successfully after | |||
| 278 | + | // the interrupt was already accepted. | |||
| 279 | + | runtime := ResidentTurnRunner(func(context.Context, string) error { | |||
| 280 | + | started <- struct{}{} | |||
| 281 | + | <-release | |||
| 282 | + | return nil | |||
| 283 | + | }) | |||
| 284 | + | manager, completions := newSteerTestManager(t, runtime) | |||
| 285 | + | spec := ResidentChildSpec{ID: "racing", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"} | |||
| 286 | + | if _, err := manager.Spawn(t.Context(), spec, "work"); err != nil { | |||
| 287 | + | t.Fatal(err) | |||
| 288 | + | } | |||
| 289 | + | <-started | |||
| 290 | + | if ok, err := manager.Interrupt(t.Context(), spec.ID); err != nil || !ok { | |||
| 291 | + | t.Fatalf("Interrupt = %t, %v", ok, err) | |||
| 292 | + | } | |||
| 293 | + | close(release) | |||
| 294 | + | got := drainCompletions(t, completions, 1) | |||
| 295 | + | if got[0].TurnID != "initial" || got[0].Completion().Status != string(ResidentInterrupted) { | |||
| 296 | + | t.Fatalf("completion = %#v", got[0]) | |||
| 297 | + | } | |||
| 298 | + | if ok, err := manager.Interrupt(t.Context(), spec.ID); err != nil || ok { | |||
| 299 | + | t.Fatalf("late Interrupt = %t, %v, want refused on idle child", ok, err) | |||
| 300 | + | } | |||
| 301 | + | if manager.Get(spec.ID) == nil { | |||
| 302 | + | t.Fatal("interrupt removed the live child") | |||
| 303 | + | } | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | // Steering must not jump ahead of a queued follow-up. | |||
| 307 | + | func TestResidentSteerQueuesBehindPendingTurns(t *testing.T) { | |||
| 308 | + | runtime := newSteerableRuntime() | |||
| 309 | + | manager, _ := newSteerTestManager(t, runtime) | |||
| 310 | + | spec := ResidentChildSpec{ID: "ordered", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"} | |||
| 311 | + | if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil { | |||
| 312 | + | t.Fatal(err) | |||
| 313 | + | } | |||
| 314 | + | runtime.awaitStart(t) | |||
| 315 | + | if _, err := manager.ResumeFollowUp(t.Context(), spec.ID, "first", ResumeQueue, "first", nil); err != nil { | |||
| 316 | + | t.Fatal(err) | |||
| 317 | + | } | |||
| 318 | + | outcome, err := manager.ResumeFollowUp(t.Context(), spec.ID, "second", ResumeSteer, "second", nil) | |||
| 319 | + | if err != nil || outcome.Steered || outcome.TurnID != "second" { | |||
| 320 | + | t.Fatalf("outcome = %#v, err = %v", outcome, err) | |||
| 321 | + | } | |||
| 322 | + | if snapshot, _ := manager.SnapshotFor(spec.ID); snapshot.PendingFollowUps != 2 { | |||
| 323 | + | t.Fatalf("pending follow-ups = %d, want 2 queued turns", snapshot.PendingFollowUps) | |||
| 324 | + | } | |||
| 325 | + | } | |||
M packages/agent/tools/resident_tools_test.go +27 -27
| @@ -17,13 +17,13 @@ import ( | |||
| 17 | 17 | ||
| 18 | 18 | func TestResidentStatusRetrievesFailedResultWithoutExecution(t *testing.T) { | |
| 19 | 19 | failure := errors.New("worker failed") | |
| 20 | - | manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 21 | - | return func(context.Context, string) error { | |
| 20 | + | manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 21 | + | return subagents.ResidentTurnRunner(func(context.Context, string) error { | |
| 22 | 22 | if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "partial finding"}}}}); err != nil { | |
| 23 | 23 | return err | |
| 24 | 24 | } | |
| 25 | 25 | return failure | |
| 26 | - | }, nil | |
| 26 | + | }), nil | |
| 27 | 27 | }) | |
| 28 | 28 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 29 | 29 | spec := subagents.ResidentChildSpec{ID: "failed-status", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test", Required: true} | |
| @@ -66,8 +66,8 @@ func TestResidentStatusRetrievesFailedResultWithoutExecution(t *testing.T) { | |||
| 66 | 66 | ||
| 67 | 67 | func TestResidentToolsUseManagerOnly(t *testing.T) { | |
| 68 | 68 | runs := make(chan string, 2) | |
| 69 | - | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 70 | - | return func(_ context.Context, prompt string) error { runs <- prompt; return nil }, nil | |
| 69 | + | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 70 | + | return subagents.ResidentTurnRunner(func(_ context.Context, prompt string) error { runs <- prompt; return nil }), nil | |
| 71 | 71 | }) | |
| 72 | 72 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 73 | 73 | spawn := &SubagentSpawnTool{ | |
| @@ -141,8 +141,8 @@ func TestResidentSpawnWaitReturnsInitialCompletion(t *testing.T) { | |||
| 141 | 141 | {"cancellation with failure", errors.Join(context.Canceled, errors.New("worker failed")), "interrupted"}, | |
| 142 | 142 | } { | |
| 143 | 143 | t.Run(tc.name, func(t *testing.T) { | |
| 144 | - | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 145 | - | return func(context.Context, string) error { return tc.err }, nil | |
| 144 | + | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 145 | + | return subagents.ResidentTurnRunner(func(context.Context, string) error { return tc.err }), nil | |
| 146 | 146 | }) | |
| 147 | 147 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 148 | 148 | spawn := &SubagentSpawnTool{ | |
| @@ -174,8 +174,8 @@ func TestResidentSpawnWaitExpiryReportsQueuedChild(t *testing.T) { | |||
| 174 | 174 | var releaseOnce sync.Once | |
| 175 | 175 | releaseBlocker := func() { releaseOnce.Do(func() { close(release) }) } | |
| 176 | 176 | finished := make(chan string, 1) | |
| 177 | - | manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 1, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 178 | - | return func(ctx context.Context, prompt string) error { | |
| 177 | + | manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 1, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 178 | + | return subagents.ResidentTurnRunner(func(ctx context.Context, prompt string) error { | |
| 179 | 179 | switch prompt { | |
| 180 | 180 | case "blocker": | |
| 181 | 181 | started <- struct{}{} | |
| @@ -189,7 +189,7 @@ func TestResidentSpawnWaitExpiryReportsQueuedChild(t *testing.T) { | |||
| 189 | 189 | finished <- prompt | |
| 190 | 190 | } | |
| 191 | 191 | return nil | |
| 192 | - | }, nil | |
| 192 | + | }), nil | |
| 193 | 193 | }) | |
| 194 | 194 | t.Cleanup(func() { | |
| 195 | 195 | releaseBlocker() | |
| @@ -234,8 +234,8 @@ func TestResidentSpawnWaitExpiryReportsQueuedChild(t *testing.T) { | |||
| 234 | 234 | } | |
| 235 | 235 | ||
| 236 | 236 | func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) { | |
| 237 | - | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 238 | - | return func(context.Context, string) error { return nil }, nil | |
| 237 | + | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 238 | + | return subagents.ResidentTurnRunner(func(context.Context, string) error { return nil }), nil | |
| 239 | 239 | }) | |
| 240 | 240 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 241 | 241 | built := 0 | |
| @@ -271,8 +271,8 @@ func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) { | |||
| 271 | 271 | } | |
| 272 | 272 | ||
| 273 | 273 | func TestResidentSpawnProfileAndExplicitOverridesReachFactory(t *testing.T) { | |
| 274 | - | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 275 | - | return func(context.Context, string) error { return nil }, nil | |
| 274 | + | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 275 | + | return subagents.ResidentTurnRunner(func(context.Context, string) error { return nil }), nil | |
| 276 | 276 | }) | |
| 277 | 277 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 278 | 278 | profileFast := false | |
| @@ -318,10 +318,10 @@ func TestResidentToolsRejectUnknownFields(t *testing.T) { | |||
| 318 | 318 | // A resume wait subscribes to the accepted follow-up turn before it can finish, | |
| 319 | 319 | // so it returns that turn's completion instead of timing out. | |
| 320 | 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 { | |
| 321 | + | manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 322 | + | return subagents.ResidentTurnRunner(func(_ context.Context, prompt string) error { | |
| 323 | 323 | return journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "answer for " + prompt}}}}) | |
| 324 | - | }, nil | |
| 324 | + | }), nil | |
| 325 | 325 | }) | |
| 326 | 326 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 327 | 327 | initial, cancelInitial := manager.WatchCompletion("waited-resume", "initial-turn") | |
| @@ -339,8 +339,8 @@ func TestResidentResumeWaitReturnsFollowUpCompletion(t *testing.T) { | |||
| 339 | 339 | if err != nil || result.IsError { | |
| 340 | 340 | t.Fatalf("resume = (%#v, %v)", result, err) | |
| 341 | 341 | } | |
| 342 | - | response, ok := result.Details.(subagentActionResponse) | |
| 343 | - | if !ok || response.Wait == nil || response.Wait.TimedOut { | |
| 342 | + | response, ok := result.Details.(subagentResumeResponse) | |
| 343 | + | if !ok || response.Wait == nil || response.Wait.TimedOut || response.Delivery != "queued" { | |
| 344 | 344 | t.Fatalf("wait outcome = %#v", result.Details) | |
| 345 | 345 | } | |
| 346 | 346 | if response.Wait.Status != string(subagents.ResidentCompleted) || response.Wait.Summary != "answer for confirm" { | |
| @@ -357,8 +357,8 @@ func TestResidentResumeWaitExpiryLeavesChildActive(t *testing.T) { | |||
| 357 | 357 | release := make(chan struct{}) | |
| 358 | 358 | var releaseOnce sync.Once | |
| 359 | 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 { | |
| 360 | + | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 361 | + | return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error { | |
| 362 | 362 | started <- struct{}{} | |
| 363 | 363 | select { | |
| 364 | 364 | case <-release: | |
| @@ -366,7 +366,7 @@ func TestResidentResumeWaitExpiryLeavesChildActive(t *testing.T) { | |||
| 366 | 366 | case <-ctx.Done(): | |
| 367 | 367 | return ctx.Err() | |
| 368 | 368 | } | |
| 369 | - | }, nil | |
| 369 | + | }), nil | |
| 370 | 370 | }) | |
| 371 | 371 | t.Cleanup(func() { | |
| 372 | 372 | releaseBlocker() | |
| @@ -381,11 +381,11 @@ func TestResidentResumeWaitExpiryLeavesChildActive(t *testing.T) { | |||
| 381 | 381 | t.Fatal("blocking turn did not start") | |
| 382 | 382 | } | |
| 383 | 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) | |
| 384 | + | result, err := resume.Execute(context.Background(), json.RawMessage(`{"agent_id":"blocked-resume","prompt":"follow up","mode":"queue","wait":1}`), nil) | |
| 385 | 385 | if err != nil || result.IsError { | |
| 386 | 386 | t.Fatalf("resume = (%#v, %v)", result, err) | |
| 387 | 387 | } | |
| 388 | - | response, ok := result.Details.(subagentActionResponse) | |
| 388 | + | response, ok := result.Details.(subagentResumeResponse) | |
| 389 | 389 | if !ok || response.Wait == nil || !response.Wait.TimedOut || response.Wait.Seconds != 1 { | |
| 390 | 390 | t.Fatalf("wait outcome = %#v", result.Details) | |
| 391 | 391 | } | |
| @@ -403,8 +403,8 @@ func TestResidentResumeWaitCancellationBeatsTimeout(t *testing.T) { | |||
| 403 | 403 | releaseBlocker := func() { releaseOnce.Do(func() { close(release) }) } | |
| 404 | 404 | var calls atomic.Int32 | |
| 405 | 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 { | |
| 406 | + | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 407 | + | return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error { | |
| 408 | 408 | if calls.Add(1) == 1 { | |
| 409 | 409 | return nil | |
| 410 | 410 | } | |
| @@ -415,7 +415,7 @@ func TestResidentResumeWaitCancellationBeatsTimeout(t *testing.T) { | |||
| 415 | 415 | case <-ctx.Done(): | |
| 416 | 416 | return ctx.Err() | |
| 417 | 417 | } | |
| 418 | - | }, nil | |
| 418 | + | }), nil | |
| 419 | 419 | }) | |
| 420 | 420 | t.Cleanup(func() { | |
| 421 | 421 | releaseBlocker() | |
| @@ -17,13 +17,13 @@ import ( | |||||
| 17 | 17 | ||||
| 18 | func TestResidentStatusRetrievesFailedResultWithoutExecution(t *testing.T) { | 18 | func TestResidentStatusRetrievesFailedResultWithoutExecution(t *testing.T) { | ||
| 19 | failure := errors.New("worker failed") | 19 | failure := errors.New("worker failed") | ||
| 20 | - | manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 20 | + | manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { |
| 21 | - | return func(context.Context, string) error { | 21 | + | return subagents.ResidentTurnRunner(func(context.Context, string) error { |
| 22 | if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "partial finding"}}}}); err != nil { | 22 | if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "partial finding"}}}}); err != nil { | ||
| 23 | return err | 23 | return err | ||
| 24 | } | 24 | } | ||
| 25 | return failure | 25 | return failure | ||
| 26 | - | }, nil | 26 | + | }), nil |
| 27 | }) | 27 | }) | ||
| 28 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | 28 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | ||
| 29 | spec := subagents.ResidentChildSpec{ID: "failed-status", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test", Required: true} | 29 | spec := subagents.ResidentChildSpec{ID: "failed-status", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test", Required: true} | ||
| @@ -66,8 +66,8 @@ func TestResidentStatusRetrievesFailedResultWithoutExecution(t *testing.T) { | |||||
| 66 | 66 | ||||
| 67 | func TestResidentToolsUseManagerOnly(t *testing.T) { | 67 | func TestResidentToolsUseManagerOnly(t *testing.T) { | ||
| 68 | runs := make(chan string, 2) | 68 | runs := make(chan string, 2) | ||
| 69 | - | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 69 | + | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { |
| 70 | - | return func(_ context.Context, prompt string) error { runs <- prompt; return nil }, nil | 70 | + | return subagents.ResidentTurnRunner(func(_ context.Context, prompt string) error { runs <- prompt; return nil }), nil |
| 71 | }) | 71 | }) | ||
| 72 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | 72 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | ||
| 73 | spawn := &SubagentSpawnTool{ | 73 | spawn := &SubagentSpawnTool{ | ||
| @@ -141,8 +141,8 @@ func TestResidentSpawnWaitReturnsInitialCompletion(t *testing.T) { | |||||
| 141 | {"cancellation with failure", errors.Join(context.Canceled, errors.New("worker failed")), "interrupted"}, | 141 | {"cancellation with failure", errors.Join(context.Canceled, errors.New("worker failed")), "interrupted"}, | ||
| 142 | } { | 142 | } { | ||
| 143 | t.Run(tc.name, func(t *testing.T) { | 143 | t.Run(tc.name, func(t *testing.T) { | ||
| 144 | - | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 144 | + | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { |
| 145 | - | return func(context.Context, string) error { return tc.err }, nil | 145 | + | return subagents.ResidentTurnRunner(func(context.Context, string) error { return tc.err }), nil |
| 146 | }) | 146 | }) | ||
| 147 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | 147 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | ||
| 148 | spawn := &SubagentSpawnTool{ | 148 | spawn := &SubagentSpawnTool{ | ||
| @@ -174,8 +174,8 @@ func TestResidentSpawnWaitExpiryReportsQueuedChild(t *testing.T) { | |||||
| 174 | var releaseOnce sync.Once | 174 | var releaseOnce sync.Once | ||
| 175 | releaseBlocker := func() { releaseOnce.Do(func() { close(release) }) } | 175 | releaseBlocker := func() { releaseOnce.Do(func() { close(release) }) } | ||
| 176 | finished := make(chan string, 1) | 176 | finished := make(chan string, 1) | ||
| 177 | - | manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 1, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 177 | + | manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 1, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { |
| 178 | - | return func(ctx context.Context, prompt string) error { | 178 | + | return subagents.ResidentTurnRunner(func(ctx context.Context, prompt string) error { |
| 179 | switch prompt { | 179 | switch prompt { | ||
| 180 | case "blocker": | 180 | case "blocker": | ||
| 181 | started <- struct{}{} | 181 | started <- struct{}{} | ||
| @@ -189,7 +189,7 @@ func TestResidentSpawnWaitExpiryReportsQueuedChild(t *testing.T) { | |||||
| 189 | finished <- prompt | 189 | finished <- prompt | ||
| 190 | } | 190 | } | ||
| 191 | return nil | 191 | return nil | ||
| 192 | - | }, nil | 192 | + | }), nil |
| 193 | }) | 193 | }) | ||
| 194 | t.Cleanup(func() { | 194 | t.Cleanup(func() { | ||
| 195 | releaseBlocker() | 195 | releaseBlocker() | ||
| @@ -234,8 +234,8 @@ func TestResidentSpawnWaitExpiryReportsQueuedChild(t *testing.T) { | |||||
| 234 | } | 234 | } | ||
| 235 | 235 | ||||
| 236 | func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) { | 236 | func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) { | ||
| 237 | - | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 237 | + | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { |
| 238 | - | return func(context.Context, string) error { return nil }, nil | 238 | + | return subagents.ResidentTurnRunner(func(context.Context, string) error { return nil }), nil |
| 239 | }) | 239 | }) | ||
| 240 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | 240 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | ||
| 241 | built := 0 | 241 | built := 0 | ||
| @@ -271,8 +271,8 @@ func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) { | |||||
| 271 | } | 271 | } | ||
| 272 | 272 | ||||
| 273 | func TestResidentSpawnProfileAndExplicitOverridesReachFactory(t *testing.T) { | 273 | func TestResidentSpawnProfileAndExplicitOverridesReachFactory(t *testing.T) { | ||
| 274 | - | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 274 | + | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { |
| 275 | - | return func(context.Context, string) error { return nil }, nil | 275 | + | return subagents.ResidentTurnRunner(func(context.Context, string) error { return nil }), nil |
| 276 | }) | 276 | }) | ||
| 277 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | 277 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | ||
| 278 | profileFast := false | 278 | profileFast := false | ||
| @@ -318,10 +318,10 @@ func TestResidentToolsRejectUnknownFields(t *testing.T) { | |||||
| 318 | // A resume wait subscribes to the accepted follow-up turn before it can finish, | 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. | 319 | // so it returns that turn's completion instead of timing out. | ||
| 320 | func TestResidentResumeWaitReturnsFollowUpCompletion(t *testing.T) { | 320 | func TestResidentResumeWaitReturnsFollowUpCompletion(t *testing.T) { | ||
| 321 | - | manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 321 | + | manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { |
| 322 | - | return func(_ context.Context, prompt string) error { | 322 | + | return subagents.ResidentTurnRunner(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}}}}) | 323 | return journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "answer for " + prompt}}}}) | ||
| 324 | - | }, nil | 324 | + | }), nil |
| 325 | }) | 325 | }) | ||
| 326 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | 326 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | ||
| 327 | initial, cancelInitial := manager.WatchCompletion("waited-resume", "initial-turn") | 327 | initial, cancelInitial := manager.WatchCompletion("waited-resume", "initial-turn") | ||
| @@ -339,8 +339,8 @@ func TestResidentResumeWaitReturnsFollowUpCompletion(t *testing.T) { | |||||
| 339 | if err != nil || result.IsError { | 339 | if err != nil || result.IsError { | ||
| 340 | t.Fatalf("resume = (%#v, %v)", result, err) | 340 | t.Fatalf("resume = (%#v, %v)", result, err) | ||
| 341 | } | 341 | } | ||
| 342 | - | response, ok := result.Details.(subagentActionResponse) | 342 | + | response, ok := result.Details.(subagentResumeResponse) |
| 343 | - | if !ok || response.Wait == nil || response.Wait.TimedOut { | 343 | + | if !ok || response.Wait == nil || response.Wait.TimedOut || response.Delivery != "queued" { |
| 344 | t.Fatalf("wait outcome = %#v", result.Details) | 344 | t.Fatalf("wait outcome = %#v", result.Details) | ||
| 345 | } | 345 | } | ||
| 346 | if response.Wait.Status != string(subagents.ResidentCompleted) || response.Wait.Summary != "answer for confirm" { | 346 | if response.Wait.Status != string(subagents.ResidentCompleted) || response.Wait.Summary != "answer for confirm" { | ||
| @@ -357,8 +357,8 @@ func TestResidentResumeWaitExpiryLeavesChildActive(t *testing.T) { | |||||
| 357 | release := make(chan struct{}) | 357 | release := make(chan struct{}) | ||
| 358 | var releaseOnce sync.Once | 358 | var releaseOnce sync.Once | ||
| 359 | releaseBlocker := func() { releaseOnce.Do(func() { close(release) }) } | 359 | releaseBlocker := func() { releaseOnce.Do(func() { close(release) }) } | ||
| 360 | - | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 360 | + | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { |
| 361 | - | return func(ctx context.Context, _ string) error { | 361 | + | return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error { |
| 362 | started <- struct{}{} | 362 | started <- struct{}{} | ||
| 363 | select { | 363 | select { | ||
| 364 | case <-release: | 364 | case <-release: | ||
| @@ -366,7 +366,7 @@ func TestResidentResumeWaitExpiryLeavesChildActive(t *testing.T) { | |||||
| 366 | case <-ctx.Done(): | 366 | case <-ctx.Done(): | ||
| 367 | return ctx.Err() | 367 | return ctx.Err() | ||
| 368 | } | 368 | } | ||
| 369 | - | }, nil | 369 | + | }), nil |
| 370 | }) | 370 | }) | ||
| 371 | t.Cleanup(func() { | 371 | t.Cleanup(func() { | ||
| 372 | releaseBlocker() | 372 | releaseBlocker() | ||
| @@ -381,11 +381,11 @@ func TestResidentResumeWaitExpiryLeavesChildActive(t *testing.T) { | |||||
| 381 | t.Fatal("blocking turn did not start") | 381 | t.Fatal("blocking turn did not start") | ||
| 382 | } | 382 | } | ||
| 383 | resume := &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }} | 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) | 384 | + | result, err := resume.Execute(context.Background(), json.RawMessage(`{"agent_id":"blocked-resume","prompt":"follow up","mode":"queue","wait":1}`), nil) |
| 385 | if err != nil || result.IsError { | 385 | if err != nil || result.IsError { | ||
| 386 | t.Fatalf("resume = (%#v, %v)", result, err) | 386 | t.Fatalf("resume = (%#v, %v)", result, err) | ||
| 387 | } | 387 | } | ||
| 388 | - | response, ok := result.Details.(subagentActionResponse) | 388 | + | response, ok := result.Details.(subagentResumeResponse) |
| 389 | if !ok || response.Wait == nil || !response.Wait.TimedOut || response.Wait.Seconds != 1 { | 389 | if !ok || response.Wait == nil || !response.Wait.TimedOut || response.Wait.Seconds != 1 { | ||
| 390 | t.Fatalf("wait outcome = %#v", result.Details) | 390 | t.Fatalf("wait outcome = %#v", result.Details) | ||
| 391 | } | 391 | } | ||
| @@ -403,8 +403,8 @@ func TestResidentResumeWaitCancellationBeatsTimeout(t *testing.T) { | |||||
| 403 | releaseBlocker := func() { releaseOnce.Do(func() { close(release) }) } | 403 | releaseBlocker := func() { releaseOnce.Do(func() { close(release) }) } | ||
| 404 | var calls atomic.Int32 | 404 | var calls atomic.Int32 | ||
| 405 | followUpStarted := make(chan struct{}, 1) | 405 | followUpStarted := make(chan struct{}, 1) | ||
| 406 | - | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 406 | + | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { |
| 407 | - | return func(ctx context.Context, _ string) error { | 407 | + | return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error { |
| 408 | if calls.Add(1) == 1 { | 408 | if calls.Add(1) == 1 { | ||
| 409 | return nil | 409 | return nil | ||
| 410 | } | 410 | } | ||
| @@ -415,7 +415,7 @@ func TestResidentResumeWaitCancellationBeatsTimeout(t *testing.T) { | |||||
| 415 | case <-ctx.Done(): | 415 | case <-ctx.Done(): | ||
| 416 | return ctx.Err() | 416 | return ctx.Err() | ||
| 417 | } | 417 | } | ||
| 418 | - | }, nil | 418 | + | }), nil |
| 419 | }) | 419 | }) | ||
| 420 | t.Cleanup(func() { | 420 | t.Cleanup(func() { | ||
| 421 | releaseBlocker() | 421 | releaseBlocker() | ||
M packages/agent/tools/subagent.go +31 -19
| @@ -16,10 +16,11 @@ import ( | |||
| 16 | 16 | // action; each action delegates to an internal implementation, and a nil | |
| 17 | 17 | // implementation means that action is unavailable under the launch-time policy. | |
| 18 | 18 | type SubagentTool struct { | |
| 19 | - | Spawn *SubagentSpawnTool | |
| 20 | - | Status *SubagentStatusTool | |
| 21 | - | Stop *SubagentStopTool | |
| 22 | - | Resume *SubagentResumeTool | |
| 19 | + | Spawn *SubagentSpawnTool | |
| 20 | + | Status *SubagentStatusTool | |
| 21 | + | Stop *SubagentStopTool | |
| 22 | + | Resume *SubagentResumeTool | |
| 23 | + | Interrupt *SubagentInterruptTool | |
| 23 | 24 | } | |
| 24 | 25 | ||
| 25 | 26 | // subagentSpawnGuidance is the operational contract for delegation. It is the | |
| @@ -27,11 +28,11 @@ type SubagentTool struct { | |||
| 27 | 28 | // cannot drift between them. | |
| 28 | 29 | 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 | ||
| 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 | |
| 31 | + | // subagentSchema merges the action schemas into the flat, model-facing schema | |
| 32 | + | // the project keeps for provider compatibility. Every property is annotated | |
| 33 | + | // with the action that owns it; agent_id carries one merged description because | |
| 34 | + | // it means "omit to list" for status and "required" for stop, resume, and | |
| 35 | + | // interrupt. Because the schema advertises every action's arguments at | |
| 35 | 36 | // once, a field that belongs to a different action is reported instead of | |
| 36 | 37 | // ignored, so a mis-selected action cannot silently run as another one. Fields | |
| 37 | 38 | // that no action owns still reach the sub-tool, whose strict decoder rejects | |
| @@ -42,8 +43,8 @@ const subagentSchema = `{ | |||
| 42 | 43 | "properties": { | |
| 43 | 44 | "action": { | |
| 44 | 45 | "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, including its current live activity, 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." | |
| 46 | + | "enum": ["spawn", "status", "stop", "resume", "interrupt"], | |
| 47 | + | "description": "One action per call. spawn delegates a new resident sub-agent. status reads bounded state for one child, including its current live activity and pending follow-ups, or lists the current set. resume sends an existing child a follow-up, keeping its session context; by default it steers a running child mid-turn, and it can wait for the answering turn. interrupt cancels only the child's running turn and keeps the child and its context, so a later resume can ask it to wrap up. stop terminates a stuck child." | |
| 47 | 48 | }, | |
| 48 | 49 | "task": { | |
| 49 | 50 | "type": "string", | |
| @@ -87,7 +88,7 @@ const subagentSchema = `{ | |||
| 87 | 88 | }, | |
| 88 | 89 | "agent_id": { | |
| 89 | 90 | "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 | + | "description": "Child id or unique id prefix. With status, omit it to list all resident sub-agents. Required when action is stop, resume, or interrupt." | |
| 91 | 92 | }, | |
| 92 | 93 | "include_result": { | |
| 93 | 94 | "type": "boolean", | |
| @@ -101,7 +102,12 @@ const subagentSchema = `{ | |||
| 101 | 102 | }, | |
| 102 | 103 | "prompt": { | |
| 103 | 104 | "type": "string", | |
| 104 | - | "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." | |
| 105 | + | "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 the turn that answers it." | |
| 106 | + | }, | |
| 107 | + | "mode": { | |
| 108 | + | "type": "string", | |
| 109 | + | "enum": ["steer", "queue"], | |
| 110 | + | "description": "How resume reaches a running child. steer (default) injects the prompt into its current turn at the next tool or model boundary, so the child sees it without waiting for the turn to end. queue runs the prompt as a separate turn after the current one. An idle child starts a new turn either way. Only valid when action is resume." | |
| 105 | 111 | } | |
| 106 | 112 | }, | |
| 107 | 113 | "required": ["action"] | |
| @@ -111,10 +117,11 @@ const subagentSchema = `{ | |||
| 111 | 117 | // the selected action, or by no action, is forwarded; a field owned by another | |
| 112 | 118 | // action is rejected before dispatch. | |
| 113 | 119 | var subagentActionFields = map[string][]string{ | |
| 114 | - | SubagentActionSpawn: {"task", "agent", "model", "provider", "reasoning", "fast_mode", "required", "wait", "isolation"}, | |
| 115 | - | SubagentActionStatus: {"agent_id", "include_result", "watch"}, | |
| 116 | - | SubagentActionStop: {"agent_id"}, | |
| 117 | - | SubagentActionResume: {"agent_id", "prompt", "wait"}, | |
| 120 | + | SubagentActionSpawn: {"task", "agent", "model", "provider", "reasoning", "fast_mode", "required", "wait", "isolation"}, | |
| 121 | + | SubagentActionStatus: {"agent_id", "include_result", "watch"}, | |
| 122 | + | SubagentActionStop: {"agent_id"}, | |
| 123 | + | SubagentActionResume: {"agent_id", "prompt", "mode", "wait"}, | |
| 124 | + | SubagentActionInterrupt: {"agent_id"}, | |
| 118 | 125 | } | |
| 119 | 126 | ||
| 120 | 127 | var ( | |
| @@ -140,12 +147,12 @@ func (e *subagentForeignFieldError) Error() string { | |||
| 140 | 147 | ||
| 141 | 148 | func (t *SubagentTool) Name() string { return SubagentToolName } | |
| 142 | 149 | func (t *SubagentTool) Description() string { | |
| 143 | - | return "Spawn, inspect, resume, and stop resident sub-agents. One action per call.\n\n" + subagentSpawnGuidance | |
| 150 | + | return "Spawn, inspect, resume, interrupt, and stop resident sub-agents. One action per call.\n\n" + subagentSpawnGuidance | |
| 144 | 151 | } | |
| 145 | 152 | func (t *SubagentTool) Schema() json.RawMessage { return json.RawMessage(subagentSchema) } | |
| 146 | 153 | ||
| 147 | 154 | // ConfirmationKey implements core.ConfirmationKeyer. The facade multiplexes | |
| 148 | - | // four actions under one tool name, so the session "always allow" grant is | |
| 155 | + | // several actions under one tool name, so the session "always allow" grant is | |
| 149 | 156 | // scoped per action instead of per name. A missing, empty, or non-string | |
| 150 | 157 | // action returns the plain base name so a malformed call cannot widen the | |
| 151 | 158 | // remembered grant. | |
| @@ -205,6 +212,11 @@ func (t *SubagentTool) Execute(ctx context.Context, raw json.RawMessage, progres | |||
| 205 | 212 | return protocolToolError("subagent: resume action is unavailable in this mode") | |
| 206 | 213 | } | |
| 207 | 214 | return t.Resume.Execute(ctx, payload, progress) | |
| 215 | + | case SubagentActionInterrupt: | |
| 216 | + | if t.Interrupt == nil { | |
| 217 | + | return protocolToolError("subagent: interrupt action is unavailable in this mode") | |
| 218 | + | } | |
| 219 | + | return t.Interrupt.Execute(ctx, payload, progress) | |
| 208 | 220 | } | |
| 209 | 221 | return protocolToolError("subagent: unknown action") | |
| 210 | 222 | } | |
| @@ -16,10 +16,11 @@ import ( | |||||
| 16 | // action; each action delegates to an internal implementation, and a nil | 16 | // action; each action delegates to an internal implementation, and a nil | ||
| 17 | // implementation means that action is unavailable under the launch-time policy. | 17 | // implementation means that action is unavailable under the launch-time policy. | ||
| 18 | type SubagentTool struct { | 18 | type SubagentTool struct { | ||
| 19 | - | Spawn *SubagentSpawnTool | 19 | + | Spawn *SubagentSpawnTool |
| 20 | - | Status *SubagentStatusTool | 20 | + | Status *SubagentStatusTool |
| 21 | - | Stop *SubagentStopTool | 21 | + | Stop *SubagentStopTool |
| 22 | - | Resume *SubagentResumeTool | 22 | + | Resume *SubagentResumeTool |
| 23 | + | Interrupt *SubagentInterruptTool | |||
| 23 | } | 24 | } | ||
| 24 | 25 | ||||
| 25 | // subagentSpawnGuidance is the operational contract for delegation. It is the | 26 | // subagentSpawnGuidance is the operational contract for delegation. It is the | ||
| @@ -27,11 +28,11 @@ type SubagentTool struct { | |||||
| 27 | // cannot drift between them. | 28 | // 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 | 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 | ||||
| 30 | - | // subagentSchema merges the four action schemas into the flat, model-facing | 31 | + | // subagentSchema merges the action schemas into the flat, model-facing schema |
| 31 | - | // schema the project keeps for provider compatibility. Every property is | 32 | + | // the project keeps for provider compatibility. Every property is annotated |
| 32 | - | // annotated with the action that owns it; agent_id carries one merged | 33 | + | // with the action that owns it; agent_id carries one merged description because |
| 33 | - | // description because it means "omit to list" for status and "required" for | 34 | + | // it means "omit to list" for status and "required" for stop, resume, and |
| 34 | - | // stop and resume. Because the schema advertises every action's arguments at | 35 | + | // interrupt. Because the schema advertises every action's arguments at |
| 35 | // once, a field that belongs to a different action is reported instead of | 36 | // 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 | // 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 | // that no action owns still reach the sub-tool, whose strict decoder rejects | ||
| @@ -42,8 +43,8 @@ const subagentSchema = `{ | |||||
| 42 | "properties": { | 43 | "properties": { | ||
| 43 | "action": { | 44 | "action": { | ||
| 44 | "type": "string", | 45 | "type": "string", | ||
| 45 | - | "enum": ["spawn", "status", "stop", "resume"], | 46 | + | "enum": ["spawn", "status", "stop", "resume", "interrupt"], |
| 46 | - | "description": "One action per call. spawn delegates a new resident sub-agent. status reads bounded state for one child, including its current live activity, 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 | + | "description": "One action per call. spawn delegates a new resident sub-agent. status reads bounded state for one child, including its current live activity and pending follow-ups, or lists the current set. resume sends an existing child a follow-up, keeping its session context; by default it steers a running child mid-turn, and it can wait for the answering turn. interrupt cancels only the child's running turn and keeps the child and its context, so a later resume can ask it to wrap up. stop terminates a stuck child." |
| 47 | }, | 48 | }, | ||
| 48 | "task": { | 49 | "task": { | ||
| 49 | "type": "string", | 50 | "type": "string", | ||
| @@ -87,7 +88,7 @@ const subagentSchema = `{ | |||||
| 87 | }, | 88 | }, | ||
| 88 | "agent_id": { | 89 | "agent_id": { | ||
| 89 | "type": "string", | 90 | "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 | + | "description": "Child id or unique id prefix. With status, omit it to list all resident sub-agents. Required when action is stop, resume, or interrupt." |
| 91 | }, | 92 | }, | ||
| 92 | "include_result": { | 93 | "include_result": { | ||
| 93 | "type": "boolean", | 94 | "type": "boolean", | ||
| @@ -101,7 +102,12 @@ const subagentSchema = `{ | |||||
| 101 | }, | 102 | }, | ||
| 102 | "prompt": { | 103 | "prompt": { | ||
| 103 | "type": "string", | 104 | "type": "string", | ||
| 104 | - | "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." | 105 | + | "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 the turn that answers it." |
| 106 | + | }, | |||
| 107 | + | "mode": { | |||
| 108 | + | "type": "string", | |||
| 109 | + | "enum": ["steer", "queue"], | |||
| 110 | + | "description": "How resume reaches a running child. steer (default) injects the prompt into its current turn at the next tool or model boundary, so the child sees it without waiting for the turn to end. queue runs the prompt as a separate turn after the current one. An idle child starts a new turn either way. Only valid when action is resume." | |||
| 105 | } | 111 | } | ||
| 106 | }, | 112 | }, | ||
| 107 | "required": ["action"] | 113 | "required": ["action"] | ||
| @@ -111,10 +117,11 @@ const subagentSchema = `{ | |||||
| 111 | // the selected action, or by no action, is forwarded; a field owned by another | 117 | // the selected action, or by no action, is forwarded; a field owned by another | ||
| 112 | // action is rejected before dispatch. | 118 | // action is rejected before dispatch. | ||
| 113 | var subagentActionFields = map[string][]string{ | 119 | var subagentActionFields = map[string][]string{ | ||
| 114 | - | SubagentActionSpawn: {"task", "agent", "model", "provider", "reasoning", "fast_mode", "required", "wait", "isolation"}, | 120 | + | SubagentActionSpawn: {"task", "agent", "model", "provider", "reasoning", "fast_mode", "required", "wait", "isolation"}, |
| 115 | - | SubagentActionStatus: {"agent_id", "include_result", "watch"}, | 121 | + | SubagentActionStatus: {"agent_id", "include_result", "watch"}, |
| 116 | - | SubagentActionStop: {"agent_id"}, | 122 | + | SubagentActionStop: {"agent_id"}, |
| 117 | - | SubagentActionResume: {"agent_id", "prompt", "wait"}, | 123 | + | SubagentActionResume: {"agent_id", "prompt", "mode", "wait"}, |
| 124 | + | SubagentActionInterrupt: {"agent_id"}, | |||
| 118 | } | 125 | } | ||
| 119 | 126 | ||||
| 120 | var ( | 127 | var ( | ||
| @@ -140,12 +147,12 @@ func (e *subagentForeignFieldError) Error() string { | |||||
| 140 | 147 | ||||
| 141 | func (t *SubagentTool) Name() string { return SubagentToolName } | 148 | func (t *SubagentTool) Name() string { return SubagentToolName } | ||
| 142 | func (t *SubagentTool) Description() string { | 149 | func (t *SubagentTool) Description() string { | ||
| 143 | - | return "Spawn, inspect, resume, and stop resident sub-agents. One action per call.\n\n" + subagentSpawnGuidance | 150 | + | return "Spawn, inspect, resume, interrupt, and stop resident sub-agents. One action per call.\n\n" + subagentSpawnGuidance |
| 144 | } | 151 | } | ||
| 145 | func (t *SubagentTool) Schema() json.RawMessage { return json.RawMessage(subagentSchema) } | 152 | func (t *SubagentTool) Schema() json.RawMessage { return json.RawMessage(subagentSchema) } | ||
| 146 | 153 | ||||
| 147 | // ConfirmationKey implements core.ConfirmationKeyer. The facade multiplexes | 154 | // ConfirmationKey implements core.ConfirmationKeyer. The facade multiplexes | ||
| 148 | - | // four actions under one tool name, so the session "always allow" grant is | 155 | + | // several actions under one tool name, so the session "always allow" grant is |
| 149 | // scoped per action instead of per name. A missing, empty, or non-string | 156 | // scoped per action instead of per name. A missing, empty, or non-string | ||
| 150 | // action returns the plain base name so a malformed call cannot widen the | 157 | // action returns the plain base name so a malformed call cannot widen the | ||
| 151 | // remembered grant. | 158 | // remembered grant. | ||
| @@ -205,6 +212,11 @@ func (t *SubagentTool) Execute(ctx context.Context, raw json.RawMessage, progres | |||||
| 205 | return protocolToolError("subagent: resume action is unavailable in this mode") | 212 | return protocolToolError("subagent: resume action is unavailable in this mode") | ||
| 206 | } | 213 | } | ||
| 207 | return t.Resume.Execute(ctx, payload, progress) | 214 | return t.Resume.Execute(ctx, payload, progress) | ||
| 215 | + | case SubagentActionInterrupt: | |||
| 216 | + | if t.Interrupt == nil { | |||
| 217 | + | return protocolToolError("subagent: interrupt action is unavailable in this mode") | |||
| 218 | + | } | |||
| 219 | + | return t.Interrupt.Execute(ctx, payload, progress) | |||
| 208 | } | 220 | } | ||
| 209 | return protocolToolError("subagent: unknown action") | 221 | return protocolToolError("subagent: unknown action") | ||
| 210 | } | 222 | } | ||
A packages/agent/tools/subagent_interrupt.go +67 -0
| @@ -0,0 +1,67 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zut/packages/agent/subagents" | |
| 10 | + | "github.com/bnema/zut/packages/core" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // SubagentInterruptTool cancels only a sub-agent's running turn. Unlike stop, | |
| 14 | + | // the child stays alive with its transcript, so a later resume continues with | |
| 15 | + | // full context. | |
| 16 | + | type SubagentInterruptTool struct { | |
| 17 | + | ResidentManager *subagents.ResidentManager | |
| 18 | + | Enabled func() bool | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | type subagentInterruptArgs struct { | |
| 22 | + | AgentID string `json:"agent_id"` | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // Name returns the shared facade name: this type is an internal | |
| 26 | + | // implementation and is never registered on its own. | |
| 27 | + | func (t *SubagentInterruptTool) Name() string { return SubagentToolName } | |
| 28 | + | ||
| 29 | + | func (t *SubagentInterruptTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) { | |
| 30 | + | if ctx != nil { | |
| 31 | + | select { | |
| 32 | + | case <-ctx.Done(): | |
| 33 | + | return core.ToolResult{}, ctx.Err() | |
| 34 | + | default: | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | prefix := t.Name() | |
| 38 | + | if t.ResidentManager == nil { | |
| 39 | + | return protocolToolError(prefix + ": subagent runtime not available in this mode") | |
| 40 | + | } | |
| 41 | + | if t.Enabled == nil || !t.Enabled() { | |
| 42 | + | return protocolToolError(prefix + ": subagent management is unavailable in this mode") | |
| 43 | + | } | |
| 44 | + | var args subagentInterruptArgs | |
| 45 | + | if err := decodeSubagentArgs(raw, &args); err != nil { | |
| 46 | + | return core.ToolResult{}, err | |
| 47 | + | } | |
| 48 | + | id := strings.TrimSpace(args.AgentID) | |
| 49 | + | if id == "" { | |
| 50 | + | return protocolToolError(prefix + ": agent_id is required") | |
| 51 | + | } | |
| 52 | + | snapshot, ok := findResidentStatusSnapshot(t.ResidentManager.Snapshot(), id) | |
| 53 | + | if !ok { | |
| 54 | + | return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id)) | |
| 55 | + | } | |
| 56 | + | interrupted, err := t.ResidentManager.Interrupt(ctx, snapshot.ID) | |
| 57 | + | if err != nil { | |
| 58 | + | return protocolToolError(prefix + ": " + err.Error()) | |
| 59 | + | } | |
| 60 | + | if !interrupted { | |
| 61 | + | return protocolToolError(fmt.Sprintf("%s: agent %q has no running turn to interrupt", prefix, snapshot.ID)) | |
| 62 | + | } | |
| 63 | + | if updated, ok := t.ResidentManager.SnapshotFor(snapshot.ID); ok { | |
| 64 | + | snapshot = updated | |
| 65 | + | } | |
| 66 | + | return renderResidentAction("interrupt_requested", publicResidentStatus(snapshot)) | |
| 67 | + | } | |
| @@ -0,0 +1,67 @@ | |||||
| 1 | + | package tools | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | "strings" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zut/packages/agent/subagents" | |||
| 10 | + | "github.com/bnema/zut/packages/core" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // SubagentInterruptTool cancels only a sub-agent's running turn. Unlike stop, | |||
| 14 | + | // the child stays alive with its transcript, so a later resume continues with | |||
| 15 | + | // full context. | |||
| 16 | + | type SubagentInterruptTool struct { | |||
| 17 | + | ResidentManager *subagents.ResidentManager | |||
| 18 | + | Enabled func() bool | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | type subagentInterruptArgs struct { | |||
| 22 | + | AgentID string `json:"agent_id"` | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // Name returns the shared facade name: this type is an internal | |||
| 26 | + | // implementation and is never registered on its own. | |||
| 27 | + | func (t *SubagentInterruptTool) Name() string { return SubagentToolName } | |||
| 28 | + | ||||
| 29 | + | func (t *SubagentInterruptTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) { | |||
| 30 | + | if ctx != nil { | |||
| 31 | + | select { | |||
| 32 | + | case <-ctx.Done(): | |||
| 33 | + | return core.ToolResult{}, ctx.Err() | |||
| 34 | + | default: | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | prefix := t.Name() | |||
| 38 | + | if t.ResidentManager == nil { | |||
| 39 | + | return protocolToolError(prefix + ": subagent runtime not available in this mode") | |||
| 40 | + | } | |||
| 41 | + | if t.Enabled == nil || !t.Enabled() { | |||
| 42 | + | return protocolToolError(prefix + ": subagent management is unavailable in this mode") | |||
| 43 | + | } | |||
| 44 | + | var args subagentInterruptArgs | |||
| 45 | + | if err := decodeSubagentArgs(raw, &args); err != nil { | |||
| 46 | + | return core.ToolResult{}, err | |||
| 47 | + | } | |||
| 48 | + | id := strings.TrimSpace(args.AgentID) | |||
| 49 | + | if id == "" { | |||
| 50 | + | return protocolToolError(prefix + ": agent_id is required") | |||
| 51 | + | } | |||
| 52 | + | snapshot, ok := findResidentStatusSnapshot(t.ResidentManager.Snapshot(), id) | |||
| 53 | + | if !ok { | |||
| 54 | + | return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id)) | |||
| 55 | + | } | |||
| 56 | + | interrupted, err := t.ResidentManager.Interrupt(ctx, snapshot.ID) | |||
| 57 | + | if err != nil { | |||
| 58 | + | return protocolToolError(prefix + ": " + err.Error()) | |||
| 59 | + | } | |||
| 60 | + | if !interrupted { | |||
| 61 | + | return protocolToolError(fmt.Sprintf("%s: agent %q has no running turn to interrupt", prefix, snapshot.ID)) | |||
| 62 | + | } | |||
| 63 | + | if updated, ok := t.ResidentManager.SnapshotFor(snapshot.ID); ok { | |||
| 64 | + | snapshot = updated | |||
| 65 | + | } | |||
| 66 | + | return renderResidentAction("interrupt_requested", publicResidentStatus(snapshot)) | |||
| 67 | + | } | |||
M packages/agent/tools/subagent_resume.go +39 -9
| @@ -11,10 +11,12 @@ import ( | |||
| 11 | 11 | "github.com/google/uuid" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | - | // SubagentResumeTool gives a sub-agent a follow-up turn while preserving its | |
| 15 | - | // existing session context. Every explicit follow-up is accepted durably and | |
| 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. | |
| 14 | + | // SubagentResumeTool sends a sub-agent a follow-up while preserving its session | |
| 15 | + | // context. In steer mode, the default, a follow-up to a running child joins its | |
| 16 | + | // current turn at the next tool or model boundary; otherwise it is accepted | |
| 17 | + | // durably as a new turn that runs FIFO after any active one. An explicit | |
| 18 | + | // bounded wait may return the answering turn's completion or expire while the | |
| 19 | + | // child stays active. | |
| 18 | 20 | type SubagentResumeTool struct { | |
| 19 | 21 | ResidentManager *subagents.ResidentManager | |
| 20 | 22 | Enabled func() bool | |
| @@ -23,9 +25,18 @@ type SubagentResumeTool struct { | |||
| 23 | 25 | type subagentResumeArgs struct { | |
| 24 | 26 | AgentID string `json:"agent_id"` | |
| 25 | 27 | Prompt string `json:"prompt"` | |
| 28 | + | Mode string `json:"mode,omitempty"` | |
| 26 | 29 | Wait *int `json:"wait,omitempty"` | |
| 27 | 30 | } | |
| 28 | 31 | ||
| 32 | + | // subagentResumeResponse reports how a follow-up was delivered. | |
| 33 | + | type subagentResumeResponse struct { | |
| 34 | + | Action string `json:"action"` | |
| 35 | + | Delivery string `json:"delivery"` | |
| 36 | + | Agent subagentStatusEntry `json:"agent"` | |
| 37 | + | Wait *subagentWaitOutcome `json:"wait,omitempty"` | |
| 38 | + | } | |
| 39 | + | ||
| 29 | 40 | // Name returns the shared facade name: this type is an internal | |
| 30 | 41 | // implementation and is never registered on its own. | |
| 31 | 42 | func (t *SubagentResumeTool) Name() string { return SubagentToolName } | |
| @@ -57,6 +68,13 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 57 | 68 | if strings.TrimSpace(args.Prompt) == "" { | |
| 58 | 69 | return protocolToolError(prefix + ": prompt is required") | |
| 59 | 70 | } | |
| 71 | + | mode := subagents.ResumeMode(strings.TrimSpace(args.Mode)) | |
| 72 | + | if mode == "" { | |
| 73 | + | mode = subagents.ResumeSteer | |
| 74 | + | } | |
| 75 | + | if mode != subagents.ResumeSteer && mode != subagents.ResumeQueue { | |
| 76 | + | return protocolToolError(prefix + ": mode must be steer or queue") | |
| 77 | + | } | |
| 60 | 78 | if args.Wait != nil && (*args.Wait < 1 || *args.Wait > maxSubagentWaitSeconds) { | |
| 61 | 79 | return protocolToolError(fmt.Sprintf("%s: wait must be between 1 and %d seconds", prefix, maxSubagentWaitSeconds)) | |
| 62 | 80 | } | |
| @@ -64,18 +82,29 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 64 | 82 | if !ok { | |
| 65 | 83 | return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id)) | |
| 66 | 84 | } | |
| 67 | - | // The watch is registered before the follow-up is accepted, so a turn that | |
| 68 | - | // finishes immediately cannot complete before this call subscribes. | |
| 85 | + | // Each watch is registered before its turn can finish: the queued turn's | |
| 86 | + | // before acceptance, and the steered turn's from the child's control loop | |
| 87 | + | // before that turn can report completion. | |
| 69 | 88 | turnID := uuid.NewString() | |
| 70 | 89 | var completionResult <-chan subagents.ResidentCompletion | |
| 71 | 90 | cancelWait := func() {} | |
| 91 | + | var onSteered func(string) | |
| 72 | 92 | if args.Wait != nil { | |
| 73 | 93 | completionResult, cancelWait = t.ResidentManager.WatchCompletion(snapshot.ID, turnID) | |
| 94 | + | onSteered = func(activeTurn string) { | |
| 95 | + | cancelWait() | |
| 96 | + | completionResult, cancelWait = t.ResidentManager.WatchCompletion(snapshot.ID, activeTurn) | |
| 97 | + | } | |
| 74 | 98 | } | |
| 75 | - | defer cancelWait() | |
| 76 | - | if err := t.ResidentManager.ResumeWithTurn(ctx, snapshot.ID, args.Prompt, turnID); err != nil { | |
| 99 | + | defer func() { cancelWait() }() | |
| 100 | + | delivered, err := t.ResidentManager.ResumeFollowUp(ctx, snapshot.ID, args.Prompt, mode, turnID, onSteered) | |
| 101 | + | if err != nil { | |
| 77 | 102 | return protocolToolError(prefix + ": " + err.Error()) | |
| 78 | 103 | } | |
| 104 | + | delivery := "queued" | |
| 105 | + | if delivered.Steered { | |
| 106 | + | delivery = "steered" | |
| 107 | + | } | |
| 79 | 108 | var outcome *subagentWaitOutcome | |
| 80 | 109 | if args.Wait != nil { | |
| 81 | 110 | completion, timedOut, err := waitForResidentCompletion(ctx, completionResult, *args.Wait, prefix) | |
| @@ -88,10 +117,11 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 88 | 117 | outcome.Status = result.Status | |
| 89 | 118 | outcome.Error = result.Error | |
| 90 | 119 | outcome.Summary = result.Summary | |
| 120 | + | outcome.Undelivered = result.Undelivered | |
| 91 | 121 | } | |
| 92 | 122 | } | |
| 93 | 123 | if updated, ok := t.ResidentManager.SnapshotFor(snapshot.ID); ok { | |
| 94 | 124 | snapshot = updated | |
| 95 | 125 | } | |
| 96 | - | return renderResidentActionWait("resumed", publicResidentStatus(snapshot), outcome) | |
| 126 | + | return renderSubagentResponse(subagentResumeResponse{Action: "resumed", Delivery: delivery, Agent: publicResidentStatus(snapshot), Wait: outcome}) | |
| 97 | 127 | } | |
| @@ -11,10 +11,12 @@ import ( | |||||
| 11 | "github.com/google/uuid" | 11 | "github.com/google/uuid" | ||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | - | // SubagentResumeTool gives a sub-agent a follow-up turn while preserving its | 14 | + | // SubagentResumeTool sends a sub-agent a follow-up while preserving its session |
| 15 | - | // existing session context. Every explicit follow-up is accepted durably and | 15 | + | // context. In steer mode, the default, a follow-up to a running child joins its |
| 16 | - | // runs FIFO after any active resident turn. An explicit bounded wait may return | 16 | + | // current turn at the next tool or model boundary; otherwise it is accepted |
| 17 | - | // that turn's completion or expire while the child stays active. | 17 | + | // durably as a new turn that runs FIFO after any active one. An explicit |
| 18 | + | // bounded wait may return the answering turn's completion or expire while the | |||
| 19 | + | // child stays active. | |||
| 18 | type SubagentResumeTool struct { | 20 | type SubagentResumeTool struct { | ||
| 19 | ResidentManager *subagents.ResidentManager | 21 | ResidentManager *subagents.ResidentManager | ||
| 20 | Enabled func() bool | 22 | Enabled func() bool | ||
| @@ -23,9 +25,18 @@ type SubagentResumeTool struct { | |||||
| 23 | type subagentResumeArgs struct { | 25 | type subagentResumeArgs struct { | ||
| 24 | AgentID string `json:"agent_id"` | 26 | AgentID string `json:"agent_id"` | ||
| 25 | Prompt string `json:"prompt"` | 27 | Prompt string `json:"prompt"` | ||
| 28 | + | Mode string `json:"mode,omitempty"` | |||
| 26 | Wait *int `json:"wait,omitempty"` | 29 | Wait *int `json:"wait,omitempty"` | ||
| 27 | } | 30 | } | ||
| 28 | 31 | ||||
| 32 | + | // subagentResumeResponse reports how a follow-up was delivered. | |||
| 33 | + | type subagentResumeResponse struct { | |||
| 34 | + | Action string `json:"action"` | |||
| 35 | + | Delivery string `json:"delivery"` | |||
| 36 | + | Agent subagentStatusEntry `json:"agent"` | |||
| 37 | + | Wait *subagentWaitOutcome `json:"wait,omitempty"` | |||
| 38 | + | } | |||
| 39 | + | ||||
| 29 | // Name returns the shared facade name: this type is an internal | 40 | // Name returns the shared facade name: this type is an internal | ||
| 30 | // implementation and is never registered on its own. | 41 | // implementation and is never registered on its own. | ||
| 31 | func (t *SubagentResumeTool) Name() string { return SubagentToolName } | 42 | func (t *SubagentResumeTool) Name() string { return SubagentToolName } | ||
| @@ -57,6 +68,13 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||||
| 57 | if strings.TrimSpace(args.Prompt) == "" { | 68 | if strings.TrimSpace(args.Prompt) == "" { | ||
| 58 | return protocolToolError(prefix + ": prompt is required") | 69 | return protocolToolError(prefix + ": prompt is required") | ||
| 59 | } | 70 | } | ||
| 71 | + | mode := subagents.ResumeMode(strings.TrimSpace(args.Mode)) | |||
| 72 | + | if mode == "" { | |||
| 73 | + | mode = subagents.ResumeSteer | |||
| 74 | + | } | |||
| 75 | + | if mode != subagents.ResumeSteer && mode != subagents.ResumeQueue { | |||
| 76 | + | return protocolToolError(prefix + ": mode must be steer or queue") | |||
| 77 | + | } | |||
| 60 | if args.Wait != nil && (*args.Wait < 1 || *args.Wait > maxSubagentWaitSeconds) { | 78 | 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)) | 79 | return protocolToolError(fmt.Sprintf("%s: wait must be between 1 and %d seconds", prefix, maxSubagentWaitSeconds)) | ||
| 62 | } | 80 | } | ||
| @@ -64,18 +82,29 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||||
| 64 | if !ok { | 82 | if !ok { | ||
| 65 | return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id)) | 83 | return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id)) | ||
| 66 | } | 84 | } | ||
| 67 | - | // The watch is registered before the follow-up is accepted, so a turn that | 85 | + | // Each watch is registered before its turn can finish: the queued turn's |
| 68 | - | // finishes immediately cannot complete before this call subscribes. | 86 | + | // before acceptance, and the steered turn's from the child's control loop |
| 87 | + | // before that turn can report completion. | |||
| 69 | turnID := uuid.NewString() | 88 | turnID := uuid.NewString() | ||
| 70 | var completionResult <-chan subagents.ResidentCompletion | 89 | var completionResult <-chan subagents.ResidentCompletion | ||
| 71 | cancelWait := func() {} | 90 | cancelWait := func() {} | ||
| 91 | + | var onSteered func(string) | |||
| 72 | if args.Wait != nil { | 92 | if args.Wait != nil { | ||
| 73 | completionResult, cancelWait = t.ResidentManager.WatchCompletion(snapshot.ID, turnID) | 93 | completionResult, cancelWait = t.ResidentManager.WatchCompletion(snapshot.ID, turnID) | ||
| 94 | + | onSteered = func(activeTurn string) { | |||
| 95 | + | cancelWait() | |||
| 96 | + | completionResult, cancelWait = t.ResidentManager.WatchCompletion(snapshot.ID, activeTurn) | |||
| 97 | + | } | |||
| 74 | } | 98 | } | ||
| 75 | - | defer cancelWait() | 99 | + | defer func() { cancelWait() }() |
| 76 | - | if err := t.ResidentManager.ResumeWithTurn(ctx, snapshot.ID, args.Prompt, turnID); err != nil { | 100 | + | delivered, err := t.ResidentManager.ResumeFollowUp(ctx, snapshot.ID, args.Prompt, mode, turnID, onSteered) |
| 101 | + | if err != nil { | |||
| 77 | return protocolToolError(prefix + ": " + err.Error()) | 102 | return protocolToolError(prefix + ": " + err.Error()) | ||
| 78 | } | 103 | } | ||
| 104 | + | delivery := "queued" | |||
| 105 | + | if delivered.Steered { | |||
| 106 | + | delivery = "steered" | |||
| 107 | + | } | |||
| 79 | var outcome *subagentWaitOutcome | 108 | var outcome *subagentWaitOutcome | ||
| 80 | if args.Wait != nil { | 109 | if args.Wait != nil { | ||
| 81 | completion, timedOut, err := waitForResidentCompletion(ctx, completionResult, *args.Wait, prefix) | 110 | completion, timedOut, err := waitForResidentCompletion(ctx, completionResult, *args.Wait, prefix) | ||
| @@ -88,10 +117,11 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||||
| 88 | outcome.Status = result.Status | 117 | outcome.Status = result.Status | ||
| 89 | outcome.Error = result.Error | 118 | outcome.Error = result.Error | ||
| 90 | outcome.Summary = result.Summary | 119 | outcome.Summary = result.Summary | ||
| 120 | + | outcome.Undelivered = result.Undelivered | |||
| 91 | } | 121 | } | ||
| 92 | } | 122 | } | ||
| 93 | if updated, ok := t.ResidentManager.SnapshotFor(snapshot.ID); ok { | 123 | if updated, ok := t.ResidentManager.SnapshotFor(snapshot.ID); ok { | ||
| 94 | snapshot = updated | 124 | snapshot = updated | ||
| 95 | } | 125 | } | ||
| 96 | - | return renderResidentActionWait("resumed", publicResidentStatus(snapshot), outcome) | 126 | + | return renderSubagentResponse(subagentResumeResponse{Action: "resumed", Delivery: delivery, Agent: publicResidentStatus(snapshot), Wait: outcome}) |
| 97 | } | 127 | } | ||
M packages/agent/tools/subagent_spawn.go +2 -0
| @@ -33,6 +33,8 @@ const ( | |||
| 33 | 33 | SubagentActionStatus = "status" | |
| 34 | 34 | SubagentActionStop = "stop" | |
| 35 | 35 | SubagentActionResume = "resume" | |
| 36 | + | // SubagentActionInterrupt cancels only the running turn and keeps the child. | |
| 37 | + | SubagentActionInterrupt = "interrupt" | |
| 36 | 38 | ||
| 37 | 39 | maxSubagentWaitSeconds = 5 * 60 | |
| 38 | 40 | ) | |
| @@ -33,6 +33,8 @@ const ( | |||||
| 33 | SubagentActionStatus = "status" | 33 | SubagentActionStatus = "status" | ||
| 34 | SubagentActionStop = "stop" | 34 | SubagentActionStop = "stop" | ||
| 35 | SubagentActionResume = "resume" | 35 | SubagentActionResume = "resume" | ||
| 36 | + | // SubagentActionInterrupt cancels only the running turn and keeps the child. | |||
| 37 | + | SubagentActionInterrupt = "interrupt" | |||
| 36 | 38 | ||||
| 37 | maxSubagentWaitSeconds = 5 * 60 | 39 | maxSubagentWaitSeconds = 5 * 60 | ||
| 38 | ) | 40 | ) | ||
M packages/agent/tools/subagent_status.go +3 -1
| @@ -83,6 +83,8 @@ type subagentStatusEntry struct { | |||
| 83 | 83 | Workspace subagents.WorkspaceMode `json:"workspace_mode,omitempty"` | |
| 84 | 84 | Required bool `json:"required,omitempty"` | |
| 85 | 85 | OwnedElsewhere bool `json:"owned_elsewhere,omitempty"` | |
| 86 | + | // PendingFollowUps counts accepted follow-ups the child has not read yet. | |
| 87 | + | PendingFollowUps int `json:"pending_followups,omitempty"` | |
| 86 | 88 | } | |
| 87 | 89 | ||
| 88 | 90 | // Name returns the shared facade name: this type is an internal | |
| @@ -295,7 +297,7 @@ func findResidentStatusSnapshot(snapshots []subagents.ResidentSnapshot, id strin | |||
| 295 | 297 | } | |
| 296 | 298 | ||
| 297 | 299 | func publicResidentStatus(snapshot subagents.ResidentSnapshot) subagentStatusEntry { | |
| 298 | - | return subagentStatusEntry{ID: snapshot.ID, State: snapshot.State, Profile: snapshot.Profile, Provider: snapshot.Provider, Model: snapshot.Model, Workspace: snapshot.WorkspaceMode, Required: snapshot.Required, OwnedElsewhere: snapshot.OwnedElsewhere} | |
| 300 | + | return subagentStatusEntry{ID: snapshot.ID, State: snapshot.State, Profile: snapshot.Profile, Provider: snapshot.Provider, Model: snapshot.Model, Workspace: snapshot.WorkspaceMode, Required: snapshot.Required, OwnedElsewhere: snapshot.OwnedElsewhere, PendingFollowUps: snapshot.PendingFollowUps} | |
| 299 | 301 | } | |
| 300 | 302 | ||
| 301 | 303 | func renderSubagentStatus(response subagentStatusResponse) (core.ToolResult, error) { | |
| @@ -83,6 +83,8 @@ type subagentStatusEntry struct { | |||||
| 83 | Workspace subagents.WorkspaceMode `json:"workspace_mode,omitempty"` | 83 | Workspace subagents.WorkspaceMode `json:"workspace_mode,omitempty"` | ||
| 84 | Required bool `json:"required,omitempty"` | 84 | Required bool `json:"required,omitempty"` | ||
| 85 | OwnedElsewhere bool `json:"owned_elsewhere,omitempty"` | 85 | OwnedElsewhere bool `json:"owned_elsewhere,omitempty"` | ||
| 86 | + | // PendingFollowUps counts accepted follow-ups the child has not read yet. | |||
| 87 | + | PendingFollowUps int `json:"pending_followups,omitempty"` | |||
| 86 | } | 88 | } | ||
| 87 | 89 | ||||
| 88 | // Name returns the shared facade name: this type is an internal | 90 | // Name returns the shared facade name: this type is an internal | ||
| @@ -295,7 +297,7 @@ func findResidentStatusSnapshot(snapshots []subagents.ResidentSnapshot, id strin | |||||
| 295 | } | 297 | } | ||
| 296 | 298 | ||||
| 297 | func publicResidentStatus(snapshot subagents.ResidentSnapshot) subagentStatusEntry { | 299 | func publicResidentStatus(snapshot subagents.ResidentSnapshot) subagentStatusEntry { | ||
| 298 | - | return subagentStatusEntry{ID: snapshot.ID, State: snapshot.State, Profile: snapshot.Profile, Provider: snapshot.Provider, Model: snapshot.Model, Workspace: snapshot.WorkspaceMode, Required: snapshot.Required, OwnedElsewhere: snapshot.OwnedElsewhere} | 300 | + | return subagentStatusEntry{ID: snapshot.ID, State: snapshot.State, Profile: snapshot.Profile, Provider: snapshot.Provider, Model: snapshot.Model, Workspace: snapshot.WorkspaceMode, Required: snapshot.Required, OwnedElsewhere: snapshot.OwnedElsewhere, PendingFollowUps: snapshot.PendingFollowUps} |
| 299 | } | 301 | } | ||
| 300 | 302 | ||||
| 301 | func renderSubagentStatus(response subagentStatusResponse) (core.ToolResult, error) { | 303 | func renderSubagentStatus(response subagentStatusResponse) (core.ToolResult, error) { | ||
M packages/agent/tools/subagent_status_test.go +6 -6
| @@ -15,8 +15,8 @@ import ( | |||
| 15 | 15 | func TestResidentStatusWatchReportsLiveActivity(t *testing.T) { | |
| 16 | 16 | ready := make(chan struct{}) | |
| 17 | 17 | release := make(chan struct{}) | |
| 18 | - | manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 19 | - | return func(ctx context.Context, _ string) error { | |
| 18 | + | manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 19 | + | return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error { | |
| 20 | 20 | _ = journal.RecordAgentEvent(core.EvTextDelta{Delta: "Reading the parser"}) | |
| 21 | 21 | _ = journal.RecordAgentEvent(core.EvToolExecutionStarted{ID: "call-1", Name: "read"}) | |
| 22 | 22 | close(ready) | |
| @@ -26,7 +26,7 @@ func TestResidentStatusWatchReportsLiveActivity(t *testing.T) { | |||
| 26 | 26 | case <-ctx.Done(): | |
| 27 | 27 | return ctx.Err() | |
| 28 | 28 | } | |
| 29 | - | }, nil | |
| 29 | + | }), nil | |
| 30 | 30 | }) | |
| 31 | 31 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 32 | 32 | spec := subagents.ResidentChildSpec{ID: "watched", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"} | |
| @@ -87,11 +87,11 @@ func TestResidentStatusResultReadErrors(t *testing.T) { | |||
| 87 | 87 | } { | |
| 88 | 88 | t.Run(scenario.name, func(t *testing.T) { | |
| 89 | 89 | root := t.TempDir() | |
| 90 | - | manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 91 | - | return func(ctx context.Context, _ string) error { | |
| 90 | + | manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 91 | + | return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error { | |
| 92 | 92 | <-ctx.Done() | |
| 93 | 93 | return ctx.Err() | |
| 94 | - | }, nil | |
| 94 | + | }), nil | |
| 95 | 95 | }) | |
| 96 | 96 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 97 | 97 | spec := subagents.ResidentChildSpec{ID: "status-error", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"} | |
| @@ -15,8 +15,8 @@ import ( | |||||
| 15 | func TestResidentStatusWatchReportsLiveActivity(t *testing.T) { | 15 | func TestResidentStatusWatchReportsLiveActivity(t *testing.T) { | ||
| 16 | ready := make(chan struct{}) | 16 | ready := make(chan struct{}) | ||
| 17 | release := make(chan struct{}) | 17 | release := make(chan struct{}) | ||
| 18 | - | manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 18 | + | manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { |
| 19 | - | return func(ctx context.Context, _ string) error { | 19 | + | return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error { |
| 20 | _ = journal.RecordAgentEvent(core.EvTextDelta{Delta: "Reading the parser"}) | 20 | _ = journal.RecordAgentEvent(core.EvTextDelta{Delta: "Reading the parser"}) | ||
| 21 | _ = journal.RecordAgentEvent(core.EvToolExecutionStarted{ID: "call-1", Name: "read"}) | 21 | _ = journal.RecordAgentEvent(core.EvToolExecutionStarted{ID: "call-1", Name: "read"}) | ||
| 22 | close(ready) | 22 | close(ready) | ||
| @@ -26,7 +26,7 @@ func TestResidentStatusWatchReportsLiveActivity(t *testing.T) { | |||||
| 26 | case <-ctx.Done(): | 26 | case <-ctx.Done(): | ||
| 27 | return ctx.Err() | 27 | return ctx.Err() | ||
| 28 | } | 28 | } | ||
| 29 | - | }, nil | 29 | + | }), nil |
| 30 | }) | 30 | }) | ||
| 31 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | 31 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | ||
| 32 | spec := subagents.ResidentChildSpec{ID: "watched", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"} | 32 | spec := subagents.ResidentChildSpec{ID: "watched", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"} | ||
| @@ -87,11 +87,11 @@ func TestResidentStatusResultReadErrors(t *testing.T) { | |||||
| 87 | } { | 87 | } { | ||
| 88 | t.Run(scenario.name, func(t *testing.T) { | 88 | t.Run(scenario.name, func(t *testing.T) { | ||
| 89 | root := t.TempDir() | 89 | root := t.TempDir() | ||
| 90 | - | manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | 90 | + | manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { |
| 91 | - | return func(ctx context.Context, _ string) error { | 91 | + | return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error { |
| 92 | <-ctx.Done() | 92 | <-ctx.Done() | ||
| 93 | return ctx.Err() | 93 | return ctx.Err() | ||
| 94 | - | }, nil | 94 | + | }), nil |
| 95 | }) | 95 | }) | ||
| 96 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | 96 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | ||
| 97 | spec := subagents.ResidentChildSpec{ID: "status-error", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"} | 97 | spec := subagents.ResidentChildSpec{ID: "status-error", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"} | ||
A packages/agent/tools/subagent_steer_test.go +146 -0
| @@ -0,0 +1,146 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/zut/packages/agent/subagents" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // steerRuntime blocks its turn until the first steer arrives, then finishes. | |
| 14 | + | type steerRuntime struct { | |
| 15 | + | started chan struct{} | |
| 16 | + | steered chan string | |
| 17 | + | ||
| 18 | + | mu sync.Mutex | |
| 19 | + | running bool | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func (r *steerRuntime) Run(ctx context.Context, _ string) error { | |
| 23 | + | r.mu.Lock() | |
| 24 | + | r.running = true | |
| 25 | + | r.mu.Unlock() | |
| 26 | + | defer func() { r.mu.Lock(); r.running = false; r.mu.Unlock() }() | |
| 27 | + | r.started <- struct{}{} | |
| 28 | + | select { | |
| 29 | + | case <-ctx.Done(): | |
| 30 | + | return ctx.Err() | |
| 31 | + | case <-r.steered: | |
| 32 | + | return nil | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func (r *steerRuntime) Steer(prompt string) bool { | |
| 37 | + | r.mu.Lock() | |
| 38 | + | defer r.mu.Unlock() | |
| 39 | + | if !r.running { | |
| 40 | + | return false | |
| 41 | + | } | |
| 42 | + | r.steered <- prompt | |
| 43 | + | return true | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (r *steerRuntime) PendingSteers() int { return 0 } | |
| 47 | + | func (r *steerRuntime) DrainSteers() []string { return nil } | |
| 48 | + | ||
| 49 | + | // Resume steers a running child by default, and its wait resolves on the | |
| 50 | + | // running turn that received the follow-up. | |
| 51 | + | func TestResidentResumeSteersRunningChildAndWaitsOnItsTurn(t *testing.T) { | |
| 52 | + | runtime := &steerRuntime{started: make(chan struct{}, 1), steered: make(chan string, 1)} | |
| 53 | + | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 54 | + | return runtime, nil | |
| 55 | + | }) | |
| 56 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 57 | + | if _, err := manager.Spawn(t.Context(), subagents.ResidentChildSpec{ID: "steer-tool", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}, "investigate"); err != nil { | |
| 58 | + | t.Fatal(err) | |
| 59 | + | } | |
| 60 | + | select { | |
| 61 | + | case <-runtime.started: | |
| 62 | + | case <-time.After(5 * time.Second): | |
| 63 | + | t.Fatal("turn did not start") | |
| 64 | + | } | |
| 65 | + | facade := &SubagentTool{Resume: &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }}} | |
| 66 | + | result, err := facade.Execute(t.Context(), json.RawMessage(`{"action":"resume","agent_id":"steer-tool","prompt":"report now","wait":5}`), nil) | |
| 67 | + | if err != nil || result.IsError { | |
| 68 | + | t.Fatalf("resume = (%#v, %v)", result, err) | |
| 69 | + | } | |
| 70 | + | response, ok := result.Details.(subagentResumeResponse) | |
| 71 | + | if !ok || response.Delivery != "steered" || response.Wait == nil || response.Wait.TimedOut || response.Wait.Status != string(subagents.ResidentCompleted) { | |
| 72 | + | t.Fatalf("response = %#v", result.Details) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestResidentResumeRejectsUnknownMode(t *testing.T) { | |
| 77 | + | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 78 | + | return subagents.ResidentTurnRunner(func(context.Context, string) error { return nil }), nil | |
| 79 | + | }) | |
| 80 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 81 | + | resume := &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }} | |
| 82 | + | result, err := resume.Execute(t.Context(), json.RawMessage(`{"agent_id":"x","prompt":"p","mode":"now"}`), nil) | |
| 83 | + | if err != nil || !result.IsError { | |
| 84 | + | t.Fatalf("result = (%#v, %v), want mode error", result, err) | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func TestResidentInterruptToolRequiresRunningTurn(t *testing.T) { | |
| 89 | + | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 90 | + | return subagents.ResidentTurnRunner(func(context.Context, string) error { return nil }), nil | |
| 91 | + | }) | |
| 92 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 93 | + | done, cancel := manager.WatchCompletion("idle-child", "initial") | |
| 94 | + | defer cancel() | |
| 95 | + | if _, err := manager.Spawn(t.Context(), subagents.ResidentChildSpec{ID: "idle-child", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}, "task"); err != nil { | |
| 96 | + | t.Fatal(err) | |
| 97 | + | } | |
| 98 | + | <-done | |
| 99 | + | interrupt := &SubagentInterruptTool{ResidentManager: manager, Enabled: func() bool { return true }} | |
| 100 | + | result, err := interrupt.Execute(t.Context(), json.RawMessage(`{"agent_id":"idle-child"}`), nil) | |
| 101 | + | if err != nil || !result.IsError { | |
| 102 | + | t.Fatalf("result = (%#v, %v), want no-running-turn error", result, err) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // The facade dispatches interrupt to a running child and keeps it live. | |
| 107 | + | func TestSubagentFacadeInterruptsRunningTurn(t *testing.T) { | |
| 108 | + | started := make(chan struct{}, 1) | |
| 109 | + | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |
| 110 | + | return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error { | |
| 111 | + | started <- struct{}{} | |
| 112 | + | <-ctx.Done() | |
| 113 | + | return ctx.Err() | |
| 114 | + | }), nil | |
| 115 | + | }) | |
| 116 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 117 | + | done, cancel := manager.WatchCompletion("running-child", "initial") | |
| 118 | + | defer cancel() | |
| 119 | + | if _, err := manager.Spawn(t.Context(), subagents.ResidentChildSpec{ID: "running-child", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}, "task"); err != nil { | |
| 120 | + | t.Fatal(err) | |
| 121 | + | } | |
| 122 | + | <-started | |
| 123 | + | facade := &SubagentTool{Interrupt: &SubagentInterruptTool{ResidentManager: manager, Enabled: func() bool { return true }}} | |
| 124 | + | result, err := facade.Execute(t.Context(), json.RawMessage(`{"action":"interrupt","agent_id":"running-child"}`), nil) | |
| 125 | + | if err != nil || result.IsError { | |
| 126 | + | t.Fatalf("interrupt = (%#v, %v)", result, err) | |
| 127 | + | } | |
| 128 | + | if response, ok := result.Details.(subagentActionResponse); !ok || response.Action != "interrupt_requested" { | |
| 129 | + | t.Fatalf("response = %#v", result.Details) | |
| 130 | + | } | |
| 131 | + | select { | |
| 132 | + | case completion := <-done: | |
| 133 | + | if completion.Completion().Status != string(subagents.ResidentInterrupted) { | |
| 134 | + | t.Fatalf("completion = %#v", completion) | |
| 135 | + | } | |
| 136 | + | case <-time.After(5 * time.Second): | |
| 137 | + | t.Fatal("interrupted turn did not complete") | |
| 138 | + | } | |
| 139 | + | if manager.Get("running-child") == nil { | |
| 140 | + | t.Fatal("interrupt removed the live child") | |
| 141 | + | } | |
| 142 | + | result, err = facade.Execute(t.Context(), json.RawMessage(`{"action":"interrupt","agent_id":"running-child","mode":"steer"}`), nil) | |
| 143 | + | if err != nil || !result.IsError { | |
| 144 | + | t.Fatalf("mode on interrupt = (%#v, %v), want foreign-field error", result, err) | |
| 145 | + | } | |
| 146 | + | } | |
| @@ -0,0 +1,146 @@ | |||||
| 1 | + | package tools | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "sync" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/zut/packages/agent/subagents" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // steerRuntime blocks its turn until the first steer arrives, then finishes. | |||
| 14 | + | type steerRuntime struct { | |||
| 15 | + | started chan struct{} | |||
| 16 | + | steered chan string | |||
| 17 | + | ||||
| 18 | + | mu sync.Mutex | |||
| 19 | + | running bool | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func (r *steerRuntime) Run(ctx context.Context, _ string) error { | |||
| 23 | + | r.mu.Lock() | |||
| 24 | + | r.running = true | |||
| 25 | + | r.mu.Unlock() | |||
| 26 | + | defer func() { r.mu.Lock(); r.running = false; r.mu.Unlock() }() | |||
| 27 | + | r.started <- struct{}{} | |||
| 28 | + | select { | |||
| 29 | + | case <-ctx.Done(): | |||
| 30 | + | return ctx.Err() | |||
| 31 | + | case <-r.steered: | |||
| 32 | + | return nil | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func (r *steerRuntime) Steer(prompt string) bool { | |||
| 37 | + | r.mu.Lock() | |||
| 38 | + | defer r.mu.Unlock() | |||
| 39 | + | if !r.running { | |||
| 40 | + | return false | |||
| 41 | + | } | |||
| 42 | + | r.steered <- prompt | |||
| 43 | + | return true | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (r *steerRuntime) PendingSteers() int { return 0 } | |||
| 47 | + | func (r *steerRuntime) DrainSteers() []string { return nil } | |||
| 48 | + | ||||
| 49 | + | // Resume steers a running child by default, and its wait resolves on the | |||
| 50 | + | // running turn that received the follow-up. | |||
| 51 | + | func TestResidentResumeSteersRunningChildAndWaitsOnItsTurn(t *testing.T) { | |||
| 52 | + | runtime := &steerRuntime{started: make(chan struct{}, 1), steered: make(chan string, 1)} | |||
| 53 | + | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |||
| 54 | + | return runtime, nil | |||
| 55 | + | }) | |||
| 56 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |||
| 57 | + | if _, err := manager.Spawn(t.Context(), subagents.ResidentChildSpec{ID: "steer-tool", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}, "investigate"); err != nil { | |||
| 58 | + | t.Fatal(err) | |||
| 59 | + | } | |||
| 60 | + | select { | |||
| 61 | + | case <-runtime.started: | |||
| 62 | + | case <-time.After(5 * time.Second): | |||
| 63 | + | t.Fatal("turn did not start") | |||
| 64 | + | } | |||
| 65 | + | facade := &SubagentTool{Resume: &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }}} | |||
| 66 | + | result, err := facade.Execute(t.Context(), json.RawMessage(`{"action":"resume","agent_id":"steer-tool","prompt":"report now","wait":5}`), nil) | |||
| 67 | + | if err != nil || result.IsError { | |||
| 68 | + | t.Fatalf("resume = (%#v, %v)", result, err) | |||
| 69 | + | } | |||
| 70 | + | response, ok := result.Details.(subagentResumeResponse) | |||
| 71 | + | if !ok || response.Delivery != "steered" || response.Wait == nil || response.Wait.TimedOut || response.Wait.Status != string(subagents.ResidentCompleted) { | |||
| 72 | + | t.Fatalf("response = %#v", result.Details) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestResidentResumeRejectsUnknownMode(t *testing.T) { | |||
| 77 | + | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |||
| 78 | + | return subagents.ResidentTurnRunner(func(context.Context, string) error { return nil }), nil | |||
| 79 | + | }) | |||
| 80 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |||
| 81 | + | resume := &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }} | |||
| 82 | + | result, err := resume.Execute(t.Context(), json.RawMessage(`{"agent_id":"x","prompt":"p","mode":"now"}`), nil) | |||
| 83 | + | if err != nil || !result.IsError { | |||
| 84 | + | t.Fatalf("result = (%#v, %v), want mode error", result, err) | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func TestResidentInterruptToolRequiresRunningTurn(t *testing.T) { | |||
| 89 | + | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |||
| 90 | + | return subagents.ResidentTurnRunner(func(context.Context, string) error { return nil }), nil | |||
| 91 | + | }) | |||
| 92 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |||
| 93 | + | done, cancel := manager.WatchCompletion("idle-child", "initial") | |||
| 94 | + | defer cancel() | |||
| 95 | + | if _, err := manager.Spawn(t.Context(), subagents.ResidentChildSpec{ID: "idle-child", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}, "task"); err != nil { | |||
| 96 | + | t.Fatal(err) | |||
| 97 | + | } | |||
| 98 | + | <-done | |||
| 99 | + | interrupt := &SubagentInterruptTool{ResidentManager: manager, Enabled: func() bool { return true }} | |||
| 100 | + | result, err := interrupt.Execute(t.Context(), json.RawMessage(`{"agent_id":"idle-child"}`), nil) | |||
| 101 | + | if err != nil || !result.IsError { | |||
| 102 | + | t.Fatalf("result = (%#v, %v), want no-running-turn error", result, err) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // The facade dispatches interrupt to a running child and keeps it live. | |||
| 107 | + | func TestSubagentFacadeInterruptsRunningTurn(t *testing.T) { | |||
| 108 | + | started := make(chan struct{}, 1) | |||
| 109 | + | manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) { | |||
| 110 | + | return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error { | |||
| 111 | + | started <- struct{}{} | |||
| 112 | + | <-ctx.Done() | |||
| 113 | + | return ctx.Err() | |||
| 114 | + | }), nil | |||
| 115 | + | }) | |||
| 116 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |||
| 117 | + | done, cancel := manager.WatchCompletion("running-child", "initial") | |||
| 118 | + | defer cancel() | |||
| 119 | + | if _, err := manager.Spawn(t.Context(), subagents.ResidentChildSpec{ID: "running-child", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}, "task"); err != nil { | |||
| 120 | + | t.Fatal(err) | |||
| 121 | + | } | |||
| 122 | + | <-started | |||
| 123 | + | facade := &SubagentTool{Interrupt: &SubagentInterruptTool{ResidentManager: manager, Enabled: func() bool { return true }}} | |||
| 124 | + | result, err := facade.Execute(t.Context(), json.RawMessage(`{"action":"interrupt","agent_id":"running-child"}`), nil) | |||
| 125 | + | if err != nil || result.IsError { | |||
| 126 | + | t.Fatalf("interrupt = (%#v, %v)", result, err) | |||
| 127 | + | } | |||
| 128 | + | if response, ok := result.Details.(subagentActionResponse); !ok || response.Action != "interrupt_requested" { | |||
| 129 | + | t.Fatalf("response = %#v", result.Details) | |||
| 130 | + | } | |||
| 131 | + | select { | |||
| 132 | + | case completion := <-done: | |||
| 133 | + | if completion.Completion().Status != string(subagents.ResidentInterrupted) { | |||
| 134 | + | t.Fatalf("completion = %#v", completion) | |||
| 135 | + | } | |||
| 136 | + | case <-time.After(5 * time.Second): | |||
| 137 | + | t.Fatal("interrupted turn did not complete") | |||
| 138 | + | } | |||
| 139 | + | if manager.Get("running-child") == nil { | |||
| 140 | + | t.Fatal("interrupt removed the live child") | |||
| 141 | + | } | |||
| 142 | + | result, err = facade.Execute(t.Context(), json.RawMessage(`{"action":"interrupt","agent_id":"running-child","mode":"steer"}`), nil) | |||
| 143 | + | if err != nil || !result.IsError { | |||
| 144 | + | t.Fatalf("mode on interrupt = (%#v, %v), want foreign-field error", result, err) | |||
| 145 | + | } | |||
| 146 | + | } | |||
M packages/agent/tools/subagent_stop.go +12 -14
| @@ -23,19 +23,20 @@ type subagentStopArgs struct { | |||
| 23 | 23 | } | |
| 24 | 24 | ||
| 25 | 25 | // subagentWaitOutcome reports the result of an explicit bounded wait on one | |
| 26 | - | // resident turn. An expired wait leaves the child active. | |
| 26 | + | // resident turn. An expired wait leaves the child active. Undelivered lists | |
| 27 | + | // steered follow-ups the child never read before that turn ended. | |
| 27 | 28 | 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"` | |
| 29 | + | Seconds int `json:"seconds"` | |
| 30 | + | TimedOut bool `json:"timed_out,omitempty"` | |
| 31 | + | Status string `json:"status,omitempty"` | |
| 32 | + | Error string `json:"error,omitempty"` | |
| 33 | + | Summary string `json:"summary,omitempty"` | |
| 34 | + | Undelivered []string `json:"undelivered,omitempty"` | |
| 33 | 35 | } | |
| 34 | 36 | ||
| 35 | 37 | type subagentActionResponse struct { | |
| 36 | - | Action string `json:"action"` | |
| 37 | - | Agent subagentStatusEntry `json:"agent"` | |
| 38 | - | Wait *subagentWaitOutcome `json:"wait,omitempty"` | |
| 38 | + | Action string `json:"action"` | |
| 39 | + | Agent subagentStatusEntry `json:"agent"` | |
| 39 | 40 | } | |
| 40 | 41 | ||
| 41 | 42 | // Name returns the shared facade name: this type is an internal | |
| @@ -80,13 +81,10 @@ func (t *SubagentStopTool) Execute(ctx context.Context, raw json.RawMessage, _ f | |||
| 80 | 81 | } | |
| 81 | 82 | ||
| 82 | 83 | func renderResidentAction(action string, entry subagentStatusEntry) (core.ToolResult, error) { | |
| 83 | - | return renderResidentActionWait(action, entry, nil) | |
| 84 | + | return renderSubagentResponse(subagentActionResponse{Action: action, Agent: entry}) | |
| 84 | 85 | } | |
| 85 | 86 | ||
| 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} | |
| 87 | + | func renderSubagentResponse(response any) (core.ToolResult, error) { | |
| 90 | 88 | data, err := json.Marshal(response) | |
| 91 | 89 | if err != nil { | |
| 92 | 90 | return core.ToolResult{}, fmt.Errorf("subagent action: encode response: %w", err) | |
| @@ -23,19 +23,20 @@ type subagentStopArgs struct { | |||||
| 23 | } | 23 | } | ||
| 24 | 24 | ||||
| 25 | // subagentWaitOutcome reports the result of an explicit bounded wait on one | 25 | // subagentWaitOutcome reports the result of an explicit bounded wait on one | ||
| 26 | - | // resident turn. An expired wait leaves the child active. | 26 | + | // resident turn. An expired wait leaves the child active. Undelivered lists |
| 27 | + | // steered follow-ups the child never read before that turn ended. | |||
| 27 | type subagentWaitOutcome struct { | 28 | type subagentWaitOutcome struct { | ||
| 28 | - | Seconds int `json:"seconds"` | 29 | + | Seconds int `json:"seconds"` |
| 29 | - | TimedOut bool `json:"timed_out,omitempty"` | 30 | + | TimedOut bool `json:"timed_out,omitempty"` |
| 30 | - | Status string `json:"status,omitempty"` | 31 | + | Status string `json:"status,omitempty"` |
| 31 | - | Error string `json:"error,omitempty"` | 32 | + | Error string `json:"error,omitempty"` |
| 32 | - | Summary string `json:"summary,omitempty"` | 33 | + | Summary string `json:"summary,omitempty"` |
| 34 | + | Undelivered []string `json:"undelivered,omitempty"` | |||
| 33 | } | 35 | } | ||
| 34 | 36 | ||||
| 35 | type subagentActionResponse struct { | 37 | type subagentActionResponse struct { | ||
| 36 | - | Action string `json:"action"` | 38 | + | Action string `json:"action"` |
| 37 | - | Agent subagentStatusEntry `json:"agent"` | 39 | + | Agent subagentStatusEntry `json:"agent"` |
| 38 | - | Wait *subagentWaitOutcome `json:"wait,omitempty"` | |||
| 39 | } | 40 | } | ||
| 40 | 41 | ||||
| 41 | // Name returns the shared facade name: this type is an internal | 42 | // Name returns the shared facade name: this type is an internal | ||
| @@ -80,13 +81,10 @@ func (t *SubagentStopTool) Execute(ctx context.Context, raw json.RawMessage, _ f | |||||
| 80 | } | 81 | } | ||
| 81 | 82 | ||||
| 82 | func renderResidentAction(action string, entry subagentStatusEntry) (core.ToolResult, error) { | 83 | func renderResidentAction(action string, entry subagentStatusEntry) (core.ToolResult, error) { | ||
| 83 | - | return renderResidentActionWait(action, entry, nil) | 84 | + | return renderSubagentResponse(subagentActionResponse{Action: action, Agent: entry}) |
| 84 | } | 85 | } | ||
| 85 | 86 | ||||
| 86 | - | // renderResidentActionWait renders an action response plus the outcome of an | 87 | + | func renderSubagentResponse(response any) (core.ToolResult, error) { |
| 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) | 88 | data, err := json.Marshal(response) | ||
| 91 | if err != nil { | 89 | if err != nil { | ||
| 92 | return core.ToolResult{}, fmt.Errorf("subagent action: encode response: %w", err) | 90 | return core.ToolResult{}, fmt.Errorf("subagent action: encode response: %w", err) | ||
M packages/agent/tools/subagent_test.go +5 -4
| @@ -63,13 +63,13 @@ func TestSubagentFacadeNameAndSchema(t *testing.T) { | |||
| 63 | 63 | } | |
| 64 | 64 | for _, field := range []string{ | |
| 65 | 65 | "action", "task", "agent", "model", "provider", "reasoning", "fast_mode", | |
| 66 | - | "required", "wait", "isolation", "agent_id", "include_result", "watch", "prompt", | |
| 66 | + | "required", "wait", "isolation", "agent_id", "include_result", "watch", "prompt", "mode", | |
| 67 | 67 | } { | |
| 68 | 68 | if _, ok := schema.Properties[field]; !ok { | |
| 69 | 69 | t.Fatalf("schema is missing property %q", field) | |
| 70 | 70 | } | |
| 71 | 71 | } | |
| 72 | - | if got := facade.Description(); got != "Spawn, inspect, resume, and stop resident sub-agents. One action per call.\n\n"+subagentSpawnGuidance { | |
| 72 | + | if got := facade.Description(); got != "Spawn, inspect, resume, interrupt, and stop resident sub-agents. One action per call.\n\n"+subagentSpawnGuidance { | |
| 73 | 73 | t.Fatalf("description = %q", got) | |
| 74 | 74 | } | |
| 75 | 75 | } | |
| @@ -121,10 +121,11 @@ var subagentActionAnnotation = map[string]string{ | |||
| 121 | 121 | "required": "Only valid when action is spawn.", | |
| 122 | 122 | "wait": "Only valid when action is spawn or resume.", | |
| 123 | 123 | "isolation": "Only valid when action is spawn.", | |
| 124 | - | "agent_id": "Required when action is stop or resume.", | |
| 124 | + | "agent_id": "Required when action is stop, resume, or interrupt.", | |
| 125 | 125 | "include_result": "Only valid when action is status.", | |
| 126 | 126 | "watch": "Only valid when action is status.", | |
| 127 | 127 | "prompt": "Required when action is resume.", | |
| 128 | + | "mode": "Only valid when action is resume.", | |
| 128 | 129 | } | |
| 129 | 130 | ||
| 130 | 131 | func TestSubagentFacadeSchemaAnnotatesActionFields(t *testing.T) { | |
| @@ -308,7 +309,7 @@ func TestSubagentFacadeRejectsForeignFieldsAndKeepsUnknownOnes(t *testing.T) { | |||
| 308 | 309 | if err != nil { | |
| 309 | 310 | t.Fatalf("unexpected host error: %v", err) | |
| 310 | 311 | } | |
| 311 | - | if !result.IsError || facadeResultText(t, result) != "subagent: agent_id is not valid for action spawn; it belongs to the resume, status, stop actions" { | |
| 312 | + | if !result.IsError || facadeResultText(t, result) != "subagent: agent_id is not valid for action spawn; it belongs to the interrupt, resume, status, stop actions" { | |
| 312 | 313 | t.Fatalf("multi-owner foreign field result = %#v", result) | |
| 313 | 314 | } | |
| 314 | 315 | _, err = facade.Execute(context.Background(), json.RawMessage(`{"action":"stop","agent_id":"nope","bogus":1}`), nil) | |
| @@ -63,13 +63,13 @@ func TestSubagentFacadeNameAndSchema(t *testing.T) { | |||||
| 63 | } | 63 | } | ||
| 64 | for _, field := range []string{ | 64 | for _, field := range []string{ | ||
| 65 | "action", "task", "agent", "model", "provider", "reasoning", "fast_mode", | 65 | "action", "task", "agent", "model", "provider", "reasoning", "fast_mode", | ||
| 66 | - | "required", "wait", "isolation", "agent_id", "include_result", "watch", "prompt", | 66 | + | "required", "wait", "isolation", "agent_id", "include_result", "watch", "prompt", "mode", |
| 67 | } { | 67 | } { | ||
| 68 | if _, ok := schema.Properties[field]; !ok { | 68 | if _, ok := schema.Properties[field]; !ok { | ||
| 69 | t.Fatalf("schema is missing property %q", field) | 69 | t.Fatalf("schema is missing property %q", field) | ||
| 70 | } | 70 | } | ||
| 71 | } | 71 | } | ||
| 72 | - | if got := facade.Description(); got != "Spawn, inspect, resume, and stop resident sub-agents. One action per call.\n\n"+subagentSpawnGuidance { | 72 | + | if got := facade.Description(); got != "Spawn, inspect, resume, interrupt, and stop resident sub-agents. One action per call.\n\n"+subagentSpawnGuidance { |
| 73 | t.Fatalf("description = %q", got) | 73 | t.Fatalf("description = %q", got) | ||
| 74 | } | 74 | } | ||
| 75 | } | 75 | } | ||
| @@ -121,10 +121,11 @@ var subagentActionAnnotation = map[string]string{ | |||||
| 121 | "required": "Only valid when action is spawn.", | 121 | "required": "Only valid when action is spawn.", | ||
| 122 | "wait": "Only valid when action is spawn or resume.", | 122 | "wait": "Only valid when action is spawn or resume.", | ||
| 123 | "isolation": "Only valid when action is spawn.", | 123 | "isolation": "Only valid when action is spawn.", | ||
| 124 | - | "agent_id": "Required when action is stop or resume.", | 124 | + | "agent_id": "Required when action is stop, resume, or interrupt.", |
| 125 | "include_result": "Only valid when action is status.", | 125 | "include_result": "Only valid when action is status.", | ||
| 126 | "watch": "Only valid when action is status.", | 126 | "watch": "Only valid when action is status.", | ||
| 127 | "prompt": "Required when action is resume.", | 127 | "prompt": "Required when action is resume.", | ||
| 128 | + | "mode": "Only valid when action is resume.", | |||
| 128 | } | 129 | } | ||
| 129 | 130 | ||||
| 130 | func TestSubagentFacadeSchemaAnnotatesActionFields(t *testing.T) { | 131 | func TestSubagentFacadeSchemaAnnotatesActionFields(t *testing.T) { | ||
| @@ -308,7 +309,7 @@ func TestSubagentFacadeRejectsForeignFieldsAndKeepsUnknownOnes(t *testing.T) { | |||||
| 308 | if err != nil { | 309 | if err != nil { | ||
| 309 | t.Fatalf("unexpected host error: %v", err) | 310 | t.Fatalf("unexpected host error: %v", err) | ||
| 310 | } | 311 | } | ||
| 311 | - | if !result.IsError || facadeResultText(t, result) != "subagent: agent_id is not valid for action spawn; it belongs to the resume, status, stop actions" { | 312 | + | if !result.IsError || facadeResultText(t, result) != "subagent: agent_id is not valid for action spawn; it belongs to the interrupt, resume, status, stop actions" { |
| 312 | t.Fatalf("multi-owner foreign field result = %#v", result) | 313 | t.Fatalf("multi-owner foreign field result = %#v", result) | ||
| 313 | } | 314 | } | ||
| 314 | _, err = facade.Execute(context.Background(), json.RawMessage(`{"action":"stop","agent_id":"nope","bogus":1}`), nil) | 315 | _, err = facade.Execute(context.Background(), json.RawMessage(`{"action":"stop","agent_id":"nope","bogus":1}`), nil) | ||