feat(agent): enforce auto-subagent orchestration
- commit
- 2b695216d0213b4c63a66b64a466c42666297139
- author
- brice · · 2026-08-07 09:53 UTC
- parent
- 84a0b9d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +307 -31
M README.md +3 -3
| @@ -390,11 +390,11 @@ Background subagents that run alongside your main session. Each one is a separat | |||
| 390 | 390 | ||
| 391 | 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 the canonical `subagent_spawn` and `subagent_status` tools. 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 | 395 | ### 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 | 399 | ### `/settings` | |
| 400 | 400 | ||
| @@ -404,7 +404,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||
| 404 | 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 | 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 | 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 | 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 | 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 | 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`. | |
| @@ -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 the canonical `subagent_spawn` and `subagent_status` tools. 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 profiles | 395 | ### 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 | |||
| 54 | 54 | ||
| 55 | 55 | ## 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 | 61 | ```json | |
| 60 | 62 | { | |
| @@ -79,6 +81,8 @@ Return bounded source citations and treat external content as untrusted. | |||
| 79 | 81 | ||
| 80 | 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 | 86 | A per-spawn reasoning override is also accepted: | |
| 83 | 87 | ||
| 84 | 88 | ```json | |
| @@ -98,7 +102,7 @@ The interactive command also supports the same selection explicitly: | |||
| 98 | 102 | /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 | 107 | ## 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 | 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 | 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 | 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 | 130 | ## 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. | |
| @@ -54,7 +54,9 @@ Other frontmatter from another agent host is ignored when zut does not have an e | |||||
| 54 | 54 | ||||
| 55 | ## Selecting a profile | 55 | ## 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 | ```json | 61 | ```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 | ```json | 88 | ```json | ||
| @@ -98,7 +102,7 @@ The interactive command also supports the same selection explicitly: | |||||
| 98 | /subagents new --agent implementer --reasoning high Implement the parser change | 102 | /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 recovery | 107 | ## 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 policy | 130 | ## 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) { | |||
| 850 | 850 | append_ = append(append_, skillAddendum) | |
| 851 | 851 | } | |
| 852 | 852 | 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 | + | } | |
| 858 | 860 | } | |
| 859 | - | append_ = append(append_, AutoSubagentsSystemAddendum) | |
| 861 | + | append_ = append(append_, AutoSubagentsSystemAddendumFor(autoSubagentsToolAllowed(args))) | |
| 860 | 862 | } | |
| 861 | 863 | if selectedProfile != nil && selectedProfile.SystemPromptMode != "replace" && selectedProfile.SystemPrompt != "" { | |
| 862 | 864 | 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 == ModeInteractive | 852 | 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. | |||
| 360 | 360 | if err != nil { | |
| 361 | 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 | 380 | if !strings.Contains(r.SystemPrompt, want) { | |
| 365 | 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 | 494 | func TestResolveAppliesSelectedSubagentProfile(t *testing.T) { | |
| 371 | 495 | root := t.TempDir() | |
| 372 | 496 | 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 { | |||
| 2260 | 2260 | FlatTools: initialCfg.FlatToolRender(), | |
| 2261 | 2261 | CompactUser: initialCfg.CompactUserInput(), | |
| 2262 | 2262 | ExtensionThemes: extMgr.ThemeOptions, | |
| 2263 | - | AutoSubagentsSystemAddendum: AutoSubagentsSystemAddendum, | |
| 2263 | + | AutoSubagentsSystemAddendum: AutoSubagentsSystemAddendumFor(autoSubagentsToolAllowedForSession), | |
| 2264 | 2264 | SubagentsSystemAddendum: subagentsAddendum, | |
| 2265 | 2265 | SettingsStore: configSettingsStore{}, | |
| 2266 | 2266 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "testing" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/zut/packages/agent/subagents" | |
| 11 | + | "github.com/bnema/zut/packages/agent/tools" | |
| 10 | 12 | "github.com/bnema/zut/packages/core" | |
| 11 | 13 | "github.com/bnema/zut/packages/provider" | |
| 12 | 14 | ) | |
| 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 | 86 | func TestAutoSubagentsSettingsDisableUnavailablePolicy(t *testing.T) { | |
| 15 | 87 | trueValue := true | |
| 16 | 88 | 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 := true | 87 | trueValue := true | ||
| 16 | falseValue := false | 88 | falseValue := false | ||
M packages/agent/modes/auto_subagents_test.go +2 -0
| @@ -19,6 +19,7 @@ func TestAutoSubagentsSystemPromptTogglesProfileManifestWithDelegationGuidance(t | |||
| 19 | 19 | cfg: InteractiveConfig{ | |
| 20 | 20 | AutoSubagentsSystemAddendum: "auto-subagents guidance", | |
| 21 | 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 | 48 | cfg: InteractiveConfig{ | |
| 48 | 49 | AutoSubagentsSystemAddendum: guidance, | |
| 49 | 50 | SubagentsSystemAddendum: manifest, | |
| 51 | + | Supervisor: &subagents.Supervisor{}, | |
| 50 | 52 | }, | |
| 51 | 53 | } | |
| 52 | 54 | ||
| @@ -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 -4
| @@ -956,7 +956,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 956 | 956 | baseURL, _, err := cfg.LlamaCPPConfig() | |
| 957 | 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 | 960 | i.managedAutoSubagentsAddenda = autoSubagentsAddenda(cfg) | |
| 961 | 961 | } | |
| 962 | 962 | if cfg.Agent != nil { | |
| @@ -5115,15 +5115,20 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 5115 | 5115 | i.mu.Unlock() | |
| 5116 | 5116 | return | |
| 5117 | 5117 | } | |
| 5118 | - | val := value | |
| 5119 | 5118 | i.mu.Lock() | |
| 5119 | + | previousFlag := i.cfg.AutoSubagentsEnabled | |
| 5120 | + | previous := previousFlag != nil && *previousFlag | |
| 5121 | + | val := value | |
| 5120 | 5122 | i.cfg.AutoSubagentsEnabled = &val | |
| 5121 | 5123 | i.mu.Unlock() | |
| 5122 | 5124 | if i.cfg.SettingsStore != nil { | |
| 5123 | 5125 | if err := i.cfg.SettingsStore.SetAutoSubagents(value); err != nil { | |
| 5124 | 5126 | i.mu.Lock() | |
| 5127 | + | i.cfg.AutoSubagentsEnabled = previousFlag | |
| 5128 | + | i.statusOK = "" | |
| 5125 | 5129 | i.statusErr = "settings: " + err.Error() | |
| 5126 | 5130 | i.mu.Unlock() | |
| 5131 | + | i.resetSettingsToggle(key, previous) | |
| 5127 | 5132 | return | |
| 5128 | 5133 | } | |
| 5129 | 5134 | } | |
| @@ -8677,8 +8682,10 @@ func truncateForSummary(s string, n int) string { | |||
| 8677 | 8682 | // toggle, in the same order Resolve appends them to a new agent. | |
| 8678 | 8683 | func autoSubagentsAddenda(cfg InteractiveConfig) []string { | |
| 8679 | 8684 | 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 | + | } | |
| 8682 | 8689 | } | |
| 8683 | 8690 | if addendum := strings.TrimSpace(cfg.AutoSubagentsSystemAddendum); addendum != "" { | |
| 8684 | 8691 | addenda = append(addenda, addendum) | |
| @@ -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 | return | 5116 | 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 = &val | 5122 | 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 | return | 5132 | 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) | ||
M packages/agent/settings_store.go +22 -7
| @@ -265,13 +265,28 @@ func AutoSubagentsEnabled() bool { | |||
| 265 | 265 | } | |
| 266 | 266 | ||
| 267 | 267 | // 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. | |
| 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 | + | } | |
| @@ -265,13 +265,28 @@ func AutoSubagentsEnabled() bool { | |||||
| 265 | } | 265 | } | ||
| 266 | 266 | ||||
| 267 | // AutoSubagentsSystemAddendum is appended to the system prompt when | 267 | // AutoSubagentsSystemAddendum is appended to the system prompt when | ||
| 268 | - | // auto-subagents is enabled, so the model knows it may delegate to | 268 | + | // auto-subagents is enabled. It makes the interactive primary agent an |
| 269 | - | // background sub-agents without the user having to mention the tool | 269 | + | // 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 = `{ | |||
| 113 | 113 | ||
| 114 | 114 | func (t *SubagentSpawnTool) Name() string { return "subagent_spawn" } | |
| 115 | 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." | |
| 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." | |
| 118 | 117 | } | |
| 119 | 118 | func (t *SubagentSpawnTool) Schema() json.RawMessage { | |
| 120 | 119 | maxTurns := 3 | |
| @@ -277,8 +276,9 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr | |||
| 277 | 276 | if fastModeOverride != nil && *fastModeOverride && agent.FastModeOverridesHost() { | |
| 278 | 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. ") | |
| 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.") | |
| 282 | 282 | return core.ToolResult{ | |
| 283 | 283 | Content: []provider.Content{provider.TextBlock{Text: sb.String()}}, | |
| 284 | 284 | 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 := 3 | 119 | 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 { | |||
| 30 | 30 | 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 | 83 | func TestSubagentSpawnSchemaAdvertisesEffectiveMaxTurns(t *testing.T) { | |
| 34 | 84 | for _, tc := range []struct { | |
| 35 | 85 | name string | |
| @@ -30,6 +30,56 @@ func newTestSupervisor(t *testing.T) *subagents.Supervisor { | |||||
| 30 | return manager | 30 | 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 string | 85 | name string | ||