Merge pull request #101 from bnema/fix/nonblocking-required-subagents
fix(subagents): keep required work asynchronous
14 files · +153 -279
M README.md +2 -2
| @@ -410,7 +410,7 @@ Background subagents that run alongside your main session. Each one is a separat | |||
| 410 | 410 | ||
| 411 | 411 | **`/session export` does NOT bundle subagents.** A `.zutsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually. | |
| 412 | 412 | ||
| 413 | - | **Subagent Orchestrator.** With `/settings` → **Subagent Orchestrator** on (or `/orchestrator`), the interactive primary agent enters strict orchestrator mode and gets whichever of the canonical `subagent_spawn` and `subagent_status` tools are permitted by launch-time policy. It must delegate all implementation, debugging/testing, and code-review work to an appropriately named profile, or to a clearly described general worker when no profile fits. It must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches itself. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize results. Give workers self-contained tasks: shared-worktree workers edit the same working directory and need coordination, while `isolation:"worktree"` is for parallel coding in separate trees; a worktree patch must be integrated by a worker rather than applied by the primary agent. Child workers cannot recursively spawn more sub-agents in v1. The `subagent_status` tool accepts an optional `agent_id`: omit it to list visible workers, or pass a full id / unique prefix to query one without waiting for completion. It returns bounded JSON metadata with the worker id, lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), underlying process/turn state, timestamps, a bounded first-line task summary, required state/target/unmet metadata when applicable, and terminal-result metadata when available; it omits full prompts, transcripts, result output, provider settings, and filesystem paths. Treat the bounded task summary as session data. The spawn tool returns the child id and initial state immediately for optional work. Set `required:true` when the parent or workflow depends on the result: the tool waits event-driven in the current turn, persists the required outcome, blocks other finalization tools, and fails closed on failure, timeout, or cancellation until a retry succeeds or the user explicitly removes the terminal worker. An outcome not durably observed before host restart becomes indeterminate and requires the user to inspect side effects before explicitly resuming, restarting, or removing the worker. Completion is a durable `turn.result` with a `subagent://<id>/result` reference. The tool accepts named profiles, timeout, `max_turns`, and shared/worktree isolation. Omit `max_turns` to use the supervisor policy; the default ceiling is 3 and `subagents.max_turns` changes it. Global concurrency limits apply equally to slash commands, tool calls, and batches. Optional completion is host-event-driven: wait for the host-injected `[auto-subagents update]`, the only optional completion signal. Required completion returns through the manager tool or a high-priority `[required-subagents update]` after cancellation or restart. 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 remain allowed. Off by default; toggle with `/orchestrator` or from `/settings`. When off, the permitted canonical subagent tools and profile manifest remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise performs work itself without the strict orchestrator contract. | |
| 413 | + | **Subagent Orchestrator.** With `/settings` → **Subagent Orchestrator** on (or `/orchestrator`), the interactive primary agent enters strict orchestrator mode and gets whichever of the canonical `subagent_spawn` and `subagent_status` tools are permitted by launch-time policy. It must delegate all implementation, debugging/testing, and code-review work to an appropriately named profile, or to a clearly described general worker when no profile fits. It must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches itself. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize results. Give workers self-contained tasks: shared-worktree workers edit the same working directory and need coordination, while `isolation:"worktree"` is for parallel coding in separate trees; a worktree patch must be integrated by a worker rather than applied by the primary agent. Child workers cannot recursively spawn more sub-agents in v1. The `subagent_status` tool accepts an optional `agent_id`: omit it to list visible workers, or pass a full id / unique prefix to query one without waiting for completion. It returns bounded JSON metadata with the worker id, lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), underlying process/turn state, timestamps, a bounded first-line task summary, required state/target/unmet metadata when applicable, and terminal-result metadata when available; it omits full prompts, transcripts, result output, provider settings, and filesystem paths. Treat the bounded task summary as session data. The spawn tool always returns the child id and initial state immediately. Set `required:true` when the parent or workflow depends on the result: the worker remains asynchronous, its outcome is persisted and delivered through a host completion update, and failure, timeout, or cancellation remains recoverable until a retry succeeds or the user explicitly removes the terminal worker. An outcome not durably observed before host restart becomes indeterminate and requires the user to inspect side effects before explicitly resuming, restarting, or removing the worker. Completion is a durable `turn.result` with a `subagent://<id>/result` reference. The tool accepts named profiles, timeout, `max_turns`, and shared/worktree isolation. Omit `max_turns` to use the supervisor policy; the default ceiling is 3 and `subagents.max_turns` changes it. Global concurrency limits apply equally to slash commands, tool calls, and batches. Completion is host-event-driven for every worker through `[auto-subagents update]`, with required state also supplied as high-priority `[required-subagents update]` context. 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 remain allowed. Off by default; toggle with `/orchestrator` or from `/settings`. When off, the permitted canonical subagent tools and profile manifest remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise performs work itself without the strict orchestrator contract. | |
| 414 | 414 | ||
| 415 | 415 | ### Named subagent profiles | |
| 416 | 416 | ||
| @@ -424,7 +424,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||
| 424 | 424 | - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol. | |
| 425 | 425 | - **terminal alerts** — emit a terminal bell when the main session stops with work that may need attention, or when an extension raises a structured alert. Enabled by default; changes apply immediately and persist as `terminal_alerts_enabled`. Terminal emulators may render the bell audibly, visually, or not at all. | |
| 426 | 426 | - **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zut: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`. | |
| 427 | - | - **Subagent Orchestrator** — enable strict primary-agent orchestrator mode: delegate implementation, debugging/testing, and code-review work to named or general workers; the primary agent coordinates but does not make direct implementation changes. It can spawn optional background or blocking required sub-agents via `subagent_spawn` and query live state via `subagent_status` when those tools are permitted. If delegation is unavailable because of launch-time restrictions, the contract remains active, profile metadata is withheld, and the primary reports the limitation rather than implementing directly. Off by default. When off, the permitted canonical subagent tools and profile manifest remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise works directly without the strict orchestrator contract. The tools accept named profiles, reasoning, per-spawn fast-mode overrides, timeout, turn limits, and shared/worktree isolation where applicable; lifecycle and result references are persisted. Optional completion is host-event-driven through `[auto-subagents update]`; required work waits in the manager call, persists its outcome, and prevents finalization until satisfied or explicitly waived by user removal. Do not poll or inspect dashboards, metadata, event logs, or files solely to wait. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details. | |
| 427 | + | - **Subagent Orchestrator** — enable strict primary-agent orchestrator mode: delegate implementation, debugging/testing, and code-review work to named or general workers; the primary agent coordinates but does not make direct implementation changes. It can spawn optional or required background sub-agents via `subagent_spawn` and query live state via `subagent_status` when those tools are permitted. If delegation is unavailable because of launch-time restrictions, the contract remains active, profile metadata is withheld, and the primary reports the limitation rather than implementing directly. Off by default. When off, the permitted canonical subagent tools and profile manifest remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise works directly without the strict orchestrator contract. The tools accept named profiles, reasoning, per-spawn fast-mode overrides, timeout, turn limits, and shared/worktree isolation where applicable; lifecycle and result references are persisted. Completion is host-event-driven through `[auto-subagents update]` for every worker; required work stays asynchronous, persists its outcome, and prevents only the terminal parent response until satisfied or explicitly waived by user removal. Do not poll or inspect dashboards, metadata, event logs, or files solely to wait. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details. | |
| 428 | 428 | - **Ponytail coding mode** — include compact engineering guidance in each resolved system prompt; the guidance tells the model to apply it to coding, debugging, and review work rather than ordinary conversation. It favors understanding the real flow, small validated changes, reuse, and preserving safety checks. Enabled by default; changes apply to the next model call and persist as `ponytail_enabled` in `$ZUT_HOME/config.json`. It is included by interactive, print, stream, JSON, RPC, subagent, bot, SDK, and Zutfile agents. | |
| 429 | 429 | - **fast mode** — request the provider's fast tier where supported. It currently uses OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; toggle it with `/fast` or `/settings`; changes apply on the next model call and persist as `fast_mode`. Other providers currently return an unsupported-provider error. Fast mode may cost more and depends on the selected model/account. | |
| 430 | 430 | - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`. | |
| @@ -410,7 +410,7 @@ Background subagents that run alongside your main session. Each one is a separat | |||||
| 410 | 410 | ||||
| 411 | **`/session export` does NOT bundle subagents.** A `.zutsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually. | 411 | **`/session export` does NOT bundle subagents.** A `.zutsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually. | ||
| 412 | 412 | ||||
| 413 | - | **Subagent Orchestrator.** With `/settings` → **Subagent Orchestrator** on (or `/orchestrator`), the interactive primary agent enters strict orchestrator mode and gets whichever of the canonical `subagent_spawn` and `subagent_status` tools are permitted by launch-time policy. It must delegate all implementation, debugging/testing, and code-review work to an appropriately named profile, or to a clearly described general worker when no profile fits. It must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches itself. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize results. Give workers self-contained tasks: shared-worktree workers edit the same working directory and need coordination, while `isolation:"worktree"` is for parallel coding in separate trees; a worktree patch must be integrated by a worker rather than applied by the primary agent. Child workers cannot recursively spawn more sub-agents in v1. The `subagent_status` tool accepts an optional `agent_id`: omit it to list visible workers, or pass a full id / unique prefix to query one without waiting for completion. It returns bounded JSON metadata with the worker id, lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), underlying process/turn state, timestamps, a bounded first-line task summary, required state/target/unmet metadata when applicable, and terminal-result metadata when available; it omits full prompts, transcripts, result output, provider settings, and filesystem paths. Treat the bounded task summary as session data. The spawn tool returns the child id and initial state immediately for optional work. Set `required:true` when the parent or workflow depends on the result: the tool waits event-driven in the current turn, persists the required outcome, blocks other finalization tools, and fails closed on failure, timeout, or cancellation until a retry succeeds or the user explicitly removes the terminal worker. An outcome not durably observed before host restart becomes indeterminate and requires the user to inspect side effects before explicitly resuming, restarting, or removing the worker. Completion is a durable `turn.result` with a `subagent://<id>/result` reference. The tool accepts named profiles, timeout, `max_turns`, and shared/worktree isolation. Omit `max_turns` to use the supervisor policy; the default ceiling is 3 and `subagents.max_turns` changes it. Global concurrency limits apply equally to slash commands, tool calls, and batches. Optional completion is host-event-driven: wait for the host-injected `[auto-subagents update]`, the only optional completion signal. Required completion returns through the manager tool or a high-priority `[required-subagents update]` after cancellation or restart. 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 remain allowed. Off by default; toggle with `/orchestrator` or from `/settings`. When off, the permitted canonical subagent tools and profile manifest remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise performs work itself without the strict orchestrator contract. | 413 | + | **Subagent Orchestrator.** With `/settings` → **Subagent Orchestrator** on (or `/orchestrator`), the interactive primary agent enters strict orchestrator mode and gets whichever of the canonical `subagent_spawn` and `subagent_status` tools are permitted by launch-time policy. It must delegate all implementation, debugging/testing, and code-review work to an appropriately named profile, or to a clearly described general worker when no profile fits. It must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches itself. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize results. Give workers self-contained tasks: shared-worktree workers edit the same working directory and need coordination, while `isolation:"worktree"` is for parallel coding in separate trees; a worktree patch must be integrated by a worker rather than applied by the primary agent. Child workers cannot recursively spawn more sub-agents in v1. The `subagent_status` tool accepts an optional `agent_id`: omit it to list visible workers, or pass a full id / unique prefix to query one without waiting for completion. It returns bounded JSON metadata with the worker id, lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), underlying process/turn state, timestamps, a bounded first-line task summary, required state/target/unmet metadata when applicable, and terminal-result metadata when available; it omits full prompts, transcripts, result output, provider settings, and filesystem paths. Treat the bounded task summary as session data. The spawn tool always returns the child id and initial state immediately. Set `required:true` when the parent or workflow depends on the result: the worker remains asynchronous, its outcome is persisted and delivered through a host completion update, and failure, timeout, or cancellation remains recoverable until a retry succeeds or the user explicitly removes the terminal worker. An outcome not durably observed before host restart becomes indeterminate and requires the user to inspect side effects before explicitly resuming, restarting, or removing the worker. Completion is a durable `turn.result` with a `subagent://<id>/result` reference. The tool accepts named profiles, timeout, `max_turns`, and shared/worktree isolation. Omit `max_turns` to use the supervisor policy; the default ceiling is 3 and `subagents.max_turns` changes it. Global concurrency limits apply equally to slash commands, tool calls, and batches. Completion is host-event-driven for every worker through `[auto-subagents update]`, with required state also supplied as high-priority `[required-subagents update]` context. 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 remain allowed. Off by default; toggle with `/orchestrator` or from `/settings`. When off, the permitted canonical subagent tools and profile manifest remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise performs work itself without the strict orchestrator contract. |
| 414 | 414 | ||||
| 415 | ### Named subagent profiles | 415 | ### Named subagent profiles | ||
| 416 | 416 | ||||
| @@ -424,7 +424,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||||
| 424 | - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol. | 424 | - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol. | ||
| 425 | - **terminal alerts** — emit a terminal bell when the main session stops with work that may need attention, or when an extension raises a structured alert. Enabled by default; changes apply immediately and persist as `terminal_alerts_enabled`. Terminal emulators may render the bell audibly, visually, or not at all. | 425 | - **terminal alerts** — emit a terminal bell when the main session stops with work that may need attention, or when an extension raises a structured alert. Enabled by default; changes apply immediately and persist as `terminal_alerts_enabled`. Terminal emulators may render the bell audibly, visually, or not at all. | ||
| 426 | - **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zut: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`. | 426 | - **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zut: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`. | ||
| 427 | - | - **Subagent Orchestrator** — enable strict primary-agent orchestrator mode: delegate implementation, debugging/testing, and code-review work to named or general workers; the primary agent coordinates but does not make direct implementation changes. It can spawn optional background or blocking required sub-agents via `subagent_spawn` and query live state via `subagent_status` when those tools are permitted. If delegation is unavailable because of launch-time restrictions, the contract remains active, profile metadata is withheld, and the primary reports the limitation rather than implementing directly. Off by default. When off, the permitted canonical subagent tools and profile manifest remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise works directly without the strict orchestrator contract. The tools accept named profiles, reasoning, per-spawn fast-mode overrides, timeout, turn limits, and shared/worktree isolation where applicable; lifecycle and result references are persisted. Optional completion is host-event-driven through `[auto-subagents update]`; required work waits in the manager call, persists its outcome, and prevents finalization until satisfied or explicitly waived by user removal. Do not poll or inspect dashboards, metadata, event logs, or files solely to wait. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details. | 427 | + | - **Subagent Orchestrator** — enable strict primary-agent orchestrator mode: delegate implementation, debugging/testing, and code-review work to named or general workers; the primary agent coordinates but does not make direct implementation changes. It can spawn optional or required background sub-agents via `subagent_spawn` and query live state via `subagent_status` when those tools are permitted. If delegation is unavailable because of launch-time restrictions, the contract remains active, profile metadata is withheld, and the primary reports the limitation rather than implementing directly. Off by default. When off, the permitted canonical subagent tools and profile manifest remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise works directly without the strict orchestrator contract. The tools accept named profiles, reasoning, per-spawn fast-mode overrides, timeout, turn limits, and shared/worktree isolation where applicable; lifecycle and result references are persisted. Completion is host-event-driven through `[auto-subagents update]` for every worker; required work stays asynchronous, persists its outcome, and prevents only the terminal parent response until satisfied or explicitly waived by user removal. Do not poll or inspect dashboards, metadata, event logs, or files solely to wait. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details. |
| 428 | - **Ponytail coding mode** — include compact engineering guidance in each resolved system prompt; the guidance tells the model to apply it to coding, debugging, and review work rather than ordinary conversation. It favors understanding the real flow, small validated changes, reuse, and preserving safety checks. Enabled by default; changes apply to the next model call and persist as `ponytail_enabled` in `$ZUT_HOME/config.json`. It is included by interactive, print, stream, JSON, RPC, subagent, bot, SDK, and Zutfile agents. | 428 | - **Ponytail coding mode** — include compact engineering guidance in each resolved system prompt; the guidance tells the model to apply it to coding, debugging, and review work rather than ordinary conversation. It favors understanding the real flow, small validated changes, reuse, and preserving safety checks. Enabled by default; changes apply to the next model call and persist as `ponytail_enabled` in `$ZUT_HOME/config.json`. It is included by interactive, print, stream, JSON, RPC, subagent, bot, SDK, and Zutfile agents. | ||
| 429 | - **fast mode** — request the provider's fast tier where supported. It currently uses OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; toggle it with `/fast` or `/settings`; changes apply on the next model call and persist as `fast_mode`. Other providers currently return an unsupported-provider error. Fast mode may cost more and depends on the selected model/account. | 429 | - **fast mode** — request the provider's fast tier where supported. It currently uses OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; toggle it with `/fast` or `/settings`; changes apply on the next model call and persist as `fast_mode`. Other providers currently return an unsupported-provider error. Fast mode may cost more and depends on the selected model/account. | ||
| 430 | - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`. | 430 | - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`. | ||
M docs/subagents.md +2 -2
| @@ -141,7 +141,7 @@ During an active delegated turn, interactive mode shows compact rows beside the | |||
| 141 | 141 | ||
| 142 | 142 | The worker and supervisor communicate over newline-delimited JSON. Current messages use version `1` envelopes with `version`, `message_id`, `agent_id`, `turn_id`, `timestamp`, and `payload`. Unknown event names and payload fields are retained. Only versioned JSONL envelopes are accepted on the worker protocol. | |
| 143 | 143 | ||
| 144 | - | A completed worker emits a `turn.result` event and writes `result.json`. Intentional cancellation records `shutdown_origin` as `targeted`, `session`, `deadline`, or `process`; manager updates describe that cancellation instead of reporting the worker as generically failed with `context canceled`. Optional work is delivered through `[auto-subagents update]`. Required work is delivered directly as the blocking manager tool result, or through a high-priority `[required-subagents update]` after cancellation or restart. These host-delivered outcomes, rather than polling or inspecting state, are the signal for the primary to continue. Inline output is bounded; after delivery, inspect the full session through the stable references: | |
| 144 | + | A completed worker emits a `turn.result` event and writes `result.json`. Intentional cancellation records `shutdown_origin` as `targeted`, `session`, `deadline`, or `process`; manager updates describe that cancellation instead of reporting the worker as generically failed with `context canceled`. Every manager call returns immediately, and every worker outcome is delivered asynchronously through `[auto-subagents update]`; required outcome state is also injected as high-priority `[required-subagents update]` context. The primary remains available for coordination and independent work while children run. These host-delivered outcomes, rather than polling or inspecting state, are the signal for the primary to continue. Inline output is bounded; after delivery, inspect the full session through the stable references: | |
| 145 | 145 | ||
| 146 | 146 | If a provider rejects a child request because its payload or context window is too large, the worker compacts its persisted transcript and continues the same request once. If compaction or that retry cannot fit, the result uses error code `context_limit` and directs the caller to narrow the task or reduce gathered context; it does not include the provider's raw request error. | |
| 147 | 147 | ||
| @@ -160,7 +160,7 @@ Use `/subagents resume-session <id>` to continue the existing session without re | |||
| 160 | 160 | ||
| 161 | 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. 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. | |
| 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, returns immediately, reports its new terminal outcome asynchronously, 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 and required completion use the normal auto-subagent update; required state is additionally supplied in the required-update context before the parent can finish. 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 | ||
| @@ -141,7 +141,7 @@ During an active delegated turn, interactive mode shows compact rows beside the | |||||
| 141 | 141 | ||||
| 142 | The worker and supervisor communicate over newline-delimited JSON. Current messages use version `1` envelopes with `version`, `message_id`, `agent_id`, `turn_id`, `timestamp`, and `payload`. Unknown event names and payload fields are retained. Only versioned JSONL envelopes are accepted on the worker protocol. | 142 | The worker and supervisor communicate over newline-delimited JSON. Current messages use version `1` envelopes with `version`, `message_id`, `agent_id`, `turn_id`, `timestamp`, and `payload`. Unknown event names and payload fields are retained. Only versioned JSONL envelopes are accepted on the worker protocol. | ||
| 143 | 143 | ||||
| 144 | - | A completed worker emits a `turn.result` event and writes `result.json`. Intentional cancellation records `shutdown_origin` as `targeted`, `session`, `deadline`, or `process`; manager updates describe that cancellation instead of reporting the worker as generically failed with `context canceled`. Optional work is delivered through `[auto-subagents update]`. Required work is delivered directly as the blocking manager tool result, or through a high-priority `[required-subagents update]` after cancellation or restart. These host-delivered outcomes, rather than polling or inspecting state, are the signal for the primary to continue. Inline output is bounded; after delivery, inspect the full session through the stable references: | 144 | + | A completed worker emits a `turn.result` event and writes `result.json`. Intentional cancellation records `shutdown_origin` as `targeted`, `session`, `deadline`, or `process`; manager updates describe that cancellation instead of reporting the worker as generically failed with `context canceled`. Every manager call returns immediately, and every worker outcome is delivered asynchronously through `[auto-subagents update]`; required outcome state is also injected as high-priority `[required-subagents update]` context. The primary remains available for coordination and independent work while children run. These host-delivered outcomes, rather than polling or inspecting state, are the signal for the primary to continue. Inline output is bounded; after delivery, inspect the full session through the stable references: |
| 145 | 145 | ||||
| 146 | If a provider rejects a child request because its payload or context window is too large, the worker compacts its persisted transcript and continues the same request once. If compaction or that retry cannot fit, the result uses error code `context_limit` and directs the caller to narrow the task or reduce gathered context; it does not include the provider's raw request error. | 146 | If a provider rejects a child request because its payload or context window is too large, the worker compacts its persisted transcript and continues the same request once. If compaction or that retry cannot fit, the result uses error code `context_limit` and directs the caller to narrow the task or reduce gathered context; it does not include the provider's raw request error. | ||
| 147 | 147 | ||||
| @@ -160,7 +160,7 @@ Use `/subagents resume-session <id>` to continue the existing session without re | |||||
| 160 | 160 | ||||
| 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. | 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. 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. | 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, returns immediately, reports its new terminal outcome asynchronously, 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 and required completion use the normal auto-subagent update; required state is additionally supplied in the required-update context before the parent can finish. 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 +26 -0
| @@ -125,6 +125,32 @@ func TestTrackSubagentWorkerReportsStartupFailure(t *testing.T) { | |||
| 125 | 125 | } | |
| 126 | 126 | } | |
| 127 | 127 | ||
| 128 | + | func TestTrackRequiredSubagentWorkerReportsFailureAsynchronously(t *testing.T) { | |
| 129 | + | mgr := subagents.New(subagents.Config{ | |
| 130 | + | Root: t.TempDir(), | |
| 131 | + | RepoRoot: t.TempDir(), | |
| 132 | + | NewRunner: func(*subagents.Agent) subagents.Runner { | |
| 133 | + | return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { | |
| 134 | + | return errors.New("required reviewer failed") | |
| 135 | + | }) | |
| 136 | + | }, | |
| 137 | + | }) | |
| 138 | + | a, err := mgr.SpawnReq(context.Background(), subagents.SpawnRequest{Task: "review the release", Required: true}) | |
| 139 | + | if err != nil { | |
| 140 | + | t.Fatal(err) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | iv := newQueuedAutoSubagentsInteractive() | |
| 144 | + | iv.TrackSubagentWorker(a, a.Task, true) | |
| 145 | + | update := waitForQueuedPrompt(t, iv) | |
| 146 | + | if !strings.Contains(update, "status: failed") || !strings.Contains(update, "required reviewer failed") { | |
| 147 | + | t.Fatalf("required worker failure was not delivered: %q", update) | |
| 148 | + | } | |
| 149 | + | if !a.Snapshot().Requirement.Unmet() { | |
| 150 | + | t.Fatal("delivered failure did not remain recoverable") | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 128 | 154 | func TestTrackStoppedSubagentWorkerDeliversTerminalUpdate(t *testing.T) { | |
| 129 | 155 | started := make(chan struct{}) | |
| 130 | 156 | mgr := subagents.New(subagents.Config{ | |
| @@ -125,6 +125,32 @@ func TestTrackSubagentWorkerReportsStartupFailure(t *testing.T) { | |||||
| 125 | } | 125 | } | ||
| 126 | } | 126 | } | ||
| 127 | 127 | ||||
| 128 | + | func TestTrackRequiredSubagentWorkerReportsFailureAsynchronously(t *testing.T) { | |||
| 129 | + | mgr := subagents.New(subagents.Config{ | |||
| 130 | + | Root: t.TempDir(), | |||
| 131 | + | RepoRoot: t.TempDir(), | |||
| 132 | + | NewRunner: func(*subagents.Agent) subagents.Runner { | |||
| 133 | + | return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { | |||
| 134 | + | return errors.New("required reviewer failed") | |||
| 135 | + | }) | |||
| 136 | + | }, | |||
| 137 | + | }) | |||
| 138 | + | a, err := mgr.SpawnReq(context.Background(), subagents.SpawnRequest{Task: "review the release", Required: true}) | |||
| 139 | + | if err != nil { | |||
| 140 | + | t.Fatal(err) | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | iv := newQueuedAutoSubagentsInteractive() | |||
| 144 | + | iv.TrackSubagentWorker(a, a.Task, true) | |||
| 145 | + | update := waitForQueuedPrompt(t, iv) | |||
| 146 | + | if !strings.Contains(update, "status: failed") || !strings.Contains(update, "required reviewer failed") { | |||
| 147 | + | t.Fatalf("required worker failure was not delivered: %q", update) | |||
| 148 | + | } | |||
| 149 | + | if !a.Snapshot().Requirement.Unmet() { | |||
| 150 | + | t.Fatal("delivered failure did not remain recoverable") | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | ||||
| 128 | func TestTrackStoppedSubagentWorkerDeliversTerminalUpdate(t *testing.T) { | 154 | func TestTrackStoppedSubagentWorkerDeliversTerminalUpdate(t *testing.T) { | ||
| 129 | started := make(chan struct{}) | 155 | started := make(chan struct{}) | ||
| 130 | mgr := subagents.New(subagents.Config{ | 156 | mgr := subagents.New(subagents.Config{ | ||
M packages/agent/modes/interactive.go +3 -21
| @@ -8743,26 +8743,14 @@ 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 required { | |
| 8748 | - | if i != nil { | |
| 8749 | - | i.invalidate() | |
| 8750 | - | } | |
| 8751 | - | return | |
| 8752 | - | } | |
| 8746 | + | func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string, _ bool) { | |
| 8753 | 8747 | i.trackSubagentWorker(a, task, false, false) | |
| 8754 | 8748 | } | |
| 8755 | 8749 | ||
| 8756 | 8750 | // TrackResumedSubagentWorker watches a resumed follow-up independently of the | |
| 8757 | 8751 | // worker's original task. A long-lived daemon can complete several manager | |
| 8758 | 8752 | // turns, each of which must produce its own automatic delivery. | |
| 8759 | - | func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt string, required bool) { | |
| 8760 | - | if required { | |
| 8761 | - | if i != nil { | |
| 8762 | - | i.invalidate() | |
| 8763 | - | } | |
| 8764 | - | return | |
| 8765 | - | } | |
| 8753 | + | func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt string, _ bool) { | |
| 8766 | 8754 | i.trackSubagentWorker(a, prompt, true, false) | |
| 8767 | 8755 | } | |
| 8768 | 8756 | ||
| @@ -8770,13 +8758,7 @@ func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt stri | |||
| 8770 | 8758 | // sent. The returned cleanup removes the registration if the resume operation | |
| 8771 | 8759 | // fails before delivery. It is used by the resume tool's pre-send hook; the | |
| 8772 | 8760 | // existing TrackResumedSubagentWorker signature remains the post-success API. | |
| 8773 | - | func (i *Interactive) PrepareResumedSubagentWorker(a *subagents.Agent, prompt string, required bool) func() { | |
| 8774 | - | if required { | |
| 8775 | - | if i != nil { | |
| 8776 | - | i.invalidate() | |
| 8777 | - | } | |
| 8778 | - | return nil | |
| 8779 | - | } | |
| 8761 | + | func (i *Interactive) PrepareResumedSubagentWorker(a *subagents.Agent, prompt string, _ bool) func() { | |
| 8780 | 8762 | return i.trackSubagentWorker(a, prompt, true, true) | |
| 8781 | 8763 | } | |
| 8782 | 8764 | ||
| @@ -8743,26 +8743,14 @@ 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, _ bool) { |
| 8747 | - | if required { | |||
| 8748 | - | if i != nil { | |||
| 8749 | - | i.invalidate() | |||
| 8750 | - | } | |||
| 8751 | - | return | |||
| 8752 | - | } | |||
| 8753 | i.trackSubagentWorker(a, task, false, false) | 8747 | i.trackSubagentWorker(a, task, false, false) | ||
| 8754 | } | 8748 | } | ||
| 8755 | 8749 | ||||
| 8756 | // TrackResumedSubagentWorker watches a resumed follow-up independently of the | 8750 | // TrackResumedSubagentWorker watches a resumed follow-up independently of the | ||
| 8757 | // worker's original task. A long-lived daemon can complete several manager | 8751 | // worker's original task. A long-lived daemon can complete several manager | ||
| 8758 | // turns, each of which must produce its own automatic delivery. | 8752 | // turns, each of which must produce its own automatic delivery. | ||
| 8759 | - | func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt string, required bool) { | 8753 | + | func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt string, _ bool) { |
| 8760 | - | if required { | |||
| 8761 | - | if i != nil { | |||
| 8762 | - | i.invalidate() | |||
| 8763 | - | } | |||
| 8764 | - | return | |||
| 8765 | - | } | |||
| 8766 | i.trackSubagentWorker(a, prompt, true, false) | 8754 | i.trackSubagentWorker(a, prompt, true, false) | ||
| 8767 | } | 8755 | } | ||
| 8768 | 8756 | ||||
| @@ -8770,13 +8758,7 @@ func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt stri | |||||
| 8770 | // sent. The returned cleanup removes the registration if the resume operation | 8758 | // 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 | 8759 | // fails before delivery. It is used by the resume tool's pre-send hook; the | ||
| 8772 | // existing TrackResumedSubagentWorker signature remains the post-success API. | 8760 | // existing TrackResumedSubagentWorker signature remains the post-success API. | ||
| 8773 | - | func (i *Interactive) PrepareResumedSubagentWorker(a *subagents.Agent, prompt string, required bool) func() { | 8761 | + | func (i *Interactive) PrepareResumedSubagentWorker(a *subagents.Agent, prompt string, _ bool) func() { |
| 8774 | - | if required { | |||
| 8775 | - | if i != nil { | |||
| 8776 | - | i.invalidate() | |||
| 8777 | - | } | |||
| 8778 | - | return nil | |||
| 8779 | - | } | |||
| 8780 | return i.trackSubagentWorker(a, prompt, true, true) | 8762 | return i.trackSubagentWorker(a, prompt, true, true) | ||
| 8781 | } | 8763 | } | ||
| 8782 | 8764 | ||||
M packages/agent/orchestration_modes.go +5 -12
| @@ -192,23 +192,16 @@ func runOrchestratedMode(parentCtx context.Context, args Args, version string, h | |||
| 192 | 192 | } | |
| 193 | 193 | ||
| 194 | 194 | func newOrchestratedRuntime(ctx context.Context, args Args, r Resolved, cfg Config, tracker *subagents.CompletionTracker) *subagentRuntime { | |
| 195 | - | onSpawned := func(a *subagents.Agent, task string, required bool) { | |
| 196 | - | if !required { | |
| 197 | - | tracker.TrackTurn(a, task, false) | |
| 198 | - | } | |
| 195 | + | onSpawned := func(a *subagents.Agent, task string, _ bool) { | |
| 196 | + | tracker.TrackTurn(a, task, false) | |
| 199 | 197 | } | |
| 200 | - | onResumed := func(a *subagents.Agent, prompt string, required bool) { | |
| 198 | + | onResumed := func(a *subagents.Agent, prompt string, _ bool) { | |
| 201 | 199 | // BeforeResumed normally owns future-turn registration. Keep this | |
| 202 | 200 | // fallback for direct runtime callers that do not install the pre-hook; | |
| 203 | 201 | // SubagentResumeTool suppresses it when BeforeResumed accepted delivery. | |
| 204 | - | if !required { | |
| 205 | - | tracker.TrackTurn(a, prompt, true) | |
| 206 | - | } | |
| 202 | + | tracker.TrackTurn(a, prompt, true) | |
| 207 | 203 | } | |
| 208 | - | onBeforeResumed := func(a *subagents.Agent, prompt string, required bool) func() { | |
| 209 | - | if required { | |
| 210 | - | return nil | |
| 211 | - | } | |
| 204 | + | onBeforeResumed := func(a *subagents.Agent, prompt string, _ bool) func() { | |
| 212 | 205 | return tracker.TrackFutureTurn(a, prompt, true) | |
| 213 | 206 | } | |
| 214 | 207 | onStopRequested := func(a *subagents.Agent) { | |
| @@ -192,23 +192,16 @@ func runOrchestratedMode(parentCtx context.Context, args Args, version string, h | |||||
| 192 | } | 192 | } | ||
| 193 | 193 | ||||
| 194 | func newOrchestratedRuntime(ctx context.Context, args Args, r Resolved, cfg Config, tracker *subagents.CompletionTracker) *subagentRuntime { | 194 | func newOrchestratedRuntime(ctx context.Context, args Args, r Resolved, cfg Config, tracker *subagents.CompletionTracker) *subagentRuntime { | ||
| 195 | - | onSpawned := func(a *subagents.Agent, task string, required bool) { | 195 | + | onSpawned := func(a *subagents.Agent, task string, _ bool) { |
| 196 | - | if !required { | 196 | + | tracker.TrackTurn(a, task, false) |
| 197 | - | tracker.TrackTurn(a, task, false) | |||
| 198 | - | } | |||
| 199 | } | 197 | } | ||
| 200 | - | onResumed := func(a *subagents.Agent, prompt string, required bool) { | 198 | + | onResumed := func(a *subagents.Agent, prompt string, _ bool) { |
| 201 | // BeforeResumed normally owns future-turn registration. Keep this | 199 | // BeforeResumed normally owns future-turn registration. Keep this | ||
| 202 | // fallback for direct runtime callers that do not install the pre-hook; | 200 | // fallback for direct runtime callers that do not install the pre-hook; | ||
| 203 | // SubagentResumeTool suppresses it when BeforeResumed accepted delivery. | 201 | // SubagentResumeTool suppresses it when BeforeResumed accepted delivery. | ||
| 204 | - | if !required { | 202 | + | tracker.TrackTurn(a, prompt, true) |
| 205 | - | tracker.TrackTurn(a, prompt, true) | |||
| 206 | - | } | |||
| 207 | } | 203 | } | ||
| 208 | - | onBeforeResumed := func(a *subagents.Agent, prompt string, required bool) func() { | 204 | + | onBeforeResumed := func(a *subagents.Agent, prompt string, _ bool) func() { |
| 209 | - | if required { | |||
| 210 | - | return nil | |||
| 211 | - | } | |||
| 212 | return tracker.TrackFutureTurn(a, prompt, true) | 205 | return tracker.TrackFutureTurn(a, prompt, true) | ||
| 213 | } | 206 | } | ||
| 214 | onStopRequested := func(a *subagents.Agent) { | 207 | onStopRequested := func(a *subagents.Agent) { | ||
M packages/agent/settings_store.go +4 -4
| @@ -272,15 +272,15 @@ Delegate all implementation, debugging/testing, and code-review work to an appro | |||
| 272 | 272 | ||
| 273 | 273 | Give every worker a self-contained task because workers start without this conversation's context. Shared-worktree workers edit the same working directory, so coordinate dependent work and avoid conflicting edits. Use isolation:"worktree" for parallel coding when workers need separate trees; its changes are returned as a patch for a worker to integrate, not for you to apply. Do not invent feature scope beyond the user's request. Child workers cannot recursively spawn more sub-agents in v1. | |
| 274 | 274 | ||
| 275 | - | Use required:true whenever your answer or a declared workflow depends on that worker, especially for required validation or finalization. A required manager call waits event-driven and returns the worker outcome in the current turn. Failed, timed-out, or canceled required work remains unmet: use an enabled manager follow-up action to retry it and do not claim completion. An indeterminate outcome after host restart must not be retried automatically; the user must inspect durable results and side effects, then explicitly resume, restart, or remove the worker. Only the user can waive required work by explicitly removing the terminal worker with /subagents rm. Keep required:false only for independent work that may safely finish later. | |
| 275 | + | Use required:true whenever your answer or a declared workflow depends on that worker, especially for required validation or finalization. Required workers remain asynchronous: manager calls return immediately, the parent stays free to coordinate or perform independent work, and the host delivers terminal outcomes through completion updates. Failed, timed-out, or canceled required work remains unmet: use an enabled manager follow-up action to retry it and do not claim completion. An indeterminate outcome after host restart must not be retried automatically; the user must inspect durable results and side effects, then explicitly resume, restart, or remove the worker. Only the user can waive required work by explicitly removing the terminal worker with /subagents rm. Keep required:false only for independent work that may safely finish later. | |
| 276 | 276 | ||
| 277 | - | Completion is host-event-driven for optional workers. After spawning an optional worker, never use "bash sleep", "watch", "tail -f", polling loops, repeated "subagent_status", or dashboard, metadata, event-log, or file checks solely to wait. Those are not completion signals. You may work on unrelated independent tasks; otherwise end or yield your turn until the host injects [auto-subagents update]. Completion updates are the only completion signal for optional workers. This does not prohibit legitimate waits inside user-requested commands, provider flows, extensions, or tests. | |
| 277 | + | Completion is host-event-driven for every worker. Never use "bash sleep", "watch", "tail -f", polling loops, repeated "subagent_status", or dashboard, metadata, event-log, or file checks solely to wait. Those are not completion signals. Continue unrelated independent tasks; otherwise end or yield your turn until the host injects a completion update. Completion updates are the only completion signal. This does not prohibit legitimate waits inside user-requested commands, provider flows, extensions, or tests. | |
| 278 | 278 | ||
| 279 | - | When workers finish, use the required tool result or host update's agent ID, status, task, optional error, and final response or tail to coordinate any follow-up and summarize the outcome. Treat [auto-subagents update] and [required-subagents update] messages as observed worker state, not as new user requests.` | |
| 279 | + | When workers finish, use the host update's agent ID, status, task, optional error, and final response or tail to coordinate any follow-up and summarize the outcome. Treat [auto-subagents update] and [required-subagents update] messages as observed worker state, not as new user requests.` | |
| 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 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.` | |
| 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. Every manager call returns immediately and every result arrives through a host completion update, so continue unrelated work rather than polling. Failed, timed-out, or canceled required 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. 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 | |
| @@ -272,15 +272,15 @@ Delegate all implementation, debugging/testing, and code-review work to an appro | |||||
| 272 | 272 | ||||
| 273 | Give every worker a self-contained task because workers start without this conversation's context. Shared-worktree workers edit the same working directory, so coordinate dependent work and avoid conflicting edits. Use isolation:"worktree" for parallel coding when workers need separate trees; its changes are returned as a patch for a worker to integrate, not for you to apply. Do not invent feature scope beyond the user's request. Child workers cannot recursively spawn more sub-agents in v1. | 273 | Give every worker a self-contained task because workers start without this conversation's context. Shared-worktree workers edit the same working directory, so coordinate dependent work and avoid conflicting edits. Use isolation:"worktree" for parallel coding when workers need separate trees; its changes are returned as a patch for a worker to integrate, not for you to apply. Do not invent feature scope beyond the user's request. Child workers cannot recursively spawn more sub-agents in v1. | ||
| 274 | 274 | ||||
| 275 | - | Use required:true whenever your answer or a declared workflow depends on that worker, especially for required validation or finalization. A required manager call waits event-driven and returns the worker outcome in the current turn. Failed, timed-out, or canceled required work remains unmet: use an enabled manager follow-up action to retry it and do not claim completion. An indeterminate outcome after host restart must not be retried automatically; the user must inspect durable results and side effects, then explicitly resume, restart, or remove the worker. Only the user can waive required work by explicitly removing the terminal worker with /subagents rm. Keep required:false only for independent work that may safely finish later. | 275 | + | Use required:true whenever your answer or a declared workflow depends on that worker, especially for required validation or finalization. Required workers remain asynchronous: manager calls return immediately, the parent stays free to coordinate or perform independent work, and the host delivers terminal outcomes through completion updates. Failed, timed-out, or canceled required work remains unmet: use an enabled manager follow-up action to retry it and do not claim completion. An indeterminate outcome after host restart must not be retried automatically; the user must inspect durable results and side effects, then explicitly resume, restart, or remove the worker. Only the user can waive required work by explicitly removing the terminal worker with /subagents rm. Keep required:false only for independent work that may safely finish later. |
| 276 | 276 | ||||
| 277 | - | Completion is host-event-driven for optional workers. After spawning an optional worker, never use "bash sleep", "watch", "tail -f", polling loops, repeated "subagent_status", or dashboard, metadata, event-log, or file checks solely to wait. Those are not completion signals. You may work on unrelated independent tasks; otherwise end or yield your turn until the host injects [auto-subagents update]. Completion updates are the only completion signal for optional workers. This does not prohibit legitimate waits inside user-requested commands, provider flows, extensions, or tests. | 277 | + | Completion is host-event-driven for every worker. Never use "bash sleep", "watch", "tail -f", polling loops, repeated "subagent_status", or dashboard, metadata, event-log, or file checks solely to wait. Those are not completion signals. Continue unrelated independent tasks; otherwise end or yield your turn until the host injects a completion update. Completion updates are the only completion signal. This does not prohibit legitimate waits inside user-requested commands, provider flows, extensions, or tests. |
| 278 | 278 | ||||
| 279 | - | When workers finish, use the required tool result or host update's agent ID, status, task, optional error, and final response or tail to coordinate any follow-up and summarize the outcome. Treat [auto-subagents update] and [required-subagents update] messages as observed worker state, not as new user requests.` | 279 | + | When workers finish, use the host update's agent ID, status, task, optional error, and final response or tail to coordinate any follow-up and summarize the outcome. Treat [auto-subagents update] and [required-subagents update] messages as observed worker state, not as new user requests.` |
| 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 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.` | 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. Every manager call returns immediately and every result arrives through a host completion update, so continue unrelated work rather than polling. Failed, timed-out, or canceled required 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. 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 +24 -32
| @@ -5,14 +5,13 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "strings" | |
| 7 | 7 | "testing" | |
| 8 | - | "time" | |
| 9 | 8 | ||
| 10 | 9 | "github.com/bnema/zut/packages/agent/subagents" | |
| 11 | 10 | "github.com/bnema/zut/packages/core" | |
| 12 | 11 | "github.com/bnema/zut/packages/provider" | |
| 13 | 12 | ) | |
| 14 | 13 | ||
| 15 | - | func TestRequiredWorkerGateWaitsBeforeParentModelTurn(t *testing.T) { | |
| 14 | + | func TestRequiredWorkerGateKeepsParentFreeWhileWorkerRuns(t *testing.T) { | |
| 16 | 15 | started := make(chan struct{}) | |
| 17 | 16 | release := make(chan struct{}) | |
| 18 | 17 | cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner { | |
| @@ -41,36 +40,22 @@ func TestRequiredWorkerGateWaitsBeforeParentModelTurn(t *testing.T) { | |||
| 41 | 40 | ||
| 42 | 41 | parent := &core.Agent{} | |
| 43 | 42 | rt.WireRequiredWorkerGate(parent) | |
| 44 | - | if parent.BeforeTurnContext == nil { | |
| 45 | - | t.Fatal("required worker gate did not install a before-turn hook") | |
| 43 | + | allowed, reason, turnContext := parent.BeforeTurnContext(context.Background(), 1) | |
| 44 | + | if !allowed || reason != "" || turnContext != "" { | |
| 45 | + | t.Fatalf("parent turn while worker runs = (%v, %q, %q), want immediate access", allowed, reason, turnContext) | |
| 46 | 46 | } | |
| 47 | - | ||
| 48 | - | type turnResult struct { | |
| 49 | - | allowed bool | |
| 50 | - | reason string | |
| 51 | - | context string | |
| 52 | - | } | |
| 53 | - | result := make(chan turnResult, 1) | |
| 54 | - | go func() { | |
| 55 | - | allowed, reason, turnContext := parent.BeforeTurnContext(context.Background(), 1) | |
| 56 | - | result <- turnResult{allowed: allowed, reason: reason, context: turnContext} | |
| 57 | - | }() | |
| 58 | - | select { | |
| 59 | - | case got := <-result: | |
| 60 | - | t.Fatalf("parent gate returned while required worker was running: %+v", got) | |
| 61 | - | default: | |
| 47 | + | if allowed, reason, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"}); !allowed || reason != "" { | |
| 48 | + | t.Fatalf("independent parent tool allowed=%v reason=%q", allowed, reason) | |
| 49 | + | } | |
| 50 | + | if allowed, reason, _ := parent.BeforeAssistantMessage("premature final answer"); allowed || !strings.Contains(reason, a.ID) { | |
| 51 | + | t.Fatalf("terminal parent answer allowed=%v reason=%q, want deferred finalization", allowed, reason) | |
| 62 | 52 | } | |
| 63 | 53 | ||
| 64 | 54 | close(release) | |
| 65 | - | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |
| 66 | - | defer cancel() | |
| 67 | - | select { | |
| 68 | - | case got := <-result: | |
| 69 | - | if !got.allowed || got.reason != "" || !strings.Contains(got.context, "required") || !strings.Contains(got.context, a.ID) { | |
| 70 | - | t.Fatalf("gate result = %+v, want required completion context", got) | |
| 71 | - | } | |
| 72 | - | case <-ctx.Done(): | |
| 73 | - | t.Fatal("parent gate did not wake when required worker finished") | |
| 55 | + | a.Wait() | |
| 56 | + | allowed, reason, turnContext = parent.BeforeTurnContext(context.Background(), 2) | |
| 57 | + | if !allowed || reason != "" || !strings.Contains(turnContext, a.ID) { | |
| 58 | + | t.Fatalf("completion context = (%v, %q, %q), want required outcome", allowed, reason, turnContext) | |
| 74 | 59 | } | |
| 75 | 60 | } | |
| 76 | 61 | ||
| @@ -150,8 +135,11 @@ func TestRequiredWorkerGateTreatsManualTerminalRemovalAsWaiver(t *testing.T) { | |||
| 150 | 135 | worker.Wait() | |
| 151 | 136 | parent := &core.Agent{} | |
| 152 | 137 | rt.WireRequiredWorkerGate(parent) | |
| 153 | - | if allowed, _, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"}); allowed { | |
| 154 | - | t.Fatal("unmet requirement did not block tools") | |
| 138 | + | if allowed, reason, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"}); !allowed || reason != "" { | |
| 139 | + | t.Fatalf("unmet requirement blocked independent tool: allowed=%v reason=%q", allowed, reason) | |
| 140 | + | } | |
| 141 | + | if allowed, _, _ := parent.BeforeAssistantMessage("done"); allowed { | |
| 142 | + | t.Fatal("unmet requirement allowed terminal answer") | |
| 155 | 143 | } | |
| 156 | 144 | if err := rt.Supervisor().Remove(worker.ID); err != nil { | |
| 157 | 145 | t.Fatalf("explicit user removal waiver: %v", err) | |
| @@ -188,9 +176,13 @@ func TestRequiredWorkerGateRejectsTerminalParentResponseUntilRetrySucceeds(t *te | |||
| 188 | 176 | ||
| 189 | 177 | parent := &core.Agent{} | |
| 190 | 178 | rt.WireRequiredWorkerGate(parent) | |
| 179 | + | turnAllowed, turnReason, turnContext := parent.BeforeTurnContext(context.Background(), 1) | |
| 180 | + | if !turnAllowed || turnReason != "" || !strings.Contains(turnContext, worker.ID) || !strings.Contains(turnContext, "subagent_resume") { | |
| 181 | + | t.Fatalf("failed worker recovery context = (%v, %q, %q)", turnAllowed, turnReason, turnContext) | |
| 182 | + | } | |
| 191 | 183 | toolAllowed, toolReason, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"}) | |
| 192 | - | if toolAllowed || !strings.Contains(toolReason, worker.ID) { | |
| 193 | - | t.Fatalf("finalization tool allowed=%v reason=%q, want failed required worker gate", toolAllowed, toolReason) | |
| 184 | + | if !toolAllowed || toolReason != "" { | |
| 185 | + | t.Fatalf("independent tool allowed=%v reason=%q, want parent to remain usable", toolAllowed, toolReason) | |
| 194 | 186 | } | |
| 195 | 187 | toolAllowed, toolReason, _ = parent.BeforeToolExecute(provider.ToolCallBlock{Name: "subagent_resume"}) | |
| 196 | 188 | if !toolAllowed || toolReason != "" { | |
| @@ -5,14 +5,13 @@ import ( | |||||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "strings" | 6 | "strings" | ||
| 7 | "testing" | 7 | "testing" | ||
| 8 | - | "time" | |||
| 9 | 8 | ||||
| 10 | "github.com/bnema/zut/packages/agent/subagents" | 9 | "github.com/bnema/zut/packages/agent/subagents" | ||
| 11 | "github.com/bnema/zut/packages/core" | 10 | "github.com/bnema/zut/packages/core" | ||
| 12 | "github.com/bnema/zut/packages/provider" | 11 | "github.com/bnema/zut/packages/provider" | ||
| 13 | ) | 12 | ) | ||
| 14 | 13 | ||||
| 15 | - | func TestRequiredWorkerGateWaitsBeforeParentModelTurn(t *testing.T) { | 14 | + | func TestRequiredWorkerGateKeepsParentFreeWhileWorkerRuns(t *testing.T) { |
| 16 | started := make(chan struct{}) | 15 | started := make(chan struct{}) | ||
| 17 | release := make(chan struct{}) | 16 | release := make(chan struct{}) | ||
| 18 | cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner { | 17 | cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner { | ||
| @@ -41,36 +40,22 @@ func TestRequiredWorkerGateWaitsBeforeParentModelTurn(t *testing.T) { | |||||
| 41 | 40 | ||||
| 42 | parent := &core.Agent{} | 41 | parent := &core.Agent{} | ||
| 43 | rt.WireRequiredWorkerGate(parent) | 42 | rt.WireRequiredWorkerGate(parent) | ||
| 44 | - | if parent.BeforeTurnContext == nil { | 43 | + | allowed, reason, turnContext := parent.BeforeTurnContext(context.Background(), 1) |
| 45 | - | t.Fatal("required worker gate did not install a before-turn hook") | 44 | + | if !allowed || reason != "" || turnContext != "" { |
| 45 | + | t.Fatalf("parent turn while worker runs = (%v, %q, %q), want immediate access", allowed, reason, turnContext) | |||
| 46 | } | 46 | } | ||
| 47 | - | 47 | + | if allowed, reason, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"}); !allowed || reason != "" { | |
| 48 | - | type turnResult struct { | 48 | + | t.Fatalf("independent parent tool allowed=%v reason=%q", allowed, reason) |
| 49 | - | allowed bool | 49 | + | } |
| 50 | - | reason string | 50 | + | if allowed, reason, _ := parent.BeforeAssistantMessage("premature final answer"); allowed || !strings.Contains(reason, a.ID) { |
| 51 | - | context string | 51 | + | t.Fatalf("terminal parent answer allowed=%v reason=%q, want deferred finalization", allowed, reason) |
| 52 | - | } | |||
| 53 | - | result := make(chan turnResult, 1) | |||
| 54 | - | go func() { | |||
| 55 | - | allowed, reason, turnContext := parent.BeforeTurnContext(context.Background(), 1) | |||
| 56 | - | result <- turnResult{allowed: allowed, reason: reason, context: turnContext} | |||
| 57 | - | }() | |||
| 58 | - | select { | |||
| 59 | - | case got := <-result: | |||
| 60 | - | t.Fatalf("parent gate returned while required worker was running: %+v", got) | |||
| 61 | - | default: | |||
| 62 | } | 52 | } | ||
| 63 | 53 | ||||
| 64 | close(release) | 54 | close(release) | ||
| 65 | - | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | 55 | + | a.Wait() |
| 66 | - | defer cancel() | 56 | + | allowed, reason, turnContext = parent.BeforeTurnContext(context.Background(), 2) |
| 67 | - | select { | 57 | + | if !allowed || reason != "" || !strings.Contains(turnContext, a.ID) { |
| 68 | - | case got := <-result: | 58 | + | t.Fatalf("completion context = (%v, %q, %q), want required outcome", allowed, reason, turnContext) |
| 69 | - | if !got.allowed || got.reason != "" || !strings.Contains(got.context, "required") || !strings.Contains(got.context, a.ID) { | |||
| 70 | - | t.Fatalf("gate result = %+v, want required completion context", got) | |||
| 71 | - | } | |||
| 72 | - | case <-ctx.Done(): | |||
| 73 | - | t.Fatal("parent gate did not wake when required worker finished") | |||
| 74 | } | 59 | } | ||
| 75 | } | 60 | } | ||
| 76 | 61 | ||||
| @@ -150,8 +135,11 @@ func TestRequiredWorkerGateTreatsManualTerminalRemovalAsWaiver(t *testing.T) { | |||||
| 150 | worker.Wait() | 135 | worker.Wait() | ||
| 151 | parent := &core.Agent{} | 136 | parent := &core.Agent{} | ||
| 152 | rt.WireRequiredWorkerGate(parent) | 137 | rt.WireRequiredWorkerGate(parent) | ||
| 153 | - | if allowed, _, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"}); allowed { | 138 | + | if allowed, reason, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"}); !allowed || reason != "" { |
| 154 | - | t.Fatal("unmet requirement did not block tools") | 139 | + | t.Fatalf("unmet requirement blocked independent tool: allowed=%v reason=%q", allowed, reason) |
| 140 | + | } | |||
| 141 | + | if allowed, _, _ := parent.BeforeAssistantMessage("done"); allowed { | |||
| 142 | + | t.Fatal("unmet requirement allowed terminal answer") | |||
| 155 | } | 143 | } | ||
| 156 | if err := rt.Supervisor().Remove(worker.ID); err != nil { | 144 | if err := rt.Supervisor().Remove(worker.ID); err != nil { | ||
| 157 | t.Fatalf("explicit user removal waiver: %v", err) | 145 | t.Fatalf("explicit user removal waiver: %v", err) | ||
| @@ -188,9 +176,13 @@ func TestRequiredWorkerGateRejectsTerminalParentResponseUntilRetrySucceeds(t *te | |||||
| 188 | 176 | ||||
| 189 | parent := &core.Agent{} | 177 | parent := &core.Agent{} | ||
| 190 | rt.WireRequiredWorkerGate(parent) | 178 | rt.WireRequiredWorkerGate(parent) | ||
| 179 | + | turnAllowed, turnReason, turnContext := parent.BeforeTurnContext(context.Background(), 1) | |||
| 180 | + | if !turnAllowed || turnReason != "" || !strings.Contains(turnContext, worker.ID) || !strings.Contains(turnContext, "subagent_resume") { | |||
| 181 | + | t.Fatalf("failed worker recovery context = (%v, %q, %q)", turnAllowed, turnReason, turnContext) | |||
| 182 | + | } | |||
| 191 | toolAllowed, toolReason, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"}) | 183 | toolAllowed, toolReason, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"}) | ||
| 192 | - | if toolAllowed || !strings.Contains(toolReason, worker.ID) { | 184 | + | if !toolAllowed || toolReason != "" { |
| 193 | - | t.Fatalf("finalization tool allowed=%v reason=%q, want failed required worker gate", toolAllowed, toolReason) | 185 | + | t.Fatalf("independent tool allowed=%v reason=%q, want parent to remain usable", toolAllowed, toolReason) |
| 194 | } | 186 | } | ||
| 195 | toolAllowed, toolReason, _ = parent.BeforeToolExecute(provider.ToolCallBlock{Name: "subagent_resume"}) | 187 | toolAllowed, toolReason, _ = parent.BeforeToolExecute(provider.ToolCallBlock{Name: "subagent_resume"}) | ||
| 196 | if !toolAllowed || toolReason != "" { | 188 | if !toolAllowed || toolReason != "" { | ||
M packages/agent/subagent_requirements.go +5 -22
| @@ -8,7 +8,6 @@ import ( | |||
| 8 | 8 | "strings" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/zut/packages/agent/subagents" | |
| 11 | - | "github.com/bnema/zut/packages/agent/tools" | |
| 12 | 11 | "github.com/bnema/zut/packages/core" | |
| 13 | 12 | "github.com/bnema/zut/packages/provider" | |
| 14 | 13 | ) | |
| @@ -16,10 +15,10 @@ import ( | |||
| 16 | 15 | const requiredWorkerContextHeader = "[required-subagents update]" | |
| 17 | 16 | ||
| 18 | 17 | // WireRequiredWorkerGate composes host-enforced requirement handling with any | |
| 19 | - | // extension hooks already installed on the parent. Blocking required tool calls | |
| 20 | - | // handle the normal in-turn path; the before-turn hook covers cancellation, | |
| 21 | - | // restart, and session resume, while the assistant hook fails closed after an | |
| 22 | - | // unsuccessful required outcome. | |
| 18 | + | // extension hooks already installed on the parent. Parent turns and tools stay | |
| 19 | + | // available while required work runs; terminal outcomes are injected on the | |
| 20 | + | // next turn, and only a terminal assistant response is held while an obligation | |
| 21 | + | // remains unmet. | |
| 23 | 22 | func (rt *subagentRuntime) WireRequiredWorkerGate(parent *core.Agent) { | |
| 24 | 23 | if rt == nil || rt.supervisor == nil || parent == nil { | |
| 25 | 24 | return | |
| @@ -37,10 +36,7 @@ func (rt *subagentRuntime) WireRequiredWorkerGate(parent *core.Agent) { | |||
| 37 | 36 | if err := rt.waitRequiredWorkerReady(ctx); err != nil { | |
| 38 | 37 | return false, fmt.Sprintf("loading persisted subagents failed: %v; resolve the supervisor reload error before continuing", err), inherited | |
| 39 | 38 | } | |
| 40 | - | required, err := rt.supervisor.WaitActiveRequirements(ctx) | |
| 41 | - | if err != nil { | |
| 42 | - | return false, fmt.Sprintf("waiting for required subagents: %v", err), inherited | |
| 43 | - | } | |
| 39 | + | required := rt.supervisor.RequiredSnapshots() | |
| 44 | 40 | update := rt.formatRequiredWorkerContext(required) | |
| 45 | 41 | if update == "" { | |
| 46 | 42 | return true, "", inherited | |
| @@ -50,10 +46,6 @@ func (rt *subagentRuntime) WireRequiredWorkerGate(parent *core.Agent) { | |||
| 50 | 46 | ||
| 51 | 47 | previousBeforeTool := parent.BeforeToolExecute | |
| 52 | 48 | parent.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) { | |
| 53 | - | unmet := rt.supervisor.UnmetRequirements() | |
| 54 | - | if len(unmet) != 0 && !requiredRecoveryTool(call.Name) { | |
| 55 | - | return false, formatRequiredWorkerBlock(unmet), nil | |
| 56 | - | } | |
| 57 | 49 | if previousBeforeTool != nil { | |
| 58 | 50 | return previousBeforeTool(call) | |
| 59 | 51 | } | |
| @@ -163,15 +155,6 @@ func (rt *subagentRuntime) formatRequiredWorkerContext(required []subagents.Agen | |||
| 163 | 155 | return contextText | |
| 164 | 156 | } | |
| 165 | 157 | ||
| 166 | - | func requiredRecoveryTool(name string) bool { | |
| 167 | - | switch name { | |
| 168 | - | case tools.SubagentResumeToolName, tools.SubagentStatusToolName, tools.SubagentStopToolName: | |
| 169 | - | return true | |
| 170 | - | default: | |
| 171 | - | return false | |
| 172 | - | } | |
| 173 | - | } | |
| 174 | - | ||
| 175 | 158 | func formatRequiredWorkerContextWithReported(required []subagents.AgentSnapshot, previouslyReported map[string]struct{}) string { | |
| 176 | 159 | var items []subagents.AgentSnapshot | |
| 177 | 160 | for _, snapshot := range required { | |
| @@ -8,7 +8,6 @@ import ( | |||||
| 8 | "strings" | 8 | "strings" | ||
| 9 | 9 | ||||
| 10 | "github.com/bnema/zut/packages/agent/subagents" | 10 | "github.com/bnema/zut/packages/agent/subagents" | ||
| 11 | - | "github.com/bnema/zut/packages/agent/tools" | |||
| 12 | "github.com/bnema/zut/packages/core" | 11 | "github.com/bnema/zut/packages/core" | ||
| 13 | "github.com/bnema/zut/packages/provider" | 12 | "github.com/bnema/zut/packages/provider" | ||
| 14 | ) | 13 | ) | ||
| @@ -16,10 +15,10 @@ import ( | |||||
| 16 | const requiredWorkerContextHeader = "[required-subagents update]" | 15 | const requiredWorkerContextHeader = "[required-subagents update]" | ||
| 17 | 16 | ||||
| 18 | // WireRequiredWorkerGate composes host-enforced requirement handling with any | 17 | // WireRequiredWorkerGate composes host-enforced requirement handling with any | ||
| 19 | - | // extension hooks already installed on the parent. Blocking required tool calls | 18 | + | // extension hooks already installed on the parent. Parent turns and tools stay |
| 20 | - | // handle the normal in-turn path; the before-turn hook covers cancellation, | 19 | + | // available while required work runs; terminal outcomes are injected on the |
| 21 | - | // restart, and session resume, while the assistant hook fails closed after an | 20 | + | // next turn, and only a terminal assistant response is held while an obligation |
| 22 | - | // unsuccessful required outcome. | 21 | + | // remains unmet. |
| 23 | func (rt *subagentRuntime) WireRequiredWorkerGate(parent *core.Agent) { | 22 | func (rt *subagentRuntime) WireRequiredWorkerGate(parent *core.Agent) { | ||
| 24 | if rt == nil || rt.supervisor == nil || parent == nil { | 23 | if rt == nil || rt.supervisor == nil || parent == nil { | ||
| 25 | return | 24 | return | ||
| @@ -37,10 +36,7 @@ func (rt *subagentRuntime) WireRequiredWorkerGate(parent *core.Agent) { | |||||
| 37 | if err := rt.waitRequiredWorkerReady(ctx); err != nil { | 36 | if err := rt.waitRequiredWorkerReady(ctx); err != nil { | ||
| 38 | return false, fmt.Sprintf("loading persisted subagents failed: %v; resolve the supervisor reload error before continuing", err), inherited | 37 | return false, fmt.Sprintf("loading persisted subagents failed: %v; resolve the supervisor reload error before continuing", err), inherited | ||
| 39 | } | 38 | } | ||
| 40 | - | required, err := rt.supervisor.WaitActiveRequirements(ctx) | 39 | + | required := rt.supervisor.RequiredSnapshots() |
| 41 | - | if err != nil { | |||
| 42 | - | return false, fmt.Sprintf("waiting for required subagents: %v", err), inherited | |||
| 43 | - | } | |||
| 44 | update := rt.formatRequiredWorkerContext(required) | 40 | update := rt.formatRequiredWorkerContext(required) | ||
| 45 | if update == "" { | 41 | if update == "" { | ||
| 46 | return true, "", inherited | 42 | return true, "", inherited | ||
| @@ -50,10 +46,6 @@ func (rt *subagentRuntime) WireRequiredWorkerGate(parent *core.Agent) { | |||||
| 50 | 46 | ||||
| 51 | previousBeforeTool := parent.BeforeToolExecute | 47 | previousBeforeTool := parent.BeforeToolExecute | ||
| 52 | parent.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) { | 48 | parent.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) { | ||
| 53 | - | unmet := rt.supervisor.UnmetRequirements() | |||
| 54 | - | if len(unmet) != 0 && !requiredRecoveryTool(call.Name) { | |||
| 55 | - | return false, formatRequiredWorkerBlock(unmet), nil | |||
| 56 | - | } | |||
| 57 | if previousBeforeTool != nil { | 49 | if previousBeforeTool != nil { | ||
| 58 | return previousBeforeTool(call) | 50 | return previousBeforeTool(call) | ||
| 59 | } | 51 | } | ||
| @@ -163,15 +155,6 @@ func (rt *subagentRuntime) formatRequiredWorkerContext(required []subagents.Agen | |||||
| 163 | return contextText | 155 | return contextText | ||
| 164 | } | 156 | } | ||
| 165 | 157 | ||||
| 166 | - | func requiredRecoveryTool(name string) bool { | |||
| 167 | - | switch name { | |||
| 168 | - | case tools.SubagentResumeToolName, tools.SubagentStatusToolName, tools.SubagentStopToolName: | |||
| 169 | - | return true | |||
| 170 | - | default: | |||
| 171 | - | return false | |||
| 172 | - | } | |||
| 173 | - | } | |||
| 174 | - | ||||
| 175 | func formatRequiredWorkerContextWithReported(required []subagents.AgentSnapshot, previouslyReported map[string]struct{}) string { | 158 | func formatRequiredWorkerContextWithReported(required []subagents.AgentSnapshot, previouslyReported map[string]struct{}) string { | ||
| 176 | var items []subagents.AgentSnapshot | 159 | var items []subagents.AgentSnapshot | ||
| 177 | for _, snapshot := range required { | 160 | for _, snapshot := range required { | ||
M packages/agent/subagents/requirement.go +3 -47
| @@ -2,7 +2,6 @@ package subagents | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "errors" | |
| 6 | 5 | "fmt" | |
| 7 | 6 | "sort" | |
| 8 | 7 | "strconv" | |
| @@ -303,24 +302,8 @@ func (a *Agent) waitRequirement(ctx context.Context) (RequirementSnapshot, error | |||
| 303 | 302 | } | |
| 304 | 303 | } | |
| 305 | 304 | ||
| 306 | - | var errRequiredAgentMissing = errors.New("subagents: required agent no longer exists") | |
| 307 | - | ||
| 308 | - | // WaitRequired blocks on requirement state transitions, never filesystem or | |
| 309 | - | // status polling, until the selected required turn reaches a terminal outcome. | |
| 310 | - | func (f *Supervisor) WaitRequired(ctx context.Context, id string) (RequirementSnapshot, error) { | |
| 311 | - | a := f.Get(strings.TrimSpace(id)) | |
| 312 | - | if a == nil { | |
| 313 | - | return RequirementSnapshot{}, fmt.Errorf("%w: %q", errRequiredAgentMissing, id) | |
| 314 | - | } | |
| 315 | - | requirement := a.requirementSnapshot() | |
| 316 | - | if !requirement.Required { | |
| 317 | - | return requirement, fmt.Errorf("subagents: agent %s is not required", a.ID) | |
| 318 | - | } | |
| 319 | - | return a.waitRequirement(ctx) | |
| 320 | - | } | |
| 321 | - | ||
| 322 | 305 | // MarkRequirementNotified records that the parent received the terminal | |
| 323 | - | // outcome through a blocking manager tool or host-injected update. | |
| 306 | + | // outcome through a host-injected update. | |
| 324 | 307 | func (f *Supervisor) MarkRequirementNotified(id string) error { | |
| 325 | 308 | a := f.Get(strings.TrimSpace(id)) | |
| 326 | 309 | if a == nil { | |
| @@ -358,8 +341,8 @@ func (f *Supervisor) RequiredSnapshots() []AgentSnapshot { | |||
| 358 | 341 | return out | |
| 359 | 342 | } | |
| 360 | 343 | ||
| 361 | - | // UnmetRequirements returns every required worker that still blocks parent | |
| 362 | - | // completion in the active session. | |
| 344 | + | // UnmetRequirements returns every required worker that still blocks the | |
| 345 | + | // parent's terminal response in the active session. | |
| 363 | 346 | func (f *Supervisor) UnmetRequirements() []AgentSnapshot { | |
| 364 | 347 | required := f.RequiredSnapshots() | |
| 365 | 348 | out := required[:0] | |
| @@ -370,30 +353,3 @@ func (f *Supervisor) UnmetRequirements() []AgentSnapshot { | |||
| 370 | 353 | } | |
| 371 | 354 | return out | |
| 372 | 355 | } | |
| 373 | - | ||
| 374 | - | // WaitActiveRequirements waits for all currently pending required workers in | |
| 375 | - | // the active session. It returns all required snapshots so the host can inject | |
| 376 | - | // both successful outcomes and unresolved failures before the next model turn. | |
| 377 | - | func (f *Supervisor) WaitActiveRequirements(ctx context.Context) ([]AgentSnapshot, error) { | |
| 378 | - | if ctx == nil { | |
| 379 | - | ctx = context.Background() | |
| 380 | - | } | |
| 381 | - | for { | |
| 382 | - | pending := false | |
| 383 | - | for _, snapshot := range f.RequiredSnapshots() { | |
| 384 | - | if snapshot.Requirement.pending() { | |
| 385 | - | pending = true | |
| 386 | - | if _, err := f.WaitRequired(ctx, snapshot.ID); err != nil { | |
| 387 | - | if errors.Is(err, errRequiredAgentMissing) { | |
| 388 | - | break | |
| 389 | - | } | |
| 390 | - | return nil, err | |
| 391 | - | } | |
| 392 | - | break | |
| 393 | - | } | |
| 394 | - | } | |
| 395 | - | if !pending { | |
| 396 | - | return f.RequiredSnapshots(), nil | |
| 397 | - | } | |
| 398 | - | } | |
| 399 | - | } | |
| @@ -2,7 +2,6 @@ package subagents | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | - | "errors" | |||
| 6 | "fmt" | 5 | "fmt" | ||
| 7 | "sort" | 6 | "sort" | ||
| 8 | "strconv" | 7 | "strconv" | ||
| @@ -303,24 +302,8 @@ func (a *Agent) waitRequirement(ctx context.Context) (RequirementSnapshot, error | |||||
| 303 | } | 302 | } | ||
| 304 | } | 303 | } | ||
| 305 | 304 | ||||
| 306 | - | var errRequiredAgentMissing = errors.New("subagents: required agent no longer exists") | |||
| 307 | - | ||||
| 308 | - | // WaitRequired blocks on requirement state transitions, never filesystem or | |||
| 309 | - | // status polling, until the selected required turn reaches a terminal outcome. | |||
| 310 | - | func (f *Supervisor) WaitRequired(ctx context.Context, id string) (RequirementSnapshot, error) { | |||
| 311 | - | a := f.Get(strings.TrimSpace(id)) | |||
| 312 | - | if a == nil { | |||
| 313 | - | return RequirementSnapshot{}, fmt.Errorf("%w: %q", errRequiredAgentMissing, id) | |||
| 314 | - | } | |||
| 315 | - | requirement := a.requirementSnapshot() | |||
| 316 | - | if !requirement.Required { | |||
| 317 | - | return requirement, fmt.Errorf("subagents: agent %s is not required", a.ID) | |||
| 318 | - | } | |||
| 319 | - | return a.waitRequirement(ctx) | |||
| 320 | - | } | |||
| 321 | - | ||||
| 322 | // MarkRequirementNotified records that the parent received the terminal | 305 | // MarkRequirementNotified records that the parent received the terminal | ||
| 323 | - | // outcome through a blocking manager tool or host-injected update. | 306 | + | // outcome through a host-injected update. |
| 324 | func (f *Supervisor) MarkRequirementNotified(id string) error { | 307 | func (f *Supervisor) MarkRequirementNotified(id string) error { | ||
| 325 | a := f.Get(strings.TrimSpace(id)) | 308 | a := f.Get(strings.TrimSpace(id)) | ||
| 326 | if a == nil { | 309 | if a == nil { | ||
| @@ -358,8 +341,8 @@ func (f *Supervisor) RequiredSnapshots() []AgentSnapshot { | |||||
| 358 | return out | 341 | return out | ||
| 359 | } | 342 | } | ||
| 360 | 343 | ||||
| 361 | - | // UnmetRequirements returns every required worker that still blocks parent | 344 | + | // UnmetRequirements returns every required worker that still blocks the |
| 362 | - | // completion in the active session. | 345 | + | // parent's terminal response in the active session. |
| 363 | func (f *Supervisor) UnmetRequirements() []AgentSnapshot { | 346 | func (f *Supervisor) UnmetRequirements() []AgentSnapshot { | ||
| 364 | required := f.RequiredSnapshots() | 347 | required := f.RequiredSnapshots() | ||
| 365 | out := required[:0] | 348 | out := required[:0] | ||
| @@ -370,30 +353,3 @@ func (f *Supervisor) UnmetRequirements() []AgentSnapshot { | |||||
| 370 | } | 353 | } | ||
| 371 | return out | 354 | return out | ||
| 372 | } | 355 | } | ||
| 373 | - | ||||
| 374 | - | // WaitActiveRequirements waits for all currently pending required workers in | |||
| 375 | - | // the active session. It returns all required snapshots so the host can inject | |||
| 376 | - | // both successful outcomes and unresolved failures before the next model turn. | |||
| 377 | - | func (f *Supervisor) WaitActiveRequirements(ctx context.Context) ([]AgentSnapshot, error) { | |||
| 378 | - | if ctx == nil { | |||
| 379 | - | ctx = context.Background() | |||
| 380 | - | } | |||
| 381 | - | for { | |||
| 382 | - | pending := false | |||
| 383 | - | for _, snapshot := range f.RequiredSnapshots() { | |||
| 384 | - | if snapshot.Requirement.pending() { | |||
| 385 | - | pending = true | |||
| 386 | - | if _, err := f.WaitRequired(ctx, snapshot.ID); err != nil { | |||
| 387 | - | if errors.Is(err, errRequiredAgentMissing) { | |||
| 388 | - | break | |||
| 389 | - | } | |||
| 390 | - | return nil, err | |||
| 391 | - | } | |||
| 392 | - | break | |||
| 393 | - | } | |||
| 394 | - | } | |||
| 395 | - | if !pending { | |||
| 396 | - | return f.RequiredSnapshots(), nil | |||
| 397 | - | } | |||
| 398 | - | } | |||
| 399 | - | } | |||
M packages/agent/subagents/requirement_test.go +3 -3
| @@ -120,7 +120,7 @@ func TestRequiredWaitCompletesOnDelegatedTurnWhileWorkerStaysAlive(t *testing.T) | |||
| 120 | 120 | } | |
| 121 | 121 | ctx, cancel := context.WithTimeout(context.Background(), runnerProcessTimeout) | |
| 122 | 122 | defer cancel() | |
| 123 | - | got, err := f.WaitRequired(ctx, a.ID) | |
| 123 | + | got, err := a.waitRequirement(ctx) | |
| 124 | 124 | if err != nil { | |
| 125 | 125 | events, _ := ReadEventLog(a.EventLogPath) | |
| 126 | 126 | t.Fatalf("wait for delegated turn: %v\nsnapshot: %+v\nevents: %#v\nformatted: %s", err, a.Snapshot(), events, formatEvents(events)) | |
| @@ -321,7 +321,7 @@ func TestWaitRequiredIsEventDrivenAndRetryCanSatisfy(t *testing.T) { | |||
| 321 | 321 | waited := make(chan RequirementSnapshot, 1) | |
| 322 | 322 | waitErr := make(chan error, 1) | |
| 323 | 323 | go func() { | |
| 324 | - | result, err := f.WaitRequired(ctx, a.ID) | |
| 324 | + | result, err := a.waitRequirement(ctx) | |
| 325 | 325 | if err != nil { | |
| 326 | 326 | waitErr <- err | |
| 327 | 327 | return | |
| @@ -354,7 +354,7 @@ func TestWaitRequiredIsEventDrivenAndRetryCanSatisfy(t *testing.T) { | |||
| 354 | 354 | t.Fatalf("required retry: %v", err) | |
| 355 | 355 | } | |
| 356 | 356 | resumed.Wait() | |
| 357 | - | got, err := f.WaitRequired(context.Background(), a.ID) | |
| 357 | + | got, err := resumed.waitRequirement(context.Background()) | |
| 358 | 358 | if err != nil { | |
| 359 | 359 | t.Fatalf("wait for retry: %v", err) | |
| 360 | 360 | } | |
| @@ -120,7 +120,7 @@ func TestRequiredWaitCompletesOnDelegatedTurnWhileWorkerStaysAlive(t *testing.T) | |||||
| 120 | } | 120 | } | ||
| 121 | ctx, cancel := context.WithTimeout(context.Background(), runnerProcessTimeout) | 121 | ctx, cancel := context.WithTimeout(context.Background(), runnerProcessTimeout) | ||
| 122 | defer cancel() | 122 | defer cancel() | ||
| 123 | - | got, err := f.WaitRequired(ctx, a.ID) | 123 | + | got, err := a.waitRequirement(ctx) |
| 124 | if err != nil { | 124 | if err != nil { | ||
| 125 | events, _ := ReadEventLog(a.EventLogPath) | 125 | events, _ := ReadEventLog(a.EventLogPath) | ||
| 126 | t.Fatalf("wait for delegated turn: %v\nsnapshot: %+v\nevents: %#v\nformatted: %s", err, a.Snapshot(), events, formatEvents(events)) | 126 | t.Fatalf("wait for delegated turn: %v\nsnapshot: %+v\nevents: %#v\nformatted: %s", err, a.Snapshot(), events, formatEvents(events)) | ||
| @@ -321,7 +321,7 @@ func TestWaitRequiredIsEventDrivenAndRetryCanSatisfy(t *testing.T) { | |||||
| 321 | waited := make(chan RequirementSnapshot, 1) | 321 | waited := make(chan RequirementSnapshot, 1) | ||
| 322 | waitErr := make(chan error, 1) | 322 | waitErr := make(chan error, 1) | ||
| 323 | go func() { | 323 | go func() { | ||
| 324 | - | result, err := f.WaitRequired(ctx, a.ID) | 324 | + | result, err := a.waitRequirement(ctx) |
| 325 | if err != nil { | 325 | if err != nil { | ||
| 326 | waitErr <- err | 326 | waitErr <- err | ||
| 327 | return | 327 | return | ||
| @@ -354,7 +354,7 @@ func TestWaitRequiredIsEventDrivenAndRetryCanSatisfy(t *testing.T) { | |||||
| 354 | t.Fatalf("required retry: %v", err) | 354 | t.Fatalf("required retry: %v", err) | ||
| 355 | } | 355 | } | ||
| 356 | resumed.Wait() | 356 | resumed.Wait() | ||
| 357 | - | got, err := f.WaitRequired(context.Background(), a.ID) | 357 | + | got, err := resumed.waitRequirement(context.Background()) |
| 358 | if err != nil { | 358 | if err != nil { | ||
| 359 | t.Fatalf("wait for retry: %v", err) | 359 | t.Fatalf("wait for retry: %v", err) | ||
| 360 | } | 360 | } | ||
M packages/agent/subagents/supervisor.go +2 -2
| @@ -357,8 +357,8 @@ type SpawnRequest struct { | |||
| 357 | 357 | RootSessionID string | |
| 358 | 358 | RequesterAgentID string | |
| 359 | 359 | ||
| 360 | - | // Required keeps this delegated turn on the parent's critical path. The | |
| 361 | - | // supervisor persists its outcome and the manager tool waits for it. | |
| 360 | + | // Required keeps this delegated turn on the parent's completion path. The | |
| 361 | + | // supervisor persists its outcome while the manager remains asynchronous. | |
| 362 | 362 | Required bool | |
| 363 | 363 | Timeout time.Duration | |
| 364 | 364 | MaxTurns int | |
| @@ -357,8 +357,8 @@ type SpawnRequest struct { | |||||
| 357 | RootSessionID string | 357 | RootSessionID string | ||
| 358 | RequesterAgentID string | 358 | RequesterAgentID string | ||
| 359 | 359 | ||||
| 360 | - | // Required keeps this delegated turn on the parent's critical path. The | 360 | + | // Required keeps this delegated turn on the parent's completion path. The |
| 361 | - | // supervisor persists its outcome and the manager tool waits for it. | 361 | + | // supervisor persists its outcome while the manager remains asynchronous. |
| 362 | Required bool | 362 | Required bool | ||
| 363 | Timeout time.Duration | 363 | Timeout time.Duration | ||
| 364 | MaxTurns int | 364 | MaxTurns int | ||
M packages/agent/tools/subagent_required_test.go +42 -51
| @@ -8,13 +8,11 @@ import ( | |||
| 8 | 8 | "path/filepath" | |
| 9 | 9 | "strings" | |
| 10 | 10 | "testing" | |
| 11 | - | "time" | |
| 12 | 11 | ||
| 13 | 12 | "github.com/bnema/zut/packages/agent/subagents" | |
| 14 | - | "github.com/bnema/zut/packages/core" | |
| 15 | 13 | ) | |
| 16 | 14 | ||
| 17 | - | func TestRequiredSubagentSpawnBlocksUntilTerminalOutcome(t *testing.T) { | |
| 15 | + | func TestRequiredSubagentSpawnReturnsWhileWorkerRuns(t *testing.T) { | |
| 18 | 16 | root := t.TempDir() | |
| 19 | 17 | started := make(chan struct{}) | |
| 20 | 18 | release := make(chan struct{}) | |
| @@ -43,24 +41,17 @@ func TestRequiredSubagentSpawnBlocksUntilTerminalOutcome(t *testing.T) { | |||
| 43 | 41 | spawned <- required | |
| 44 | 42 | }, | |
| 45 | 43 | } | |
| 46 | - | result := make(chan core.ToolResult, 1) | |
| 47 | - | errCh := make(chan error, 1) | |
| 48 | - | go func() { | |
| 49 | - | got, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"finish the release","required":true}`), nil) | |
| 50 | - | if err != nil { | |
| 51 | - | errCh <- err | |
| 52 | - | return | |
| 53 | - | } | |
| 54 | - | result <- got | |
| 55 | - | }() | |
| 56 | - | ||
| 44 | + | got, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"finish the release","required":true}`), nil) | |
| 45 | + | if err != nil { | |
| 46 | + | t.Fatal(err) | |
| 47 | + | } | |
| 57 | 48 | <-started | |
| 58 | - | select { | |
| 59 | - | case got := <-result: | |
| 60 | - | t.Fatalf("required spawn returned while worker was running: %s", textResult(got.Content)) | |
| 61 | - | case err := <-errCh: | |
| 62 | - | t.Fatalf("required spawn failed while worker was running: %v", err) | |
| 63 | - | default: | |
| 49 | + | text := textResult(got.Content) | |
| 50 | + | if got.IsError || !strings.Contains(text, "required: pending") || !strings.Contains(text, "manager remains free") { | |
| 51 | + | t.Fatalf("required spawn result = %#v\n%s", got, text) | |
| 52 | + | } | |
| 53 | + | if details, ok := got.Details.(map[string]any); !ok || details["required"] != true || details["requirement_state"] != "pending" { | |
| 54 | + | t.Fatalf("required details = %#v", got.Details) | |
| 64 | 55 | } | |
| 65 | 56 | select { | |
| 66 | 57 | case required := <-spawned: | |
| @@ -72,52 +63,45 @@ func TestRequiredSubagentSpawnBlocksUntilTerminalOutcome(t *testing.T) { | |||
| 72 | 63 | } | |
| 73 | 64 | ||
| 74 | 65 | close(release) | |
| 75 | - | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |
| 76 | - | defer cancel() | |
| 77 | - | select { | |
| 78 | - | case err := <-errCh: | |
| 79 | - | t.Fatalf("required spawn failed: %v", err) | |
| 80 | - | case got := <-result: | |
| 81 | - | text := textResult(got.Content) | |
| 82 | - | if got.IsError || !strings.Contains(text, "required") || !strings.Contains(text, "satisfied") { | |
| 83 | - | t.Fatalf("required result = %#v\n%s", got, text) | |
| 84 | - | } | |
| 85 | - | if details, ok := got.Details.(map[string]any); !ok || details["required"] != true || details["requirement_state"] != "satisfied" { | |
| 86 | - | t.Fatalf("required details = %#v", got.Details) | |
| 87 | - | } | |
| 88 | - | case <-ctx.Done(): | |
| 89 | - | t.Fatal("required spawn did not return after worker completion") | |
| 90 | - | } | |
| 66 | + | manager.List()[0].Wait() | |
| 91 | 67 | } | |
| 92 | 68 | ||
| 93 | - | func TestRequiredSubagentResumeRetriesUnmetWorkAsRequired(t *testing.T) { | |
| 69 | + | func TestRequiredSubagentResumeRetriesUnmetWorkWithoutBlocking(t *testing.T) { | |
| 94 | 70 | root := t.TempDir() | |
| 95 | 71 | attempt := 0 | |
| 72 | + | retryStarted := make(chan struct{}) | |
| 73 | + | releaseRetry := make(chan struct{}) | |
| 96 | 74 | manager := subagents.New(subagents.Config{ | |
| 97 | 75 | Root: filepath.Join(root, "subagents"), | |
| 98 | 76 | RepoRoot: root, | |
| 99 | 77 | NewRunner: func(*subagents.Agent) subagents.Runner { | |
| 100 | 78 | attempt++ | |
| 101 | 79 | current := attempt | |
| 102 | - | return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { | |
| 80 | + | return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error { | |
| 103 | 81 | if current == 1 { | |
| 104 | 82 | return errors.New("first review failed") | |
| 105 | 83 | } | |
| 106 | - | return nil | |
| 84 | + | close(retryStarted) | |
| 85 | + | select { | |
| 86 | + | case <-releaseRetry: | |
| 87 | + | return nil | |
| 88 | + | case <-ctx.Done(): | |
| 89 | + | return ctx.Err() | |
| 90 | + | } | |
| 107 | 91 | }) | |
| 108 | 92 | }, | |
| 109 | 93 | }) | |
| 110 | 94 | t.Cleanup(manager.StopAll) | |
| 111 | 95 | spawn := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }} | |
| 112 | 96 | first, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","required":true}`), nil) | |
| 113 | - | if err != nil { | |
| 114 | - | t.Fatal(err) | |
| 115 | - | } | |
| 116 | - | if !first.IsError { | |
| 117 | - | t.Fatalf("first required result should remain unmet: %s", textResult(first.Content)) | |
| 97 | + | if err != nil || first.IsError { | |
| 98 | + | t.Fatalf("required spawn = (%+v, %v), want asynchronous success", first, err) | |
| 118 | 99 | } | |
| 119 | 100 | worker := manager.List()[0] | |
| 120 | 101 | worker.Wait() | |
| 102 | + | if !worker.Snapshot().Requirement.Unmet() { | |
| 103 | + | t.Fatal("failed required worker was not retained as unmet") | |
| 104 | + | } | |
| 121 | 105 | ||
| 122 | 106 | var callbackRequired bool | |
| 123 | 107 | resume := &SubagentResumeTool{ | |
| @@ -132,9 +116,15 @@ func TestRequiredSubagentResumeRetriesUnmetWorkAsRequired(t *testing.T) { | |||
| 132 | 116 | if err != nil { | |
| 133 | 117 | t.Fatal(err) | |
| 134 | 118 | } | |
| 135 | - | if got.IsError || !callbackRequired || !strings.Contains(textResult(got.Content), "satisfied") { | |
| 119 | + | <-retryStarted | |
| 120 | + | if got.IsError || !callbackRequired || !strings.Contains(textResult(got.Content), `"requirement":{"state":"pending"`) { | |
| 136 | 121 | t.Fatalf("required retry = %#v callback_required=%v\n%s", got, callbackRequired, textResult(got.Content)) | |
| 137 | 122 | } | |
| 123 | + | close(releaseRetry) | |
| 124 | + | manager.List()[0].Wait() | |
| 125 | + | if state := manager.List()[0].Snapshot().Requirement.State; state != subagents.RequirementSatisfied { | |
| 126 | + | t.Fatalf("required retry state = %s, want satisfied", state) | |
| 127 | + | } | |
| 138 | 128 | } | |
| 139 | 129 | ||
| 140 | 130 | func TestIndeterminateRequiredWorkNeedsExplicitUserReconciliation(t *testing.T) { | |
| @@ -204,14 +194,15 @@ func TestRequiredSubagentFailureStaysUnmet(t *testing.T) { | |||
| 204 | 194 | tool := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }} | |
| 205 | 195 | ||
| 206 | 196 | got, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"finish the release","required":true}`), nil) | |
| 207 | - | if err != nil { | |
| 208 | - | t.Fatal(err) | |
| 209 | - | } | |
| 210 | - | if !strings.Contains(textResult(got.Content), "failed") { | |
| 211 | - | t.Fatalf("required failure was not surfaced: %s", textResult(got.Content)) | |
| 197 | + | if err != nil || got.IsError || !strings.Contains(textResult(got.Content), "required: pending") { | |
| 198 | + | t.Fatalf("required spawn = (%+v, %v), want asynchronous pending result", got, err) | |
| 212 | 199 | } | |
| 213 | 200 | agents := manager.List() | |
| 214 | - | if len(agents) != 1 || !agents[0].Snapshot().Requirement.Unmet() { | |
| 201 | + | if len(agents) != 1 { | |
| 202 | + | t.Fatalf("spawned agents = %d, want 1", len(agents)) | |
| 203 | + | } | |
| 204 | + | agents[0].Wait() | |
| 205 | + | if !agents[0].Snapshot().Requirement.Unmet() { | |
| 215 | 206 | t.Fatalf("failed required worker was not retained as unmet: %#v", agents) | |
| 216 | 207 | } | |
| 217 | 208 | status := &SubagentStatusTool{Supervisor: manager, Enabled: func() bool { return true }} | |
| @@ -8,13 +8,11 @@ import ( | |||||
| 8 | "path/filepath" | 8 | "path/filepath" | ||
| 9 | "strings" | 9 | "strings" | ||
| 10 | "testing" | 10 | "testing" | ||
| 11 | - | "time" | |||
| 12 | 11 | ||||
| 13 | "github.com/bnema/zut/packages/agent/subagents" | 12 | "github.com/bnema/zut/packages/agent/subagents" | ||
| 14 | - | "github.com/bnema/zut/packages/core" | |||
| 15 | ) | 13 | ) | ||
| 16 | 14 | ||||
| 17 | - | func TestRequiredSubagentSpawnBlocksUntilTerminalOutcome(t *testing.T) { | 15 | + | func TestRequiredSubagentSpawnReturnsWhileWorkerRuns(t *testing.T) { |
| 18 | root := t.TempDir() | 16 | root := t.TempDir() | ||
| 19 | started := make(chan struct{}) | 17 | started := make(chan struct{}) | ||
| 20 | release := make(chan struct{}) | 18 | release := make(chan struct{}) | ||
| @@ -43,24 +41,17 @@ func TestRequiredSubagentSpawnBlocksUntilTerminalOutcome(t *testing.T) { | |||||
| 43 | spawned <- required | 41 | spawned <- required | ||
| 44 | }, | 42 | }, | ||
| 45 | } | 43 | } | ||
| 46 | - | result := make(chan core.ToolResult, 1) | 44 | + | got, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"finish the release","required":true}`), nil) |
| 47 | - | errCh := make(chan error, 1) | 45 | + | if err != nil { |
| 48 | - | go func() { | 46 | + | t.Fatal(err) |
| 49 | - | got, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"finish the release","required":true}`), nil) | 47 | + | } |
| 50 | - | if err != nil { | |||
| 51 | - | errCh <- err | |||
| 52 | - | return | |||
| 53 | - | } | |||
| 54 | - | result <- got | |||
| 55 | - | }() | |||
| 56 | - | ||||
| 57 | <-started | 48 | <-started | ||
| 58 | - | select { | 49 | + | text := textResult(got.Content) |
| 59 | - | case got := <-result: | 50 | + | if got.IsError || !strings.Contains(text, "required: pending") || !strings.Contains(text, "manager remains free") { |
| 60 | - | t.Fatalf("required spawn returned while worker was running: %s", textResult(got.Content)) | 51 | + | t.Fatalf("required spawn result = %#v\n%s", got, text) |
| 61 | - | case err := <-errCh: | 52 | + | } |
| 62 | - | t.Fatalf("required spawn failed while worker was running: %v", err) | 53 | + | if details, ok := got.Details.(map[string]any); !ok || details["required"] != true || details["requirement_state"] != "pending" { |
| 63 | - | default: | 54 | + | t.Fatalf("required details = %#v", got.Details) |
| 64 | } | 55 | } | ||
| 65 | select { | 56 | select { | ||
| 66 | case required := <-spawned: | 57 | case required := <-spawned: | ||
| @@ -72,52 +63,45 @@ func TestRequiredSubagentSpawnBlocksUntilTerminalOutcome(t *testing.T) { | |||||
| 72 | } | 63 | } | ||
| 73 | 64 | ||||
| 74 | close(release) | 65 | close(release) | ||
| 75 | - | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | 66 | + | manager.List()[0].Wait() |
| 76 | - | defer cancel() | |||
| 77 | - | select { | |||
| 78 | - | case err := <-errCh: | |||
| 79 | - | t.Fatalf("required spawn failed: %v", err) | |||
| 80 | - | case got := <-result: | |||
| 81 | - | text := textResult(got.Content) | |||
| 82 | - | if got.IsError || !strings.Contains(text, "required") || !strings.Contains(text, "satisfied") { | |||
| 83 | - | t.Fatalf("required result = %#v\n%s", got, text) | |||
| 84 | - | } | |||
| 85 | - | if details, ok := got.Details.(map[string]any); !ok || details["required"] != true || details["requirement_state"] != "satisfied" { | |||
| 86 | - | t.Fatalf("required details = %#v", got.Details) | |||
| 87 | - | } | |||
| 88 | - | case <-ctx.Done(): | |||
| 89 | - | t.Fatal("required spawn did not return after worker completion") | |||
| 90 | - | } | |||
| 91 | } | 67 | } | ||
| 92 | 68 | ||||
| 93 | - | func TestRequiredSubagentResumeRetriesUnmetWorkAsRequired(t *testing.T) { | 69 | + | func TestRequiredSubagentResumeRetriesUnmetWorkWithoutBlocking(t *testing.T) { |
| 94 | root := t.TempDir() | 70 | root := t.TempDir() | ||
| 95 | attempt := 0 | 71 | attempt := 0 | ||
| 72 | + | retryStarted := make(chan struct{}) | |||
| 73 | + | releaseRetry := make(chan struct{}) | |||
| 96 | manager := subagents.New(subagents.Config{ | 74 | manager := subagents.New(subagents.Config{ | ||
| 97 | Root: filepath.Join(root, "subagents"), | 75 | Root: filepath.Join(root, "subagents"), | ||
| 98 | RepoRoot: root, | 76 | RepoRoot: root, | ||
| 99 | NewRunner: func(*subagents.Agent) subagents.Runner { | 77 | NewRunner: func(*subagents.Agent) subagents.Runner { | ||
| 100 | attempt++ | 78 | attempt++ | ||
| 101 | current := attempt | 79 | current := attempt | ||
| 102 | - | return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { | 80 | + | return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error { |
| 103 | if current == 1 { | 81 | if current == 1 { | ||
| 104 | return errors.New("first review failed") | 82 | return errors.New("first review failed") | ||
| 105 | } | 83 | } | ||
| 106 | - | return nil | 84 | + | close(retryStarted) |
| 85 | + | select { | |||
| 86 | + | case <-releaseRetry: | |||
| 87 | + | return nil | |||
| 88 | + | case <-ctx.Done(): | |||
| 89 | + | return ctx.Err() | |||
| 90 | + | } | |||
| 107 | }) | 91 | }) | ||
| 108 | }, | 92 | }, | ||
| 109 | }) | 93 | }) | ||
| 110 | t.Cleanup(manager.StopAll) | 94 | t.Cleanup(manager.StopAll) | ||
| 111 | spawn := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }} | 95 | spawn := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }} | ||
| 112 | first, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","required":true}`), nil) | 96 | first, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","required":true}`), nil) | ||
| 113 | - | if err != nil { | 97 | + | if err != nil || first.IsError { |
| 114 | - | t.Fatal(err) | 98 | + | t.Fatalf("required spawn = (%+v, %v), want asynchronous success", first, err) |
| 115 | - | } | |||
| 116 | - | if !first.IsError { | |||
| 117 | - | t.Fatalf("first required result should remain unmet: %s", textResult(first.Content)) | |||
| 118 | } | 99 | } | ||
| 119 | worker := manager.List()[0] | 100 | worker := manager.List()[0] | ||
| 120 | worker.Wait() | 101 | worker.Wait() | ||
| 102 | + | if !worker.Snapshot().Requirement.Unmet() { | |||
| 103 | + | t.Fatal("failed required worker was not retained as unmet") | |||
| 104 | + | } | |||
| 121 | 105 | ||||
| 122 | var callbackRequired bool | 106 | var callbackRequired bool | ||
| 123 | resume := &SubagentResumeTool{ | 107 | resume := &SubagentResumeTool{ | ||
| @@ -132,9 +116,15 @@ func TestRequiredSubagentResumeRetriesUnmetWorkAsRequired(t *testing.T) { | |||||
| 132 | if err != nil { | 116 | if err != nil { | ||
| 133 | t.Fatal(err) | 117 | t.Fatal(err) | ||
| 134 | } | 118 | } | ||
| 135 | - | if got.IsError || !callbackRequired || !strings.Contains(textResult(got.Content), "satisfied") { | 119 | + | <-retryStarted |
| 120 | + | if got.IsError || !callbackRequired || !strings.Contains(textResult(got.Content), `"requirement":{"state":"pending"`) { | |||
| 136 | t.Fatalf("required retry = %#v callback_required=%v\n%s", got, callbackRequired, textResult(got.Content)) | 121 | t.Fatalf("required retry = %#v callback_required=%v\n%s", got, callbackRequired, textResult(got.Content)) | ||
| 137 | } | 122 | } | ||
| 123 | + | close(releaseRetry) | |||
| 124 | + | manager.List()[0].Wait() | |||
| 125 | + | if state := manager.List()[0].Snapshot().Requirement.State; state != subagents.RequirementSatisfied { | |||
| 126 | + | t.Fatalf("required retry state = %s, want satisfied", state) | |||
| 127 | + | } | |||
| 138 | } | 128 | } | ||
| 139 | 129 | ||||
| 140 | func TestIndeterminateRequiredWorkNeedsExplicitUserReconciliation(t *testing.T) { | 130 | func TestIndeterminateRequiredWorkNeedsExplicitUserReconciliation(t *testing.T) { | ||
| @@ -204,14 +194,15 @@ func TestRequiredSubagentFailureStaysUnmet(t *testing.T) { | |||||
| 204 | tool := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }} | 194 | tool := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }} | ||
| 205 | 195 | ||||
| 206 | got, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"finish the release","required":true}`), nil) | 196 | got, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"finish the release","required":true}`), nil) | ||
| 207 | - | if err != nil { | 197 | + | if err != nil || got.IsError || !strings.Contains(textResult(got.Content), "required: pending") { |
| 208 | - | t.Fatal(err) | 198 | + | t.Fatalf("required spawn = (%+v, %v), want asynchronous pending result", got, err) |
| 209 | - | } | |||
| 210 | - | if !strings.Contains(textResult(got.Content), "failed") { | |||
| 211 | - | t.Fatalf("required failure was not surfaced: %s", textResult(got.Content)) | |||
| 212 | } | 199 | } | ||
| 213 | agents := manager.List() | 200 | agents := manager.List() | ||
| 214 | - | if len(agents) != 1 || !agents[0].Snapshot().Requirement.Unmet() { | 201 | + | if len(agents) != 1 { |
| 202 | + | t.Fatalf("spawned agents = %d, want 1", len(agents)) | |||
| 203 | + | } | |||
| 204 | + | agents[0].Wait() | |||
| 205 | + | if !agents[0].Snapshot().Requirement.Unmet() { | |||
| 215 | t.Fatalf("failed required worker was not retained as unmet: %#v", agents) | 206 | t.Fatalf("failed required worker was not retained as unmet: %#v", agents) | ||
| 216 | } | 207 | } | ||
| 217 | status := &SubagentStatusTool{Supervisor: manager, Enabled: func() bool { return true }} | 208 | status := &SubagentStatusTool{Supervisor: manager, Enabled: func() bool { return true }} | ||
M packages/agent/tools/subagent_resume.go +2 -6
| @@ -51,7 +51,7 @@ const subagentResumeSchema = `{ | |||
| 51 | 51 | }, | |
| 52 | 52 | "required": { | |
| 53 | 53 | "type": "boolean", | |
| 54 | - | "description": "Set true to make this follow-up mandatory. Failed, timed-out, or canceled required work remains required automatically; indeterminate work needs explicit user reconciliation." | |
| 54 | + | "description": "Set true to make this asynchronous follow-up mandatory before the parent's terminal response. Failed, timed-out, or canceled required work remains required automatically; indeterminate work needs explicit user reconciliation." | |
| 55 | 55 | } | |
| 56 | 56 | }, | |
| 57 | 57 | "required": ["agent_id", "prompt"] | |
| @@ -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, progress func(string)) (core.ToolResult, error) { | |
| 70 | + | func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) { | |
| 71 | 71 | if ctx != nil { | |
| 72 | 72 | select { | |
| 73 | 73 | case <-ctx.Done(): | |
| @@ -121,10 +121,6 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, p | |||
| 121 | 121 | if t.BeforeResumed == nil && t.OnResumed != nil { | |
| 122 | 122 | t.OnResumed(resumed, args.Prompt, required) | |
| 123 | 123 | } | |
| 124 | - | if required { | |
| 125 | - | return waitRequiredSubagent(ctx, prefix, t.Supervisor, resumed, args.Prompt, progress) | |
| 126 | - | } | |
| 127 | - | ||
| 128 | 124 | snapshot, ok = findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), snapshot.ID) | |
| 129 | 125 | if !ok { | |
| 130 | 126 | return core.ToolResult{}, fmt.Errorf("%s: resumed agent disappeared from supervisor", prefix) | |
| @@ -51,7 +51,7 @@ const subagentResumeSchema = `{ | |||||
| 51 | }, | 51 | }, | ||
| 52 | "required": { | 52 | "required": { | ||
| 53 | "type": "boolean", | 53 | "type": "boolean", | ||
| 54 | - | "description": "Set true to make this follow-up mandatory. Failed, timed-out, or canceled required work remains required automatically; indeterminate work needs explicit user reconciliation." | 54 | + | "description": "Set true to make this asynchronous follow-up mandatory before the parent's terminal response. Failed, timed-out, or canceled required work remains required automatically; indeterminate work needs explicit user reconciliation." |
| 55 | } | 55 | } | ||
| 56 | }, | 56 | }, | ||
| 57 | "required": ["agent_id", "prompt"] | 57 | "required": ["agent_id", "prompt"] | ||
| @@ -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, progress func(string)) (core.ToolResult, error) { | 70 | + | func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ 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(): | ||
| @@ -121,10 +121,6 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, p | |||||
| 121 | if t.BeforeResumed == nil && t.OnResumed != nil { | 121 | if t.BeforeResumed == nil && t.OnResumed != nil { | ||
| 122 | t.OnResumed(resumed, args.Prompt, required) | 122 | t.OnResumed(resumed, args.Prompt, required) | ||
| 123 | } | 123 | } | ||
| 124 | - | if required { | |||
| 125 | - | return waitRequiredSubagent(ctx, prefix, t.Supervisor, resumed, args.Prompt, progress) | |||
| 126 | - | } | |||
| 127 | - | ||||
| 128 | snapshot, ok = findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), snapshot.ID) | 124 | snapshot, ok = findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), snapshot.ID) | ||
| 129 | if !ok { | 125 | if !ok { | ||
| 130 | return core.ToolResult{}, fmt.Errorf("%s: resumed agent disappeared from supervisor", prefix) | 126 | return core.ToolResult{}, fmt.Errorf("%s: resumed agent disappeared from supervisor", prefix) | ||
M packages/agent/tools/subagent_spawn.go +30 -75
| @@ -13,9 +13,9 @@ import ( | |||
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | 15 | // SubagentSpawnTool lets the main agent delegate work against the host's cwd | |
| 16 | - | // via subagents.Supervisor.SpawnReq. Optional work returns immediately and runs | |
| 17 | - | // in the background. Required work keeps the tool call open until the delegated | |
| 18 | - | // turn reaches a durable terminal outcome. | |
| 16 | + | // via subagents.Supervisor.SpawnReq. Every spawn returns immediately and runs in | |
| 17 | + | // the background. Required work records a durable obligation that must be | |
| 18 | + | // resolved before the parent can produce its terminal response. | |
| 19 | 19 | // | |
| 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 | |
| @@ -49,8 +49,7 @@ type SubagentSpawnTool struct { | |||
| 49 | 49 | ResolveSubagent func(name string) (*subagents.Profile, error) | |
| 50 | 50 | ||
| 51 | 51 | // OnSpawned, if set, is called after every successful spawn with the | |
| 52 | - | // requirement mode. Hosts track optional work for asynchronous completion; | |
| 53 | - | // required work is delivered by this blocking tool call instead. | |
| 52 | + | // requirement mode. Hosts track every worker for asynchronous completion. | |
| 54 | 53 | OnSpawned func(agent *subagents.Agent, task string, required bool) | |
| 55 | 54 | } | |
| 56 | 55 | ||
| @@ -103,7 +102,7 @@ const subagentSpawnSchemaTemplate = `{ | |||
| 103 | 102 | }, | |
| 104 | 103 | "required": { | |
| 105 | 104 | "type": "boolean", | |
| 106 | - | "description": "Set true when the parent must receive this delegated result before it can finish. Required calls wait event-driven for a terminal outcome; failed, timed-out, or canceled work remains unmet until a successful retry or explicit user removal. An outcome unobserved across host restart requires explicit user reconciliation." | |
| 105 | + | "description": "Set true when the parent must receive this delegated result before it can finish. The worker remains asynchronous and reports through a host completion update. Failed, timed-out, or canceled work remains unmet until a successful retry or explicit user removal. An outcome unobserved across host restart requires explicit user reconciliation." | |
| 107 | 106 | }, | |
| 108 | 107 | "isolation": { | |
| 109 | 108 | "type": "string", | |
| @@ -126,7 +125,7 @@ const subagentSpawnSchemaTemplate = `{ | |||
| 126 | 125 | ||
| 127 | 126 | func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName } | |
| 128 | 127 | func (t *SubagentSpawnTool) Description() string { | |
| 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." | |
| 128 | + | return "Delegate work to a sub-agent. Every spawn returns immediately and completion is host-event-driven through [auto-subagents update]. Set required=true when the outcome is mandatory before the parent's terminal response; failures remain recoverable through subagent_resume. Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, 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." | |
| 130 | 129 | } | |
| 131 | 130 | func (t *SubagentSpawnTool) Schema() json.RawMessage { | |
| 132 | 131 | maxTurns := 3 | |
| @@ -138,7 +137,7 @@ func (t *SubagentSpawnTool) Schema() json.RawMessage { | |||
| 138 | 137 | return json.RawMessage(fmt.Sprintf(subagentSpawnSchemaTemplate, maxTurns, maxTurns)) | |
| 139 | 138 | } | |
| 140 | 139 | ||
| 141 | - | func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | |
| 140 | + | func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) { | |
| 142 | 141 | prefix := t.Name() | |
| 143 | 142 | if t.Supervisor == nil { | |
| 144 | 143 | return protocolToolError(prefix + ": subagent supervisor not available in this mode") | |
| @@ -272,10 +271,6 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr | |||
| 272 | 271 | if t.OnSpawned != nil { | |
| 273 | 272 | t.OnSpawned(agent, task, a.Required) | |
| 274 | 273 | } | |
| 275 | - | if a.Required { | |
| 276 | - | return waitRequiredSubagent(ctx, t.Name(), t.Supervisor, agent, task, progress) | |
| 277 | - | } | |
| 278 | - | ||
| 279 | 274 | var sb strings.Builder | |
| 280 | 275 | fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID) | |
| 281 | 276 | fmt.Fprintf(&sb, "state: %s/%s\n", agent.ProcessState(), agent.TurnState()) | |
| @@ -299,75 +294,35 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr | |||
| 299 | 294 | if fastModeFromProfile && fastModeOverride != nil && *fastModeOverride && agent.FastModeOverridesHost() { | |
| 300 | 295 | sb.WriteString("warning: subagent profile has fast mode enabled, overriding global fast mode off\n") | |
| 301 | 296 | } | |
| 302 | - | sb.WriteString("\nThe sub-agent is running in the background. Completion is host-event-driven: wait for the injected [auto-subagents update], the only completion signal. ") | |
| 297 | + | if a.Required { | |
| 298 | + | sb.WriteString("required: pending\n") | |
| 299 | + | } | |
| 300 | + | sb.WriteString("\nThe sub-agent is running in the background. Completion is host-event-driven through [auto-subagents update], the only completion signal; the manager remains free for independent work. ") | |
| 303 | 301 | sb.WriteString("Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. ") | |
| 304 | 302 | sb.WriteString("Work on unrelated independent tasks; otherwise end or yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are allowed.") | |
| 305 | - | return core.ToolResult{ | |
| 306 | - | Content: []provider.Content{provider.TextBlock{Text: sb.String()}}, | |
| 307 | - | Details: map[string]any{ | |
| 308 | - | "agent_id": agent.ID, | |
| 309 | - | "task": task, | |
| 310 | - | "agent": agentName, | |
| 311 | - | "model": model, | |
| 312 | - | "provider": providerID, | |
| 313 | - | "reasoning": reasoning, | |
| 314 | - | "fast_mode": agent.FastMode, | |
| 315 | - | "required": false, | |
| 316 | - | "isolation": string(workspaceMode), | |
| 317 | - | "timeout": agent.Timeout.String(), | |
| 318 | - | "max_turns": agent.MaxTurns, | |
| 319 | - | "state": agent.Status(), | |
| 320 | - | "process_state": string(agent.ProcessState()), | |
| 321 | - | "turn_state": string(agent.TurnState()), | |
| 322 | - | "result_ref": subagents.ResultRef(agent.ID), | |
| 323 | - | }, | |
| 324 | - | }, nil | |
| 325 | - | } | |
| 326 | - | ||
| 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 | - | } | |
| 331 | - | requirement, err := supervisor.WaitRequired(ctx, agent.ID) | |
| 332 | - | if err != nil { | |
| 333 | - | return core.ToolResult{}, fmt.Errorf("%s: wait for required agent %s: %w", toolName, agent.ID, err) | |
| 303 | + | details := map[string]any{ | |
| 304 | + | "agent_id": agent.ID, | |
| 305 | + | "task": task, | |
| 306 | + | "agent": agentName, | |
| 307 | + | "model": model, | |
| 308 | + | "provider": providerID, | |
| 309 | + | "reasoning": reasoning, | |
| 310 | + | "fast_mode": agent.FastMode, | |
| 311 | + | "required": a.Required, | |
| 312 | + | "isolation": string(workspaceMode), | |
| 313 | + | "timeout": agent.Timeout.String(), | |
| 314 | + | "max_turns": agent.MaxTurns, | |
| 315 | + | "state": agent.Status(), | |
| 316 | + | "process_state": string(agent.ProcessState()), | |
| 317 | + | "turn_state": string(agent.TurnState()), | |
| 318 | + | "result_ref": subagents.ResultRef(agent.ID), | |
| 334 | 319 | } | |
| 335 | - | snapshot := agent.Snapshot() | |
| 336 | - | var sb strings.Builder | |
| 337 | - | fmt.Fprintf(&sb, "required sub-agent %s reached %s\n", agent.ID, requirement.State) | |
| 338 | - | fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200)) | |
| 339 | - | fmt.Fprintf(&sb, "requirement: %s\n", requirement.State) | |
| 340 | - | if requirement.ErrorCode != "" { | |
| 341 | - | fmt.Fprintf(&sb, "error_code: %s\n", requirement.ErrorCode) | |
| 342 | - | fmt.Fprintf(&sb, "diagnostic: %s\n", subagents.ResultRef(agent.ID)) | |
| 343 | - | } | |
| 344 | - | output := strings.TrimSpace(snapshot.LastAssistant) | |
| 345 | - | if snapshot.Result != nil && strings.TrimSpace(snapshot.Result.Output) != "" { | |
| 346 | - | output = strings.TrimSpace(snapshot.Result.Output) | |
| 347 | - | } | |
| 348 | - | if output != "" { | |
| 349 | - | sb.WriteString("\nresult:\n") | |
| 350 | - | sb.WriteString(output) | |
| 351 | - | sb.WriteByte('\n') | |
| 352 | - | } | |
| 353 | - | if requirement.Unmet() { | |
| 354 | - | sb.WriteString("\nThis required work is still unmet. Retry it with subagent_resume before producing a terminal answer. The user may explicitly waive it by removing the terminal worker with /subagents rm.") | |
| 355 | - | } else { | |
| 356 | - | sb.WriteString("\nThe required result is now available in this turn; continue with it before answering.") | |
| 320 | + | if a.Required { | |
| 321 | + | details["requirement_state"] = string(subagents.RequirementPending) | |
| 357 | 322 | } | |
| 358 | 323 | return core.ToolResult{ | |
| 359 | 324 | Content: []provider.Content{provider.TextBlock{Text: sb.String()}}, | |
| 360 | - | Details: map[string]any{ | |
| 361 | - | "agent_id": agent.ID, | |
| 362 | - | "task": task, | |
| 363 | - | "required": true, | |
| 364 | - | "requirement_state": string(requirement.State), | |
| 365 | - | "state": snapshot.Status, | |
| 366 | - | "process_state": string(snapshot.ProcessState), | |
| 367 | - | "turn_state": string(snapshot.TurnState), | |
| 368 | - | "result_ref": subagents.ResultRef(agent.ID), | |
| 369 | - | }, | |
| 370 | - | IsError: requirement.Unmet(), | |
| 325 | + | Details: details, | |
| 371 | 326 | }, nil | |
| 372 | 327 | } | |
| 373 | 328 | ||
| @@ -13,9 +13,9 @@ import ( | |||||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | // SubagentSpawnTool lets the main agent delegate work against the host's cwd | 15 | // SubagentSpawnTool lets the main agent delegate work against the host's cwd | ||
| 16 | - | // via subagents.Supervisor.SpawnReq. Optional work returns immediately and runs | 16 | + | // via subagents.Supervisor.SpawnReq. Every spawn returns immediately and runs in |
| 17 | - | // in the background. Required work keeps the tool call open until the delegated | 17 | + | // the background. Required work records a durable obligation that must be |
| 18 | - | // turn reaches a durable terminal outcome. | 18 | + | // resolved before the parent can produce its terminal response. |
| 19 | // | 19 | // | ||
| 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 | ||
| @@ -49,8 +49,7 @@ type SubagentSpawnTool struct { | |||||
| 49 | ResolveSubagent func(name string) (*subagents.Profile, error) | 49 | ResolveSubagent func(name string) (*subagents.Profile, error) | ||
| 50 | 50 | ||||
| 51 | // OnSpawned, if set, is called after every successful spawn with the | 51 | // OnSpawned, if set, is called after every successful spawn with the | ||
| 52 | - | // requirement mode. Hosts track optional work for asynchronous completion; | 52 | + | // requirement mode. Hosts track every worker for asynchronous completion. |
| 53 | - | // required work is delivered by this blocking tool call instead. | |||
| 54 | OnSpawned func(agent *subagents.Agent, task string, required bool) | 53 | OnSpawned func(agent *subagents.Agent, task string, required bool) | ||
| 55 | } | 54 | } | ||
| 56 | 55 | ||||
| @@ -103,7 +102,7 @@ const subagentSpawnSchemaTemplate = `{ | |||||
| 103 | }, | 102 | }, | ||
| 104 | "required": { | 103 | "required": { | ||
| 105 | "type": "boolean", | 104 | "type": "boolean", | ||
| 106 | - | "description": "Set true when the parent must receive this delegated result before it can finish. Required calls wait event-driven for a terminal outcome; failed, timed-out, or canceled work remains unmet until a successful retry or explicit user removal. An outcome unobserved across host restart requires explicit user reconciliation." | 105 | + | "description": "Set true when the parent must receive this delegated result before it can finish. The worker remains asynchronous and reports through a host completion update. Failed, timed-out, or canceled work remains unmet until a successful retry or explicit user removal. An outcome unobserved across host restart requires explicit user reconciliation." |
| 107 | }, | 106 | }, | ||
| 108 | "isolation": { | 107 | "isolation": { | ||
| 109 | "type": "string", | 108 | "type": "string", | ||
| @@ -126,7 +125,7 @@ const subagentSpawnSchemaTemplate = `{ | |||||
| 126 | 125 | ||||
| 127 | func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName } | 126 | func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName } | ||
| 128 | func (t *SubagentSpawnTool) Description() string { | 127 | func (t *SubagentSpawnTool) Description() string { | ||
| 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." | 128 | + | return "Delegate work to a sub-agent. Every spawn returns immediately and completion is host-event-driven through [auto-subagents update]. Set required=true when the outcome is mandatory before the parent's terminal response; failures remain recoverable through subagent_resume. Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, 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." |
| 130 | } | 129 | } | ||
| 131 | func (t *SubagentSpawnTool) Schema() json.RawMessage { | 130 | func (t *SubagentSpawnTool) Schema() json.RawMessage { | ||
| 132 | maxTurns := 3 | 131 | maxTurns := 3 | ||
| @@ -138,7 +137,7 @@ func (t *SubagentSpawnTool) Schema() json.RawMessage { | |||||
| 138 | return json.RawMessage(fmt.Sprintf(subagentSpawnSchemaTemplate, maxTurns, maxTurns)) | 137 | return json.RawMessage(fmt.Sprintf(subagentSpawnSchemaTemplate, maxTurns, maxTurns)) | ||
| 139 | } | 138 | } | ||
| 140 | 139 | ||||
| 141 | - | func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | 140 | + | func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) { |
| 142 | prefix := t.Name() | 141 | prefix := t.Name() | ||
| 143 | if t.Supervisor == nil { | 142 | if t.Supervisor == nil { | ||
| 144 | return protocolToolError(prefix + ": subagent supervisor not available in this mode") | 143 | return protocolToolError(prefix + ": subagent supervisor not available in this mode") | ||
| @@ -272,10 +271,6 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr | |||||
| 272 | if t.OnSpawned != nil { | 271 | if t.OnSpawned != nil { | ||
| 273 | t.OnSpawned(agent, task, a.Required) | 272 | t.OnSpawned(agent, task, a.Required) | ||
| 274 | } | 273 | } | ||
| 275 | - | if a.Required { | |||
| 276 | - | return waitRequiredSubagent(ctx, t.Name(), t.Supervisor, agent, task, progress) | |||
| 277 | - | } | |||
| 278 | - | ||||
| 279 | var sb strings.Builder | 274 | var sb strings.Builder | ||
| 280 | fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID) | 275 | fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID) | ||
| 281 | fmt.Fprintf(&sb, "state: %s/%s\n", agent.ProcessState(), agent.TurnState()) | 276 | fmt.Fprintf(&sb, "state: %s/%s\n", agent.ProcessState(), agent.TurnState()) | ||
| @@ -299,75 +294,35 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr | |||||
| 299 | if fastModeFromProfile && fastModeOverride != nil && *fastModeOverride && agent.FastModeOverridesHost() { | 294 | if fastModeFromProfile && fastModeOverride != nil && *fastModeOverride && agent.FastModeOverridesHost() { | ||
| 300 | sb.WriteString("warning: subagent profile has fast mode enabled, overriding global fast mode off\n") | 295 | sb.WriteString("warning: subagent profile has fast mode enabled, overriding global fast mode off\n") | ||
| 301 | } | 296 | } | ||
| 302 | - | sb.WriteString("\nThe sub-agent is running in the background. Completion is host-event-driven: wait for the injected [auto-subagents update], the only completion signal. ") | 297 | + | if a.Required { |
| 298 | + | sb.WriteString("required: pending\n") | |||
| 299 | + | } | |||
| 300 | + | sb.WriteString("\nThe sub-agent is running in the background. Completion is host-event-driven through [auto-subagents update], the only completion signal; the manager remains free for independent work. ") | |||
| 303 | sb.WriteString("Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. ") | 301 | sb.WriteString("Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. ") | ||
| 304 | sb.WriteString("Work on unrelated independent tasks; otherwise end or yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are allowed.") | 302 | sb.WriteString("Work on unrelated independent tasks; otherwise end or yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are allowed.") | ||
| 305 | - | return core.ToolResult{ | 303 | + | details := map[string]any{ |
| 306 | - | Content: []provider.Content{provider.TextBlock{Text: sb.String()}}, | 304 | + | "agent_id": agent.ID, |
| 307 | - | Details: map[string]any{ | 305 | + | "task": task, |
| 308 | - | "agent_id": agent.ID, | 306 | + | "agent": agentName, |
| 309 | - | "task": task, | 307 | + | "model": model, |
| 310 | - | "agent": agentName, | 308 | + | "provider": providerID, |
| 311 | - | "model": model, | 309 | + | "reasoning": reasoning, |
| 312 | - | "provider": providerID, | 310 | + | "fast_mode": agent.FastMode, |
| 313 | - | "reasoning": reasoning, | 311 | + | "required": a.Required, |
| 314 | - | "fast_mode": agent.FastMode, | 312 | + | "isolation": string(workspaceMode), |
| 315 | - | "required": false, | 313 | + | "timeout": agent.Timeout.String(), |
| 316 | - | "isolation": string(workspaceMode), | 314 | + | "max_turns": agent.MaxTurns, |
| 317 | - | "timeout": agent.Timeout.String(), | 315 | + | "state": agent.Status(), |
| 318 | - | "max_turns": agent.MaxTurns, | 316 | + | "process_state": string(agent.ProcessState()), |
| 319 | - | "state": agent.Status(), | 317 | + | "turn_state": string(agent.TurnState()), |
| 320 | - | "process_state": string(agent.ProcessState()), | 318 | + | "result_ref": subagents.ResultRef(agent.ID), |
| 321 | - | "turn_state": string(agent.TurnState()), | |||
| 322 | - | "result_ref": subagents.ResultRef(agent.ID), | |||
| 323 | - | }, | |||
| 324 | - | }, nil | |||
| 325 | - | } | |||
| 326 | - | ||||
| 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 | - | } | |||
| 331 | - | requirement, err := supervisor.WaitRequired(ctx, agent.ID) | |||
| 332 | - | if err != nil { | |||
| 333 | - | return core.ToolResult{}, fmt.Errorf("%s: wait for required agent %s: %w", toolName, agent.ID, err) | |||
| 334 | } | 319 | } | ||
| 335 | - | snapshot := agent.Snapshot() | 320 | + | if a.Required { |
| 336 | - | var sb strings.Builder | 321 | + | details["requirement_state"] = string(subagents.RequirementPending) |
| 337 | - | fmt.Fprintf(&sb, "required sub-agent %s reached %s\n", agent.ID, requirement.State) | |||
| 338 | - | fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200)) | |||
| 339 | - | fmt.Fprintf(&sb, "requirement: %s\n", requirement.State) | |||
| 340 | - | if requirement.ErrorCode != "" { | |||
| 341 | - | fmt.Fprintf(&sb, "error_code: %s\n", requirement.ErrorCode) | |||
| 342 | - | fmt.Fprintf(&sb, "diagnostic: %s\n", subagents.ResultRef(agent.ID)) | |||
| 343 | - | } | |||
| 344 | - | output := strings.TrimSpace(snapshot.LastAssistant) | |||
| 345 | - | if snapshot.Result != nil && strings.TrimSpace(snapshot.Result.Output) != "" { | |||
| 346 | - | output = strings.TrimSpace(snapshot.Result.Output) | |||
| 347 | - | } | |||
| 348 | - | if output != "" { | |||
| 349 | - | sb.WriteString("\nresult:\n") | |||
| 350 | - | sb.WriteString(output) | |||
| 351 | - | sb.WriteByte('\n') | |||
| 352 | - | } | |||
| 353 | - | if requirement.Unmet() { | |||
| 354 | - | sb.WriteString("\nThis required work is still unmet. Retry it with subagent_resume before producing a terminal answer. The user may explicitly waive it by removing the terminal worker with /subagents rm.") | |||
| 355 | - | } else { | |||
| 356 | - | sb.WriteString("\nThe required result is now available in this turn; continue with it before answering.") | |||
| 357 | } | 322 | } | ||
| 358 | return core.ToolResult{ | 323 | return core.ToolResult{ | ||
| 359 | Content: []provider.Content{provider.TextBlock{Text: sb.String()}}, | 324 | Content: []provider.Content{provider.TextBlock{Text: sb.String()}}, | ||
| 360 | - | Details: map[string]any{ | 325 | + | Details: details, |
| 361 | - | "agent_id": agent.ID, | |||
| 362 | - | "task": task, | |||
| 363 | - | "required": true, | |||
| 364 | - | "requirement_state": string(requirement.State), | |||
| 365 | - | "state": snapshot.Status, | |||
| 366 | - | "process_state": string(snapshot.ProcessState), | |||
| 367 | - | "turn_state": string(snapshot.TurnState), | |||
| 368 | - | "result_ref": subagents.ResultRef(agent.ID), | |||
| 369 | - | }, | |||
| 370 | - | IsError: requirement.Unmet(), | |||
| 371 | }, nil | 326 | }, nil | ||
| 372 | } | 327 | } | ||
| 373 | 328 | ||||