detached 8a8e2b3

Merge pull request #68 from bnema/feat/auto-subagent-orchestrator-mode

feat: enforce auto-subagent orchestrator mode
commit
8a8e2b31112a1dd7eaea6809e235f610d98b1cbe
author
bnema · · 2026-08-07 11:13 UTC
committer
GitHub · · 2026-08-07 11:13 UTC
merge of
0c58a62 + 708ea73 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.10.0
11 files · +307 -35
M README.md +3 -3
@@ -390,11 +390,11 @@ Background subagents that run alongside your main session. Each one is a separat
390390
391391 **`/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.
392392
393-**Auto-subagents.** With `/settings` -> auto-subagents on, the main agent gets the canonical `subagent_spawn` tool and a system-prompt nudge to delegate independent work. The companion `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 privacy-safe 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, and terminal-result metadata when available; it does not return prompts, transcripts, result output, credentials, provider settings, or filesystem paths. The spawn tool returns the child id and initial state immediately; completion is a durable `turn.result` with a `subagent://<id>/result` reference. It accepts named profiles, timeout, `max_turns`, and `isolation:"shared"` or `isolation:"worktree"`. 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. Off by default; toggle from `/settings`.
393+**Auto-subagents.** With `/settings` -> auto-subagents on, 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 privacy-safe 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, and terminal-result metadata when available; it does not return prompts, transcripts, result output, credentials, provider settings, or filesystem paths. The spawn tool returns the child id and initial state immediately; completion is a durable `turn.result` with a `subagent://<id>/result` reference. It 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: wait for the host-injected `[auto-subagents update]`, the only completion signal. 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 from `/settings`.
394394
395395 ### Named subagent profiles
396396
397-zut discovers the common markdown/frontmatter profile layout from `~/.agents/agents/*.md` by default. With auto-subagents enabled, the main agent receives `[subagents_list]` metadata and can select a named profile through `subagent_spawn`. Profile bodies are applied only to the selected child. See [docs/subagents.md](docs/subagents.md) for discovery, supported fields, reasoning levels, and examples.
397+zut discovers the common markdown/frontmatter profile layout from `~/.agents/agents/*.md` by default. With auto-subagents enabled and `subagent_spawn` available, the orchestrator receives `[subagents_list]` metadata and should select the appropriately named profile for each worker when one matches; otherwise it can spawn a clearly described general worker without an `agent` field. If launch-time policy withholds `subagent_spawn`, strict orchestrator guidance remains active but profile metadata is omitted; the primary reports that delegation is unavailable instead of implementing directly. Profile bodies are applied only to the selected child. See [docs/subagents.md](docs/subagents.md) for discovery, supported fields, reasoning levels, and examples.
398398
399399 ### `/settings`
400400
@@ -404,7 +404,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
404404 - **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.
405405 - **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.
406406 - **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`.
407-- **auto-subagents** — let the main agent spawn background sub-agents in parallel via `subagent_spawn` and query live state via `subagent_status`. Off by default. The tools accept named profiles, reasoning, timeout, turn limits, and shared/worktree isolation where applicable; lifecycle and result references are persisted. Completion updates still arrive through the auto-subagents watcher. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details.
407+- **auto-subagents** — 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 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. The tools accept named profiles, reasoning, timeout, turn limits, and shared/worktree isolation where applicable; lifecycle and result references are persisted. Completion is host-event-driven: `[auto-subagents update]` completion updates are the only signal; 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.
408408 - **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.
409409 - **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.
410410 - **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`.
@@ -390,11 +390,11 @@ Background subagents that run alongside your main session. Each one is a separat
390 390
391 **`/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.391 **`/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.
392 392
393-**Auto-subagents.** With `/settings` -> auto-subagents on, the main agent gets the canonical `subagent_spawn` tool and a system-prompt nudge to delegate independent work. The companion `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 privacy-safe 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, and terminal-result metadata when available; it does not return prompts, transcripts, result output, credentials, provider settings, or filesystem paths. The spawn tool returns the child id and initial state immediately; completion is a durable `turn.result` with a `subagent://<id>/result` reference. It accepts named profiles, timeout, `max_turns`, and `isolation:"shared"` or `isolation:"worktree"`. 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. Off by default; toggle from `/settings`.393+**Auto-subagents.** With `/settings` -> auto-subagents on, 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 privacy-safe 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, and terminal-result metadata when available; it does not return prompts, transcripts, result output, credentials, provider settings, or filesystem paths. The spawn tool returns the child id and initial state immediately; completion is a durable `turn.result` with a `subagent://<id>/result` reference. It 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: wait for the host-injected `[auto-subagents update]`, the only completion signal. 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 from `/settings`.
394 394
395 ### Named subagent profiles395 ### Named subagent profiles
396 396
397-zut discovers the common markdown/frontmatter profile layout from `~/.agents/agents/*.md` by default. With auto-subagents enabled, the main agent receives `[subagents_list]` metadata and can select a named profile through `subagent_spawn`. Profile bodies are applied only to the selected child. See [docs/subagents.md](docs/subagents.md) for discovery, supported fields, reasoning levels, and examples.397+zut discovers the common markdown/frontmatter profile layout from `~/.agents/agents/*.md` by default. With auto-subagents enabled and `subagent_spawn` available, the orchestrator receives `[subagents_list]` metadata and should select the appropriately named profile for each worker when one matches; otherwise it can spawn a clearly described general worker without an `agent` field. If launch-time policy withholds `subagent_spawn`, strict orchestrator guidance remains active but profile metadata is omitted; the primary reports that delegation is unavailable instead of implementing directly. Profile bodies are applied only to the selected child. See [docs/subagents.md](docs/subagents.md) for discovery, supported fields, reasoning levels, and examples.
398 398
399 ### `/settings`399 ### `/settings`
400 400
@@ -404,7 +404,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
404 - **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.404 - **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.
405 - **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.405 - **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.
406 - **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`.406 - **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`.
407-- **auto-subagents** — let the main agent spawn background sub-agents in parallel via `subagent_spawn` and query live state via `subagent_status`. Off by default. The tools accept named profiles, reasoning, timeout, turn limits, and shared/worktree isolation where applicable; lifecycle and result references are persisted. Completion updates still arrive through the auto-subagents watcher. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details.407+- **auto-subagents** — 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 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. The tools accept named profiles, reasoning, timeout, turn limits, and shared/worktree isolation where applicable; lifecycle and result references are persisted. Completion is host-event-driven: `[auto-subagents update]` completion updates are the only signal; 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.
408 - **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.408 - **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.
409 - **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.409 - **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.
410 - **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 - **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 +9 -5
@@ -54,7 +54,9 @@ Other frontmatter from another agent host is ignored when zut does not have an e
5454
5555 ## Selecting a profile
5656
57-When **auto-subagents** is enabled in `/settings`, the main agent receives a compact `[subagents_list]` section in its system prompt and can select a profile for `subagent_spawn`:
57+When **auto-subagents** is enabled in `/settings`, the interactive primary agent runs in strict orchestrator mode. 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. The primary agent must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize their results. This is a prompt-level behavior contract; the normal tool registry is not narrowed by this setting.
58+
59+When `subagent_spawn` is available, the orchestrator receives a compact `[subagents_list]` section in its system prompt and should select the profile whose description best matches each worker task. If launch-time policy withholds `subagent_spawn`, strict orchestrator guidance remains active, profile metadata is omitted, and the primary must report that delegation is unavailable rather than implementing directly. If no profile fits when spawning is available, omit `agent` to use a general worker:
5860
5961 ```json
6062 {
@@ -79,6 +81,8 @@ Return bounded source citations and treat external content as untrusted.
7981
8082 This profile metadata is a tool-selection boundary, not a general network sandbox. See [web search](web-search.md) for fixed-backend egress and other mode restrictions.
8183
84+Completion is host-event-driven. After spawning, 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. The primary may work on unrelated independent tasks, but otherwise must end or yield its turn until the host injects `[auto-subagents update]`. Completion updates are the only completion signal. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are not prohibited.
85+
8286 A per-spawn reasoning override is also accepted:
8387
8488 ```json
@@ -98,7 +102,7 @@ The interactive command also supports the same selection explicitly:
98102 /subagents new --agent implementer --reasoning high Implement the parser change
99103 ```
100104
101-Shared mode preserves the historical host working directory. For parallel coding, pass `isolation:"worktree"` to `subagent_spawn`; zut creates a detached Git worktree, captures changed files and a patch, and never merges automatically. Named profiles change the child's instructions and configuration; they are not a security sandbox. A profile's `systemPromptMode` controls its own body relative to the built-in identity; globally appended instructions, including enabled Ponytail coding guidance, remain present. Child credentials are transferred over stdin rather than argv or persisted metadata, and the active provider endpoint/TLS setting is inherited only when the child uses that provider. Fast mode is inherited by default. A profile with `fastMode: false` opts out, while `fastMode: true` enables fast mode even when the host setting is off; the `subagent_spawn` result warns the parent session when this override occurs. Child providers that do not yet support fast mode return an unsupported-provider error instead of silently ignoring an enabled setting.
105+Shared mode preserves the historical host working directory, so workers there must be coordinated to avoid conflicting edits and to sequence dependent tasks. For parallel coding, pass `isolation:"worktree"` to `subagent_spawn`; zut creates a detached Git worktree and captures changed files and a patch without merging automatically. In orchestrator mode, assign any worktree patch integration to a worker rather than applying it in the primary session. Named profiles change the child's instructions and configuration; they are not a security sandbox. A profile's `systemPromptMode` controls its own body relative to the built-in identity; globally appended instructions, including enabled Ponytail coding guidance, remain present. Child credentials are transferred over stdin rather than argv or persisted metadata, and the active provider endpoint/TLS setting is inherited only when the child uses that provider. Fast mode is inherited by default. A profile with `fastMode: false` opts out, while `fastMode: true` enables fast mode even when the host setting is off; the `subagent_spawn` result warns the parent session when this override occurs. Child providers that do not yet support fast mode return an unsupported-provider error instead of silently ignoring an enabled setting.
102106
103107 ## Lifecycle, results, and recovery
104108
@@ -108,7 +112,7 @@ During an active delegated turn, interactive mode shows compact rows beside the
108112
109113 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.
110114
111-A completed worker emits a `turn.result` event and writes `result.json`. The inline output is bounded; inspect the full session through the stable references:
115+A completed worker emits a `turn.result` event and writes `result.json`. The host then injects `[auto-subagents update]`; that completion update, rather than polling or inspecting state, is the signal for the primary to continue. The inline output is bounded; after that update, inspect the full session through the stable references:
112116
113117 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.
114118
@@ -121,8 +125,8 @@ subagent://<id>/patch
121125
122126 Use `/subagents resume-session <id>` to continue the existing session without replaying its original task. Use `/subagents restart-task <id>` only when intentionally starting the stored task again. Cancellation requests graceful shutdown first, then forcefully cancels after the configured grace period.
123127
124-When auto-subagents is enabled, the model also receives the read-only `subagent_status` tool. Call it 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. 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`, start/update/finish timestamps, a bounded first-line task summary, and terminal-result metadata/reference when available. It intentionally omits prompts, transcripts, result output, credentials, provider settings, and filesystem paths. 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 `subagent_status` when the status tool is needed.
128+When auto-subagents is enabled and permitted by launch-time policy, the model also receives the read-only `subagent_status` tool. Call it 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`, start/update/finish timestamps, a bounded first-line task summary, and terminal-result metadata/reference when available. It intentionally omits prompts, transcripts, result output, credentials, provider settings, and filesystem paths. 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 `subagent_status` when the status tool is needed, but it must not be polled solely to wait for completion.
125129
126130 ## Resource policy
127131
128-The persisted `subagents` config object supports `max_concurrent`, `max_concurrent_per_parent`, `queue_timeout`, `default_timeout`, `max_turns`, output caps, allowed tools/roots, heartbeat and idle timeouts. Previously saved `max_total_spawned` values are accepted but ignored. A missing or non-positive configured `max_turns` uses the default ceiling of 3. The `max_turns` field in `subagent_spawn` is optional: omit it to use the policy ceiling, or provide a value from 1 through the configured maximum. Concurrency limits apply to slash commands, `subagent_spawn`, and batch operations. A child cannot create descendants in v1. Per-agent timeouts are retained across reload/resume. Packaged `zut run` agents keep their declared capability ceiling by disabling subagent delegation; profiles are not a substitute for that permission boundary.
132+The persisted `subagents` config object supports `max_concurrent`, `max_concurrent_per_parent`, `queue_timeout`, `default_timeout`, `max_turns`, output caps, allowed tools/roots, heartbeat and idle timeouts. Previously saved `max_total_spawned` values are accepted but ignored. A missing or non-positive configured `max_turns` uses the default ceiling of 3. The `max_turns` field in `subagent_spawn` is optional: omit it to use the policy ceiling, or provide a value from 1 through the configured maximum. Concurrency limits apply to slash commands, `subagent_spawn`, and batch operations. A child cannot create descendants in v1. Per-agent timeouts are retained across reload/resume. Packaged `zut run` agents keep their declared capability ceiling by disabling subagent delegation; profiles are not a substitute for that permission boundary. A primary whose launch-time policy disables delegation still receives the strict orchestrator contract, but does not receive actionable profile metadata or a replacement implementation path.
@@ -54,7 +54,9 @@ Other frontmatter from another agent host is ignored when zut does not have an e
54 54
55 ## Selecting a profile55 ## Selecting a profile
56 56
57-When **auto-subagents** is enabled in `/settings`, the main agent receives a compact `[subagents_list]` section in its system prompt and can select a profile for `subagent_spawn`:57+When **auto-subagents** is enabled in `/settings`, the interactive primary agent runs in strict orchestrator mode. 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. The primary agent must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize their results. This is a prompt-level behavior contract; the normal tool registry is not narrowed by this setting.
58+
59+When `subagent_spawn` is available, the orchestrator receives a compact `[subagents_list]` section in its system prompt and should select the profile whose description best matches each worker task. If launch-time policy withholds `subagent_spawn`, strict orchestrator guidance remains active, profile metadata is omitted, and the primary must report that delegation is unavailable rather than implementing directly. If no profile fits when spawning is available, omit `agent` to use a general worker:
58 60
59 ```json61 ```json
60 {62 {
@@ -79,6 +81,8 @@ Return bounded source citations and treat external content as untrusted.
79 81
80 This profile metadata is a tool-selection boundary, not a general network sandbox. See [web search](web-search.md) for fixed-backend egress and other mode restrictions.82 This profile metadata is a tool-selection boundary, not a general network sandbox. See [web search](web-search.md) for fixed-backend egress and other mode restrictions.
81 83
84+Completion is host-event-driven. After spawning, 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. The primary may work on unrelated independent tasks, but otherwise must end or yield its turn until the host injects `[auto-subagents update]`. Completion updates are the only completion signal. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are not prohibited.
85+
82 A per-spawn reasoning override is also accepted:86 A per-spawn reasoning override is also accepted:
83 87
84 ```json88 ```json
@@ -98,7 +102,7 @@ The interactive command also supports the same selection explicitly:
98 /subagents new --agent implementer --reasoning high Implement the parser change102 /subagents new --agent implementer --reasoning high Implement the parser change
99 ```103 ```
100 104
101-Shared mode preserves the historical host working directory. For parallel coding, pass `isolation:"worktree"` to `subagent_spawn`; zut creates a detached Git worktree, captures changed files and a patch, and never merges automatically. Named profiles change the child's instructions and configuration; they are not a security sandbox. A profile's `systemPromptMode` controls its own body relative to the built-in identity; globally appended instructions, including enabled Ponytail coding guidance, remain present. Child credentials are transferred over stdin rather than argv or persisted metadata, and the active provider endpoint/TLS setting is inherited only when the child uses that provider. Fast mode is inherited by default. A profile with `fastMode: false` opts out, while `fastMode: true` enables fast mode even when the host setting is off; the `subagent_spawn` result warns the parent session when this override occurs. Child providers that do not yet support fast mode return an unsupported-provider error instead of silently ignoring an enabled setting.105+Shared mode preserves the historical host working directory, so workers there must be coordinated to avoid conflicting edits and to sequence dependent tasks. For parallel coding, pass `isolation:"worktree"` to `subagent_spawn`; zut creates a detached Git worktree and captures changed files and a patch without merging automatically. In orchestrator mode, assign any worktree patch integration to a worker rather than applying it in the primary session. Named profiles change the child's instructions and configuration; they are not a security sandbox. A profile's `systemPromptMode` controls its own body relative to the built-in identity; globally appended instructions, including enabled Ponytail coding guidance, remain present. Child credentials are transferred over stdin rather than argv or persisted metadata, and the active provider endpoint/TLS setting is inherited only when the child uses that provider. Fast mode is inherited by default. A profile with `fastMode: false` opts out, while `fastMode: true` enables fast mode even when the host setting is off; the `subagent_spawn` result warns the parent session when this override occurs. Child providers that do not yet support fast mode return an unsupported-provider error instead of silently ignoring an enabled setting.
102 106
103 ## Lifecycle, results, and recovery107 ## Lifecycle, results, and recovery
104 108
@@ -108,7 +112,7 @@ During an active delegated turn, interactive mode shows compact rows beside the
108 112
109 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.113 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.
110 114
111-A completed worker emits a `turn.result` event and writes `result.json`. The inline output is bounded; inspect the full session through the stable references:115+A completed worker emits a `turn.result` event and writes `result.json`. The host then injects `[auto-subagents update]`; that completion update, rather than polling or inspecting state, is the signal for the primary to continue. The inline output is bounded; after that update, inspect the full session through the stable references:
112 116
113 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.117 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.
114 118
@@ -121,8 +125,8 @@ subagent://<id>/patch
121 125
122 Use `/subagents resume-session <id>` to continue the existing session without replaying its original task. Use `/subagents restart-task <id>` only when intentionally starting the stored task again. Cancellation requests graceful shutdown first, then forcefully cancels after the configured grace period.126 Use `/subagents resume-session <id>` to continue the existing session without replaying its original task. Use `/subagents restart-task <id>` only when intentionally starting the stored task again. Cancellation requests graceful shutdown first, then forcefully cancels after the configured grace period.
123 127
124-When auto-subagents is enabled, the model also receives the read-only `subagent_status` tool. Call it 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. 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`, start/update/finish timestamps, a bounded first-line task summary, and terminal-result metadata/reference when available. It intentionally omits prompts, transcripts, result output, credentials, provider settings, and filesystem paths. 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 `subagent_status` when the status tool is needed.128+When auto-subagents is enabled and permitted by launch-time policy, the model also receives the read-only `subagent_status` tool. Call it 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`, start/update/finish timestamps, a bounded first-line task summary, and terminal-result metadata/reference when available. It intentionally omits prompts, transcripts, result output, credentials, provider settings, and filesystem paths. 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 `subagent_status` when the status tool is needed, but it must not be polled solely to wait for completion.
125 129
126 ## Resource policy130 ## Resource policy
127 131
128-The persisted `subagents` config object supports `max_concurrent`, `max_concurrent_per_parent`, `queue_timeout`, `default_timeout`, `max_turns`, output caps, allowed tools/roots, heartbeat and idle timeouts. Previously saved `max_total_spawned` values are accepted but ignored. A missing or non-positive configured `max_turns` uses the default ceiling of 3. The `max_turns` field in `subagent_spawn` is optional: omit it to use the policy ceiling, or provide a value from 1 through the configured maximum. Concurrency limits apply to slash commands, `subagent_spawn`, and batch operations. A child cannot create descendants in v1. Per-agent timeouts are retained across reload/resume. Packaged `zut run` agents keep their declared capability ceiling by disabling subagent delegation; profiles are not a substitute for that permission boundary.132+The persisted `subagents` config object supports `max_concurrent`, `max_concurrent_per_parent`, `queue_timeout`, `default_timeout`, `max_turns`, output caps, allowed tools/roots, heartbeat and idle timeouts. Previously saved `max_total_spawned` values are accepted but ignored. A missing or non-positive configured `max_turns` uses the default ceiling of 3. The `max_turns` field in `subagent_spawn` is optional: omit it to use the policy ceiling, or provide a value from 1 through the configured maximum. Concurrency limits apply to slash commands, `subagent_spawn`, and batch operations. A child cannot create descendants in v1. Per-agent timeouts are retained across reload/resume. Packaged `zut run` agents keep their declared capability ceiling by disabling subagent delegation; profiles are not a substitute for that permission boundary. A primary whose launch-time policy disables delegation still receives the strict orchestrator contract, but does not receive actionable profile metadata or a replacement implementation path.
M packages/agent/build.go +8 -6
@@ -850,13 +850,15 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
850850 append_ = append(append_, skillAddendum)
851851 }
852852 interactiveMode := args.Mode == "" || args.Mode == ModeInteractive
853- if selectedProfile == nil && interactiveMode && autoSubagentsAnyToolAllowed(args) && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
854- homeDir, _ := os.UserHomeDir()
855- profiles, _ := subagents.Discover(args.CWD, homeDir)
856- if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {
857- append_ = append(append_, subagentsAddendum)
853+ if selectedProfile == nil && interactiveMode && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
854+ if autoSubagentsToolAllowed(args) {
855+ homeDir, _ := os.UserHomeDir()
856+ profiles, _ := subagents.Discover(args.CWD, homeDir)
857+ if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {
858+ append_ = append(append_, subagentsAddendum)
859+ }
858860 }
859- append_ = append(append_, AutoSubagentsSystemAddendum)
861+ append_ = append(append_, AutoSubagentsSystemAddendumFor(autoSubagentsToolAllowed(args)))
860862 }
861863 if selectedProfile != nil && selectedProfile.SystemPromptMode != "replace" && selectedProfile.SystemPrompt != "" {
862864 append_ = append(append_, selectedProfile.SystemPrompt)
@@ -850,13 +850,15 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
850 append_ = append(append_, skillAddendum)850 append_ = append(append_, skillAddendum)
851 }851 }
852 interactiveMode := args.Mode == "" || args.Mode == ModeInteractive852 interactiveMode := args.Mode == "" || args.Mode == ModeInteractive
853- if selectedProfile == nil && interactiveMode && autoSubagentsAnyToolAllowed(args) && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {853+ if selectedProfile == nil && interactiveMode && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
854- homeDir, _ := os.UserHomeDir()854+ if autoSubagentsToolAllowed(args) {
855- profiles, _ := subagents.Discover(args.CWD, homeDir)855+ homeDir, _ := os.UserHomeDir()
856- if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {856+ profiles, _ := subagents.Discover(args.CWD, homeDir)
857- append_ = append(append_, subagentsAddendum)857+ if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {
858+ append_ = append(append_, subagentsAddendum)
859+ }
858 }860 }
859- append_ = append(append_, AutoSubagentsSystemAddendum)861+ append_ = append(append_, AutoSubagentsSystemAddendumFor(autoSubagentsToolAllowed(args)))
860 }862 }
861 if selectedProfile != nil && selectedProfile.SystemPromptMode != "replace" && selectedProfile.SystemPrompt != "" {863 if selectedProfile != nil && selectedProfile.SystemPromptMode != "replace" && selectedProfile.SystemPrompt != "" {
862 append_ = append(append_, selectedProfile.SystemPrompt)864 append_ = append(append_, selectedProfile.SystemPrompt)
M packages/agent/build_test.go +125 -1
@@ -360,13 +360,137 @@ Review without editing.
360360 if err != nil {
361361 t.Fatal(err)
362362 }
363- for _, want := range []string{"[subagents_list]", "reviewer", "Read-only code reviewer", "[/subagents_list]"} {
363+ for _, want := range []string{
364+ "[subagents_list]",
365+ "reviewer",
366+ "Read-only code reviewer",
367+ "[/subagents_list]",
368+ "primary-agent orchestrator",
369+ "Delegate all implementation, debugging/testing, and code-review work",
370+ "appropriately named subagent profile",
371+ "general worker",
372+ "Do not write or edit code yourself",
373+ "direct implementation tool calls",
374+ "inspect or review code",
375+ "apply worker patches",
376+ "Shared-worktree",
377+ "isolation:\"worktree\"",
378+ "Child workers cannot recursively spawn more sub-agents in v1",
379+ } {
364380 if !strings.Contains(r.SystemPrompt, want) {
365381 t.Fatalf("system prompt missing %q:\n%s", want, r.SystemPrompt)
366382 }
367383 }
368384 }
369385
386+func TestResolveKeepsStrictContractButOmitsProfilesWhenSpawnIsUnavailable(t *testing.T) {
387+ root := t.TempDir()
388+ home := filepath.Join(root, "home")
389+ zutHome := filepath.Join(root, "zut-home")
390+ project := filepath.Join(root, "repo")
391+ if err := os.MkdirAll(filepath.Join(home, ".agents", "agents"), 0o755); err != nil {
392+ t.Fatal(err)
393+ }
394+ if err := os.MkdirAll(project, 0o755); err != nil {
395+ t.Fatal(err)
396+ }
397+ t.Setenv("HOME", home)
398+ t.Setenv("ZUT_HOME", zutHome)
399+ t.Setenv("ZUT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents"))
400+ enabled := true
401+ if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", AutoSubagentsEnabled: &enabled}); err != nil {
402+ t.Fatal(err)
403+ }
404+ profile := "---\nname: reviewer\ndescription: Read-only code reviewer\n---\nReview without editing.\n"
405+ if err := os.WriteFile(filepath.Join(home, ".agents", "agents", "reviewer.md"), []byte(profile), 0o600); err != nil {
406+ t.Fatal(err)
407+ }
408+
409+ cases := []struct {
410+ name string
411+ args Args
412+ }{
413+ {name: "no tools", args: Args{CWD: project, NoTools: true}},
414+ {name: "read allowlist", args: Args{CWD: project, Tools: []string{"read"}, ToolsSet: true}},
415+ {name: "status allowlist", args: Args{CWD: project, Tools: []string{"subagent_status"}, ToolsSet: true}},
416+ {name: "permission set", args: Args{CWD: project, PermissionSet: &tools.PermissionSet{}}},
417+ }
418+ for _, tc := range cases {
419+ t.Run(tc.name, func(t *testing.T) {
420+ r, err := Resolve(tc.args, false)
421+ if err != nil {
422+ t.Fatal(err)
423+ }
424+ for _, want := range []string{
425+ "primary-agent orchestrator",
426+ "Do not write or edit code yourself",
427+ "subagent_spawn",
428+ "report this limitation to the user",
429+ "Completion is host-event-driven",
430+ } {
431+ if !strings.Contains(r.SystemPrompt, want) {
432+ t.Fatalf("system prompt missing %q:\n%s", want, r.SystemPrompt)
433+ }
434+ }
435+ for _, unwanted := range []string{"[subagents_list]", "reviewer", "Read-only code reviewer"} {
436+ if strings.Contains(r.SystemPrompt, unwanted) {
437+ t.Fatalf("system prompt advertises unusable profile %q:\n%s", unwanted, r.SystemPrompt)
438+ }
439+ }
440+ })
441+ }
442+}
443+
444+func TestAutoSubagentsSystemAddendumRequiresHostCompletionUpdate(t *testing.T) {
445+ for _, want := range []string{
446+ "bash sleep",
447+ "watch",
448+ "tail -f",
449+ "polling loops",
450+ "repeated \"subagent_status\"",
451+ "dashboard, metadata, event-log, or file checks",
452+ "unrelated independent tasks",
453+ "end or yield your turn",
454+ "[auto-subagents update]",
455+ "Completion updates are the only completion signal",
456+ "user-requested commands, provider flows, extensions, or tests",
457+ } {
458+ if !strings.Contains(AutoSubagentsSystemAddendum, want) {
459+ t.Fatalf("auto-subagents contract missing %q:\n%s", want, AutoSubagentsSystemAddendum)
460+ }
461+ }
462+}
463+
464+func TestResolveOmitsAutoSubagentsOrchestratorContractWhenDisabled(t *testing.T) {
465+ root := t.TempDir()
466+ home := filepath.Join(root, "home")
467+ zutHome := filepath.Join(root, "zut-home")
468+ project := filepath.Join(root, "repo")
469+ if err := os.MkdirAll(project, 0o755); err != nil {
470+ t.Fatal(err)
471+ }
472+ t.Setenv("HOME", home)
473+ t.Setenv("ZUT_HOME", zutHome)
474+ disabled := false
475+ if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", AutoSubagentsEnabled: &disabled}); err != nil {
476+ t.Fatal(err)
477+ }
478+
479+ r, err := Resolve(Args{CWD: project}, false)
480+ if err != nil {
481+ t.Fatal(err)
482+ }
483+ for _, unwanted := range []string{
484+ AutoSubagentsSystemAddendum,
485+ "primary-agent orchestrator",
486+ "[subagents_list]",
487+ } {
488+ if strings.Contains(r.SystemPrompt, unwanted) {
489+ t.Fatalf("disabled system prompt contains auto-subagents contract %q:\n%s", unwanted, r.SystemPrompt)
490+ }
491+ }
492+}
493+
370494 func TestResolveAppliesSelectedSubagentProfile(t *testing.T) {
371495 root := t.TempDir()
372496 home := filepath.Join(root, "home")
@@ -360,13 +360,137 @@ Review without editing.
360 if err != nil {360 if err != nil {
361 t.Fatal(err)361 t.Fatal(err)
362 }362 }
363- for _, want := range []string{"[subagents_list]", "reviewer", "Read-only code reviewer", "[/subagents_list]"} {363+ for _, want := range []string{
364+ "[subagents_list]",
365+ "reviewer",
366+ "Read-only code reviewer",
367+ "[/subagents_list]",
368+ "primary-agent orchestrator",
369+ "Delegate all implementation, debugging/testing, and code-review work",
370+ "appropriately named subagent profile",
371+ "general worker",
372+ "Do not write or edit code yourself",
373+ "direct implementation tool calls",
374+ "inspect or review code",
375+ "apply worker patches",
376+ "Shared-worktree",
377+ "isolation:\"worktree\"",
378+ "Child workers cannot recursively spawn more sub-agents in v1",
379+ } {
364 if !strings.Contains(r.SystemPrompt, want) {380 if !strings.Contains(r.SystemPrompt, want) {
365 t.Fatalf("system prompt missing %q:\n%s", want, r.SystemPrompt)381 t.Fatalf("system prompt missing %q:\n%s", want, r.SystemPrompt)
366 }382 }
367 }383 }
368 }384 }
369 385
386+func TestResolveKeepsStrictContractButOmitsProfilesWhenSpawnIsUnavailable(t *testing.T) {
387+ root := t.TempDir()
388+ home := filepath.Join(root, "home")
389+ zutHome := filepath.Join(root, "zut-home")
390+ project := filepath.Join(root, "repo")
391+ if err := os.MkdirAll(filepath.Join(home, ".agents", "agents"), 0o755); err != nil {
392+ t.Fatal(err)
393+ }
394+ if err := os.MkdirAll(project, 0o755); err != nil {
395+ t.Fatal(err)
396+ }
397+ t.Setenv("HOME", home)
398+ t.Setenv("ZUT_HOME", zutHome)
399+ t.Setenv("ZUT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents"))
400+ enabled := true
401+ if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", AutoSubagentsEnabled: &enabled}); err != nil {
402+ t.Fatal(err)
403+ }
404+ profile := "---\nname: reviewer\ndescription: Read-only code reviewer\n---\nReview without editing.\n"
405+ if err := os.WriteFile(filepath.Join(home, ".agents", "agents", "reviewer.md"), []byte(profile), 0o600); err != nil {
406+ t.Fatal(err)
407+ }
408+
409+ cases := []struct {
410+ name string
411+ args Args
412+ }{
413+ {name: "no tools", args: Args{CWD: project, NoTools: true}},
414+ {name: "read allowlist", args: Args{CWD: project, Tools: []string{"read"}, ToolsSet: true}},
415+ {name: "status allowlist", args: Args{CWD: project, Tools: []string{"subagent_status"}, ToolsSet: true}},
416+ {name: "permission set", args: Args{CWD: project, PermissionSet: &tools.PermissionSet{}}},
417+ }
418+ for _, tc := range cases {
419+ t.Run(tc.name, func(t *testing.T) {
420+ r, err := Resolve(tc.args, false)
421+ if err != nil {
422+ t.Fatal(err)
423+ }
424+ for _, want := range []string{
425+ "primary-agent orchestrator",
426+ "Do not write or edit code yourself",
427+ "subagent_spawn",
428+ "report this limitation to the user",
429+ "Completion is host-event-driven",
430+ } {
431+ if !strings.Contains(r.SystemPrompt, want) {
432+ t.Fatalf("system prompt missing %q:\n%s", want, r.SystemPrompt)
433+ }
434+ }
435+ for _, unwanted := range []string{"[subagents_list]", "reviewer", "Read-only code reviewer"} {
436+ if strings.Contains(r.SystemPrompt, unwanted) {
437+ t.Fatalf("system prompt advertises unusable profile %q:\n%s", unwanted, r.SystemPrompt)
438+ }
439+ }
440+ })
441+ }
442+}
443+
444+func TestAutoSubagentsSystemAddendumRequiresHostCompletionUpdate(t *testing.T) {
445+ for _, want := range []string{
446+ "bash sleep",
447+ "watch",
448+ "tail -f",
449+ "polling loops",
450+ "repeated \"subagent_status\"",
451+ "dashboard, metadata, event-log, or file checks",
452+ "unrelated independent tasks",
453+ "end or yield your turn",
454+ "[auto-subagents update]",
455+ "Completion updates are the only completion signal",
456+ "user-requested commands, provider flows, extensions, or tests",
457+ } {
458+ if !strings.Contains(AutoSubagentsSystemAddendum, want) {
459+ t.Fatalf("auto-subagents contract missing %q:\n%s", want, AutoSubagentsSystemAddendum)
460+ }
461+ }
462+}
463+
464+func TestResolveOmitsAutoSubagentsOrchestratorContractWhenDisabled(t *testing.T) {
465+ root := t.TempDir()
466+ home := filepath.Join(root, "home")
467+ zutHome := filepath.Join(root, "zut-home")
468+ project := filepath.Join(root, "repo")
469+ if err := os.MkdirAll(project, 0o755); err != nil {
470+ t.Fatal(err)
471+ }
472+ t.Setenv("HOME", home)
473+ t.Setenv("ZUT_HOME", zutHome)
474+ disabled := false
475+ if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", AutoSubagentsEnabled: &disabled}); err != nil {
476+ t.Fatal(err)
477+ }
478+
479+ r, err := Resolve(Args{CWD: project}, false)
480+ if err != nil {
481+ t.Fatal(err)
482+ }
483+ for _, unwanted := range []string{
484+ AutoSubagentsSystemAddendum,
485+ "primary-agent orchestrator",
486+ "[subagents_list]",
487+ } {
488+ if strings.Contains(r.SystemPrompt, unwanted) {
489+ t.Fatalf("disabled system prompt contains auto-subagents contract %q:\n%s", unwanted, r.SystemPrompt)
490+ }
491+ }
492+}
493+
370 func TestResolveAppliesSelectedSubagentProfile(t *testing.T) {494 func TestResolveAppliesSelectedSubagentProfile(t *testing.T) {
371 root := t.TempDir()495 root := t.TempDir()
372 home := filepath.Join(root, "home")496 home := filepath.Join(root, "home")
M packages/agent/cli.go +1 -1
@@ -2260,7 +2260,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
22602260 FlatTools: initialCfg.FlatToolRender(),
22612261 CompactUser: initialCfg.CompactUserInput(),
22622262 ExtensionThemes: extMgr.ThemeOptions,
2263- AutoSubagentsSystemAddendum: AutoSubagentsSystemAddendum,
2263+ AutoSubagentsSystemAddendum: AutoSubagentsSystemAddendumFor(autoSubagentsToolAllowedForSession),
22642264 SubagentsSystemAddendum: subagentsAddendum,
22652265 SettingsStore: configSettingsStore{},
22662266 Model: r.Model,
@@ -2260,7 +2260,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
2260 FlatTools: initialCfg.FlatToolRender(),2260 FlatTools: initialCfg.FlatToolRender(),
2261 CompactUser: initialCfg.CompactUserInput(),2261 CompactUser: initialCfg.CompactUserInput(),
2262 ExtensionThemes: extMgr.ThemeOptions,2262 ExtensionThemes: extMgr.ThemeOptions,
2263- AutoSubagentsSystemAddendum: AutoSubagentsSystemAddendum,2263+ AutoSubagentsSystemAddendum: AutoSubagentsSystemAddendumFor(autoSubagentsToolAllowedForSession),
2264 SubagentsSystemAddendum: subagentsAddendum,2264 SubagentsSystemAddendum: subagentsAddendum,
2265 SettingsStore: configSettingsStore{},2265 SettingsStore: configSettingsStore{},
2266 Model: r.Model,2266 Model: r.Model,
M packages/agent/modes/auto_subagents_policy_test.go +72 -0
@@ -2,15 +2,87 @@ package modes
22
33 import (
44 "context"
5+ "errors"
56 "strings"
67 "testing"
78 "time"
89
910 "github.com/bnema/zut/packages/agent/subagents"
11+ "github.com/bnema/zut/packages/agent/tools"
1012 "github.com/bnema/zut/packages/core"
1113 "github.com/bnema/zut/packages/provider"
1214 )
1315
16+type failingAutoSubagentsSettingsStore struct {
17+ SettingsStore
18+ err error
19+ calls int
20+}
21+
22+func (s *failingAutoSubagentsSettingsStore) SetAutoSubagents(bool) error {
23+ s.calls++
24+ return s.err
25+}
26+
27+func TestAutoSubagentsToggleRollsBackInMemoryStateWhenPersistenceFails(t *testing.T) {
28+ previous := true
29+ allowed := true
30+ supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
31+ t.Cleanup(supervisor.StopAll)
32+ store := &failingAutoSubagentsSettingsStore{err: errors.New("disk full")}
33+ agent := core.NewAgent(nil, "test-model", "base system\n\nauto-subagents guidance", core.Registry{
34+ "subagent_spawn": &tools.SubagentSpawnTool{Supervisor: supervisor},
35+ "subagent_status": &tools.SubagentStatusTool{Supervisor: supervisor},
36+ })
37+ interactive := NewInteractive(InteractiveConfig{
38+ Agent: agent,
39+ Supervisor: supervisor,
40+ AutoSubagentsEnabled: &previous,
41+ AutoSubagentsToolAllowed: &allowed,
42+ AutoSubagentsStatusToolAllowed: &allowed,
43+ AutoSubagentsSystemAddendum: "auto-subagents guidance",
44+ SettingsStore: store,
45+ })
46+ interactive.settingsDialog = newSettingsDialog()
47+ interactive.rend = nil
48+ interactive.openSettingsDialog()
49+ beforeSystem, _ := agent.PromptConfig()
50+ beforeTools := agent.ToolsSnapshot()
51+
52+ interactive.applySettingToggle("auto_subagents_enabled", false)
53+
54+ interactive.mu.Lock()
55+ gotEnabled := interactive.cfg.AutoSubagentsEnabled != nil && *interactive.cfg.AutoSubagentsEnabled
56+ statusErr := interactive.statusErr
57+ interactive.mu.Unlock()
58+ if !gotEnabled {
59+ t.Fatal("auto-subagents flag stayed disabled after persistence failure")
60+ }
61+ if store.calls != 1 {
62+ t.Fatalf("SetAutoSubagents calls = %d, want 1", store.calls)
63+ }
64+ if !strings.Contains(statusErr, "disk full") {
65+ t.Fatalf("status error = %q, want persistence failure", statusErr)
66+ }
67+ afterSystem, _ := agent.PromptConfig()
68+ if afterSystem != beforeSystem {
69+ t.Fatalf("system prompt changed after persistence failure: %q -> %q", beforeSystem, afterSystem)
70+ }
71+ afterTools := agent.ToolsSnapshot()
72+ if len(afterTools) != len(beforeTools) {
73+ t.Fatalf("tool registry size = %d, want unchanged %d", len(afterTools), len(beforeTools))
74+ }
75+ for name, before := range beforeTools {
76+ if afterTools[name] != before {
77+ t.Fatalf("tool %q changed after persistence failure", name)
78+ }
79+ }
80+ item := findSettingsItem(interactive.settingsDialog.items, "auto_subagents_enabled")
81+ if item == nil || !item.value {
82+ t.Fatalf("settings row = %#v, want restored enabled value", item)
83+ }
84+}
85+
1486 func TestAutoSubagentsSettingsDisableUnavailablePolicy(t *testing.T) {
1587 trueValue := true
1688 falseValue := false
@@ -2,15 +2,87 @@ package modes
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "strings"6 "strings"
6 "testing"7 "testing"
7 "time"8 "time"
8 9
9 "github.com/bnema/zut/packages/agent/subagents"10 "github.com/bnema/zut/packages/agent/subagents"
11+ "github.com/bnema/zut/packages/agent/tools"
10 "github.com/bnema/zut/packages/core"12 "github.com/bnema/zut/packages/core"
11 "github.com/bnema/zut/packages/provider"13 "github.com/bnema/zut/packages/provider"
12 )14 )
13 15
16+type failingAutoSubagentsSettingsStore struct {
17+ SettingsStore
18+ err error
19+ calls int
20+}
21+
22+func (s *failingAutoSubagentsSettingsStore) SetAutoSubagents(bool) error {
23+ s.calls++
24+ return s.err
25+}
26+
27+func TestAutoSubagentsToggleRollsBackInMemoryStateWhenPersistenceFails(t *testing.T) {
28+ previous := true
29+ allowed := true
30+ supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
31+ t.Cleanup(supervisor.StopAll)
32+ store := &failingAutoSubagentsSettingsStore{err: errors.New("disk full")}
33+ agent := core.NewAgent(nil, "test-model", "base system\n\nauto-subagents guidance", core.Registry{
34+ "subagent_spawn": &tools.SubagentSpawnTool{Supervisor: supervisor},
35+ "subagent_status": &tools.SubagentStatusTool{Supervisor: supervisor},
36+ })
37+ interactive := NewInteractive(InteractiveConfig{
38+ Agent: agent,
39+ Supervisor: supervisor,
40+ AutoSubagentsEnabled: &previous,
41+ AutoSubagentsToolAllowed: &allowed,
42+ AutoSubagentsStatusToolAllowed: &allowed,
43+ AutoSubagentsSystemAddendum: "auto-subagents guidance",
44+ SettingsStore: store,
45+ })
46+ interactive.settingsDialog = newSettingsDialog()
47+ interactive.rend = nil
48+ interactive.openSettingsDialog()
49+ beforeSystem, _ := agent.PromptConfig()
50+ beforeTools := agent.ToolsSnapshot()
51+
52+ interactive.applySettingToggle("auto_subagents_enabled", false)
53+
54+ interactive.mu.Lock()
55+ gotEnabled := interactive.cfg.AutoSubagentsEnabled != nil && *interactive.cfg.AutoSubagentsEnabled
56+ statusErr := interactive.statusErr
57+ interactive.mu.Unlock()
58+ if !gotEnabled {
59+ t.Fatal("auto-subagents flag stayed disabled after persistence failure")
60+ }
61+ if store.calls != 1 {
62+ t.Fatalf("SetAutoSubagents calls = %d, want 1", store.calls)
63+ }
64+ if !strings.Contains(statusErr, "disk full") {
65+ t.Fatalf("status error = %q, want persistence failure", statusErr)
66+ }
67+ afterSystem, _ := agent.PromptConfig()
68+ if afterSystem != beforeSystem {
69+ t.Fatalf("system prompt changed after persistence failure: %q -> %q", beforeSystem, afterSystem)
70+ }
71+ afterTools := agent.ToolsSnapshot()
72+ if len(afterTools) != len(beforeTools) {
73+ t.Fatalf("tool registry size = %d, want unchanged %d", len(afterTools), len(beforeTools))
74+ }
75+ for name, before := range beforeTools {
76+ if afterTools[name] != before {
77+ t.Fatalf("tool %q changed after persistence failure", name)
78+ }
79+ }
80+ item := findSettingsItem(interactive.settingsDialog.items, "auto_subagents_enabled")
81+ if item == nil || !item.value {
82+ t.Fatalf("settings row = %#v, want restored enabled value", item)
83+ }
84+}
85+
14 func TestAutoSubagentsSettingsDisableUnavailablePolicy(t *testing.T) {86 func TestAutoSubagentsSettingsDisableUnavailablePolicy(t *testing.T) {
15 trueValue := true87 trueValue := true
16 falseValue := false88 falseValue := false
M packages/agent/modes/auto_subagents_test.go +2 -0
@@ -19,6 +19,7 @@ func TestAutoSubagentsSystemPromptTogglesProfileManifestWithDelegationGuidance(t
1919 cfg: InteractiveConfig{
2020 AutoSubagentsSystemAddendum: "auto-subagents guidance",
2121 SubagentsSystemAddendum: "[subagents_list]\n- reviewer\n[/subagents_list]",
22+ Supervisor: &subagents.Supervisor{},
2223 },
2324 }
2425
@@ -47,6 +48,7 @@ func TestAutoSubagentsTogglePreservesMatchingBasePromptBlocks(t *testing.T) {
4748 cfg: InteractiveConfig{
4849 AutoSubagentsSystemAddendum: guidance,
4950 SubagentsSystemAddendum: manifest,
51+ Supervisor: &subagents.Supervisor{},
5052 },
5153 }
5254
@@ -19,6 +19,7 @@ func TestAutoSubagentsSystemPromptTogglesProfileManifestWithDelegationGuidance(t
19 cfg: InteractiveConfig{19 cfg: InteractiveConfig{
20 AutoSubagentsSystemAddendum: "auto-subagents guidance",20 AutoSubagentsSystemAddendum: "auto-subagents guidance",
21 SubagentsSystemAddendum: "[subagents_list]\n- reviewer\n[/subagents_list]",21 SubagentsSystemAddendum: "[subagents_list]\n- reviewer\n[/subagents_list]",
22+ Supervisor: &subagents.Supervisor{},
22 },23 },
23 }24 }
24 25
@@ -47,6 +48,7 @@ func TestAutoSubagentsTogglePreservesMatchingBasePromptBlocks(t *testing.T) {
47 cfg: InteractiveConfig{48 cfg: InteractiveConfig{
48 AutoSubagentsSystemAddendum: guidance,49 AutoSubagentsSystemAddendum: guidance,
49 SubagentsSystemAddendum: manifest,50 SubagentsSystemAddendum: manifest,
51+ Supervisor: &subagents.Supervisor{},
50 },52 },
51 }53 }
52 54
M packages/agent/modes/interactive.go +11 -8
@@ -956,7 +956,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
956956 baseURL, _, err := cfg.LlamaCPPConfig()
957957 i.llamaConfigured = err == nil && baseURL != ""
958958 }
959- if cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled && cfg.Supervisor != nil && autoSubagentsAnyToolAllowedConfig(cfg) {
959+ if cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
960960 i.managedAutoSubagentsAddenda = autoSubagentsAddenda(cfg)
961961 }
962962 if cfg.Agent != nil {
@@ -5115,15 +5115,20 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
51155115 i.mu.Unlock()
51165116 return
51175117 }
5118- val := value
51195118 i.mu.Lock()
5119+ previousFlag := i.cfg.AutoSubagentsEnabled
5120+ previous := previousFlag != nil && *previousFlag
5121+ val := value
51205122 i.cfg.AutoSubagentsEnabled = &val
51215123 i.mu.Unlock()
51225124 if i.cfg.SettingsStore != nil {
51235125 if err := i.cfg.SettingsStore.SetAutoSubagents(value); err != nil {
51245126 i.mu.Lock()
5127+ i.cfg.AutoSubagentsEnabled = previousFlag
5128+ i.statusOK = ""
51255129 i.statusErr = "settings: " + err.Error()
51265130 i.mu.Unlock()
5131+ i.resetSettingsToggle(key, previous)
51275132 return
51285133 }
51295134 }
@@ -8677,8 +8682,10 @@ func truncateForSummary(s string, n int) string {
86778682 // toggle, in the same order Resolve appends them to a new agent.
86788683 func autoSubagentsAddenda(cfg InteractiveConfig) []string {
86798684 addenda := make([]string, 0, 2)
8680- if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" {
8681- addenda = append(addenda, addendum)
8685+ if cfg.Supervisor != nil && autoSubagentsToolAllowedConfig(cfg) {
8686+ if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" {
8687+ addenda = append(addenda, addendum)
8688+ }
86828689 }
86838690 if addendum := strings.TrimSpace(cfg.AutoSubagentsSystemAddendum); addendum != "" {
86848691 addenda = append(addenda, addendum)
@@ -8782,10 +8789,6 @@ func autoSubagentsStatusToolAllowedConfig(cfg InteractiveConfig) bool {
87828789 return autoSubagentsToolAllowedConfig(cfg)
87838790 }
87848791
8785-func autoSubagentsAnyToolAllowedConfig(cfg InteractiveConfig) bool {
8786- return autoSubagentsToolAllowedConfig(cfg) || autoSubagentsStatusToolAllowedConfig(cfg)
8787-}
8788-
87898792 func (i *Interactive) autoSubagentsToolAllowed() bool {
87908793 return autoSubagentsToolAllowedConfig(i.cfg)
87918794 }
@@ -956,7 +956,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
956 baseURL, _, err := cfg.LlamaCPPConfig()956 baseURL, _, err := cfg.LlamaCPPConfig()
957 i.llamaConfigured = err == nil && baseURL != ""957 i.llamaConfigured = err == nil && baseURL != ""
958 }958 }
959- if cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled && cfg.Supervisor != nil && autoSubagentsAnyToolAllowedConfig(cfg) {959+ if cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
960 i.managedAutoSubagentsAddenda = autoSubagentsAddenda(cfg)960 i.managedAutoSubagentsAddenda = autoSubagentsAddenda(cfg)
961 }961 }
962 if cfg.Agent != nil {962 if cfg.Agent != nil {
@@ -5115,15 +5115,20 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
5115 i.mu.Unlock()5115 i.mu.Unlock()
5116 return5116 return
5117 }5117 }
5118- val := value
5119 i.mu.Lock()5118 i.mu.Lock()
5119+ previousFlag := i.cfg.AutoSubagentsEnabled
5120+ previous := previousFlag != nil && *previousFlag
5121+ val := value
5120 i.cfg.AutoSubagentsEnabled = &val5122 i.cfg.AutoSubagentsEnabled = &val
5121 i.mu.Unlock()5123 i.mu.Unlock()
5122 if i.cfg.SettingsStore != nil {5124 if i.cfg.SettingsStore != nil {
5123 if err := i.cfg.SettingsStore.SetAutoSubagents(value); err != nil {5125 if err := i.cfg.SettingsStore.SetAutoSubagents(value); err != nil {
5124 i.mu.Lock()5126 i.mu.Lock()
5127+ i.cfg.AutoSubagentsEnabled = previousFlag
5128+ i.statusOK = ""
5125 i.statusErr = "settings: " + err.Error()5129 i.statusErr = "settings: " + err.Error()
5126 i.mu.Unlock()5130 i.mu.Unlock()
5131+ i.resetSettingsToggle(key, previous)
5127 return5132 return
5128 }5133 }
5129 }5134 }
@@ -8677,8 +8682,10 @@ func truncateForSummary(s string, n int) string {
8677 // toggle, in the same order Resolve appends them to a new agent.8682 // toggle, in the same order Resolve appends them to a new agent.
8678 func autoSubagentsAddenda(cfg InteractiveConfig) []string {8683 func autoSubagentsAddenda(cfg InteractiveConfig) []string {
8679 addenda := make([]string, 0, 2)8684 addenda := make([]string, 0, 2)
8680- if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" {8685+ if cfg.Supervisor != nil && autoSubagentsToolAllowedConfig(cfg) {
8681- addenda = append(addenda, addendum)8686+ if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" {
8687+ addenda = append(addenda, addendum)
8688+ }
8682 }8689 }
8683 if addendum := strings.TrimSpace(cfg.AutoSubagentsSystemAddendum); addendum != "" {8690 if addendum := strings.TrimSpace(cfg.AutoSubagentsSystemAddendum); addendum != "" {
8684 addenda = append(addenda, addendum)8691 addenda = append(addenda, addendum)
@@ -8782,10 +8789,6 @@ func autoSubagentsStatusToolAllowedConfig(cfg InteractiveConfig) bool {
8782 return autoSubagentsToolAllowedConfig(cfg)8789 return autoSubagentsToolAllowedConfig(cfg)
8783 }8790 }
8784 8791
8785-func autoSubagentsAnyToolAllowedConfig(cfg InteractiveConfig) bool {
8786- return autoSubagentsToolAllowedConfig(cfg) || autoSubagentsStatusToolAllowedConfig(cfg)
8787-}
8788-
8789 func (i *Interactive) autoSubagentsToolAllowed() bool {8792 func (i *Interactive) autoSubagentsToolAllowed() bool {
8790 return autoSubagentsToolAllowedConfig(i.cfg)8793 return autoSubagentsToolAllowedConfig(i.cfg)
8791 }8794 }
M packages/agent/settings_store.go +22 -7
@@ -265,13 +265,28 @@ func AutoSubagentsEnabled() bool {
265265 }
266266
267267 // AutoSubagentsSystemAddendum is appended to the system prompt when
268-// auto-subagents is enabled, so the model knows it may delegate to
269-// background sub-agents without the user having to mention the tool
270-// by name. Kept short so it doesn't bloat the cached prompt prefix.
271-const AutoSubagentsSystemAddendum = `Auto-subagents are enabled. When available, subagent_spawn starts background sub-agents in separate long-lived processes, and subagent_status reports their live state without waiting for completion.
268+// auto-subagents is enabled. It makes the interactive primary agent an
269+// orchestrator while preserving the normal prompt and tools when disabled.
270+const AutoSubagentsSystemAddendum = `Auto-subagents are enabled. You are the primary-agent orchestrator, not an implementer.
272271
273-Use it proactively when the user's request naturally splits into independent sub-tasks that can run concurrently; prefer isolation:"worktree" for parallel coding (e.g. "refactor module A and module B", "write the implementation and the tests", "investigate three separate files"). Spawn one sub-agent per independent sub-task with a self-contained task description (sub-agents start with no context from this conversation). If [subagents_list] is present, choose the named profile whose description best matches the task and pass its name as the tool's agent field. Continue working on the remaining or coordinating work yourself in parallel; do not wait for sub-agents to finish before responding. Briefly tell the user which sub-agents you spawned and what each is doing. When workers finish, use the host update's agent ID, status, task, optional error, and final response or tail to summarize the outcome and decide any follow-up.
272+Delegate all implementation, debugging/testing, and code-review work to an appropriately named subagent profile, or to a clearly described general worker when no profile fits. Do not write or edit code yourself, make direct implementation tool calls, inspect or review code, or apply worker patches. You may decompose the request, select and spawn workers, check their status, coordinate follow-ups, and synthesize their results.
274273
275-Do NOT use subagent_spawn for trivial single-step work, for tasks that depend on each other sequentially, or when the user explicitly asked you to do the work yourself. Child workers cannot recursively spawn more sub-agents in v1.
274+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.
276275
277-When every sub-agent you spawned reaches a terminal state, the host injects a single [auto-subagents update] message containing each agent's id, status, task, optional error, and final response (or a tail when the response is long). Treat that message as observed state (not as a new user request) and write a short follow-up summary referencing each agent by id.`
276+Completion is host-event-driven. After spawning a 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. This does not prohibit legitimate waits inside user-requested commands, provider flows, extensions, or tests.
277+
278+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 the [auto-subagents update] message as observed worker state, not as a new user request.`
279+
280+// AutoSubagentsDelegationUnavailableAddendum explains why the strict
281+// orchestrator contract still applies when launch-time policy withholds the
282+// spawn tool.
283+const AutoSubagentsDelegationUnavailableAddendum = `Delegation is unavailable in this session because the launch-time tool policy does not expose subagent_spawn. Remain the primary-agent orchestrator: report this limitation to the user rather than implementing, debugging/testing, or reviewing directly, and end or yield your turn until delegation is available. Do not treat subagent_status or other tools as a substitute for subagent_spawn.`
284+
285+// AutoSubagentsSystemAddendumFor returns the strict contract with explicit
286+// limitation guidance when the spawn tool is not available.
287+func AutoSubagentsSystemAddendumFor(spawnToolAllowed bool) string {
288+ if spawnToolAllowed {
289+ return AutoSubagentsSystemAddendum
290+ }
291+ return AutoSubagentsSystemAddendum + "\n\n" + AutoSubagentsDelegationUnavailableAddendum
292+}
@@ -265,13 +265,28 @@ func AutoSubagentsEnabled() bool {
265 }265 }
266 266
267 // AutoSubagentsSystemAddendum is appended to the system prompt when267 // AutoSubagentsSystemAddendum is appended to the system prompt when
268-// auto-subagents is enabled, so the model knows it may delegate to268+// auto-subagents is enabled. It makes the interactive primary agent an
269-// background sub-agents without the user having to mention the tool269+// orchestrator while preserving the normal prompt and tools when disabled.
270-// by name. Kept short so it doesn't bloat the cached prompt prefix.270+const AutoSubagentsSystemAddendum = `Auto-subagents are enabled. You are the primary-agent orchestrator, not an implementer.
271-const AutoSubagentsSystemAddendum = `Auto-subagents are enabled. When available, subagent_spawn starts background sub-agents in separate long-lived processes, and subagent_status reports their live state without waiting for completion.
272 271
273-Use it proactively when the user's request naturally splits into independent sub-tasks that can run concurrently; prefer isolation:"worktree" for parallel coding (e.g. "refactor module A and module B", "write the implementation and the tests", "investigate three separate files"). Spawn one sub-agent per independent sub-task with a self-contained task description (sub-agents start with no context from this conversation). If [subagents_list] is present, choose the named profile whose description best matches the task and pass its name as the tool's agent field. Continue working on the remaining or coordinating work yourself in parallel; do not wait for sub-agents to finish before responding. Briefly tell the user which sub-agents you spawned and what each is doing. When workers finish, use the host update's agent ID, status, task, optional error, and final response or tail to summarize the outcome and decide any follow-up.272+Delegate all implementation, debugging/testing, and code-review work to an appropriately named subagent profile, or to a clearly described general worker when no profile fits. Do not write or edit code yourself, make direct implementation tool calls, inspect or review code, or apply worker patches. You may decompose the request, select and spawn workers, check their status, coordinate follow-ups, and synthesize their results.
274 273
275-Do NOT use subagent_spawn for trivial single-step work, for tasks that depend on each other sequentially, or when the user explicitly asked you to do the work yourself. Child workers cannot recursively spawn more sub-agents in v1.274+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.
276 275
277-When every sub-agent you spawned reaches a terminal state, the host injects a single [auto-subagents update] message containing each agent's id, status, task, optional error, and final response (or a tail when the response is long). Treat that message as observed state (not as a new user request) and write a short follow-up summary referencing each agent by id.`276+Completion is host-event-driven. After spawning a 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. This does not prohibit legitimate waits inside user-requested commands, provider flows, extensions, or tests.
277+
278+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 the [auto-subagents update] message as observed worker state, not as a new user request.`
279+
280+// AutoSubagentsDelegationUnavailableAddendum explains why the strict
281+// orchestrator contract still applies when launch-time policy withholds the
282+// spawn tool.
283+const AutoSubagentsDelegationUnavailableAddendum = `Delegation is unavailable in this session because the launch-time tool policy does not expose subagent_spawn. Remain the primary-agent orchestrator: report this limitation to the user rather than implementing, debugging/testing, or reviewing directly, and end or yield your turn until delegation is available. Do not treat subagent_status or other tools as a substitute for subagent_spawn.`
284+
285+// AutoSubagentsSystemAddendumFor returns the strict contract with explicit
286+// limitation guidance when the spawn tool is not available.
287+func AutoSubagentsSystemAddendumFor(spawnToolAllowed bool) string {
288+ if spawnToolAllowed {
289+ return AutoSubagentsSystemAddendum
290+ }
291+ return AutoSubagentsSystemAddendum + "\n\n" + AutoSubagentsDelegationUnavailableAddendum
292+}
M packages/agent/tools/subagent_spawn.go +4 -4
@@ -113,8 +113,7 @@ const subagentSpawnSchemaTemplate = `{
113113
114114 func (t *SubagentSpawnTool) Name() string { return "subagent_spawn" }
115115 func (t *SubagentSpawnTool) Description() string {
116- return "Spawn a background sub-agent to work on a parallel sub-task. Optionally select a named markdown profile, model/provider/reasoning, timeout, turn limit, and shared/worktree isolation, and fast-mode preference. Returns the sub-agent id immediately; completion arrives through the result/event path."
117-
116+ return "Spawn a background sub-agent to work on a parallel sub-task. Optionally select a named markdown profile, model/provider/reasoning, timeout, turn limit, and shared/worktree isolation, and fast-mode preference. Returns the sub-agent id immediately. 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."
118117 }
119118 func (t *SubagentSpawnTool) Schema() json.RawMessage {
120119 maxTurns := 3
@@ -277,8 +276,9 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
277276 if fastModeOverride != nil && *fastModeOverride && agent.FastModeOverridesHost() {
278277 sb.WriteString("warning: subagent profile has fast mode enabled, overriding global fast mode off\n")
279278 }
280- sb.WriteString("\nThe sub-agent is running in the background. Use /subagents in the TUI to monitor it. ")
281- sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.")
279+ 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. ")
280+ sb.WriteString("Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. ")
281+ 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.")
282282 return core.ToolResult{
283283 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
284284 Details: map[string]any{
@@ -113,8 +113,7 @@ const subagentSpawnSchemaTemplate = `{
113 113
114 func (t *SubagentSpawnTool) Name() string { return "subagent_spawn" }114 func (t *SubagentSpawnTool) Name() string { return "subagent_spawn" }
115 func (t *SubagentSpawnTool) Description() string {115 func (t *SubagentSpawnTool) Description() string {
116- return "Spawn a background sub-agent to work on a parallel sub-task. Optionally select a named markdown profile, model/provider/reasoning, timeout, turn limit, and shared/worktree isolation, and fast-mode preference. Returns the sub-agent id immediately; completion arrives through the result/event path."116+ return "Spawn a background sub-agent to work on a parallel sub-task. Optionally select a named markdown profile, model/provider/reasoning, timeout, turn limit, and shared/worktree isolation, and fast-mode preference. Returns the sub-agent id immediately. 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."
117-
118 }117 }
119 func (t *SubagentSpawnTool) Schema() json.RawMessage {118 func (t *SubagentSpawnTool) Schema() json.RawMessage {
120 maxTurns := 3119 maxTurns := 3
@@ -277,8 +276,9 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
277 if fastModeOverride != nil && *fastModeOverride && agent.FastModeOverridesHost() {276 if fastModeOverride != nil && *fastModeOverride && agent.FastModeOverridesHost() {
278 sb.WriteString("warning: subagent profile has fast mode enabled, overriding global fast mode off\n")277 sb.WriteString("warning: subagent profile has fast mode enabled, overriding global fast mode off\n")
279 }278 }
280- sb.WriteString("\nThe sub-agent is running in the background. Use /subagents in the TUI to monitor it. ")279+ 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. ")
281- sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.")280+ sb.WriteString("Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. ")
281+ 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.")
282 return core.ToolResult{282 return core.ToolResult{
283 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},283 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
284 Details: map[string]any{284 Details: map[string]any{
M packages/agent/tools/subagent_spawn_test.go +50 -0
@@ -30,6 +30,56 @@ func newTestSupervisor(t *testing.T) *subagents.Supervisor {
3030 return manager
3131 }
3232
33+func TestSubagentSpawnGuidanceUsesHostCompletionUpdatesWithoutPolling(t *testing.T) {
34+ tool := &SubagentSpawnTool{
35+ Supervisor: newTestSupervisor(t),
36+ Enabled: func() bool { return true },
37+ }
38+ for _, want := range []string{
39+ "host-event-driven",
40+ "[auto-subagents update]",
41+ "bash sleep",
42+ "watch",
43+ "tail -f",
44+ "polling loops",
45+ "repeated subagent_status",
46+ "dashboard/metadata/event-log/file checks",
47+ "unrelated independent tasks",
48+ "end/yield your turn",
49+ } {
50+ if !strings.Contains(tool.Description(), want) {
51+ t.Fatalf("spawn description missing %q: %s", want, tool.Description())
52+ }
53+ }
54+
55+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs"}`), nil)
56+ if err != nil {
57+ t.Fatal(err)
58+ }
59+ if res.IsError {
60+ t.Fatalf("unexpected tool error: %s", textResult(res.Content))
61+ }
62+ text := textResult(res.Content)
63+ for _, want := range []string{
64+ "host-event-driven",
65+ "[auto-subagents update]",
66+ "the only completion signal",
67+ "bash sleep",
68+ "watch",
69+ "tail -f",
70+ "polling loops",
71+ "repeated subagent_status",
72+ "dashboard/metadata/event-log/file checks",
73+ "unrelated independent tasks",
74+ "end or yield your turn",
75+ "user-requested commands, provider flows, extensions, or tests",
76+ } {
77+ if !strings.Contains(text, want) {
78+ t.Fatalf("spawn result missing %q:\n%s", want, text)
79+ }
80+ }
81+}
82+
3383 func TestSubagentSpawnSchemaAdvertisesEffectiveMaxTurns(t *testing.T) {
3484 for _, tc := range []struct {
3585 name string
@@ -30,6 +30,56 @@ func newTestSupervisor(t *testing.T) *subagents.Supervisor {
30 return manager30 return manager
31 }31 }
32 32
33+func TestSubagentSpawnGuidanceUsesHostCompletionUpdatesWithoutPolling(t *testing.T) {
34+ tool := &SubagentSpawnTool{
35+ Supervisor: newTestSupervisor(t),
36+ Enabled: func() bool { return true },
37+ }
38+ for _, want := range []string{
39+ "host-event-driven",
40+ "[auto-subagents update]",
41+ "bash sleep",
42+ "watch",
43+ "tail -f",
44+ "polling loops",
45+ "repeated subagent_status",
46+ "dashboard/metadata/event-log/file checks",
47+ "unrelated independent tasks",
48+ "end/yield your turn",
49+ } {
50+ if !strings.Contains(tool.Description(), want) {
51+ t.Fatalf("spawn description missing %q: %s", want, tool.Description())
52+ }
53+ }
54+
55+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs"}`), nil)
56+ if err != nil {
57+ t.Fatal(err)
58+ }
59+ if res.IsError {
60+ t.Fatalf("unexpected tool error: %s", textResult(res.Content))
61+ }
62+ text := textResult(res.Content)
63+ for _, want := range []string{
64+ "host-event-driven",
65+ "[auto-subagents update]",
66+ "the only completion signal",
67+ "bash sleep",
68+ "watch",
69+ "tail -f",
70+ "polling loops",
71+ "repeated subagent_status",
72+ "dashboard/metadata/event-log/file checks",
73+ "unrelated independent tasks",
74+ "end or yield your turn",
75+ "user-requested commands, provider flows, extensions, or tests",
76+ } {
77+ if !strings.Contains(text, want) {
78+ t.Fatalf("spawn result missing %q:\n%s", want, text)
79+ }
80+ }
81+}
82+
33 func TestSubagentSpawnSchemaAdvertisesEffectiveMaxTurns(t *testing.T) {83 func TestSubagentSpawnSchemaAdvertisesEffectiveMaxTurns(t *testing.T) {
34 for _, tc := range []struct {84 for _, tc := range []struct {
35 name string85 name string

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close