fix(subagents): harden required worker recovery
- commit
- ec485d726a25e9d2631d19f39ffefec70853cb9d
- author
- brice · · 2026-08-10 04:58 UTC
- parent
- a6e4bd2
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
16 files · +181 -60
M docs/subagents.md +2 -2
| @@ -158,9 +158,9 @@ A subagent timeout applies to each active delegated turn. When the deadline expi | |||
| 158 | 158 | ||
| 159 | 159 | Use `/subagents resume-session <id>` to continue the existing session without replaying its original task. Use `/subagents restart-task <id>` only when intentionally starting the stored task again. Cancellation requests graceful shutdown first, then forcefully cancels after the configured grace period. | |
| 160 | 160 | ||
| 161 | - | When auto-subagents is enabled and permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. Call `subagent_status` with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use in-process snapshots and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, a normalized lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), the underlying `process_state` and `turn_state`, `lifetime_turns` and `current_run_turns` counters, start/update/finish timestamps, a bounded first-line task summary, required state/target/unmet/error-code metadata when applicable, and terminal-result metadata/reference when available. It omits full prompts, transcripts, result output, provider settings, and filesystem paths. The bounded task summary can reflect user task text and must be treated as session data. | |
| 161 | + | When the relevant manager tool is permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. This includes explicit user-requested delegation and required-work recovery when auto-subagents orchestration is disabled. Call `subagent_status` with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use in-process snapshots and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, a normalized lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), the underlying `process_state` and `turn_state`, `lifetime_turns` and `current_run_turns` counters, start/update/finish timestamps, a bounded first-line task summary, required state/target/unmet/error-code metadata when applicable, and terminal-result metadata/reference when available. It omits full prompts, transcripts, result output, provider settings, and filesystem paths. The bounded task summary can reflect user task text and must be treated as session data. | |
| 162 | 162 | ||
| 163 | - | Use `subagent_stop` with an `agent_id` to request termination of a stuck worker through the normal graceful-then-forceful supervisor lifecycle. Its `stop_requested` result confirms that shutdown has started; wait for the host completion update before attempting a restart. Use `subagent_resume` with an `agent_id` and `prompt` to start a fresh max-turn budget while retaining the transcript/session/context, send the follow-up to an idle worker, queue it for the next available message turn when the worker is active, or restart a terminal worker from its retained session with that follow-up as the initial turn. Set `required:true` to make a new follow-up mandatory. A resume of failed, timed-out, or canceled required work is mandatory automatically, blocks for its new terminal outcome, and advances the persisted target turn. An indeterminate outcome requires the explicit user reconciliation described above before a new target turn starts. A follow-up remains durably pending until its matching user message is flushed to the session and the delegated `turn.started` boundary is recorded; a worker-ready event alone does not acknowledge it. Automatic retries, model loops, and compaction remain within the current run budget; multiple active follow-ups are delivered in order. Commands have stable identities so cancellation or an ambiguous socket-write outcome can be retried without replaying an accepted command. It never replays the original task. Optional completion uses the normal auto-subagent update; required completion returns from the blocking resume call. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include each manager tool that the model needs, and `subagent_status` must not be polled solely to wait for completion. | |
| 163 | + | Use `subagent_stop` with an `agent_id` to request termination of a stuck worker through the normal graceful-then-forceful supervisor lifecycle. Its `stop_requested` result confirms that shutdown has started; wait for the host completion update before attempting a restart. Use `subagent_resume` with an `agent_id` and `prompt` to start a fresh max-turn budget while retaining the transcript/session/context, send the follow-up to an idle worker, queue it for the next available message turn when the worker is active, or restart a terminal worker from its retained session with that follow-up as the initial turn. Set `required:true` to make a new follow-up mandatory. A resume of failed, timed-out, or canceled required work is mandatory automatically, blocks for its new terminal outcome, and advances the persisted target turn. An indeterminate outcome requires the explicit user reconciliation described above before a new target turn starts. A follow-up remains durably pending until its matching user message is flushed to the session and the delegated `turn.started` boundary is recorded; a worker-ready event alone does not acknowledge it. Automatic retries, model loops, and compaction remain within the current run budget; multiple active follow-ups are delivered in order. Only same-worker `turn.start` retries have stable in-memory command deduplication; that deduplication is lost when the worker restarts. Do not retry `turn.cancel`, `agent.shutdown`, or `agent.ping` after an ambiguous outcome. After a worker restart, explicitly reconcile durable results and side effects before retrying non-idempotent work. It never replays the original task. Optional completion uses the normal auto-subagent update; required completion returns from the blocking resume call. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include each manager tool that the model needs, and `subagent_status` must not be polled solely to wait for completion. | |
| 164 | 164 | ||
| 165 | 165 | ## Resource policy | |
| 166 | 166 | ||
| @@ -158,9 +158,9 @@ A subagent timeout applies to each active delegated turn. When the deadline expi | |||||
| 158 | 158 | ||||
| 159 | Use `/subagents resume-session <id>` to continue the existing session without replaying its original task. Use `/subagents restart-task <id>` only when intentionally starting the stored task again. Cancellation requests graceful shutdown first, then forcefully cancels after the configured grace period. | 159 | Use `/subagents resume-session <id>` to continue the existing session without replaying its original task. Use `/subagents restart-task <id>` only when intentionally starting the stored task again. Cancellation requests graceful shutdown first, then forcefully cancels after the configured grace period. | ||
| 160 | 160 | ||||
| 161 | - | When auto-subagents is enabled and permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. Call `subagent_status` with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use in-process snapshots and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, a normalized lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), the underlying `process_state` and `turn_state`, `lifetime_turns` and `current_run_turns` counters, start/update/finish timestamps, a bounded first-line task summary, required state/target/unmet/error-code metadata when applicable, and terminal-result metadata/reference when available. It omits full prompts, transcripts, result output, provider settings, and filesystem paths. The bounded task summary can reflect user task text and must be treated as session data. | 161 | + | When the relevant manager tool is permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. This includes explicit user-requested delegation and required-work recovery when auto-subagents orchestration is disabled. Call `subagent_status` with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use in-process snapshots and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, a normalized lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), the underlying `process_state` and `turn_state`, `lifetime_turns` and `current_run_turns` counters, start/update/finish timestamps, a bounded first-line task summary, required state/target/unmet/error-code metadata when applicable, and terminal-result metadata/reference when available. It omits full prompts, transcripts, result output, provider settings, and filesystem paths. The bounded task summary can reflect user task text and must be treated as session data. |
| 162 | 162 | ||||
| 163 | - | Use `subagent_stop` with an `agent_id` to request termination of a stuck worker through the normal graceful-then-forceful supervisor lifecycle. Its `stop_requested` result confirms that shutdown has started; wait for the host completion update before attempting a restart. Use `subagent_resume` with an `agent_id` and `prompt` to start a fresh max-turn budget while retaining the transcript/session/context, send the follow-up to an idle worker, queue it for the next available message turn when the worker is active, or restart a terminal worker from its retained session with that follow-up as the initial turn. Set `required:true` to make a new follow-up mandatory. A resume of failed, timed-out, or canceled required work is mandatory automatically, blocks for its new terminal outcome, and advances the persisted target turn. An indeterminate outcome requires the explicit user reconciliation described above before a new target turn starts. A follow-up remains durably pending until its matching user message is flushed to the session and the delegated `turn.started` boundary is recorded; a worker-ready event alone does not acknowledge it. Automatic retries, model loops, and compaction remain within the current run budget; multiple active follow-ups are delivered in order. Commands have stable identities so cancellation or an ambiguous socket-write outcome can be retried without replaying an accepted command. It never replays the original task. Optional completion uses the normal auto-subagent update; required completion returns from the blocking resume call. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include each manager tool that the model needs, and `subagent_status` must not be polled solely to wait for completion. | 163 | + | Use `subagent_stop` with an `agent_id` to request termination of a stuck worker through the normal graceful-then-forceful supervisor lifecycle. Its `stop_requested` result confirms that shutdown has started; wait for the host completion update before attempting a restart. Use `subagent_resume` with an `agent_id` and `prompt` to start a fresh max-turn budget while retaining the transcript/session/context, send the follow-up to an idle worker, queue it for the next available message turn when the worker is active, or restart a terminal worker from its retained session with that follow-up as the initial turn. Set `required:true` to make a new follow-up mandatory. A resume of failed, timed-out, or canceled required work is mandatory automatically, blocks for its new terminal outcome, and advances the persisted target turn. An indeterminate outcome requires the explicit user reconciliation described above before a new target turn starts. A follow-up remains durably pending until its matching user message is flushed to the session and the delegated `turn.started` boundary is recorded; a worker-ready event alone does not acknowledge it. Automatic retries, model loops, and compaction remain within the current run budget; multiple active follow-ups are delivered in order. Only same-worker `turn.start` retries have stable in-memory command deduplication; that deduplication is lost when the worker restarts. Do not retry `turn.cancel`, `agent.shutdown`, or `agent.ping` after an ambiguous outcome. After a worker restart, explicitly reconcile durable results and side effects before retrying non-idempotent work. It never replays the original task. Optional completion uses the normal auto-subagent update; required completion returns from the blocking resume call. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include each manager tool that the model needs, and `subagent_status` must not be polled solely to wait for completion. |
| 164 | 164 | ||||
| 165 | ## Resource policy | 165 | ## Resource policy | ||
| 166 | 166 | ||||
M packages/agent/modes/auto_subagents_test.go +9 -9
| @@ -91,7 +91,7 @@ func TestAutoSubagentsSummaryIncludesCompleteFinalResponseAfterLongTask(t *testi | |||
| 91 | 91 | } | |
| 92 | 92 | ||
| 93 | 93 | iv := newQueuedAutoSubagentsInteractive() | |
| 94 | - | iv.TrackSubagentWorker(a, a.Task) | |
| 94 | + | iv.TrackSubagentWorker(a, a.Task, false) | |
| 95 | 95 | update := waitForQueuedPrompt(t, iv) | |
| 96 | 96 | if !strings.Contains(update, "final response:\n"+response) { | |
| 97 | 97 | t.Fatalf("update missing complete final response: %q", update) | |
| @@ -114,7 +114,7 @@ func TestTrackSubagentWorkerReportsStartupFailure(t *testing.T) { | |||
| 114 | 114 | } | |
| 115 | 115 | ||
| 116 | 116 | iv := newQueuedAutoSubagentsInteractive() | |
| 117 | - | iv.TrackSubagentWorker(a, a.Task) | |
| 117 | + | iv.TrackSubagentWorker(a, a.Task, false) | |
| 118 | 118 | ||
| 119 | 119 | update := waitForQueuedPrompt(t, iv) | |
| 120 | 120 | if !strings.Contains(update, "status: failed") { | |
| @@ -182,7 +182,7 @@ func TestTrackDeadlineFailureUsesAttributedResultError(t *testing.T) { | |||
| 182 | 182 | } | |
| 183 | 183 | ||
| 184 | 184 | iv := newQueuedAutoSubagentsInteractive() | |
| 185 | - | iv.TrackSubagentWorker(a, a.Task) | |
| 185 | + | iv.TrackSubagentWorker(a, a.Task, false) | |
| 186 | 186 | update := waitForQueuedPrompt(t, iv) | |
| 187 | 187 | if !strings.Contains(update, "status: failed") { | |
| 188 | 188 | t.Fatalf("deadline update changed failed status: %q", update) | |
| @@ -218,7 +218,7 @@ func TestTrackResumedSubagentWorkerDeliversFollowUp(t *testing.T) { | |||
| 218 | 218 | ||
| 219 | 219 | iv := newQueuedAutoSubagentsInteractive() | |
| 220 | 220 | const followUp = "I applied your review. What do you think now?" | |
| 221 | - | iv.TrackResumedSubagentWorker(a, followUp) | |
| 221 | + | iv.TrackResumedSubagentWorker(a, followUp, false) | |
| 222 | 222 | if a.OnTurnEnd == nil { | |
| 223 | 223 | t.Fatal("resumed worker has no completion watcher") | |
| 224 | 224 | } | |
| @@ -260,9 +260,9 @@ func TestTrackResumedSubagentWorkerQueuesActiveFollowUpOnSameWorker(t *testing.T | |||
| 260 | 260 | }) | |
| 261 | 261 | ||
| 262 | 262 | iv := newQueuedAutoSubagentsInteractive() | |
| 263 | - | iv.TrackSubagentWorker(first, first.Task) | |
| 263 | + | iv.TrackSubagentWorker(first, first.Task, false) | |
| 264 | 264 | const followUp = "queued follow-up" | |
| 265 | - | cancel := iv.PrepareResumedSubagentWorker(first, followUp) | |
| 265 | + | cancel := iv.PrepareResumedSubagentWorker(first, followUp, false) | |
| 266 | 266 | if cancel == nil { | |
| 267 | 267 | t.Fatal("resume registration returned nil cleanup") | |
| 268 | 268 | } | |
| @@ -309,9 +309,9 @@ func TestCompletionDeliveryHoldBatchesFastAndLateRegistrations(t *testing.T) { | |||
| 309 | 309 | ||
| 310 | 310 | iv := newQueuedAutoSubagentsInteractive() | |
| 311 | 311 | release := iv.beginCompletionDeliveryHold() | |
| 312 | - | iv.TrackSubagentWorker(first, first.Task) | |
| 312 | + | iv.TrackSubagentWorker(first, first.Task, false) | |
| 313 | 313 | first.OnTurnEnd(1, "") | |
| 314 | - | iv.TrackSubagentWorker(second, second.Task) | |
| 314 | + | iv.TrackSubagentWorker(second, second.Task, false) | |
| 315 | 315 | second.OnTurnEnd(1, "") | |
| 316 | 316 | release() | |
| 317 | 317 | ||
| @@ -354,7 +354,7 @@ func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) { | |||
| 354 | 354 | }) | |
| 355 | 355 | ||
| 356 | 356 | iv := newQueuedAutoSubagentsInteractive() | |
| 357 | - | iv.TrackSubagentWorker(a, a.Task) | |
| 357 | + | iv.TrackSubagentWorker(a, a.Task, false) | |
| 358 | 358 | if a.OnTurnEnd == nil { | |
| 359 | 359 | t.Fatal("worker has no completion watcher") | |
| 360 | 360 | } | |
| @@ -91,7 +91,7 @@ func TestAutoSubagentsSummaryIncludesCompleteFinalResponseAfterLongTask(t *testi | |||||
| 91 | } | 91 | } | ||
| 92 | 92 | ||||
| 93 | iv := newQueuedAutoSubagentsInteractive() | 93 | iv := newQueuedAutoSubagentsInteractive() | ||
| 94 | - | iv.TrackSubagentWorker(a, a.Task) | 94 | + | iv.TrackSubagentWorker(a, a.Task, false) |
| 95 | update := waitForQueuedPrompt(t, iv) | 95 | update := waitForQueuedPrompt(t, iv) | ||
| 96 | if !strings.Contains(update, "final response:\n"+response) { | 96 | if !strings.Contains(update, "final response:\n"+response) { | ||
| 97 | t.Fatalf("update missing complete final response: %q", update) | 97 | t.Fatalf("update missing complete final response: %q", update) | ||
| @@ -114,7 +114,7 @@ func TestTrackSubagentWorkerReportsStartupFailure(t *testing.T) { | |||||
| 114 | } | 114 | } | ||
| 115 | 115 | ||||
| 116 | iv := newQueuedAutoSubagentsInteractive() | 116 | iv := newQueuedAutoSubagentsInteractive() | ||
| 117 | - | iv.TrackSubagentWorker(a, a.Task) | 117 | + | iv.TrackSubagentWorker(a, a.Task, false) |
| 118 | 118 | ||||
| 119 | update := waitForQueuedPrompt(t, iv) | 119 | update := waitForQueuedPrompt(t, iv) | ||
| 120 | if !strings.Contains(update, "status: failed") { | 120 | if !strings.Contains(update, "status: failed") { | ||
| @@ -182,7 +182,7 @@ func TestTrackDeadlineFailureUsesAttributedResultError(t *testing.T) { | |||||
| 182 | } | 182 | } | ||
| 183 | 183 | ||||
| 184 | iv := newQueuedAutoSubagentsInteractive() | 184 | iv := newQueuedAutoSubagentsInteractive() | ||
| 185 | - | iv.TrackSubagentWorker(a, a.Task) | 185 | + | iv.TrackSubagentWorker(a, a.Task, false) |
| 186 | update := waitForQueuedPrompt(t, iv) | 186 | update := waitForQueuedPrompt(t, iv) | ||
| 187 | if !strings.Contains(update, "status: failed") { | 187 | if !strings.Contains(update, "status: failed") { | ||
| 188 | t.Fatalf("deadline update changed failed status: %q", update) | 188 | t.Fatalf("deadline update changed failed status: %q", update) | ||
| @@ -218,7 +218,7 @@ func TestTrackResumedSubagentWorkerDeliversFollowUp(t *testing.T) { | |||||
| 218 | 218 | ||||
| 219 | iv := newQueuedAutoSubagentsInteractive() | 219 | iv := newQueuedAutoSubagentsInteractive() | ||
| 220 | const followUp = "I applied your review. What do you think now?" | 220 | const followUp = "I applied your review. What do you think now?" | ||
| 221 | - | iv.TrackResumedSubagentWorker(a, followUp) | 221 | + | iv.TrackResumedSubagentWorker(a, followUp, false) |
| 222 | if a.OnTurnEnd == nil { | 222 | if a.OnTurnEnd == nil { | ||
| 223 | t.Fatal("resumed worker has no completion watcher") | 223 | t.Fatal("resumed worker has no completion watcher") | ||
| 224 | } | 224 | } | ||
| @@ -260,9 +260,9 @@ func TestTrackResumedSubagentWorkerQueuesActiveFollowUpOnSameWorker(t *testing.T | |||||
| 260 | }) | 260 | }) | ||
| 261 | 261 | ||||
| 262 | iv := newQueuedAutoSubagentsInteractive() | 262 | iv := newQueuedAutoSubagentsInteractive() | ||
| 263 | - | iv.TrackSubagentWorker(first, first.Task) | 263 | + | iv.TrackSubagentWorker(first, first.Task, false) |
| 264 | const followUp = "queued follow-up" | 264 | const followUp = "queued follow-up" | ||
| 265 | - | cancel := iv.PrepareResumedSubagentWorker(first, followUp) | 265 | + | cancel := iv.PrepareResumedSubagentWorker(first, followUp, false) |
| 266 | if cancel == nil { | 266 | if cancel == nil { | ||
| 267 | t.Fatal("resume registration returned nil cleanup") | 267 | t.Fatal("resume registration returned nil cleanup") | ||
| 268 | } | 268 | } | ||
| @@ -309,9 +309,9 @@ func TestCompletionDeliveryHoldBatchesFastAndLateRegistrations(t *testing.T) { | |||||
| 309 | 309 | ||||
| 310 | iv := newQueuedAutoSubagentsInteractive() | 310 | iv := newQueuedAutoSubagentsInteractive() | ||
| 311 | release := iv.beginCompletionDeliveryHold() | 311 | release := iv.beginCompletionDeliveryHold() | ||
| 312 | - | iv.TrackSubagentWorker(first, first.Task) | 312 | + | iv.TrackSubagentWorker(first, first.Task, false) |
| 313 | first.OnTurnEnd(1, "") | 313 | first.OnTurnEnd(1, "") | ||
| 314 | - | iv.TrackSubagentWorker(second, second.Task) | 314 | + | iv.TrackSubagentWorker(second, second.Task, false) |
| 315 | second.OnTurnEnd(1, "") | 315 | second.OnTurnEnd(1, "") | ||
| 316 | release() | 316 | release() | ||
| 317 | 317 | ||||
| @@ -354,7 +354,7 @@ func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) { | |||||
| 354 | }) | 354 | }) | ||
| 355 | 355 | ||||
| 356 | iv := newQueuedAutoSubagentsInteractive() | 356 | iv := newQueuedAutoSubagentsInteractive() | ||
| 357 | - | iv.TrackSubagentWorker(a, a.Task) | 357 | + | iv.TrackSubagentWorker(a, a.Task, false) |
| 358 | if a.OnTurnEnd == nil { | 358 | if a.OnTurnEnd == nil { | ||
| 359 | t.Fatal("worker has no completion watcher") | 359 | t.Fatal("worker has no completion watcher") | ||
| 360 | } | 360 | } | ||
M packages/agent/modes/interactive.go +6 -6
| @@ -8743,8 +8743,8 @@ func (a telegramSenderAdapter) Active() bool { | |||
| 8743 | 8743 | ||
| 8744 | 8744 | // TrackSubagentWorker is the exported entry point used by the cli to | |
| 8745 | 8745 | // hand a freshly-spawned auto-subagents agent off to the shared tracker. | |
| 8746 | - | func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string, required ...bool) { | |
| 8747 | - | if len(required) != 0 && required[0] { | |
| 8746 | + | func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string, required bool) { | |
| 8747 | + | if required { | |
| 8748 | 8748 | if i != nil { | |
| 8749 | 8749 | i.invalidate() | |
| 8750 | 8750 | } | |
| @@ -8756,8 +8756,8 @@ func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string, requi | |||
| 8756 | 8756 | // TrackResumedSubagentWorker watches a resumed follow-up independently of the | |
| 8757 | 8757 | // worker's original task. A long-lived daemon can complete several manager | |
| 8758 | 8758 | // turns, each of which must produce its own automatic delivery. | |
| 8759 | - | func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt string, required ...bool) { | |
| 8760 | - | if len(required) != 0 && required[0] { | |
| 8759 | + | func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt string, required bool) { | |
| 8760 | + | if required { | |
| 8761 | 8761 | if i != nil { | |
| 8762 | 8762 | i.invalidate() | |
| 8763 | 8763 | } | |
| @@ -8770,8 +8770,8 @@ func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt stri | |||
| 8770 | 8770 | // sent. The returned cleanup removes the registration if the resume operation | |
| 8771 | 8771 | // fails before delivery. It is used by the resume tool's pre-send hook; the | |
| 8772 | 8772 | // existing TrackResumedSubagentWorker signature remains the post-success API. | |
| 8773 | - | func (i *Interactive) PrepareResumedSubagentWorker(a *subagents.Agent, prompt string, required ...bool) func() { | |
| 8774 | - | if len(required) != 0 && required[0] { | |
| 8773 | + | func (i *Interactive) PrepareResumedSubagentWorker(a *subagents.Agent, prompt string, required bool) func() { | |
| 8774 | + | if required { | |
| 8775 | 8775 | if i != nil { | |
| 8776 | 8776 | i.invalidate() | |
| 8777 | 8777 | } | |
| @@ -8743,8 +8743,8 @@ func (a telegramSenderAdapter) Active() bool { | |||||
| 8743 | 8743 | ||||
| 8744 | // TrackSubagentWorker is the exported entry point used by the cli to | 8744 | // TrackSubagentWorker is the exported entry point used by the cli to | ||
| 8745 | // hand a freshly-spawned auto-subagents agent off to the shared tracker. | 8745 | // hand a freshly-spawned auto-subagents agent off to the shared tracker. | ||
| 8746 | - | func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string, required ...bool) { | 8746 | + | func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string, required bool) { |
| 8747 | - | if len(required) != 0 && required[0] { | 8747 | + | if required { |
| 8748 | if i != nil { | 8748 | if i != nil { | ||
| 8749 | i.invalidate() | 8749 | i.invalidate() | ||
| 8750 | } | 8750 | } | ||
| @@ -8756,8 +8756,8 @@ func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string, requi | |||||
| 8756 | // TrackResumedSubagentWorker watches a resumed follow-up independently of the | 8756 | // TrackResumedSubagentWorker watches a resumed follow-up independently of the | ||
| 8757 | // worker's original task. A long-lived daemon can complete several manager | 8757 | // worker's original task. A long-lived daemon can complete several manager | ||
| 8758 | // turns, each of which must produce its own automatic delivery. | 8758 | // turns, each of which must produce its own automatic delivery. | ||
| 8759 | - | func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt string, required ...bool) { | 8759 | + | func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt string, required bool) { |
| 8760 | - | if len(required) != 0 && required[0] { | 8760 | + | if required { |
| 8761 | if i != nil { | 8761 | if i != nil { | ||
| 8762 | i.invalidate() | 8762 | i.invalidate() | ||
| 8763 | } | 8763 | } | ||
| @@ -8770,8 +8770,8 @@ func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt stri | |||||
| 8770 | // sent. The returned cleanup removes the registration if the resume operation | 8770 | // sent. The returned cleanup removes the registration if the resume operation | ||
| 8771 | // fails before delivery. It is used by the resume tool's pre-send hook; the | 8771 | // fails before delivery. It is used by the resume tool's pre-send hook; the | ||
| 8772 | // existing TrackResumedSubagentWorker signature remains the post-success API. | 8772 | // existing TrackResumedSubagentWorker signature remains the post-success API. | ||
| 8773 | - | func (i *Interactive) PrepareResumedSubagentWorker(a *subagents.Agent, prompt string, required ...bool) func() { | 8773 | + | func (i *Interactive) PrepareResumedSubagentWorker(a *subagents.Agent, prompt string, required bool) func() { |
| 8774 | - | if len(required) != 0 && required[0] { | 8774 | + | if required { |
| 8775 | if i != nil { | 8775 | if i != nil { | ||
| 8776 | i.invalidate() | 8776 | i.invalidate() | ||
| 8777 | } | 8777 | } | ||
M packages/agent/settings_store.go +1 -1
| @@ -280,7 +280,7 @@ When workers finish, use the required tool result or host update's agent ID, sta | |||
| 280 | 280 | ||
| 281 | 281 | // OnDemandSubagentsSystemAddendum keeps the canonical subagent tools | |
| 282 | 282 | // available without enabling proactive primary-agent orchestration. | |
| 283 | - | const OnDemandSubagentsSystemAddendum = `Subagent tools are available when the user asks you to delegate or an active skill workflow requires delegation. Use them only in those cases. Mark delegated work required:true when the user's request or workflow requires its outcome before completion; failed required work must be retried and cannot be silently ignored. Do not delegate proactively or switch into an orchestrator role; otherwise perform the work yourself.` | |
| 283 | + | const OnDemandSubagentsSystemAddendum = `Subagent tools are available only when the user asks you to delegate and the relevant launch-time tool permission allows it, or an active skill workflow requires delegation. Use them only in those cases. Mark delegated work required:true when the user's request or workflow requires its outcome before completion. Required calls wait for a durable terminal outcome: timed-out or canceled work remains unmet and must be retried; after host restart, an indeterminate outcome requires user-directed reconciliation of durable results and side effects before non-idempotent work is retried. Do not produce a terminal answer until required work is satisfied. Deliver required results through the blocking tool result and optional results only through host updates. Only the user may waive terminal required work by removing the worker with /subagents rm. Do not delegate proactively or switch into an orchestrator role; otherwise perform the work yourself.` | |
| 284 | 284 | ||
| 285 | 285 | // AutoSubagentsDelegationUnavailableAddendum explains why the strict | |
| 286 | 286 | // orchestrator contract still applies when launch-time policy withholds the | |
| @@ -280,7 +280,7 @@ When workers finish, use the required tool result or host update's agent ID, sta | |||||
| 280 | 280 | ||||
| 281 | // OnDemandSubagentsSystemAddendum keeps the canonical subagent tools | 281 | // OnDemandSubagentsSystemAddendum keeps the canonical subagent tools | ||
| 282 | // available without enabling proactive primary-agent orchestration. | 282 | // available without enabling proactive primary-agent orchestration. | ||
| 283 | - | const OnDemandSubagentsSystemAddendum = `Subagent tools are available when the user asks you to delegate or an active skill workflow requires delegation. Use them only in those cases. Mark delegated work required:true when the user's request or workflow requires its outcome before completion; failed required work must be retried and cannot be silently ignored. Do not delegate proactively or switch into an orchestrator role; otherwise perform the work yourself.` | 283 | + | const OnDemandSubagentsSystemAddendum = `Subagent tools are available only when the user asks you to delegate and the relevant launch-time tool permission allows it, or an active skill workflow requires delegation. Use them only in those cases. Mark delegated work required:true when the user's request or workflow requires its outcome before completion. Required calls wait for a durable terminal outcome: timed-out or canceled work remains unmet and must be retried; after host restart, an indeterminate outcome requires user-directed reconciliation of durable results and side effects before non-idempotent work is retried. Do not produce a terminal answer until required work is satisfied. Deliver required results through the blocking tool result and optional results only through host updates. Only the user may waive terminal required work by removing the worker with /subagents rm. Do not delegate proactively or switch into an orchestrator role; otherwise perform the work yourself.` |
| 284 | 284 | ||||
| 285 | // AutoSubagentsDelegationUnavailableAddendum explains why the strict | 285 | // AutoSubagentsDelegationUnavailableAddendum explains why the strict | ||
| 286 | // orchestrator contract still applies when launch-time policy withholds the | 286 | // orchestrator contract still applies when launch-time policy withholds the | ||
M packages/agent/subagent_requirement_gate_test.go +1 -1
| @@ -127,7 +127,7 @@ func TestRequiredWorkerGateFailsClosedOnReloadError(t *testing.T) { | |||
| 127 | 127 | parent := &core.Agent{} | |
| 128 | 128 | rt.WireRequiredWorkerGate(parent) | |
| 129 | 129 | allowed, reason, _ := parent.BeforeTurnContext(context.Background(), 1) | |
| 130 | - | if allowed || reason != "loading persisted subagents failed; resolve the supervisor reload error before continuing" { | |
| 130 | + | if allowed || reason != "loading persisted subagents failed: malformed persisted worker; resolve the supervisor reload error before continuing" { | |
| 131 | 131 | t.Fatalf("reload gate allowed=%v reason=%q, want fail-closed reload error", allowed, reason) | |
| 132 | 132 | } | |
| 133 | 133 | } | |
| @@ -127,7 +127,7 @@ func TestRequiredWorkerGateFailsClosedOnReloadError(t *testing.T) { | |||||
| 127 | parent := &core.Agent{} | 127 | parent := &core.Agent{} | ||
| 128 | rt.WireRequiredWorkerGate(parent) | 128 | rt.WireRequiredWorkerGate(parent) | ||
| 129 | allowed, reason, _ := parent.BeforeTurnContext(context.Background(), 1) | 129 | allowed, reason, _ := parent.BeforeTurnContext(context.Background(), 1) | ||
| 130 | - | if allowed || reason != "loading persisted subagents failed; resolve the supervisor reload error before continuing" { | 130 | + | if allowed || reason != "loading persisted subagents failed: malformed persisted worker; resolve the supervisor reload error before continuing" { |
| 131 | t.Fatalf("reload gate allowed=%v reason=%q, want fail-closed reload error", allowed, reason) | 131 | t.Fatalf("reload gate allowed=%v reason=%q, want fail-closed reload error", allowed, reason) | ||
| 132 | } | 132 | } | ||
| 133 | } | 133 | } | ||
M packages/agent/subagent_requirements.go +67 -5
| @@ -4,9 +4,11 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | 6 | "fmt" | |
| 7 | + | "sort" | |
| 7 | 8 | "strings" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/zut/packages/agent/subagents" | |
| 11 | + | "github.com/bnema/zut/packages/agent/tools" | |
| 10 | 12 | "github.com/bnema/zut/packages/core" | |
| 11 | 13 | "github.com/bnema/zut/packages/provider" | |
| 12 | 14 | ) | |
| @@ -33,13 +35,13 @@ func (rt *subagentRuntime) WireRequiredWorkerGate(parent *core.Agent) { | |||
| 33 | 35 | inherited = contextText | |
| 34 | 36 | } | |
| 35 | 37 | if err := rt.waitRequiredWorkerReady(ctx); err != nil { | |
| 36 | - | return false, "loading persisted subagents failed; resolve the supervisor reload error before continuing", inherited | |
| 38 | + | return false, fmt.Sprintf("loading persisted subagents failed: %v; resolve the supervisor reload error before continuing", err), inherited | |
| 37 | 39 | } | |
| 38 | 40 | required, err := rt.supervisor.WaitActiveRequirements(ctx) | |
| 39 | 41 | if err != nil { | |
| 40 | 42 | return false, fmt.Sprintf("waiting for required subagents: %v", err), inherited | |
| 41 | 43 | } | |
| 42 | - | update := formatRequiredWorkerContext(required) | |
| 44 | + | update := rt.formatRequiredWorkerContext(required) | |
| 43 | 45 | if update == "" { | |
| 44 | 46 | return true, "", inherited | |
| 45 | 47 | } | |
| @@ -63,9 +65,12 @@ func (rt *subagentRuntime) WireRequiredWorkerGate(parent *core.Agent) { | |||
| 63 | 65 | unmet := rt.supervisor.UnmetRequirements() | |
| 64 | 66 | if len(unmet) != 0 { | |
| 65 | 67 | reason := formatRequiredWorkerBlock(unmet) | |
| 66 | - | parent.QueueMessage(reason) | |
| 68 | + | if rt.noteRequiredBlock(unmet) { | |
| 69 | + | parent.QueueMessage(reason) | |
| 70 | + | } | |
| 67 | 71 | return false, reason, "" | |
| 68 | 72 | } | |
| 73 | + | rt.clearRequiredBlock() | |
| 69 | 74 | if previousBeforeAssistant != nil { | |
| 70 | 75 | allowed, reason, replacement := previousBeforeAssistant(text) | |
| 71 | 76 | if !allowed { | |
| @@ -119,9 +124,48 @@ func (rt *subagentRuntime) acknowledgeSatisfiedRequirements() { | |||
| 119 | 124 | } | |
| 120 | 125 | } | |
| 121 | 126 | ||
| 127 | + | const requiredWorkerContextRuneBudget = 6000 | |
| 128 | + | ||
| 129 | + | func (rt *subagentRuntime) noteRequiredBlock(unmet []subagents.AgentSnapshot) bool { | |
| 130 | + | ids := make([]string, 0, len(unmet)) | |
| 131 | + | for _, snapshot := range unmet { | |
| 132 | + | ids = append(ids, fmt.Sprintf("%s:%s:%d:%s", snapshot.ID, snapshot.Requirement.State, snapshot.Requirement.TargetTurn, snapshot.Requirement.ErrorCode)) | |
| 133 | + | } | |
| 134 | + | sort.Strings(ids) | |
| 135 | + | key := strings.Join(ids, "\n") | |
| 136 | + | rt.requiredContextMu.Lock() | |
| 137 | + | defer rt.requiredContextMu.Unlock() | |
| 138 | + | if key == rt.lastRequiredBlock { | |
| 139 | + | return false | |
| 140 | + | } | |
| 141 | + | rt.lastRequiredBlock = key | |
| 142 | + | return true | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func (rt *subagentRuntime) clearRequiredBlock() { | |
| 146 | + | rt.requiredContextMu.Lock() | |
| 147 | + | rt.lastRequiredBlock = "" | |
| 148 | + | rt.requiredContextMu.Unlock() | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func (rt *subagentRuntime) formatRequiredWorkerContext(required []subagents.AgentSnapshot) string { | |
| 152 | + | rt.requiredContextMu.Lock() | |
| 153 | + | defer rt.requiredContextMu.Unlock() | |
| 154 | + | contextText := formatRequiredWorkerContextWithReported(required, rt.reportedRequiredUnmet) | |
| 155 | + | if rt.reportedRequiredUnmet == nil { | |
| 156 | + | rt.reportedRequiredUnmet = make(map[string]struct{}) | |
| 157 | + | } | |
| 158 | + | for _, snapshot := range required { | |
| 159 | + | if snapshot.Requirement.Unmet() { | |
| 160 | + | rt.reportedRequiredUnmet[snapshot.ID] = struct{}{} | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | return contextText | |
| 164 | + | } | |
| 165 | + | ||
| 122 | 166 | func requiredRecoveryTool(name string) bool { | |
| 123 | 167 | switch name { | |
| 124 | - | case "subagent_resume", "subagent_status", "subagent_stop": | |
| 168 | + | case tools.SubagentResumeToolName, tools.SubagentStatusToolName, tools.SubagentStopToolName: | |
| 125 | 169 | return true | |
| 126 | 170 | default: | |
| 127 | 171 | return false | |
| @@ -129,6 +173,10 @@ func requiredRecoveryTool(name string) bool { | |||
| 129 | 173 | } | |
| 130 | 174 | ||
| 131 | 175 | func formatRequiredWorkerContext(required []subagents.AgentSnapshot) string { | |
| 176 | + | return formatRequiredWorkerContextWithReported(required, nil) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func formatRequiredWorkerContextWithReported(required []subagents.AgentSnapshot, previouslyReported map[string]struct{}) string { | |
| 132 | 180 | var items []subagents.AgentSnapshot | |
| 133 | 181 | for _, snapshot := range required { | |
| 134 | 182 | requirement := snapshot.Requirement | |
| @@ -148,6 +196,12 @@ func formatRequiredWorkerContext(required []subagents.AgentSnapshot) string { | |||
| 148 | 196 | sb.WriteString("\nThe host waited for required delegated work before this model turn. These outcomes are durable and authoritative:\n") | |
| 149 | 197 | for _, snapshot := range items { | |
| 150 | 198 | requirement := snapshot.Requirement | |
| 199 | + | if requirement.Unmet() { | |
| 200 | + | if _, reported := previouslyReported[snapshot.ID]; reported { | |
| 201 | + | fmt.Fprintf(&sb, "\n- %s: %s (turn %d; previously reported; diagnostic: %s)\n", snapshot.ID, requirement.State, requirement.TargetTurn, subagents.ResultRef(snapshot.ID)) | |
| 202 | + | continue | |
| 203 | + | } | |
| 204 | + | } | |
| 151 | 205 | fmt.Fprintf(&sb, "\n- %s: %s (turn %d)\n task: %s\n", snapshot.ID, requirement.State, requirement.TargetTurn, strings.TrimSpace(snapshot.Task)) | |
| 152 | 206 | if requirement.ErrorCode != "" { | |
| 153 | 207 | fmt.Fprintf(&sb, " error_code: %s\n diagnostic: %s\n", requirement.ErrorCode, subagents.ResultRef(snapshot.ID)) | |
| @@ -167,7 +221,15 @@ func formatRequiredWorkerContext(required []subagents.AgentSnapshot) string { | |||
| 167 | 221 | } else { | |
| 168 | 222 | sb.WriteString("\nAll listed requirements are satisfied. Use their results before answering.") | |
| 169 | 223 | } | |
| 170 | - | return sb.String() | |
| 224 | + | return truncateRequiredContext(sb.String(), requiredWorkerContextRuneBudget) | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func truncateRequiredContext(text string, limit int) string { | |
| 228 | + | runes := []rune(text) | |
| 229 | + | if len(runes) <= limit { | |
| 230 | + | return text | |
| 231 | + | } | |
| 232 | + | return string(runes[:limit]) | |
| 171 | 233 | } | |
| 172 | 234 | ||
| 173 | 235 | func formatRequiredWorkerBlock(unmet []subagents.AgentSnapshot) string { | |
| @@ -4,9 +4,11 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | + | "sort" | |||
| 7 | "strings" | 8 | "strings" | ||
| 8 | 9 | ||||
| 9 | "github.com/bnema/zut/packages/agent/subagents" | 10 | "github.com/bnema/zut/packages/agent/subagents" | ||
| 11 | + | "github.com/bnema/zut/packages/agent/tools" | |||
| 10 | "github.com/bnema/zut/packages/core" | 12 | "github.com/bnema/zut/packages/core" | ||
| 11 | "github.com/bnema/zut/packages/provider" | 13 | "github.com/bnema/zut/packages/provider" | ||
| 12 | ) | 14 | ) | ||
| @@ -33,13 +35,13 @@ func (rt *subagentRuntime) WireRequiredWorkerGate(parent *core.Agent) { | |||||
| 33 | inherited = contextText | 35 | inherited = contextText | ||
| 34 | } | 36 | } | ||
| 35 | if err := rt.waitRequiredWorkerReady(ctx); err != nil { | 37 | if err := rt.waitRequiredWorkerReady(ctx); err != nil { | ||
| 36 | - | return false, "loading persisted subagents failed; resolve the supervisor reload error before continuing", inherited | 38 | + | return false, fmt.Sprintf("loading persisted subagents failed: %v; resolve the supervisor reload error before continuing", err), inherited |
| 37 | } | 39 | } | ||
| 38 | required, err := rt.supervisor.WaitActiveRequirements(ctx) | 40 | required, err := rt.supervisor.WaitActiveRequirements(ctx) | ||
| 39 | if err != nil { | 41 | if err != nil { | ||
| 40 | return false, fmt.Sprintf("waiting for required subagents: %v", err), inherited | 42 | return false, fmt.Sprintf("waiting for required subagents: %v", err), inherited | ||
| 41 | } | 43 | } | ||
| 42 | - | update := formatRequiredWorkerContext(required) | 44 | + | update := rt.formatRequiredWorkerContext(required) |
| 43 | if update == "" { | 45 | if update == "" { | ||
| 44 | return true, "", inherited | 46 | return true, "", inherited | ||
| 45 | } | 47 | } | ||
| @@ -63,9 +65,12 @@ func (rt *subagentRuntime) WireRequiredWorkerGate(parent *core.Agent) { | |||||
| 63 | unmet := rt.supervisor.UnmetRequirements() | 65 | unmet := rt.supervisor.UnmetRequirements() | ||
| 64 | if len(unmet) != 0 { | 66 | if len(unmet) != 0 { | ||
| 65 | reason := formatRequiredWorkerBlock(unmet) | 67 | reason := formatRequiredWorkerBlock(unmet) | ||
| 66 | - | parent.QueueMessage(reason) | 68 | + | if rt.noteRequiredBlock(unmet) { |
| 69 | + | parent.QueueMessage(reason) | |||
| 70 | + | } | |||
| 67 | return false, reason, "" | 71 | return false, reason, "" | ||
| 68 | } | 72 | } | ||
| 73 | + | rt.clearRequiredBlock() | |||
| 69 | if previousBeforeAssistant != nil { | 74 | if previousBeforeAssistant != nil { | ||
| 70 | allowed, reason, replacement := previousBeforeAssistant(text) | 75 | allowed, reason, replacement := previousBeforeAssistant(text) | ||
| 71 | if !allowed { | 76 | if !allowed { | ||
| @@ -119,9 +124,48 @@ func (rt *subagentRuntime) acknowledgeSatisfiedRequirements() { | |||||
| 119 | } | 124 | } | ||
| 120 | } | 125 | } | ||
| 121 | 126 | ||||
| 127 | + | const requiredWorkerContextRuneBudget = 6000 | |||
| 128 | + | ||||
| 129 | + | func (rt *subagentRuntime) noteRequiredBlock(unmet []subagents.AgentSnapshot) bool { | |||
| 130 | + | ids := make([]string, 0, len(unmet)) | |||
| 131 | + | for _, snapshot := range unmet { | |||
| 132 | + | ids = append(ids, fmt.Sprintf("%s:%s:%d:%s", snapshot.ID, snapshot.Requirement.State, snapshot.Requirement.TargetTurn, snapshot.Requirement.ErrorCode)) | |||
| 133 | + | } | |||
| 134 | + | sort.Strings(ids) | |||
| 135 | + | key := strings.Join(ids, "\n") | |||
| 136 | + | rt.requiredContextMu.Lock() | |||
| 137 | + | defer rt.requiredContextMu.Unlock() | |||
| 138 | + | if key == rt.lastRequiredBlock { | |||
| 139 | + | return false | |||
| 140 | + | } | |||
| 141 | + | rt.lastRequiredBlock = key | |||
| 142 | + | return true | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func (rt *subagentRuntime) clearRequiredBlock() { | |||
| 146 | + | rt.requiredContextMu.Lock() | |||
| 147 | + | rt.lastRequiredBlock = "" | |||
| 148 | + | rt.requiredContextMu.Unlock() | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func (rt *subagentRuntime) formatRequiredWorkerContext(required []subagents.AgentSnapshot) string { | |||
| 152 | + | rt.requiredContextMu.Lock() | |||
| 153 | + | defer rt.requiredContextMu.Unlock() | |||
| 154 | + | contextText := formatRequiredWorkerContextWithReported(required, rt.reportedRequiredUnmet) | |||
| 155 | + | if rt.reportedRequiredUnmet == nil { | |||
| 156 | + | rt.reportedRequiredUnmet = make(map[string]struct{}) | |||
| 157 | + | } | |||
| 158 | + | for _, snapshot := range required { | |||
| 159 | + | if snapshot.Requirement.Unmet() { | |||
| 160 | + | rt.reportedRequiredUnmet[snapshot.ID] = struct{}{} | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | return contextText | |||
| 164 | + | } | |||
| 165 | + | ||||
| 122 | func requiredRecoveryTool(name string) bool { | 166 | func requiredRecoveryTool(name string) bool { | ||
| 123 | switch name { | 167 | switch name { | ||
| 124 | - | case "subagent_resume", "subagent_status", "subagent_stop": | 168 | + | case tools.SubagentResumeToolName, tools.SubagentStatusToolName, tools.SubagentStopToolName: |
| 125 | return true | 169 | return true | ||
| 126 | default: | 170 | default: | ||
| 127 | return false | 171 | return false | ||
| @@ -129,6 +173,10 @@ func requiredRecoveryTool(name string) bool { | |||||
| 129 | } | 173 | } | ||
| 130 | 174 | ||||
| 131 | func formatRequiredWorkerContext(required []subagents.AgentSnapshot) string { | 175 | func formatRequiredWorkerContext(required []subagents.AgentSnapshot) string { | ||
| 176 | + | return formatRequiredWorkerContextWithReported(required, nil) | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func formatRequiredWorkerContextWithReported(required []subagents.AgentSnapshot, previouslyReported map[string]struct{}) string { | |||
| 132 | var items []subagents.AgentSnapshot | 180 | var items []subagents.AgentSnapshot | ||
| 133 | for _, snapshot := range required { | 181 | for _, snapshot := range required { | ||
| 134 | requirement := snapshot.Requirement | 182 | requirement := snapshot.Requirement | ||
| @@ -148,6 +196,12 @@ func formatRequiredWorkerContext(required []subagents.AgentSnapshot) string { | |||||
| 148 | sb.WriteString("\nThe host waited for required delegated work before this model turn. These outcomes are durable and authoritative:\n") | 196 | sb.WriteString("\nThe host waited for required delegated work before this model turn. These outcomes are durable and authoritative:\n") | ||
| 149 | for _, snapshot := range items { | 197 | for _, snapshot := range items { | ||
| 150 | requirement := snapshot.Requirement | 198 | requirement := snapshot.Requirement | ||
| 199 | + | if requirement.Unmet() { | |||
| 200 | + | if _, reported := previouslyReported[snapshot.ID]; reported { | |||
| 201 | + | fmt.Fprintf(&sb, "\n- %s: %s (turn %d; previously reported; diagnostic: %s)\n", snapshot.ID, requirement.State, requirement.TargetTurn, subagents.ResultRef(snapshot.ID)) | |||
| 202 | + | continue | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 151 | fmt.Fprintf(&sb, "\n- %s: %s (turn %d)\n task: %s\n", snapshot.ID, requirement.State, requirement.TargetTurn, strings.TrimSpace(snapshot.Task)) | 205 | fmt.Fprintf(&sb, "\n- %s: %s (turn %d)\n task: %s\n", snapshot.ID, requirement.State, requirement.TargetTurn, strings.TrimSpace(snapshot.Task)) | ||
| 152 | if requirement.ErrorCode != "" { | 206 | if requirement.ErrorCode != "" { | ||
| 153 | fmt.Fprintf(&sb, " error_code: %s\n diagnostic: %s\n", requirement.ErrorCode, subagents.ResultRef(snapshot.ID)) | 207 | fmt.Fprintf(&sb, " error_code: %s\n diagnostic: %s\n", requirement.ErrorCode, subagents.ResultRef(snapshot.ID)) | ||
| @@ -167,7 +221,15 @@ func formatRequiredWorkerContext(required []subagents.AgentSnapshot) string { | |||||
| 167 | } else { | 221 | } else { | ||
| 168 | sb.WriteString("\nAll listed requirements are satisfied. Use their results before answering.") | 222 | sb.WriteString("\nAll listed requirements are satisfied. Use their results before answering.") | ||
| 169 | } | 223 | } | ||
| 170 | - | return sb.String() | 224 | + | return truncateRequiredContext(sb.String(), requiredWorkerContextRuneBudget) |
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | func truncateRequiredContext(text string, limit int) string { | |||
| 228 | + | runes := []rune(text) | |||
| 229 | + | if len(runes) <= limit { | |||
| 230 | + | return text | |||
| 231 | + | } | |||
| 232 | + | return string(runes[:limit]) | |||
| 171 | } | 233 | } | ||
| 172 | 234 | ||||
| 173 | func formatRequiredWorkerBlock(unmet []subagents.AgentSnapshot) string { | 235 | func formatRequiredWorkerBlock(unmet []subagents.AgentSnapshot) string { | ||
M packages/agent/subagent_runtime.go +9 -6
| @@ -43,12 +43,15 @@ type subagentRuntime struct { | |||
| 43 | 43 | beforeResumed func(*subagents.Agent, string, bool) func() | |
| 44 | 44 | onStopRequested func(*subagents.Agent) | |
| 45 | 45 | ||
| 46 | - | requiredReadyMu sync.RWMutex | |
| 47 | - | requiredReady <-chan struct{} | |
| 48 | - | requiredReadyErr func() error | |
| 49 | - | settingsMu sync.RWMutex | |
| 50 | - | closeMu sync.Mutex | |
| 51 | - | closed bool | |
| 46 | + | requiredReadyMu sync.RWMutex | |
| 47 | + | requiredReady <-chan struct{} | |
| 48 | + | requiredReadyErr func() error | |
| 49 | + | requiredContextMu sync.Mutex | |
| 50 | + | reportedRequiredUnmet map[string]struct{} | |
| 51 | + | lastRequiredBlock string | |
| 52 | + | settingsMu sync.RWMutex | |
| 53 | + | closeMu sync.Mutex | |
| 54 | + | closed bool | |
| 52 | 55 | } | |
| 53 | 56 | ||
| 54 | 57 | type subagentRuntimeConfiguration struct { | |
| @@ -43,12 +43,15 @@ type subagentRuntime struct { | |||||
| 43 | beforeResumed func(*subagents.Agent, string, bool) func() | 43 | beforeResumed func(*subagents.Agent, string, bool) func() | ||
| 44 | onStopRequested func(*subagents.Agent) | 44 | onStopRequested func(*subagents.Agent) | ||
| 45 | 45 | ||||
| 46 | - | requiredReadyMu sync.RWMutex | 46 | + | requiredReadyMu sync.RWMutex |
| 47 | - | requiredReady <-chan struct{} | 47 | + | requiredReady <-chan struct{} |
| 48 | - | requiredReadyErr func() error | 48 | + | requiredReadyErr func() error |
| 49 | - | settingsMu sync.RWMutex | 49 | + | requiredContextMu sync.Mutex |
| 50 | - | closeMu sync.Mutex | 50 | + | reportedRequiredUnmet map[string]struct{} |
| 51 | - | closed bool | 51 | + | lastRequiredBlock string |
| 52 | + | settingsMu sync.RWMutex | |||
| 53 | + | closeMu sync.Mutex | |||
| 54 | + | closed bool | |||
| 52 | } | 55 | } | ||
| 53 | 56 | ||||
| 54 | type subagentRuntimeConfiguration struct { | 57 | type subagentRuntimeConfiguration struct { | ||
M packages/agent/subagents/persist.go +29 -5
| @@ -1355,20 +1355,44 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo | |||
| 1355 | 1355 | // scheduler can start the worker and emit a fast turn_end. A durable pending | |
| 1356 | 1356 | // prompt is consumed before the newly requested prompt, so tracker | |
| 1357 | 1357 | // registrations must preserve that scheduler order. | |
| 1358 | + | var beforeCleanups []func() | |
| 1358 | 1359 | if before != nil { | |
| 1360 | + | register := func(prompt string) { | |
| 1361 | + | if cleanup := before(a, prompt); cleanup != nil { | |
| 1362 | + | beforeCleanups = append(beforeCleanups, cleanup) | |
| 1363 | + | } | |
| 1364 | + | } | |
| 1359 | 1365 | if hadPendingResumePrompt { | |
| 1360 | - | _ = before(a, pendingResumePrompt) | |
| 1366 | + | register(pendingResumePrompt) | |
| 1361 | 1367 | } | |
| 1362 | 1368 | if resumePrompt != "" { | |
| 1363 | - | // No rejecting operation remains before scheduling, so there is no path | |
| 1364 | - | // that needs the hook's rollback after this point. | |
| 1365 | - | _ = before(a, resumePrompt) | |
| 1369 | + | register(resumePrompt) | |
| 1366 | 1370 | } | |
| 1367 | 1371 | if !hadPendingResumePrompt && resumePrompt == "" { | |
| 1368 | - | _ = before(a, "") | |
| 1372 | + | register("") | |
| 1369 | 1373 | } | |
| 1370 | 1374 | if err := f.persistAgent(a); err != nil { | |
| 1371 | 1375 | a.recordPersistenceError(err) | |
| 1376 | + | for idx := len(beforeCleanups) - 1; idx >= 0; idx-- { | |
| 1377 | + | beforeCleanups[idx]() | |
| 1378 | + | } | |
| 1379 | + | f.mu.Lock() | |
| 1380 | + | f.agents[a.ID] = existing | |
| 1381 | + | for idx, queued := range f.queue { | |
| 1382 | + | if queued == a { | |
| 1383 | + | f.queue = append(f.queue[:idx], f.queue[idx+1:]...) | |
| 1384 | + | break | |
| 1385 | + | } | |
| 1386 | + | } | |
| 1387 | + | f.mu.Unlock() | |
| 1388 | + | if restoreErr := f.persistAgent(existing); restoreErr != nil { | |
| 1389 | + | existing.recordPersistenceError(restoreErr) | |
| 1390 | + | } | |
| 1391 | + | if a.cancel != nil { | |
| 1392 | + | a.cancel() | |
| 1393 | + | } | |
| 1394 | + | _ = lease.Close() | |
| 1395 | + | return nil, err | |
| 1372 | 1396 | } | |
| 1373 | 1397 | } | |
| 1374 | 1398 | f.armQueueTimeout(a) | |
| @@ -1355,20 +1355,44 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo | |||||
| 1355 | // scheduler can start the worker and emit a fast turn_end. A durable pending | 1355 | // scheduler can start the worker and emit a fast turn_end. A durable pending | ||
| 1356 | // prompt is consumed before the newly requested prompt, so tracker | 1356 | // prompt is consumed before the newly requested prompt, so tracker | ||
| 1357 | // registrations must preserve that scheduler order. | 1357 | // registrations must preserve that scheduler order. | ||
| 1358 | + | var beforeCleanups []func() | |||
| 1358 | if before != nil { | 1359 | if before != nil { | ||
| 1360 | + | register := func(prompt string) { | |||
| 1361 | + | if cleanup := before(a, prompt); cleanup != nil { | |||
| 1362 | + | beforeCleanups = append(beforeCleanups, cleanup) | |||
| 1363 | + | } | |||
| 1364 | + | } | |||
| 1359 | if hadPendingResumePrompt { | 1365 | if hadPendingResumePrompt { | ||
| 1360 | - | _ = before(a, pendingResumePrompt) | 1366 | + | register(pendingResumePrompt) |
| 1361 | } | 1367 | } | ||
| 1362 | if resumePrompt != "" { | 1368 | if resumePrompt != "" { | ||
| 1363 | - | // No rejecting operation remains before scheduling, so there is no path | 1369 | + | register(resumePrompt) |
| 1364 | - | // that needs the hook's rollback after this point. | |||
| 1365 | - | _ = before(a, resumePrompt) | |||
| 1366 | } | 1370 | } | ||
| 1367 | if !hadPendingResumePrompt && resumePrompt == "" { | 1371 | if !hadPendingResumePrompt && resumePrompt == "" { | ||
| 1368 | - | _ = before(a, "") | 1372 | + | register("") |
| 1369 | } | 1373 | } | ||
| 1370 | if err := f.persistAgent(a); err != nil { | 1374 | if err := f.persistAgent(a); err != nil { | ||
| 1371 | a.recordPersistenceError(err) | 1375 | a.recordPersistenceError(err) | ||
| 1376 | + | for idx := len(beforeCleanups) - 1; idx >= 0; idx-- { | |||
| 1377 | + | beforeCleanups[idx]() | |||
| 1378 | + | } | |||
| 1379 | + | f.mu.Lock() | |||
| 1380 | + | f.agents[a.ID] = existing | |||
| 1381 | + | for idx, queued := range f.queue { | |||
| 1382 | + | if queued == a { | |||
| 1383 | + | f.queue = append(f.queue[:idx], f.queue[idx+1:]...) | |||
| 1384 | + | break | |||
| 1385 | + | } | |||
| 1386 | + | } | |||
| 1387 | + | f.mu.Unlock() | |||
| 1388 | + | if restoreErr := f.persistAgent(existing); restoreErr != nil { | |||
| 1389 | + | existing.recordPersistenceError(restoreErr) | |||
| 1390 | + | } | |||
| 1391 | + | if a.cancel != nil { | |||
| 1392 | + | a.cancel() | |||
| 1393 | + | } | |||
| 1394 | + | _ = lease.Close() | |||
| 1395 | + | return nil, err | |||
| 1372 | } | 1396 | } | ||
| 1373 | } | 1397 | } | ||
| 1374 | f.armQueueTimeout(a) | 1398 | f.armQueueTimeout(a) | ||
M packages/agent/subagents/requirement.go +20 -3
| @@ -154,11 +154,28 @@ func (a *Agent) resolveRequirement(step int, result *TurnResult, errMsg string, | |||
| 154 | 154 | if result != nil && a.persistFn != nil && !unobservedRequirementResult(result) { | |
| 155 | 155 | durable, err := readTurnResult(a.stateDirectory("")) | |
| 156 | 156 | if err != nil || durable.TurnID != result.TurnID || durable.AgentID != result.AgentID { | |
| 157 | + | if err == nil { | |
| 158 | + | err = fmt.Errorf("durable result identity does not match agent %s turn %s", result.AgentID, result.TurnID) | |
| 159 | + | } | |
| 160 | + | persistenceErr := fmt.Errorf("persist required result: %w", err) | |
| 161 | + | a.requirement.State = RequirementIndeterminate | |
| 162 | + | a.requirement.ErrorCode = "required_outcome_unobserved" | |
| 163 | + | a.requirement.Notified = false | |
| 164 | + | a.requirement.UpdatedAt = time.Now().UTC() | |
| 165 | + | resolved := a.requirement | |
| 166 | + | a.requirementPersisting = true | |
| 157 | 167 | a.lifecycleMu.Unlock() | |
| 158 | - | if err != nil { | |
| 159 | - | a.recordPersistenceError(fmt.Errorf("persist required result: %w", err)) | |
| 168 | + | a.recordPersistenceError(persistenceErr) | |
| 169 | + | if err := a.persistFn(a); err != nil { | |
| 170 | + | a.recordPersistenceError(err) | |
| 160 | 171 | } | |
| 161 | - | return current | |
| 172 | + | a.lifecycleMu.Lock() | |
| 173 | + | if a.requirement == resolved { | |
| 174 | + | a.requirementPersisting = false | |
| 175 | + | a.signalRequirementLocked() | |
| 176 | + | } | |
| 177 | + | a.lifecycleMu.Unlock() | |
| 178 | + | return resolved | |
| 162 | 179 | } | |
| 163 | 180 | } | |
| 164 | 181 | state, errorCode := classifyRequirementOutcome(result, errMsg) | |
| @@ -154,11 +154,28 @@ func (a *Agent) resolveRequirement(step int, result *TurnResult, errMsg string, | |||||
| 154 | if result != nil && a.persistFn != nil && !unobservedRequirementResult(result) { | 154 | if result != nil && a.persistFn != nil && !unobservedRequirementResult(result) { | ||
| 155 | durable, err := readTurnResult(a.stateDirectory("")) | 155 | durable, err := readTurnResult(a.stateDirectory("")) | ||
| 156 | if err != nil || durable.TurnID != result.TurnID || durable.AgentID != result.AgentID { | 156 | if err != nil || durable.TurnID != result.TurnID || durable.AgentID != result.AgentID { | ||
| 157 | + | if err == nil { | |||
| 158 | + | err = fmt.Errorf("durable result identity does not match agent %s turn %s", result.AgentID, result.TurnID) | |||
| 159 | + | } | |||
| 160 | + | persistenceErr := fmt.Errorf("persist required result: %w", err) | |||
| 161 | + | a.requirement.State = RequirementIndeterminate | |||
| 162 | + | a.requirement.ErrorCode = "required_outcome_unobserved" | |||
| 163 | + | a.requirement.Notified = false | |||
| 164 | + | a.requirement.UpdatedAt = time.Now().UTC() | |||
| 165 | + | resolved := a.requirement | |||
| 166 | + | a.requirementPersisting = true | |||
| 157 | a.lifecycleMu.Unlock() | 167 | a.lifecycleMu.Unlock() | ||
| 158 | - | if err != nil { | 168 | + | a.recordPersistenceError(persistenceErr) |
| 159 | - | a.recordPersistenceError(fmt.Errorf("persist required result: %w", err)) | 169 | + | if err := a.persistFn(a); err != nil { |
| 170 | + | a.recordPersistenceError(err) | |||
| 160 | } | 171 | } | ||
| 161 | - | return current | 172 | + | a.lifecycleMu.Lock() |
| 173 | + | if a.requirement == resolved { | |||
| 174 | + | a.requirementPersisting = false | |||
| 175 | + | a.signalRequirementLocked() | |||
| 176 | + | } | |||
| 177 | + | a.lifecycleMu.Unlock() | |||
| 178 | + | return resolved | |||
| 162 | } | 179 | } | ||
| 163 | } | 180 | } | ||
| 164 | state, errorCode := classifyRequirementOutcome(result, errMsg) | 181 | state, errorCode := classifyRequirementOutcome(result, errMsg) | ||
M packages/agent/subagents/requirement_test.go +7 -6
| @@ -28,16 +28,17 @@ func TestRequiredWaitWakesOnlyAfterOutcomePersistence(t *testing.T) { | |||
| 28 | 28 | close(resolved) | |
| 29 | 29 | }() | |
| 30 | 30 | <-persisting | |
| 31 | + | a.lifecycleMu.Lock() | |
| 32 | + | visible := a.visibleRequirementLocked() | |
| 33 | + | a.lifecycleMu.Unlock() | |
| 34 | + | if visible.State != RequirementPending { | |
| 35 | + | t.Fatalf("visible requirement while persistence is blocked = %+v, want pending", visible) | |
| 36 | + | } | |
| 31 | 37 | waited := make(chan RequirementSnapshot, 1) | |
| 32 | 38 | go func() { | |
| 33 | 39 | got, _ := a.waitRequirement(context.Background()) | |
| 34 | 40 | waited <- got | |
| 35 | 41 | }() | |
| 36 | - | select { | |
| 37 | - | case got := <-waited: | |
| 38 | - | t.Fatalf("wait woke before durable persistence: %+v", got) | |
| 39 | - | default: | |
| 40 | - | } | |
| 41 | 42 | close(release) | |
| 42 | 43 | <-resolved | |
| 43 | 44 | if got := <-waited; got.State != RequirementSatisfied { | |
| @@ -353,7 +354,7 @@ func TestWaitRequiredIsEventDrivenAndRetryCanSatisfy(t *testing.T) { | |||
| 353 | 354 | t.Fatalf("required retry: %v", err) | |
| 354 | 355 | } | |
| 355 | 356 | resumed.Wait() | |
| 356 | - | got, err := f.WaitRequired(ctx, a.ID) | |
| 357 | + | got, err := f.WaitRequired(context.Background(), a.ID) | |
| 357 | 358 | if err != nil { | |
| 358 | 359 | t.Fatalf("wait for retry: %v", err) | |
| 359 | 360 | } | |
| @@ -28,16 +28,17 @@ func TestRequiredWaitWakesOnlyAfterOutcomePersistence(t *testing.T) { | |||||
| 28 | close(resolved) | 28 | close(resolved) | ||
| 29 | }() | 29 | }() | ||
| 30 | <-persisting | 30 | <-persisting | ||
| 31 | + | a.lifecycleMu.Lock() | |||
| 32 | + | visible := a.visibleRequirementLocked() | |||
| 33 | + | a.lifecycleMu.Unlock() | |||
| 34 | + | if visible.State != RequirementPending { | |||
| 35 | + | t.Fatalf("visible requirement while persistence is blocked = %+v, want pending", visible) | |||
| 36 | + | } | |||
| 31 | waited := make(chan RequirementSnapshot, 1) | 37 | waited := make(chan RequirementSnapshot, 1) | ||
| 32 | go func() { | 38 | go func() { | ||
| 33 | got, _ := a.waitRequirement(context.Background()) | 39 | got, _ := a.waitRequirement(context.Background()) | ||
| 34 | waited <- got | 40 | waited <- got | ||
| 35 | }() | 41 | }() | ||
| 36 | - | select { | |||
| 37 | - | case got := <-waited: | |||
| 38 | - | t.Fatalf("wait woke before durable persistence: %+v", got) | |||
| 39 | - | default: | |||
| 40 | - | } | |||
| 41 | close(release) | 42 | close(release) | ||
| 42 | <-resolved | 43 | <-resolved | ||
| 43 | if got := <-waited; got.State != RequirementSatisfied { | 44 | if got := <-waited; got.State != RequirementSatisfied { | ||
| @@ -353,7 +354,7 @@ func TestWaitRequiredIsEventDrivenAndRetryCanSatisfy(t *testing.T) { | |||||
| 353 | t.Fatalf("required retry: %v", err) | 354 | t.Fatalf("required retry: %v", err) | ||
| 354 | } | 355 | } | ||
| 355 | resumed.Wait() | 356 | resumed.Wait() | ||
| 356 | - | got, err := f.WaitRequired(ctx, a.ID) | 357 | + | got, err := f.WaitRequired(context.Background(), a.ID) |
| 357 | if err != nil { | 358 | if err != nil { | ||
| 358 | t.Fatalf("wait for retry: %v", err) | 359 | t.Fatalf("wait for retry: %v", err) | ||
| 359 | } | 360 | } | ||
M packages/agent/subagents/result.go +7 -4
| @@ -573,11 +573,14 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) { | |||
| 573 | 573 | result = result.Bounded(a.maxOutputBytes, a.maxOutputLines) | |
| 574 | 574 | a.setResult(result) | |
| 575 | 575 | stateDir := a.stateDirectory(f.cfg.Root) | |
| 576 | - | if err := writeTurnResult(stateDir, result); err == nil { | |
| 577 | - | a.lifecycleMu.Lock() | |
| 578 | - | a.resultRef = ResultRef(a.ID) | |
| 579 | - | a.lifecycleMu.Unlock() | |
| 576 | + | if err := writeTurnResult(stateDir, result); err != nil { | |
| 577 | + | a.recordPersistenceError(fmt.Errorf("write turn result: %w", err)) | |
| 578 | + | a.resolveRequirement(0, nil, fmt.Sprintf("write turn result: %v", err), true) | |
| 579 | + | return | |
| 580 | 580 | } | |
| 581 | + | a.lifecycleMu.Lock() | |
| 582 | + | a.resultRef = ResultRef(a.ID) | |
| 583 | + | a.lifecycleMu.Unlock() | |
| 581 | 584 | a.resolveRequirement(0, result, "", true) | |
| 582 | 585 | } | |
| 583 | 586 | ||
| @@ -573,11 +573,14 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) { | |||||
| 573 | result = result.Bounded(a.maxOutputBytes, a.maxOutputLines) | 573 | result = result.Bounded(a.maxOutputBytes, a.maxOutputLines) | ||
| 574 | a.setResult(result) | 574 | a.setResult(result) | ||
| 575 | stateDir := a.stateDirectory(f.cfg.Root) | 575 | stateDir := a.stateDirectory(f.cfg.Root) | ||
| 576 | - | if err := writeTurnResult(stateDir, result); err == nil { | 576 | + | if err := writeTurnResult(stateDir, result); err != nil { |
| 577 | - | a.lifecycleMu.Lock() | 577 | + | a.recordPersistenceError(fmt.Errorf("write turn result: %w", err)) |
| 578 | - | a.resultRef = ResultRef(a.ID) | 578 | + | a.resolveRequirement(0, nil, fmt.Sprintf("write turn result: %v", err), true) |
| 579 | - | a.lifecycleMu.Unlock() | 579 | + | return |
| 580 | } | 580 | } | ||
| 581 | + | a.lifecycleMu.Lock() | |||
| 582 | + | a.resultRef = ResultRef(a.ID) | |||
| 583 | + | a.lifecycleMu.Unlock() | |||
| 581 | a.resolveRequirement(0, result, "", true) | 584 | a.resolveRequirement(0, result, "", true) | ||
| 582 | } | 585 | } | ||
| 583 | 586 | ||||
M packages/agent/tools/subagent_required_test.go +1 -0
| @@ -165,6 +165,7 @@ func TestIndeterminateRequiredWorkNeedsExplicitUserReconciliation(t *testing.T) | |||
| 165 | 165 | return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { return nil }) | |
| 166 | 166 | }, | |
| 167 | 167 | }) | |
| 168 | + | t.Cleanup(second.StopAll) | |
| 168 | 169 | if _, errs := second.Reload(); len(errs) != 0 { | |
| 169 | 170 | t.Fatalf("reload errors: %v", errs) | |
| 170 | 171 | } | |
| @@ -165,6 +165,7 @@ func TestIndeterminateRequiredWorkNeedsExplicitUserReconciliation(t *testing.T) | |||||
| 165 | return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { return nil }) | 165 | return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { return nil }) | ||
| 166 | }, | 166 | }, | ||
| 167 | }) | 167 | }) | ||
| 168 | + | t.Cleanup(second.StopAll) | |||
| 168 | if _, errs := second.Reload(); len(errs) != 0 { | 169 | if _, errs := second.Reload(); len(errs) != 0 { | ||
| 169 | t.Fatalf("reload errors: %v", errs) | 170 | t.Fatalf("reload errors: %v", errs) | ||
| 170 | } | 171 | } | ||
M packages/agent/tools/subagent_resume.go +7 -7
| @@ -57,7 +57,7 @@ const subagentResumeSchema = `{ | |||
| 57 | 57 | "required": ["agent_id", "prompt"] | |
| 58 | 58 | }` | |
| 59 | 59 | ||
| 60 | - | func (t *SubagentResumeTool) Name() string { return "subagent_resume" } | |
| 60 | + | func (t *SubagentResumeTool) Name() string { return SubagentResumeToolName } | |
| 61 | 61 | ||
| 62 | 62 | func (t *SubagentResumeTool) Description() string { | |
| 63 | 63 | return "Continue a sub-agent with a fresh turn budget and retained session. Set required=true for a mandatory follow-up; retries of failed, timed-out, or canceled required work stay required and wait event-driven for the new outcome. Indeterminate work requires explicit user reconciliation." | |
| @@ -67,7 +67,7 @@ func (t *SubagentResumeTool) Schema() json.RawMessage { | |||
| 67 | 67 | return json.RawMessage(subagentResumeSchema) | |
| 68 | 68 | } | |
| 69 | 69 | ||
| 70 | - | func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) { | |
| 70 | + | func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | |
| 71 | 71 | if ctx != nil { | |
| 72 | 72 | select { | |
| 73 | 73 | case <-ctx.Done(): | |
| @@ -102,11 +102,11 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 102 | 102 | return protocolToolError(fmt.Sprintf("%s: required outcome for %s is indeterminate after host restart; the user must inspect the durable result or external side effect, then explicitly use /subagents resume-session, /subagents restart-task, or /subagents remove", prefix, snapshot.ID)) | |
| 103 | 103 | } | |
| 104 | 104 | required := args.Required || snapshot.Requirement.Unmet() | |
| 105 | - | before := func(agent *subagents.Agent, prompt string) func() { | |
| 106 | - | if t.BeforeResumed == nil { | |
| 107 | - | return nil | |
| 105 | + | var before func(*subagents.Agent, string) func() | |
| 106 | + | if t.BeforeResumed != nil { | |
| 107 | + | before = func(agent *subagents.Agent, prompt string) func() { | |
| 108 | + | return t.BeforeResumed(agent, prompt, required) | |
| 108 | 109 | } | |
| 109 | - | return t.BeforeResumed(agent, prompt, required) | |
| 110 | 110 | } | |
| 111 | 111 | var resumed *subagents.Agent | |
| 112 | 112 | var err error | |
| @@ -122,7 +122,7 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 122 | 122 | t.OnResumed(resumed, args.Prompt, required) | |
| 123 | 123 | } | |
| 124 | 124 | if required { | |
| 125 | - | return waitRequiredSubagent(ctx, prefix, t.Supervisor, resumed, args.Prompt) | |
| 125 | + | return waitRequiredSubagent(ctx, prefix, t.Supervisor, resumed, args.Prompt, progress) | |
| 126 | 126 | } | |
| 127 | 127 | ||
| 128 | 128 | snapshot, ok = findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), snapshot.ID) | |
| @@ -57,7 +57,7 @@ const subagentResumeSchema = `{ | |||||
| 57 | "required": ["agent_id", "prompt"] | 57 | "required": ["agent_id", "prompt"] | ||
| 58 | }` | 58 | }` | ||
| 59 | 59 | ||||
| 60 | - | func (t *SubagentResumeTool) Name() string { return "subagent_resume" } | 60 | + | func (t *SubagentResumeTool) Name() string { return SubagentResumeToolName } |
| 61 | 61 | ||||
| 62 | func (t *SubagentResumeTool) Description() string { | 62 | func (t *SubagentResumeTool) Description() string { | ||
| 63 | return "Continue a sub-agent with a fresh turn budget and retained session. Set required=true for a mandatory follow-up; retries of failed, timed-out, or canceled required work stay required and wait event-driven for the new outcome. Indeterminate work requires explicit user reconciliation." | 63 | return "Continue a sub-agent with a fresh turn budget and retained session. Set required=true for a mandatory follow-up; retries of failed, timed-out, or canceled required work stay required and wait event-driven for the new outcome. Indeterminate work requires explicit user reconciliation." | ||
| @@ -67,7 +67,7 @@ func (t *SubagentResumeTool) Schema() json.RawMessage { | |||||
| 67 | return json.RawMessage(subagentResumeSchema) | 67 | return json.RawMessage(subagentResumeSchema) | ||
| 68 | } | 68 | } | ||
| 69 | 69 | ||||
| 70 | - | func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) { | 70 | + | func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { |
| 71 | if ctx != nil { | 71 | if ctx != nil { | ||
| 72 | select { | 72 | select { | ||
| 73 | case <-ctx.Done(): | 73 | case <-ctx.Done(): | ||
| @@ -102,11 +102,11 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||||
| 102 | return protocolToolError(fmt.Sprintf("%s: required outcome for %s is indeterminate after host restart; the user must inspect the durable result or external side effect, then explicitly use /subagents resume-session, /subagents restart-task, or /subagents remove", prefix, snapshot.ID)) | 102 | return protocolToolError(fmt.Sprintf("%s: required outcome for %s is indeterminate after host restart; the user must inspect the durable result or external side effect, then explicitly use /subagents resume-session, /subagents restart-task, or /subagents remove", prefix, snapshot.ID)) | ||
| 103 | } | 103 | } | ||
| 104 | required := args.Required || snapshot.Requirement.Unmet() | 104 | required := args.Required || snapshot.Requirement.Unmet() | ||
| 105 | - | before := func(agent *subagents.Agent, prompt string) func() { | 105 | + | var before func(*subagents.Agent, string) func() |
| 106 | - | if t.BeforeResumed == nil { | 106 | + | if t.BeforeResumed != nil { |
| 107 | - | return nil | 107 | + | before = func(agent *subagents.Agent, prompt string) func() { |
| 108 | + | return t.BeforeResumed(agent, prompt, required) | |||
| 108 | } | 109 | } | ||
| 109 | - | return t.BeforeResumed(agent, prompt, required) | |||
| 110 | } | 110 | } | ||
| 111 | var resumed *subagents.Agent | 111 | var resumed *subagents.Agent | ||
| 112 | var err error | 112 | var err error | ||
| @@ -122,7 +122,7 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||||
| 122 | t.OnResumed(resumed, args.Prompt, required) | 122 | t.OnResumed(resumed, args.Prompt, required) | ||
| 123 | } | 123 | } | ||
| 124 | if required { | 124 | if required { | ||
| 125 | - | return waitRequiredSubagent(ctx, prefix, t.Supervisor, resumed, args.Prompt) | 125 | + | return waitRequiredSubagent(ctx, prefix, t.Supervisor, resumed, args.Prompt, progress) |
| 126 | } | 126 | } | ||
| 127 | 127 | ||||
| 128 | snapshot, ok = findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), snapshot.ID) | 128 | snapshot, ok = findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), snapshot.ID) | ||
M packages/agent/tools/subagent_spawn.go +13 -3
| @@ -20,6 +20,13 @@ import ( | |||
| 20 | 20 | // Available when the host's launch-time tool policy permits delegation. The | |
| 21 | 21 | // primary-agent prompt decides whether delegation is proactive or only on an | |
| 22 | 22 | // explicit user request. | |
| 23 | + | const ( | |
| 24 | + | SubagentSpawnToolName = "subagent_spawn" | |
| 25 | + | SubagentStatusToolName = "subagent_status" | |
| 26 | + | SubagentStopToolName = "subagent_stop" | |
| 27 | + | SubagentResumeToolName = "subagent_resume" | |
| 28 | + | ) | |
| 29 | + | ||
| 23 | 30 | type SubagentSpawnTool struct { | |
| 24 | 31 | // Supervisor is the supervisor used to spawn agents. Nil means subagents | |
| 25 | 32 | // are unavailable in this mode and the tool always errors. | |
| @@ -117,7 +124,7 @@ const subagentSpawnSchemaTemplate = `{ | |||
| 117 | 124 | "required": ["task"] | |
| 118 | 125 | }` | |
| 119 | 126 | ||
| 120 | - | func (t *SubagentSpawnTool) Name() string { return "subagent_spawn" } | |
| 127 | + | func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName } | |
| 121 | 128 | func (t *SubagentSpawnTool) Description() string { | |
| 122 | 129 | return "Delegate work to a sub-agent. Set required=true when its outcome is mandatory before the parent can finish; the tool then waits event-driven and returns that outcome in the current turn. Optional work runs in the background and returns an id immediately. Optional completion is host-event-driven: wait only for the injected [auto-subagents update]; never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. Work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are allowed." | |
| 123 | 130 | } | |
| @@ -266,7 +273,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr | |||
| 266 | 273 | t.OnSpawned(agent, task, a.Required) | |
| 267 | 274 | } | |
| 268 | 275 | if a.Required { | |
| 269 | - | return waitRequiredSubagent(ctx, t.Name(), t.Supervisor, agent, task) | |
| 276 | + | return waitRequiredSubagent(ctx, t.Name(), t.Supervisor, agent, task, progress) | |
| 270 | 277 | } | |
| 271 | 278 | ||
| 272 | 279 | var sb strings.Builder | |
| @@ -317,7 +324,10 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr | |||
| 317 | 324 | }, nil | |
| 318 | 325 | } | |
| 319 | 326 | ||
| 320 | - | func waitRequiredSubagent(ctx context.Context, toolName string, supervisor *subagents.Supervisor, agent *subagents.Agent, task string) (core.ToolResult, error) { | |
| 327 | + | func waitRequiredSubagent(ctx context.Context, toolName string, supervisor *subagents.Supervisor, agent *subagents.Agent, task string, progress func(string)) (core.ToolResult, error) { | |
| 328 | + | if progress != nil { | |
| 329 | + | progress(fmt.Sprintf("waiting for required sub-agent %s", agent.ID)) | |
| 330 | + | } | |
| 321 | 331 | requirement, err := supervisor.WaitRequired(ctx, agent.ID) | |
| 322 | 332 | if err != nil { | |
| 323 | 333 | return core.ToolResult{}, fmt.Errorf("%s: wait for required agent %s: %w", toolName, agent.ID, err) | |
| @@ -20,6 +20,13 @@ import ( | |||||
| 20 | // Available when the host's launch-time tool policy permits delegation. The | 20 | // Available when the host's launch-time tool policy permits delegation. The | ||
| 21 | // primary-agent prompt decides whether delegation is proactive or only on an | 21 | // primary-agent prompt decides whether delegation is proactive or only on an | ||
| 22 | // explicit user request. | 22 | // explicit user request. | ||
| 23 | + | const ( | |||
| 24 | + | SubagentSpawnToolName = "subagent_spawn" | |||
| 25 | + | SubagentStatusToolName = "subagent_status" | |||
| 26 | + | SubagentStopToolName = "subagent_stop" | |||
| 27 | + | SubagentResumeToolName = "subagent_resume" | |||
| 28 | + | ) | |||
| 29 | + | ||||
| 23 | type SubagentSpawnTool struct { | 30 | type SubagentSpawnTool struct { | ||
| 24 | // Supervisor is the supervisor used to spawn agents. Nil means subagents | 31 | // Supervisor is the supervisor used to spawn agents. Nil means subagents | ||
| 25 | // are unavailable in this mode and the tool always errors. | 32 | // are unavailable in this mode and the tool always errors. | ||
| @@ -117,7 +124,7 @@ const subagentSpawnSchemaTemplate = `{ | |||||
| 117 | "required": ["task"] | 124 | "required": ["task"] | ||
| 118 | }` | 125 | }` | ||
| 119 | 126 | ||||
| 120 | - | func (t *SubagentSpawnTool) Name() string { return "subagent_spawn" } | 127 | + | func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName } |
| 121 | func (t *SubagentSpawnTool) Description() string { | 128 | func (t *SubagentSpawnTool) Description() string { | ||
| 122 | return "Delegate work to a sub-agent. Set required=true when its outcome is mandatory before the parent can finish; the tool then waits event-driven and returns that outcome in the current turn. Optional work runs in the background and returns an id immediately. Optional completion is host-event-driven: wait only for the injected [auto-subagents update]; never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. Work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are allowed." | 129 | return "Delegate work to a sub-agent. Set required=true when its outcome is mandatory before the parent can finish; the tool then waits event-driven and returns that outcome in the current turn. Optional work runs in the background and returns an id immediately. Optional completion is host-event-driven: wait only for the injected [auto-subagents update]; never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. Work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are allowed." | ||
| 123 | } | 130 | } | ||
| @@ -266,7 +273,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr | |||||
| 266 | t.OnSpawned(agent, task, a.Required) | 273 | t.OnSpawned(agent, task, a.Required) | ||
| 267 | } | 274 | } | ||
| 268 | if a.Required { | 275 | if a.Required { | ||
| 269 | - | return waitRequiredSubagent(ctx, t.Name(), t.Supervisor, agent, task) | 276 | + | return waitRequiredSubagent(ctx, t.Name(), t.Supervisor, agent, task, progress) |
| 270 | } | 277 | } | ||
| 271 | 278 | ||||
| 272 | var sb strings.Builder | 279 | var sb strings.Builder | ||
| @@ -317,7 +324,10 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr | |||||
| 317 | }, nil | 324 | }, nil | ||
| 318 | } | 325 | } | ||
| 319 | 326 | ||||
| 320 | - | func waitRequiredSubagent(ctx context.Context, toolName string, supervisor *subagents.Supervisor, agent *subagents.Agent, task string) (core.ToolResult, error) { | 327 | + | func waitRequiredSubagent(ctx context.Context, toolName string, supervisor *subagents.Supervisor, agent *subagents.Agent, task string, progress func(string)) (core.ToolResult, error) { |
| 328 | + | if progress != nil { | |||
| 329 | + | progress(fmt.Sprintf("waiting for required sub-agent %s", agent.ID)) | |||
| 330 | + | } | |||
| 321 | requirement, err := supervisor.WaitRequired(ctx, agent.ID) | 331 | requirement, err := supervisor.WaitRequired(ctx, agent.ID) | ||
| 322 | if err != nil { | 332 | if err != nil { | ||
| 323 | return core.ToolResult{}, fmt.Errorf("%s: wait for required agent %s: %w", toolName, agent.ID, err) | 333 | return core.ToolResult{}, fmt.Errorf("%s: wait for required agent %s: %w", toolName, agent.ID, err) | ||
M packages/agent/tools/subagent_status.go +1 -1
| @@ -71,7 +71,7 @@ const subagentStatusSchema = `{ | |||
| 71 | 71 | } | |
| 72 | 72 | }` | |
| 73 | 73 | ||
| 74 | - | func (t *SubagentStatusTool) Name() string { return "subagent_status" } | |
| 74 | + | func (t *SubagentStatusTool) Name() string { return SubagentStatusToolName } | |
| 75 | 75 | ||
| 76 | 76 | func (t *SubagentStatusTool) Description() string { | |
| 77 | 77 | return "Query live status for one background sub-agent or list all visible workers without waiting for completion." | |
| @@ -71,7 +71,7 @@ const subagentStatusSchema = `{ | |||||
| 71 | } | 71 | } | ||
| 72 | }` | 72 | }` | ||
| 73 | 73 | ||||
| 74 | - | func (t *SubagentStatusTool) Name() string { return "subagent_status" } | 74 | + | func (t *SubagentStatusTool) Name() string { return SubagentStatusToolName } |
| 75 | 75 | ||||
| 76 | func (t *SubagentStatusTool) Description() string { | 76 | func (t *SubagentStatusTool) Description() string { | ||
| 77 | return "Query live status for one background sub-agent or list all visible workers without waiting for completion." | 77 | return "Query live status for one background sub-agent or list all visible workers without waiting for completion." | ||
M packages/agent/tools/subagent_stop.go +1 -1
| @@ -44,7 +44,7 @@ const subagentStopSchema = `{ | |||
| 44 | 44 | "required": ["agent_id"] | |
| 45 | 45 | }` | |
| 46 | 46 | ||
| 47 | - | func (t *SubagentStopTool) Name() string { return "subagent_stop" } | |
| 47 | + | func (t *SubagentStopTool) Name() string { return SubagentStopToolName } | |
| 48 | 48 | ||
| 49 | 49 | func (t *SubagentStopTool) Description() string { | |
| 50 | 50 | return "Request termination of a stuck background sub-agent. It gracefully requests shutdown, then cancels or force-stops the worker when necessary." | |
| @@ -44,7 +44,7 @@ const subagentStopSchema = `{ | |||||
| 44 | "required": ["agent_id"] | 44 | "required": ["agent_id"] | ||
| 45 | }` | 45 | }` | ||
| 46 | 46 | ||||
| 47 | - | func (t *SubagentStopTool) Name() string { return "subagent_stop" } | 47 | + | func (t *SubagentStopTool) Name() string { return SubagentStopToolName } |
| 48 | 48 | ||||
| 49 | func (t *SubagentStopTool) Description() string { | 49 | func (t *SubagentStopTool) Description() string { | ||
| 50 | return "Request termination of a stuck background sub-agent. It gracefully requests shutdown, then cancels or force-stops the worker when necessary." | 50 | return "Request termination of a stuck background sub-agent. It gracefully requests shutdown, then cancels or force-stops the worker when necessary." | ||