detached 2ae56a3

refactor(subagents): separate turn limits from observation

commit
2ae56a3920706ce42f0488512b42ad74c7360e44
author
brice · · 2026-08-12 16:44 UTC
parent
4ed3d6d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
26 files · +669 -112
M README.md +1 -1
@@ -425,7 +425,7 @@ Background subagents that run alongside your main session. Each one is a separat
425425
426426 **`/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.
427427
428-**Subagent Orchestrator.** With `/settings` → **Subagent Orchestrator** on (or `/orchestrator`), the interactive primary agent enters strict orchestrator mode and gets whichever of the canonical `subagent_spawn` and `subagent_status` tools are permitted by launch-time policy. It must delegate all implementation, debugging/testing, and code-review work to an appropriately named profile, or to a clearly described general worker when no profile fits. It must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches itself. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize results. Give workers self-contained tasks: shared-worktree workers edit the same working directory and need coordination, while `isolation:"worktree"` is for parallel coding in separate trees; a worktree patch must be integrated by a worker rather than applied by the primary agent. Child workers cannot recursively spawn more sub-agents in v1. The `subagent_status` tool accepts an optional `agent_id`: omit it to list visible workers, or pass a full id / unique prefix to query one without waiting for completion. It returns bounded JSON metadata with the worker id, lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), underlying process/turn state, timestamps, a bounded first-line task summary, required state/target/unmet metadata when applicable, and terminal-result metadata when available; it omits full prompts, transcripts, result output, provider settings, and filesystem paths. Treat the bounded task summary as session data. The spawn tool always returns the child id and initial state immediately. Set `required:true` when the parent or workflow depends on the result: the worker remains asynchronous, its outcome is persisted and delivered through a host completion update, and failure, timeout, or cancellation remains recoverable until a retry succeeds or the user explicitly removes the terminal worker. An outcome not durably observed before host restart becomes indeterminate and requires the user to inspect side effects before explicitly resuming, restarting, or removing the worker. Completion is a durable `turn.result` with a `subagent://<id>/result` reference. The tool accepts named profiles, timeout, `max_turns`, and shared/worktree isolation. Omit `max_turns` to use the supervisor policy; the default ceiling is 3 and `subagents.max_turns` changes it. Global concurrency limits apply equally to slash commands, tool calls, and batches. Completion is host-event-driven for every worker through `[auto-subagents update]`, with required state also supplied as high-priority `[required-subagents update]` context. Never use `bash sleep`, `watch`, `tail -f`, polling loops, repeated `subagent_status`, or dashboard/metadata/event-log/file checks solely to wait; work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests remain allowed. Off by default; toggle with `/orchestrator` or from `/settings`. When off, the permitted canonical subagent tools and profile manifest remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise performs work itself without the strict orchestrator contract.
428+**Subagent Orchestrator.** With `/settings` → **Subagent Orchestrator** on (or `/orchestrator`), the interactive primary agent enters strict orchestrator mode and gets whichever of the canonical `subagent_spawn` and `subagent_status` tools are permitted by launch-time policy. It must delegate all implementation, debugging/testing, and code-review work to an appropriately named profile, or to a clearly described general worker when no profile fits. It must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches itself. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize results. Give workers self-contained tasks: shared-worktree workers edit the same working directory and need coordination, while `isolation:"worktree"` is for parallel coding in separate trees; a worktree patch must be integrated by a worker rather than applied by the primary agent. Child workers cannot recursively spawn more sub-agents in v1. The `subagent_status` tool accepts an optional `agent_id`: omit it to list visible workers, or pass a full id / unique prefix to query one without waiting for completion. It returns bounded JSON metadata with the worker id, lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), underlying process/turn state, timestamps, a bounded first-line task summary, required state/target/unmet metadata when applicable, and terminal-result metadata when available; it omits full prompts, transcripts, result output, provider settings, and filesystem paths. Treat the bounded task summary as session data. The spawn tool always returns the child id and initial state immediately. Set `required:true` when the parent or workflow depends on the result: the worker remains asynchronous, its outcome is persisted and delivered through a host completion update, and failure, timeout, or cancellation remains recoverable until a retry succeeds or the user explicitly removes the terminal worker. An outcome not durably observed before host restart becomes indeterminate and requires the user to inspect side effects before explicitly resuming, restarting, or removing the worker. Completion is a durable `turn.result` with a `subagent://<id>/result` reference. The tool accepts named profiles, `max_turns`, and shared/worktree isolation. Delegated turns are unlimited by default unless a positive manager timeout is configured; a global `subagents.max_steps` budget (128 by default) bounds model/tool loops for every profile. Omit `max_turns` to use the supervisor policy; the default ceiling is 3 and `subagents.max_turns` changes it. Global concurrency limits apply equally to slash commands, tool calls, and batches. Completion is host-event-driven for every worker through `[auto-subagents update]`, with required state also supplied as high-priority `[required-subagents update]` context. Never use `bash sleep`, `watch`, `tail -f`, polling loops, repeated `subagent_status`, or dashboard/metadata/event-log/file checks solely to wait; work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests remain allowed. Off by default; toggle with `/orchestrator` or from `/settings`. When off, the permitted canonical subagent tools and profile manifest remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise performs work itself without the strict orchestrator contract.
429429
430430 ### Named subagent profiles
431431
@@ -425,7 +425,7 @@ Background subagents that run alongside your main session. Each one is a separat
425 425
426 **`/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.426 **`/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.
427 427
428-**Subagent Orchestrator.** With `/settings` → **Subagent Orchestrator** on (or `/orchestrator`), the interactive primary agent enters strict orchestrator mode and gets whichever of the canonical `subagent_spawn` and `subagent_status` tools are permitted by launch-time policy. It must delegate all implementation, debugging/testing, and code-review work to an appropriately named profile, or to a clearly described general worker when no profile fits. It must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches itself. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize results. Give workers self-contained tasks: shared-worktree workers edit the same working directory and need coordination, while `isolation:"worktree"` is for parallel coding in separate trees; a worktree patch must be integrated by a worker rather than applied by the primary agent. Child workers cannot recursively spawn more sub-agents in v1. The `subagent_status` tool accepts an optional `agent_id`: omit it to list visible workers, or pass a full id / unique prefix to query one without waiting for completion. It returns bounded JSON metadata with the worker id, lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), underlying process/turn state, timestamps, a bounded first-line task summary, required state/target/unmet metadata when applicable, and terminal-result metadata when available; it omits full prompts, transcripts, result output, provider settings, and filesystem paths. Treat the bounded task summary as session data. The spawn tool always returns the child id and initial state immediately. Set `required:true` when the parent or workflow depends on the result: the worker remains asynchronous, its outcome is persisted and delivered through a host completion update, and failure, timeout, or cancellation remains recoverable until a retry succeeds or the user explicitly removes the terminal worker. An outcome not durably observed before host restart becomes indeterminate and requires the user to inspect side effects before explicitly resuming, restarting, or removing the worker. Completion is a durable `turn.result` with a `subagent://<id>/result` reference. The tool accepts named profiles, timeout, `max_turns`, and shared/worktree isolation. Omit `max_turns` to use the supervisor policy; the default ceiling is 3 and `subagents.max_turns` changes it. Global concurrency limits apply equally to slash commands, tool calls, and batches. Completion is host-event-driven for every worker through `[auto-subagents update]`, with required state also supplied as high-priority `[required-subagents update]` context. Never use `bash sleep`, `watch`, `tail -f`, polling loops, repeated `subagent_status`, or dashboard/metadata/event-log/file checks solely to wait; work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests remain allowed. Off by default; toggle with `/orchestrator` or from `/settings`. When off, the permitted canonical subagent tools and profile manifest remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise performs work itself without the strict orchestrator contract.428+**Subagent Orchestrator.** With `/settings` → **Subagent Orchestrator** on (or `/orchestrator`), the interactive primary agent enters strict orchestrator mode and gets whichever of the canonical `subagent_spawn` and `subagent_status` tools are permitted by launch-time policy. It must delegate all implementation, debugging/testing, and code-review work to an appropriately named profile, or to a clearly described general worker when no profile fits. It must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches itself. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize results. Give workers self-contained tasks: shared-worktree workers edit the same working directory and need coordination, while `isolation:"worktree"` is for parallel coding in separate trees; a worktree patch must be integrated by a worker rather than applied by the primary agent. Child workers cannot recursively spawn more sub-agents in v1. The `subagent_status` tool accepts an optional `agent_id`: omit it to list visible workers, or pass a full id / unique prefix to query one without waiting for completion. It returns bounded JSON metadata with the worker id, lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), underlying process/turn state, timestamps, a bounded first-line task summary, required state/target/unmet metadata when applicable, and terminal-result metadata when available; it omits full prompts, transcripts, result output, provider settings, and filesystem paths. Treat the bounded task summary as session data. The spawn tool always returns the child id and initial state immediately. Set `required:true` when the parent or workflow depends on the result: the worker remains asynchronous, its outcome is persisted and delivered through a host completion update, and failure, timeout, or cancellation remains recoverable until a retry succeeds or the user explicitly removes the terminal worker. An outcome not durably observed before host restart becomes indeterminate and requires the user to inspect side effects before explicitly resuming, restarting, or removing the worker. Completion is a durable `turn.result` with a `subagent://<id>/result` reference. The tool accepts named profiles, `max_turns`, and shared/worktree isolation. Delegated turns are unlimited by default unless a positive manager timeout is configured; a global `subagents.max_steps` budget (128 by default) bounds model/tool loops for every profile. Omit `max_turns` to use the supervisor policy; the default ceiling is 3 and `subagents.max_turns` changes it. Global concurrency limits apply equally to slash commands, tool calls, and batches. Completion is host-event-driven for every worker through `[auto-subagents update]`, with required state also supplied as high-priority `[required-subagents update]` context. Never use `bash sleep`, `watch`, `tail -f`, polling loops, repeated `subagent_status`, or dashboard/metadata/event-log/file checks solely to wait; work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests remain allowed. Off by default; toggle with `/orchestrator` or from `/settings`. When off, the permitted canonical subagent tools and profile manifest remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise performs work itself without the strict orchestrator contract.
429 429
430 ### Named subagent profiles430 ### Named subagent profiles
431 431
M docs/subagents.md +3 -3
@@ -139,7 +139,7 @@ Every child retains narrowly scoped internal process control and durable task/re
139139
140140 During a trace-observed open operation, interactive mode shows compact rows beside the input area: a themed spinner, the named profile (or agent ID), the most specific open operation, and its elapsed duration. A recent safe observation is shown alongside it when available, for example `assistant streaming 1s ago`; normal mode never displays message text, reasoning text, tool arguments, or tool output. Rows default below the input and can move above it through **running subagent position** in `/settings` → **tui settings**. On short terminals, omitted operations collapse into one count summary so the editor remains visible. A process lifecycle, heartbeat, or generic `working` label never creates a row; use `/subagents` for the current factual operation or last observed fact.
141141
142-`/subagents` opens on workers from the current host session. Press **Tab** to switch to the all-sessions history, use **Up/Down** to select a worker, and press **Right** or **l** to expand a bounded live snapshot of its user messages, assistant output, and tool activity (**Left** or **h** collapses it). The dashboard derives its row budget from the terminal height, keeping the selected worker visible on short terminals. Press **Enter** for the complete transcript.
142+`/subagents` opens on workers from the current host session. Press **Tab** to switch to the all-sessions history, use **Up/Down** to select a worker, and press **Right** or **l** to expand a bounded live snapshot of its user messages, assistant output, and tool activity (**Left** or **h** collapses it). The dashboard derives its row budget from the terminal height, keeping the selected worker visible on short terminals. Press **Enter** for the complete transcript. `/subagents wait <id>` observes the worker's current delegated turn, not its long-lived daemon process. Canceling that wait only removes the observer; it never stops the worker.
143143
144144 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.
145145
@@ -156,7 +156,7 @@ subagent://<id>/patch
156156
157157 Workers send protocol events to their parent supervisor. The supervisor maintains the live transcript and, when execution tracing is enabled, writes one ordered local trace for the parent/child tree. The trace is diagnostic data, not a second session transcript.
158158
159-A subagent timeout applies to each active delegated turn. When the deadline expires, the worker cancels that turn, writes a failed result with error code `deadline_exceeded`, preserves any output already received, and remains idle so an explicit follow-up can resume its session. Idle time does not consume the next turn's timeout, and each resumed turn receives a fresh deadline. Process shutdown still allows the configured grace period for the worker to write its session and result before forceful cancellation.
159+Delegated turns have no wall-clock deadline by default. A positive configured `default_timeout` applies only to the active delegated turn; timeout selection is manager-owned and is not exposed to the model-facing spawn tool. When that deadline expires, the worker cancels the turn, writes a failed result with error code `deadline_exceeded`, preserves any output already received, and remains idle so an explicit follow-up can resume its session. Idle time does not consume the next turn's timeout, and each resumed turn receives a fresh deadline. Provider stream-idle detection and retry limits remain independently bounded, so an unlimited turn does not mean a silent connection can hang forever. Process shutdown still allows the configured grace period for the worker to write its session and result before forceful cancellation.
160160
161161 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.
162162
@@ -178,4 +178,4 @@ Use `subagent_stop` with an `agent_id` to request termination of a stuck worker
178178
179179 ## Resource policy
180180
181-The persisted `subagents` config object supports `max_concurrent`, `max_concurrent_per_parent`, `queue_timeout`, `startup_timeout`, `default_timeout`, `max_turns`, output caps, allowed tools/roots, heartbeat and idle timeouts. A worker fails with an actionable queue-timeout error when no execution slot becomes available before `queue_timeout` (5 minutes by default). Once admitted, it must report `agent_ready` before `startup_timeout` (1 minute by default) or fails with guidance to inspect worker output and retry. `max_turns` bounds one active worker run; lifetime and current-run turn counters remain persisted and visible in worker status. An explicit `subagent_resume` starts a new run with a fresh budget without replaying the original task or losing session context. 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.
181+The persisted `subagents` config object supports `max_concurrent`, `max_concurrent_per_parent`, `queue_timeout`, `startup_timeout`, `default_timeout`, `max_turns`, `max_steps`, output caps, allowed tools/roots, heartbeat and idle timeouts. A worker fails with an actionable queue-timeout error when no execution slot becomes available before `queue_timeout` (5 minutes by default). Once admitted, it must report `agent_ready` before `startup_timeout` (1 minute by default) or fails with guidance to inspect worker output and retry. Missing or non-positive `default_timeout` leaves delegated turns unlimited. `max_steps` defaults to 128 and bounds provider/model/tool-loop iterations for every delegated turn, independent of profile type. Exhausting it produces a failed result with error code `step_limit`; partial output and session history remain available for an explicit resume. `max_turns` bounds one active worker run; lifetime and current-run turn counters remain persisted and visible in worker status. An explicit `subagent_resume` starts a new run with fresh turn and step budgets without replaying the original task or losing session context. 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-worker deadline mode and step limit 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.
@@ -139,7 +139,7 @@ Every child retains narrowly scoped internal process control and durable task/re
139 139
140 During a trace-observed open operation, interactive mode shows compact rows beside the input area: a themed spinner, the named profile (or agent ID), the most specific open operation, and its elapsed duration. A recent safe observation is shown alongside it when available, for example `assistant streaming 1s ago`; normal mode never displays message text, reasoning text, tool arguments, or tool output. Rows default below the input and can move above it through **running subagent position** in `/settings` → **tui settings**. On short terminals, omitted operations collapse into one count summary so the editor remains visible. A process lifecycle, heartbeat, or generic `working` label never creates a row; use `/subagents` for the current factual operation or last observed fact.140 During a trace-observed open operation, interactive mode shows compact rows beside the input area: a themed spinner, the named profile (or agent ID), the most specific open operation, and its elapsed duration. A recent safe observation is shown alongside it when available, for example `assistant streaming 1s ago`; normal mode never displays message text, reasoning text, tool arguments, or tool output. Rows default below the input and can move above it through **running subagent position** in `/settings` → **tui settings**. On short terminals, omitted operations collapse into one count summary so the editor remains visible. A process lifecycle, heartbeat, or generic `working` label never creates a row; use `/subagents` for the current factual operation or last observed fact.
141 141
142-`/subagents` opens on workers from the current host session. Press **Tab** to switch to the all-sessions history, use **Up/Down** to select a worker, and press **Right** or **l** to expand a bounded live snapshot of its user messages, assistant output, and tool activity (**Left** or **h** collapses it). The dashboard derives its row budget from the terminal height, keeping the selected worker visible on short terminals. Press **Enter** for the complete transcript.142+`/subagents` opens on workers from the current host session. Press **Tab** to switch to the all-sessions history, use **Up/Down** to select a worker, and press **Right** or **l** to expand a bounded live snapshot of its user messages, assistant output, and tool activity (**Left** or **h** collapses it). The dashboard derives its row budget from the terminal height, keeping the selected worker visible on short terminals. Press **Enter** for the complete transcript. `/subagents wait <id>` observes the worker's current delegated turn, not its long-lived daemon process. Canceling that wait only removes the observer; it never stops the worker.
143 143
144 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.144 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.
145 145
@@ -156,7 +156,7 @@ subagent://<id>/patch
156 156
157 Workers send protocol events to their parent supervisor. The supervisor maintains the live transcript and, when execution tracing is enabled, writes one ordered local trace for the parent/child tree. The trace is diagnostic data, not a second session transcript.157 Workers send protocol events to their parent supervisor. The supervisor maintains the live transcript and, when execution tracing is enabled, writes one ordered local trace for the parent/child tree. The trace is diagnostic data, not a second session transcript.
158 158
159-A subagent timeout applies to each active delegated turn. When the deadline expires, the worker cancels that turn, writes a failed result with error code `deadline_exceeded`, preserves any output already received, and remains idle so an explicit follow-up can resume its session. Idle time does not consume the next turn's timeout, and each resumed turn receives a fresh deadline. Process shutdown still allows the configured grace period for the worker to write its session and result before forceful cancellation.159+Delegated turns have no wall-clock deadline by default. A positive configured `default_timeout` applies only to the active delegated turn; timeout selection is manager-owned and is not exposed to the model-facing spawn tool. When that deadline expires, the worker cancels the turn, writes a failed result with error code `deadline_exceeded`, preserves any output already received, and remains idle so an explicit follow-up can resume its session. Idle time does not consume the next turn's timeout, and each resumed turn receives a fresh deadline. Provider stream-idle detection and retry limits remain independently bounded, so an unlimited turn does not mean a silent connection can hang forever. Process shutdown still allows the configured grace period for the worker to write its session and result before forceful cancellation.
160 160
161 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.161 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.
162 162
@@ -178,4 +178,4 @@ Use `subagent_stop` with an `agent_id` to request termination of a stuck worker
178 178
179 ## Resource policy179 ## Resource policy
180 180
181-The persisted `subagents` config object supports `max_concurrent`, `max_concurrent_per_parent`, `queue_timeout`, `startup_timeout`, `default_timeout`, `max_turns`, output caps, allowed tools/roots, heartbeat and idle timeouts. A worker fails with an actionable queue-timeout error when no execution slot becomes available before `queue_timeout` (5 minutes by default). Once admitted, it must report `agent_ready` before `startup_timeout` (1 minute by default) or fails with guidance to inspect worker output and retry. `max_turns` bounds one active worker run; lifetime and current-run turn counters remain persisted and visible in worker status. An explicit `subagent_resume` starts a new run with a fresh budget without replaying the original task or losing session context. 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.181+The persisted `subagents` config object supports `max_concurrent`, `max_concurrent_per_parent`, `queue_timeout`, `startup_timeout`, `default_timeout`, `max_turns`, `max_steps`, output caps, allowed tools/roots, heartbeat and idle timeouts. A worker fails with an actionable queue-timeout error when no execution slot becomes available before `queue_timeout` (5 minutes by default). Once admitted, it must report `agent_ready` before `startup_timeout` (1 minute by default) or fails with guidance to inspect worker output and retry. Missing or non-positive `default_timeout` leaves delegated turns unlimited. `max_steps` defaults to 128 and bounds provider/model/tool-loop iterations for every delegated turn, independent of profile type. Exhausting it produces a failed result with error code `step_limit`; partial output and session history remain available for an explicit resume. `max_turns` bounds one active worker run; lifetime and current-run turn counters remain persisted and visible in worker status. An explicit `subagent_resume` starts a new run with fresh turn and step budgets without replaying the original task or losing session context. 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-worker deadline mode and step limit 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/config.go +1 -0
@@ -31,6 +31,7 @@ type SubagentsConfig struct {
3131 StartupTimeout string `json:"startup_timeout,omitempty"`
3232 DefaultTimeout string `json:"default_timeout,omitempty"`
3333 MaxTurns int `json:"max_turns,omitempty"`
34+ MaxSteps int `json:"max_steps,omitempty"`
3435 MaxOutputBytes int `json:"max_output_bytes,omitempty"`
3536 MaxOutputLines int `json:"max_output_lines,omitempty"`
3637 AllowedTools []string `json:"allowed_tools,omitempty"`
@@ -31,6 +31,7 @@ type SubagentsConfig struct {
31 StartupTimeout string `json:"startup_timeout,omitempty"`31 StartupTimeout string `json:"startup_timeout,omitempty"`
32 DefaultTimeout string `json:"default_timeout,omitempty"`32 DefaultTimeout string `json:"default_timeout,omitempty"`
33 MaxTurns int `json:"max_turns,omitempty"`33 MaxTurns int `json:"max_turns,omitempty"`
34+ MaxSteps int `json:"max_steps,omitempty"`
34 MaxOutputBytes int `json:"max_output_bytes,omitempty"`35 MaxOutputBytes int `json:"max_output_bytes,omitempty"`
35 MaxOutputLines int `json:"max_output_lines,omitempty"`36 MaxOutputLines int `json:"max_output_lines,omitempty"`
36 AllowedTools []string `json:"allowed_tools,omitempty"`37 AllowedTools []string `json:"allowed_tools,omitempty"`
M packages/agent/modes/subagent_indicator.go +21 -4
@@ -24,7 +24,7 @@ func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots []
2424 if operation == nil {
2525 continue
2626 }
27- line := renderSubagentActivityLine(th, spinnerGlyph, snapshot, *operation, view.ObservationFor(*operation), width, now)
27+ line := renderSubagentActivityLine(th, spinnerGlyph, snapshot, *operation, view.TurnStartedAt(*operation), view.ObservationFor(*operation), width, now)
2828 if line != "" {
2929 lines = append(lines, line)
3030 }
@@ -32,7 +32,7 @@ func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots []
3232 return lines
3333 }
3434
35-func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot subagents.AgentSnapshot, operation subagents.Operation, observation *subagents.LiveObservation, width int, now time.Time) string {
35+func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot subagents.AgentSnapshot, operation subagents.Operation, turnStartedAt time.Time, observation *subagents.LiveObservation, width int, now time.Time) string {
3636 name := sanitizeSubagentIndicatorText(snapshot.Subagent)
3737 if name == "" {
3838 name = sanitizeSubagentIndicatorText(snapshot.ID)
@@ -48,7 +48,13 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba
4848 if observation != nil {
4949 activity += " · " + observation.Label() + " " + formatSubagentActivityAge(observation.At, now) + " ago"
5050 }
51- age := formatSubagentActivityAge(operation.StartedAt, now)
51+ // The rightmost age is elapsed time for the open delegated turn, stable
52+ // across its nested provider/tool cycles. Fall back to the operation start
53+ // only for legacy/incomplete traces without a matching turn boundary.
54+ if turnStartedAt.IsZero() {
55+ turnStartedAt = operation.StartedAt
56+ }
57+ age := formatSubagentActivityAge(turnStartedAt, now)
5258 plain, layout := fitSubagentActivityLine(spinnerGlyph, name, activity, age, width-2)
5359 if plain == "" {
5460 return ""
@@ -63,7 +69,7 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba
6369 name = strings.TrimPrefix(parts[0], spinnerGlyph+" ")
6470 return " " + th.FGColor(th.Spinner, spinnerGlyph) + " " +
6571 th.FGColor(th.Assistant, name) + th.FGColor(th.Muted, " · ") +
66- th.FGColor(th.FG, parts[1]) + th.FGColor(th.Muted, " · "+parts[2])
72+ th.FGColor(subagentOperationColor(th, operation), parts[1]) + th.FGColor(th.Muted, " · "+parts[2])
6773 case subagentActivityNameAge:
6874 parts := strings.SplitN(plain, " · ", 2)
6975 if len(parts) != 2 {
@@ -77,6 +83,17 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba
7783 }
7884 }
7985
86+func subagentOperationColor(th tui.Theme, operation subagents.Operation) tui.TerminalColor {
87+ switch operation.Type {
88+ case "provider.request.started":
89+ return th.Spinner
90+ case "tool.started":
91+ return th.Tool
92+ default:
93+ return th.Assistant
94+ }
95+}
96+
8097 type subagentActivityLayout uint8
8198
8299 const (
@@ -24,7 +24,7 @@ func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots []
24 if operation == nil {24 if operation == nil {
25 continue25 continue
26 }26 }
27- line := renderSubagentActivityLine(th, spinnerGlyph, snapshot, *operation, view.ObservationFor(*operation), width, now)27+ line := renderSubagentActivityLine(th, spinnerGlyph, snapshot, *operation, view.TurnStartedAt(*operation), view.ObservationFor(*operation), width, now)
28 if line != "" {28 if line != "" {
29 lines = append(lines, line)29 lines = append(lines, line)
30 }30 }
@@ -32,7 +32,7 @@ func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots []
32 return lines32 return lines
33 }33 }
34 34
35-func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot subagents.AgentSnapshot, operation subagents.Operation, observation *subagents.LiveObservation, width int, now time.Time) string {35+func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot subagents.AgentSnapshot, operation subagents.Operation, turnStartedAt time.Time, observation *subagents.LiveObservation, width int, now time.Time) string {
36 name := sanitizeSubagentIndicatorText(snapshot.Subagent)36 name := sanitizeSubagentIndicatorText(snapshot.Subagent)
37 if name == "" {37 if name == "" {
38 name = sanitizeSubagentIndicatorText(snapshot.ID)38 name = sanitizeSubagentIndicatorText(snapshot.ID)
@@ -48,7 +48,13 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba
48 if observation != nil {48 if observation != nil {
49 activity += " · " + observation.Label() + " " + formatSubagentActivityAge(observation.At, now) + " ago"49 activity += " · " + observation.Label() + " " + formatSubagentActivityAge(observation.At, now) + " ago"
50 }50 }
51- age := formatSubagentActivityAge(operation.StartedAt, now)51+ // The rightmost age is elapsed time for the open delegated turn, stable
52+ // across its nested provider/tool cycles. Fall back to the operation start
53+ // only for legacy/incomplete traces without a matching turn boundary.
54+ if turnStartedAt.IsZero() {
55+ turnStartedAt = operation.StartedAt
56+ }
57+ age := formatSubagentActivityAge(turnStartedAt, now)
52 plain, layout := fitSubagentActivityLine(spinnerGlyph, name, activity, age, width-2)58 plain, layout := fitSubagentActivityLine(spinnerGlyph, name, activity, age, width-2)
53 if plain == "" {59 if plain == "" {
54 return ""60 return ""
@@ -63,7 +69,7 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba
63 name = strings.TrimPrefix(parts[0], spinnerGlyph+" ")69 name = strings.TrimPrefix(parts[0], spinnerGlyph+" ")
64 return " " + th.FGColor(th.Spinner, spinnerGlyph) + " " +70 return " " + th.FGColor(th.Spinner, spinnerGlyph) + " " +
65 th.FGColor(th.Assistant, name) + th.FGColor(th.Muted, " · ") +71 th.FGColor(th.Assistant, name) + th.FGColor(th.Muted, " · ") +
66- th.FGColor(th.FG, parts[1]) + th.FGColor(th.Muted, " · "+parts[2])72+ th.FGColor(subagentOperationColor(th, operation), parts[1]) + th.FGColor(th.Muted, " · "+parts[2])
67 case subagentActivityNameAge:73 case subagentActivityNameAge:
68 parts := strings.SplitN(plain, " · ", 2)74 parts := strings.SplitN(plain, " · ", 2)
69 if len(parts) != 2 {75 if len(parts) != 2 {
@@ -77,6 +83,17 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba
77 }83 }
78 }84 }
79 85
86+func subagentOperationColor(th tui.Theme, operation subagents.Operation) tui.TerminalColor {
87+ switch operation.Type {
88+ case "provider.request.started":
89+ return th.Spinner
90+ case "tool.started":
91+ return th.Tool
92+ default:
93+ return th.Assistant
94+ }
95+}
96+
80 type subagentActivityLayout uint897 type subagentActivityLayout uint8
81 98
82 const (99 const (
M packages/agent/modes/subagent_indicator_test.go +33 -0
@@ -37,6 +37,26 @@ func TestRenderSubagentActivityLinesShowsSafeLastObservation(t *testing.T) {
3737 }
3838 }
3939
40+func TestRenderSubagentActivityLinesShowsRecentCompletedToolUnderProviderRequest(t *testing.T) {
41+ now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
42+ lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{
43+ "review-123": {PrimaryOperation: &subagents.Operation{Type: "provider.request.started", TurnID: "turn-1", StartedAt: now.Add(-2 * time.Second)}, LastObservation: &subagents.LiveObservation{Type: "tool.finished", Name: "read", CallID: "call-1", TurnID: "turn-1", At: now.Add(-3 * time.Second)}},
44+ }, 100, now))
45+ got := strings.Join(lines, "\n")
46+ if !strings.Contains(got, "waiting for model response · finished read 3s ago · 2s") {
47+ t.Fatalf("activity lines = %#v", lines)
48+ }
49+}
50+
51+func TestRenderSubagentActivityLineColorsProviderWaitAsActive(t *testing.T) {
52+ now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
53+ operation := subagents.Operation{Type: "provider.request.started", TurnID: "turn-1", StartedAt: now.Add(-2 * time.Second)}
54+ line := renderSubagentActivityLine(tui.Dark, "/", subagents.AgentSnapshot{ID: "review-123", Subagent: "reviewer"}, operation, operation.StartedAt, nil, 100, now)
55+ if !strings.Contains(line, tui.Dark.FGColor(tui.Dark.Spinner, "waiting for model response")) {
56+ t.Fatalf("provider wait is not rendered with the active color: %q", line)
57+ }
58+}
59+
4060 func TestRenderSubagentActivityLinesOmitsPriorTurnObservation(t *testing.T) {
4161 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
4262 lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{
@@ -78,6 +98,19 @@ func TestTruncateSubagentIndicatorTextPreservesGraphemeClusters(t *testing.T) {
7898 }
7999 }
80100
101+func TestRenderSubagentActivityLinesUsesStableDelegatedTurnElapsedTime(t *testing.T) {
102+ now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
103+ turn := subagents.Operation{Type: "turn.started", TurnID: "turn-1", StartedAt: now.Add(-12 * time.Second)}
104+ request := subagents.Operation{Type: "provider.request.started", TurnID: "turn-1", StartedAt: now.Add(-time.Second)}
105+ lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer", Started: now.Add(-5*time.Hour - 16*time.Minute)}}, map[string]subagents.AgentTraceView{
106+ "review-123": {PrimaryOperation: &request, OpenOperations: []subagents.Operation{request, turn}},
107+ }, 100, now))
108+ got := strings.Join(lines, "\n")
109+ if !strings.Contains(got, "waiting for model response · 12s") || strings.Contains(got, "5h16m") {
110+ t.Fatalf("activity lines = %#v, want current delegated-turn elapsed time", lines)
111+ }
112+}
113+
81114 func TestFormatSubagentActivityAgeUsesOperationStart(t *testing.T) {
82115 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
83116 if got, want := formatSubagentActivityAge(now.Add(-7*time.Second), now), "7s"; got != want {
@@ -37,6 +37,26 @@ func TestRenderSubagentActivityLinesShowsSafeLastObservation(t *testing.T) {
37 }37 }
38 }38 }
39 39
40+func TestRenderSubagentActivityLinesShowsRecentCompletedToolUnderProviderRequest(t *testing.T) {
41+ now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
42+ lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{
43+ "review-123": {PrimaryOperation: &subagents.Operation{Type: "provider.request.started", TurnID: "turn-1", StartedAt: now.Add(-2 * time.Second)}, LastObservation: &subagents.LiveObservation{Type: "tool.finished", Name: "read", CallID: "call-1", TurnID: "turn-1", At: now.Add(-3 * time.Second)}},
44+ }, 100, now))
45+ got := strings.Join(lines, "\n")
46+ if !strings.Contains(got, "waiting for model response · finished read 3s ago · 2s") {
47+ t.Fatalf("activity lines = %#v", lines)
48+ }
49+}
50+
51+func TestRenderSubagentActivityLineColorsProviderWaitAsActive(t *testing.T) {
52+ now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
53+ operation := subagents.Operation{Type: "provider.request.started", TurnID: "turn-1", StartedAt: now.Add(-2 * time.Second)}
54+ line := renderSubagentActivityLine(tui.Dark, "/", subagents.AgentSnapshot{ID: "review-123", Subagent: "reviewer"}, operation, operation.StartedAt, nil, 100, now)
55+ if !strings.Contains(line, tui.Dark.FGColor(tui.Dark.Spinner, "waiting for model response")) {
56+ t.Fatalf("provider wait is not rendered with the active color: %q", line)
57+ }
58+}
59+
40 func TestRenderSubagentActivityLinesOmitsPriorTurnObservation(t *testing.T) {60 func TestRenderSubagentActivityLinesOmitsPriorTurnObservation(t *testing.T) {
41 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)61 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
42 lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{62 lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{
@@ -78,6 +98,19 @@ func TestTruncateSubagentIndicatorTextPreservesGraphemeClusters(t *testing.T) {
78 }98 }
79 }99 }
80 100
101+func TestRenderSubagentActivityLinesUsesStableDelegatedTurnElapsedTime(t *testing.T) {
102+ now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
103+ turn := subagents.Operation{Type: "turn.started", TurnID: "turn-1", StartedAt: now.Add(-12 * time.Second)}
104+ request := subagents.Operation{Type: "provider.request.started", TurnID: "turn-1", StartedAt: now.Add(-time.Second)}
105+ lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer", Started: now.Add(-5*time.Hour - 16*time.Minute)}}, map[string]subagents.AgentTraceView{
106+ "review-123": {PrimaryOperation: &request, OpenOperations: []subagents.Operation{request, turn}},
107+ }, 100, now))
108+ got := strings.Join(lines, "\n")
109+ if !strings.Contains(got, "waiting for model response · 12s") || strings.Contains(got, "5h16m") {
110+ t.Fatalf("activity lines = %#v, want current delegated-turn elapsed time", lines)
111+ }
112+}
113+
81 func TestFormatSubagentActivityAgeUsesOperationStart(t *testing.T) {114 func TestFormatSubagentActivityAgeUsesOperationStart(t *testing.T) {
82 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)115 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
83 if got, want := formatSubagentActivityAge(now.Add(-7*time.Second), now), "7s"; got != want {116 if got, want := formatSubagentActivityAge(now.Add(-7*time.Second), now), "7s"; got != want {
M packages/agent/modes/subagents_slash.go +1 -1
@@ -261,7 +261,7 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) {
261261 waitWatcherDone()
262262 }
263263 }()
264- if err := a.WaitContext(ctx); err != nil {
264+ if _, err := a.WaitTurnResult(ctx, a.WaitTargetTurnID()); err != nil {
265265 return
266266 }
267267 // If cancellation became observable at the same time as
@@ -261,7 +261,7 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) {
261 waitWatcherDone()261 waitWatcherDone()
262 }262 }
263 }()263 }()
264- if err := a.WaitContext(ctx); err != nil {264+ if _, err := a.WaitTurnResult(ctx, a.WaitTargetTurnID()); err != nil {
265 return265 return
266 }266 }
267 // If cancellation became observable at the same time as267 // If cancellation became observable at the same time as
M packages/agent/subagent_runtime.go +1 -0
@@ -500,6 +500,7 @@ func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy {
500500 StartupTimeout: parseDuration(cfg.StartupTimeout),
501501 DefaultTimeout: parseDuration(cfg.DefaultTimeout),
502502 MaxTurns: cfg.MaxTurns,
503+ MaxSteps: cfg.MaxSteps,
503504 MaxOutputBytes: cfg.MaxOutputBytes,
504505 MaxOutputLines: cfg.MaxOutputLines,
505506 AllowedTools: append([]string(nil), cfg.AllowedTools...),
@@ -500,6 +500,7 @@ func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy {
500 StartupTimeout: parseDuration(cfg.StartupTimeout),500 StartupTimeout: parseDuration(cfg.StartupTimeout),
501 DefaultTimeout: parseDuration(cfg.DefaultTimeout),501 DefaultTimeout: parseDuration(cfg.DefaultTimeout),
502 MaxTurns: cfg.MaxTurns,502 MaxTurns: cfg.MaxTurns,
503+ MaxSteps: cfg.MaxSteps,
503 MaxOutputBytes: cfg.MaxOutputBytes,504 MaxOutputBytes: cfg.MaxOutputBytes,
504 MaxOutputLines: cfg.MaxOutputLines,505 MaxOutputLines: cfg.MaxOutputLines,
505 AllowedTools: append([]string(nil), cfg.AllowedTools...),506 AllowedTools: append([]string(nil), cfg.AllowedTools...),
M packages/agent/subagent_worker.go +4 -1
@@ -557,7 +557,7 @@ func canonicalWorkerEvent(typ string) string {
557557 return subagents.EventTurnStarted
558558 case "turn_progress":
559559 return subagents.EventTurnProgress
560- case "tool_call":
560+ case "tool_execution_started":
561561 return subagents.EventToolStarted
562562 case "tool_result":
563563 return subagents.EventToolFinished
@@ -712,6 +712,9 @@ func resultErrorPayload(err error, shutdownOrigin subagents.ShutdownOrigin) map[
712712 if errors.Is(err, core.ErrStreamIdleTimeout) {
713713 return map[string]any{"code": "stream_idle_timeout", "message": "provider stream produced no events for five minutes; the delegated turn stopped safely"}
714714 }
715+ if errors.Is(err, core.ErrMaxSteps) {
716+ return map[string]any{"code": "step_limit", "message": "subagent model-step limit reached; partial output is preserved and the worker can be resumed"}
717+ }
715718 if errors.Is(err, context.DeadlineExceeded) || shutdownOrigin == subagents.ShutdownOriginDeadline {
716719 return map[string]any{"code": "deadline_exceeded", "message": "subagent turn deadline exceeded; partial output is preserved in the result and history"}
717720 }
@@ -557,7 +557,7 @@ func canonicalWorkerEvent(typ string) string {
557 return subagents.EventTurnStarted557 return subagents.EventTurnStarted
558 case "turn_progress":558 case "turn_progress":
559 return subagents.EventTurnProgress559 return subagents.EventTurnProgress
560- case "tool_call":560+ case "tool_execution_started":
561 return subagents.EventToolStarted561 return subagents.EventToolStarted
562 case "tool_result":562 case "tool_result":
563 return subagents.EventToolFinished563 return subagents.EventToolFinished
@@ -712,6 +712,9 @@ func resultErrorPayload(err error, shutdownOrigin subagents.ShutdownOrigin) map[
712 if errors.Is(err, core.ErrStreamIdleTimeout) {712 if errors.Is(err, core.ErrStreamIdleTimeout) {
713 return map[string]any{"code": "stream_idle_timeout", "message": "provider stream produced no events for five minutes; the delegated turn stopped safely"}713 return map[string]any{"code": "stream_idle_timeout", "message": "provider stream produced no events for five minutes; the delegated turn stopped safely"}
714 }714 }
715+ if errors.Is(err, core.ErrMaxSteps) {
716+ return map[string]any{"code": "step_limit", "message": "subagent model-step limit reached; partial output is preserved and the worker can be resumed"}
717+ }
715 if errors.Is(err, context.DeadlineExceeded) || shutdownOrigin == subagents.ShutdownOriginDeadline {718 if errors.Is(err, context.DeadlineExceeded) || shutdownOrigin == subagents.ShutdownOriginDeadline {
716 return map[string]any{"code": "deadline_exceeded", "message": "subagent turn deadline exceeded; partial output is preserved in the result and history"}719 return map[string]any{"code": "deadline_exceeded", "message": "subagent turn deadline exceeded; partial output is preserved in the result and history"}
717 }720 }
M packages/agent/subagent_worker_test.go +7 -0
@@ -66,6 +66,13 @@ func TestResultErrorPayloadClassifiesTurnDeadline(t *testing.T) {
6666 }
6767 }
6868
69+func TestResultErrorPayloadClassifiesModelStepLimit(t *testing.T) {
70+ payload := resultErrorPayload(core.ErrMaxSteps, "")
71+ if payload["code"] != "step_limit" {
72+ t.Fatalf("step-limit error code = %v, want step_limit", payload["code"])
73+ }
74+}
75+
6976 func TestResultErrorPayloadAttributesShutdownCancellation(t *testing.T) {
7077 payload := resultErrorPayload(context.Canceled, subagents.ShutdownOriginSession)
7178 if payload["code"] != "shutdown" || payload["message"] != "subagent stopped during session shutdown" {
@@ -66,6 +66,13 @@ func TestResultErrorPayloadClassifiesTurnDeadline(t *testing.T) {
66 }66 }
67 }67 }
68 68
69+func TestResultErrorPayloadClassifiesModelStepLimit(t *testing.T) {
70+ payload := resultErrorPayload(core.ErrMaxSteps, "")
71+ if payload["code"] != "step_limit" {
72+ t.Fatalf("step-limit error code = %v, want step_limit", payload["code"])
73+ }
74+}
75+
69 func TestResultErrorPayloadAttributesShutdownCancellation(t *testing.T) {76 func TestResultErrorPayloadAttributesShutdownCancellation(t *testing.T) {
70 payload := resultErrorPayload(context.Canceled, subagents.ShutdownOriginSession)77 payload := resultErrorPayload(context.Canceled, subagents.ShutdownOriginSession)
71 if payload["code"] != "shutdown" || payload["message"] != "subagent stopped during session shutdown" {78 if payload["code"] != "shutdown" || payload["message"] != "subagent stopped during session shutdown" {
M packages/agent/subagents/agent.go +54 -18
@@ -50,6 +50,7 @@ type Agent struct {
5050 WorkspaceBase string
5151 WorkspaceCapture CaptureMode
5252 MaxTurns int
53+ MaxSteps int
5354
5455 // LifetimeTurns counts every accepted message turn for this worker across
5556 // all explicit runs. CurrentRunTurns resets only when an explicit
@@ -58,8 +59,8 @@ type Agent struct {
5859 LifetimeTurns int
5960 CurrentRunTurns int
6061
61- // Timeout is the effective per-agent lifetime, persisted so a resumed
62- // worker keeps the timeout selected at its spawn boundary.
62+ // Timeout is the effective per-turn deadline. Zero means unlimited. It is
63+ // persisted so a resumed worker keeps the policy selected at spawn time.
6364 Timeout time.Duration
6465 HeartbeatInterval time.Duration
6566 Tools []string
@@ -209,9 +210,9 @@ type Agent struct {
209210 // done closes when the run goroutine finalises the agent's
210211 // status (done / failed / killed). Wait blocks on this so
211212 // callers don't have to poll.
212- done chan struct{}
213- turnResults chan *TurnResult
214- doneOnce sync.Once
213+ done chan struct{}
214+ resultChanged chan struct{}
215+ doneOnce sync.Once
215216 }
216217
217218 // Inbox exposes the supervisor-side socket handle. Returns nil for
@@ -270,6 +271,19 @@ func (a *Agent) CurrentTurnID() string {
270271 return a.currentTurnID
271272 }
272273
274+// WaitTargetTurnID returns the delegated turn that a user-facing wait should
275+// observe. A queued follow-up has not emitted turn.started yet, so derive its
276+// stable next id from the persisted lifetime counter instead of returning the
277+// previous turn's completed result.
278+func (a *Agent) WaitTargetTurnID() string {
279+ a.lifecycleMu.Lock()
280+ defer a.lifecycleMu.Unlock()
281+ if a.turnState == TurnQueued {
282+ return fmt.Sprintf("turn-%d", a.LifetimeTurns+1)
283+ }
284+ return a.currentTurnID
285+}
286+
273287 // AttemptValue returns the current process attempt number.
274288 func (a *Agent) AttemptValue() int {
275289 a.lifecycleMu.Lock()
@@ -447,6 +461,10 @@ func (a *Agent) setResult(result *TurnResult) {
447461 a.lifecycleMu.Lock()
448462 a.result = cloneTurnResult(result)
449463 a.updatedAt = time.Now()
464+ if a.resultChanged != nil {
465+ close(a.resultChanged)
466+ }
467+ a.resultChanged = make(chan struct{})
450468 a.lifecycleMu.Unlock()
451469 }
452470
@@ -492,8 +510,7 @@ func (a *Agent) Err() error {
492510 return a.lastErr
493511 }
494512
495-// Wait blocks until the agent reaches a terminal state. Used by tests
496-// and by /subagents wait <id>.
513+// Wait blocks until the agent process reaches a terminal state.
497514 func (a *Agent) Wait() { <-a.done }
498515
499516 // WaitContext waits for the agent to finish or for ctx cancellation.
@@ -510,22 +527,41 @@ func (a *Agent) WaitContext(ctx context.Context) error {
510527 }
511528
512529 func (a *Agent) waitForTurnResult(ctx context.Context) (*TurnResult, error) {
530+ return a.WaitTurnResult(ctx, a.CurrentTurnID())
531+}
532+
533+// WaitTurnResult observes one delegated turn without consuming its result or
534+// affecting the worker when the observer is canceled. An empty turnID accepts
535+// the next available result.
536+func (a *Agent) WaitTurnResult(ctx context.Context, turnID string) (*TurnResult, error) {
513537 if ctx == nil {
514538 ctx = context.Background()
515539 }
516- select {
517- case result := <-a.turnResults:
518- if result == nil {
519- return nil, fmt.Errorf("subagents: nil turn result for %s", a.ID)
520- }
521- return cloneTurnResult(result), nil
522- case <-a.done:
523- if result := a.Result(); result != nil {
540+ for {
541+ a.lifecycleMu.Lock()
542+ result := cloneTurnResult(a.result)
543+ if result != nil && (turnID == "" || result.TurnID == turnID) {
544+ a.lifecycleMu.Unlock()
524545 return result, nil
525546 }
526- return nil, fmt.Errorf("subagents: agent %s exited without a turn result", a.ID)
527- case <-ctx.Done():
528- return nil, ctx.Err()
547+ if a.resultChanged == nil {
548+ a.resultChanged = make(chan struct{})
549+ }
550+ changed := a.resultChanged
551+ done := a.done
552+ a.lifecycleMu.Unlock()
553+
554+ select {
555+ case <-changed:
556+ continue
557+ case <-done:
558+ if result := a.Result(); result != nil && (turnID == "" || result.TurnID == turnID) {
559+ return result, nil
560+ }
561+ return nil, fmt.Errorf("subagents: agent %s exited without turn result %q", a.ID, turnID)
562+ case <-ctx.Done():
563+ return nil, ctx.Err()
564+ }
529565 }
530566 }
531567
@@ -50,6 +50,7 @@ type Agent struct {
50 WorkspaceBase string50 WorkspaceBase string
51 WorkspaceCapture CaptureMode51 WorkspaceCapture CaptureMode
52 MaxTurns int52 MaxTurns int
53+ MaxSteps int
53 54
54 // LifetimeTurns counts every accepted message turn for this worker across55 // LifetimeTurns counts every accepted message turn for this worker across
55 // all explicit runs. CurrentRunTurns resets only when an explicit56 // all explicit runs. CurrentRunTurns resets only when an explicit
@@ -58,8 +59,8 @@ type Agent struct {
58 LifetimeTurns int59 LifetimeTurns int
59 CurrentRunTurns int60 CurrentRunTurns int
60 61
61- // Timeout is the effective per-agent lifetime, persisted so a resumed62+ // Timeout is the effective per-turn deadline. Zero means unlimited. It is
62- // worker keeps the timeout selected at its spawn boundary.63+ // persisted so a resumed worker keeps the policy selected at spawn time.
63 Timeout time.Duration64 Timeout time.Duration
64 HeartbeatInterval time.Duration65 HeartbeatInterval time.Duration
65 Tools []string66 Tools []string
@@ -209,9 +210,9 @@ type Agent struct {
209 // done closes when the run goroutine finalises the agent's210 // done closes when the run goroutine finalises the agent's
210 // status (done / failed / killed). Wait blocks on this so211 // status (done / failed / killed). Wait blocks on this so
211 // callers don't have to poll.212 // callers don't have to poll.
212- done chan struct{}213+ done chan struct{}
213- turnResults chan *TurnResult214+ resultChanged chan struct{}
214- doneOnce sync.Once215+ doneOnce sync.Once
215 }216 }
216 217
217 // Inbox exposes the supervisor-side socket handle. Returns nil for218 // Inbox exposes the supervisor-side socket handle. Returns nil for
@@ -270,6 +271,19 @@ func (a *Agent) CurrentTurnID() string {
270 return a.currentTurnID271 return a.currentTurnID
271 }272 }
272 273
274+// WaitTargetTurnID returns the delegated turn that a user-facing wait should
275+// observe. A queued follow-up has not emitted turn.started yet, so derive its
276+// stable next id from the persisted lifetime counter instead of returning the
277+// previous turn's completed result.
278+func (a *Agent) WaitTargetTurnID() string {
279+ a.lifecycleMu.Lock()
280+ defer a.lifecycleMu.Unlock()
281+ if a.turnState == TurnQueued {
282+ return fmt.Sprintf("turn-%d", a.LifetimeTurns+1)
283+ }
284+ return a.currentTurnID
285+}
286+
273 // AttemptValue returns the current process attempt number.287 // AttemptValue returns the current process attempt number.
274 func (a *Agent) AttemptValue() int {288 func (a *Agent) AttemptValue() int {
275 a.lifecycleMu.Lock()289 a.lifecycleMu.Lock()
@@ -447,6 +461,10 @@ func (a *Agent) setResult(result *TurnResult) {
447 a.lifecycleMu.Lock()461 a.lifecycleMu.Lock()
448 a.result = cloneTurnResult(result)462 a.result = cloneTurnResult(result)
449 a.updatedAt = time.Now()463 a.updatedAt = time.Now()
464+ if a.resultChanged != nil {
465+ close(a.resultChanged)
466+ }
467+ a.resultChanged = make(chan struct{})
450 a.lifecycleMu.Unlock()468 a.lifecycleMu.Unlock()
451 }469 }
452 470
@@ -492,8 +510,7 @@ func (a *Agent) Err() error {
492 return a.lastErr510 return a.lastErr
493 }511 }
494 512
495-// Wait blocks until the agent reaches a terminal state. Used by tests513+// Wait blocks until the agent process reaches a terminal state.
496-// and by /subagents wait <id>.
497 func (a *Agent) Wait() { <-a.done }514 func (a *Agent) Wait() { <-a.done }
498 515
499 // WaitContext waits for the agent to finish or for ctx cancellation.516 // WaitContext waits for the agent to finish or for ctx cancellation.
@@ -510,22 +527,41 @@ func (a *Agent) WaitContext(ctx context.Context) error {
510 }527 }
511 528
512 func (a *Agent) waitForTurnResult(ctx context.Context) (*TurnResult, error) {529 func (a *Agent) waitForTurnResult(ctx context.Context) (*TurnResult, error) {
530+ return a.WaitTurnResult(ctx, a.CurrentTurnID())
531+}
532+
533+// WaitTurnResult observes one delegated turn without consuming its result or
534+// affecting the worker when the observer is canceled. An empty turnID accepts
535+// the next available result.
536+func (a *Agent) WaitTurnResult(ctx context.Context, turnID string) (*TurnResult, error) {
513 if ctx == nil {537 if ctx == nil {
514 ctx = context.Background()538 ctx = context.Background()
515 }539 }
516- select {540+ for {
517- case result := <-a.turnResults:541+ a.lifecycleMu.Lock()
518- if result == nil {542+ result := cloneTurnResult(a.result)
519- return nil, fmt.Errorf("subagents: nil turn result for %s", a.ID)543+ if result != nil && (turnID == "" || result.TurnID == turnID) {
520- }544+ a.lifecycleMu.Unlock()
521- return cloneTurnResult(result), nil
522- case <-a.done:
523- if result := a.Result(); result != nil {
524 return result, nil545 return result, nil
525 }546 }
526- return nil, fmt.Errorf("subagents: agent %s exited without a turn result", a.ID)547+ if a.resultChanged == nil {
527- case <-ctx.Done():548+ a.resultChanged = make(chan struct{})
528- return nil, ctx.Err()549+ }
550+ changed := a.resultChanged
551+ done := a.done
552+ a.lifecycleMu.Unlock()
553+
554+ select {
555+ case <-changed:
556+ continue
557+ case <-done:
558+ if result := a.Result(); result != nil && (turnID == "" || result.TurnID == turnID) {
559+ return result, nil
560+ }
561+ return nil, fmt.Errorf("subagents: agent %s exited without turn result %q", a.ID, turnID)
562+ case <-ctx.Done():
563+ return nil, ctx.Err()
564+ }
529 }565 }
530 }566 }
531 567
M packages/agent/subagents/agent_test.go +35 -0
@@ -1,8 +1,10 @@
11 package subagents
22
33 import (
4+ "context"
45 "sync"
56 "testing"
7+ "time"
68 )
79
810 func TestAgentAttemptValueAndTranscriptAccounting(t *testing.T) {
@@ -77,3 +79,36 @@ func TestAgentSnapshotConcurrentTranscriptTruncation(t *testing.T) {
7779 t.Fatal("snapshot did not report transcript truncation")
7880 }
7981 }
82+
83+func TestWaitTurnResultBroadcastsToMultipleObservers(t *testing.T) {
84+ a := &Agent{ID: "worker-1", done: make(chan struct{})}
85+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
86+ defer cancel()
87+
88+ results := make(chan *TurnResult, 2)
89+ errs := make(chan error, 2)
90+ for range 2 {
91+ go func() {
92+ result, err := a.WaitTurnResult(ctx, "turn-1")
93+ results <- result
94+ errs <- err
95+ }()
96+ }
97+ a.setResult(&TurnResult{Version: ProtocolVersion, AgentID: a.ID, TurnID: "turn-1", Status: ResultSucceeded})
98+
99+ for range 2 {
100+ if err := <-errs; err != nil {
101+ t.Fatalf("wait error = %v", err)
102+ }
103+ if result := <-results; result == nil || result.TurnID != "turn-1" {
104+ t.Fatalf("wait result = %#v, want turn-1", result)
105+ }
106+ }
107+}
108+
109+func TestWaitTargetTurnIDAdvancesQueuedResume(t *testing.T) {
110+ a := &Agent{LifetimeTurns: 1, turnState: TurnQueued, currentTurnID: "turn-1"}
111+ if got := a.WaitTargetTurnID(); got != "turn-2" {
112+ t.Fatalf("wait target = %q, want turn-2", got)
113+ }
114+}
@@ -1,8 +1,10 @@
1 package subagents1 package subagents
2 2
3 import (3 import (
4+ "context"
4 "sync"5 "sync"
5 "testing"6 "testing"
7+ "time"
6 )8 )
7 9
8 func TestAgentAttemptValueAndTranscriptAccounting(t *testing.T) {10 func TestAgentAttemptValueAndTranscriptAccounting(t *testing.T) {
@@ -77,3 +79,36 @@ func TestAgentSnapshotConcurrentTranscriptTruncation(t *testing.T) {
77 t.Fatal("snapshot did not report transcript truncation")79 t.Fatal("snapshot did not report transcript truncation")
78 }80 }
79 }81 }
82+
83+func TestWaitTurnResultBroadcastsToMultipleObservers(t *testing.T) {
84+ a := &Agent{ID: "worker-1", done: make(chan struct{})}
85+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
86+ defer cancel()
87+
88+ results := make(chan *TurnResult, 2)
89+ errs := make(chan error, 2)
90+ for range 2 {
91+ go func() {
92+ result, err := a.WaitTurnResult(ctx, "turn-1")
93+ results <- result
94+ errs <- err
95+ }()
96+ }
97+ a.setResult(&TurnResult{Version: ProtocolVersion, AgentID: a.ID, TurnID: "turn-1", Status: ResultSucceeded})
98+
99+ for range 2 {
100+ if err := <-errs; err != nil {
101+ t.Fatalf("wait error = %v", err)
102+ }
103+ if result := <-results; result == nil || result.TurnID != "turn-1" {
104+ t.Fatalf("wait result = %#v, want turn-1", result)
105+ }
106+ }
107+}
108+
109+func TestWaitTargetTurnIDAdvancesQueuedResume(t *testing.T) {
110+ a := &Agent{LifetimeTurns: 1, turnState: TurnQueued, currentTurnID: "turn-1"}
111+ if got := a.WaitTargetTurnID(); got != "turn-2" {
112+ t.Fatalf("wait target = %q, want turn-2", got)
113+ }
114+}
M packages/agent/subagents/batch.go +29 -8
@@ -76,6 +76,18 @@ func (b *Batch) Result() *BatchResult {
7676 func (b *Batch) Wait() { <-b.done }
7777 func (b *Batch) closeDone() { b.once.Do(func() { close(b.done) }) }
7878
79+func (b *Batch) WaitContext(ctx context.Context) error {
80+ if ctx == nil {
81+ ctx = context.Background()
82+ }
83+ select {
84+ case <-b.done:
85+ return nil
86+ case <-ctx.Done():
87+ return ctx.Err()
88+ }
89+}
90+
7991 func (f *Supervisor) SpawnBatch(ctx context.Context, req BatchRequest) (*Batch, error) {
8092 if len(req.Tasks) == 0 {
8193 return nil, fmt.Errorf("subagents: batch requires at least one task")
@@ -186,7 +198,7 @@ func (f *Supervisor) collectBatch(batch *Batch) {
186198 }
187199 continue
188200 }
189- result, err := f.waitForBatchResult(a)
201+ result, err := f.waitForBatchResult(f.lifetimeCtx, a)
190202 if err == nil {
191203 results[id] = result
192204 switch result.Status {
@@ -222,15 +234,18 @@ func (f *Supervisor) collectBatch(batch *Batch) {
222234 batch.closeDone()
223235 }
224236
225-func (f *Supervisor) waitForBatchResult(a *Agent) (*TurnResult, error) {
237+func (f *Supervisor) waitForBatchResult(ctx context.Context, a *Agent) (*TurnResult, error) {
226238 if a == nil {
227239 return nil, errors.New("subagents: missing batch child")
228240 }
241+ if ctx == nil {
242+ ctx = context.Background()
243+ }
229244 if result := a.Result(); result != nil {
230245 return result, nil
231246 }
232247 if a.Status() != StatusDetached {
233- return a.waitForTurnResult(context.Background())
248+ return a.waitForTurnResult(ctx)
234249 }
235250
236251 // A reloaded worker has a closed supervisor-side done channel even when
@@ -268,8 +283,6 @@ func (f *Supervisor) waitForBatchResult(a *Agent) (*TurnResult, error) {
268283 }
269284
270285 interval := 100 * time.Millisecond
271- deadline := time.NewTimer(f.cfg.Policy.DefaultTimeout)
272- defer deadline.Stop()
273286 ticker := time.NewTicker(interval)
274287 defer ticker.Stop()
275288 for {
@@ -281,20 +294,28 @@ func (f *Supervisor) waitForBatchResult(a *Agent) (*TurnResult, error) {
281294 if !inboxLive(a.InboxPath) {
282295 return nil, fmt.Errorf("subagents: agent %s exited without a turn result", a.ID)
283296 }
284- case <-deadline.C:
285- return nil, fmt.Errorf("subagents: agent %s batch result timed out", a.ID)
297+ case <-ctx.Done():
298+ return nil, ctx.Err()
286299 }
287300 }
288301 }
289302
290303 func (f *Supervisor) WaitBatch(id string) (*BatchResult, error) {
304+ return f.WaitBatchContext(context.Background(), id)
305+}
306+
307+// WaitBatchContext waits for the aggregate result without changing the batch
308+// or any child when the observer's context ends.
309+func (f *Supervisor) WaitBatchContext(ctx context.Context, id string) (*BatchResult, error) {
291310 f.mu.Lock()
292311 batch := f.batches[id]
293312 f.mu.Unlock()
294313 if batch == nil {
295314 return nil, fmt.Errorf("subagents: no such batch %q", id)
296315 }
297- batch.Wait()
316+ if err := batch.WaitContext(ctx); err != nil {
317+ return nil, err
318+ }
298319 return batch.Result(), nil
299320 }
300321
@@ -76,6 +76,18 @@ func (b *Batch) Result() *BatchResult {
76 func (b *Batch) Wait() { <-b.done }76 func (b *Batch) Wait() { <-b.done }
77 func (b *Batch) closeDone() { b.once.Do(func() { close(b.done) }) }77 func (b *Batch) closeDone() { b.once.Do(func() { close(b.done) }) }
78 78
79+func (b *Batch) WaitContext(ctx context.Context) error {
80+ if ctx == nil {
81+ ctx = context.Background()
82+ }
83+ select {
84+ case <-b.done:
85+ return nil
86+ case <-ctx.Done():
87+ return ctx.Err()
88+ }
89+}
90+
79 func (f *Supervisor) SpawnBatch(ctx context.Context, req BatchRequest) (*Batch, error) {91 func (f *Supervisor) SpawnBatch(ctx context.Context, req BatchRequest) (*Batch, error) {
80 if len(req.Tasks) == 0 {92 if len(req.Tasks) == 0 {
81 return nil, fmt.Errorf("subagents: batch requires at least one task")93 return nil, fmt.Errorf("subagents: batch requires at least one task")
@@ -186,7 +198,7 @@ func (f *Supervisor) collectBatch(batch *Batch) {
186 }198 }
187 continue199 continue
188 }200 }
189- result, err := f.waitForBatchResult(a)201+ result, err := f.waitForBatchResult(f.lifetimeCtx, a)
190 if err == nil {202 if err == nil {
191 results[id] = result203 results[id] = result
192 switch result.Status {204 switch result.Status {
@@ -222,15 +234,18 @@ func (f *Supervisor) collectBatch(batch *Batch) {
222 batch.closeDone()234 batch.closeDone()
223 }235 }
224 236
225-func (f *Supervisor) waitForBatchResult(a *Agent) (*TurnResult, error) {237+func (f *Supervisor) waitForBatchResult(ctx context.Context, a *Agent) (*TurnResult, error) {
226 if a == nil {238 if a == nil {
227 return nil, errors.New("subagents: missing batch child")239 return nil, errors.New("subagents: missing batch child")
228 }240 }
241+ if ctx == nil {
242+ ctx = context.Background()
243+ }
229 if result := a.Result(); result != nil {244 if result := a.Result(); result != nil {
230 return result, nil245 return result, nil
231 }246 }
232 if a.Status() != StatusDetached {247 if a.Status() != StatusDetached {
233- return a.waitForTurnResult(context.Background())248+ return a.waitForTurnResult(ctx)
234 }249 }
235 250
236 // A reloaded worker has a closed supervisor-side done channel even when251 // A reloaded worker has a closed supervisor-side done channel even when
@@ -268,8 +283,6 @@ func (f *Supervisor) waitForBatchResult(a *Agent) (*TurnResult, error) {
268 }283 }
269 284
270 interval := 100 * time.Millisecond285 interval := 100 * time.Millisecond
271- deadline := time.NewTimer(f.cfg.Policy.DefaultTimeout)
272- defer deadline.Stop()
273 ticker := time.NewTicker(interval)286 ticker := time.NewTicker(interval)
274 defer ticker.Stop()287 defer ticker.Stop()
275 for {288 for {
@@ -281,20 +294,28 @@ func (f *Supervisor) waitForBatchResult(a *Agent) (*TurnResult, error) {
281 if !inboxLive(a.InboxPath) {294 if !inboxLive(a.InboxPath) {
282 return nil, fmt.Errorf("subagents: agent %s exited without a turn result", a.ID)295 return nil, fmt.Errorf("subagents: agent %s exited without a turn result", a.ID)
283 }296 }
284- case <-deadline.C:297+ case <-ctx.Done():
285- return nil, fmt.Errorf("subagents: agent %s batch result timed out", a.ID)298+ return nil, ctx.Err()
286 }299 }
287 }300 }
288 }301 }
289 302
290 func (f *Supervisor) WaitBatch(id string) (*BatchResult, error) {303 func (f *Supervisor) WaitBatch(id string) (*BatchResult, error) {
304+ return f.WaitBatchContext(context.Background(), id)
305+}
306+
307+// WaitBatchContext waits for the aggregate result without changing the batch
308+// or any child when the observer's context ends.
309+func (f *Supervisor) WaitBatchContext(ctx context.Context, id string) (*BatchResult, error) {
291 f.mu.Lock()310 f.mu.Lock()
292 batch := f.batches[id]311 batch := f.batches[id]
293 f.mu.Unlock()312 f.mu.Unlock()
294 if batch == nil {313 if batch == nil {
295 return nil, fmt.Errorf("subagents: no such batch %q", id)314 return nil, fmt.Errorf("subagents: no such batch %q", id)
296 }315 }
297- batch.Wait()316+ if err := batch.WaitContext(ctx); err != nil {
317+ return nil, err
318+ }
298 return batch.Result(), nil319 return batch.Result(), nil
299 }320 }
300 321
M packages/agent/subagents/batch_test.go +22 -0
@@ -3,6 +3,7 @@ package subagents
33 import (
44 "context"
55 "encoding/json"
6+ "errors"
67 "fmt"
78 "os"
89 "path/filepath"
@@ -137,6 +138,27 @@ func TestBatchSpawnWaitAndCollect(t *testing.T) {
137138 }
138139 }
139140
141+func TestBatchResultWaitCancellationDoesNotCancelWorker(t *testing.T) {
142+ f := New(Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
143+ a := &Agent{
144+ ID: "worker-1",
145+ status: StatusRunning,
146+ }
147+ ctx, cancel := context.WithCancel(context.Background())
148+ cancel()
149+
150+ result, err := f.waitForBatchResult(ctx, a)
151+ if !errors.Is(err, context.Canceled) {
152+ t.Fatalf("wait error = %v, want context.Canceled", err)
153+ }
154+ if result != nil {
155+ t.Fatalf("wait result = %#v, want nil", result)
156+ }
157+ if got := a.Status(); got != StatusRunning {
158+ t.Fatalf("worker status = %s, want running", got)
159+ }
160+}
161+
140162 func TestBatchAllowsMoreThanFormerLifetimeLimit(t *testing.T) {
141163 const workersBeyondFormerLifetimeLimit = 33
142164 tasks := make([]string, workersBeyondFormerLifetimeLimit)
@@ -3,6 +3,7 @@ package subagents
3 import (3 import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6+ "errors"
6 "fmt"7 "fmt"
7 "os"8 "os"
8 "path/filepath"9 "path/filepath"
@@ -137,6 +138,27 @@ func TestBatchSpawnWaitAndCollect(t *testing.T) {
137 }138 }
138 }139 }
139 140
141+func TestBatchResultWaitCancellationDoesNotCancelWorker(t *testing.T) {
142+ f := New(Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
143+ a := &Agent{
144+ ID: "worker-1",
145+ status: StatusRunning,
146+ }
147+ ctx, cancel := context.WithCancel(context.Background())
148+ cancel()
149+
150+ result, err := f.waitForBatchResult(ctx, a)
151+ if !errors.Is(err, context.Canceled) {
152+ t.Fatalf("wait error = %v, want context.Canceled", err)
153+ }
154+ if result != nil {
155+ t.Fatalf("wait result = %#v, want nil", result)
156+ }
157+ if got := a.Status(); got != StatusRunning {
158+ t.Fatalf("worker status = %s, want running", got)
159+ }
160+}
161+
140 func TestBatchAllowsMoreThanFormerLifetimeLimit(t *testing.T) {162 func TestBatchAllowsMoreThanFormerLifetimeLimit(t *testing.T) {
141 const workersBeyondFormerLifetimeLimit = 33163 const workersBeyondFormerLifetimeLimit = 33
142 tasks := make([]string, workersBeyondFormerLifetimeLimit)164 tasks := make([]string, workersBeyondFormerLifetimeLimit)
M packages/agent/subagents/model.go +8 -6
@@ -106,9 +106,10 @@ const (
106106 )
107107
108108 // SubagentPolicy contains manager-owned safety and resource limits. normalize
109-// replaces zero MaxOutputBytes, MaxOutputLines, QueueTimeout, StartupTimeout,
110-// and DefaultTimeout values with positive manager defaults. Other zero values
111-// retain their documented semantics.
109+// replaces zero MaxOutputBytes, MaxOutputLines, QueueTimeout, and StartupTimeout
110+// values with positive manager defaults. A zero DefaultTimeout leaves delegated
111+// turns unlimited; queue, startup, stream-idle, and idle-process limits remain
112+// independently bounded.
112113 type SubagentPolicy struct {
113114 MaxConcurrent int
114115 MaxConcurrentPerParent int
@@ -116,6 +117,7 @@ type SubagentPolicy struct {
116117 StartupTimeout time.Duration
117118 DefaultTimeout time.Duration
118119 MaxTurns int
120+ MaxSteps int
119121 MaxOutputBytes int
120122 MaxOutputLines int
121123 AllowedTools []string
@@ -139,12 +141,12 @@ func (p *SubagentPolicy) normalize() {
139141 if p.StartupTimeout <= 0 {
140142 p.StartupTimeout = time.Minute
141143 }
142- if p.DefaultTimeout <= 0 {
143- p.DefaultTimeout = 20 * time.Minute
144- }
145144 if p.MaxTurns <= 0 {
146145 p.MaxTurns = 3
147146 }
147+ if p.MaxSteps <= 0 {
148+ p.MaxSteps = 128
149+ }
148150 if p.MaxOutputBytes <= 0 {
149151 p.MaxOutputBytes = 500_000
150152 }
@@ -106,9 +106,10 @@ const (
106 )106 )
107 107
108 // SubagentPolicy contains manager-owned safety and resource limits. normalize108 // SubagentPolicy contains manager-owned safety and resource limits. normalize
109-// replaces zero MaxOutputBytes, MaxOutputLines, QueueTimeout, StartupTimeout,109+// replaces zero MaxOutputBytes, MaxOutputLines, QueueTimeout, and StartupTimeout
110-// and DefaultTimeout values with positive manager defaults. Other zero values110+// values with positive manager defaults. A zero DefaultTimeout leaves delegated
111-// retain their documented semantics.111+// turns unlimited; queue, startup, stream-idle, and idle-process limits remain
112+// independently bounded.
112 type SubagentPolicy struct {113 type SubagentPolicy struct {
113 MaxConcurrent int114 MaxConcurrent int
114 MaxConcurrentPerParent int115 MaxConcurrentPerParent int
@@ -116,6 +117,7 @@ type SubagentPolicy struct {
116 StartupTimeout time.Duration117 StartupTimeout time.Duration
117 DefaultTimeout time.Duration118 DefaultTimeout time.Duration
118 MaxTurns int119 MaxTurns int
120+ MaxSteps int
119 MaxOutputBytes int121 MaxOutputBytes int
120 MaxOutputLines int122 MaxOutputLines int
121 AllowedTools []string123 AllowedTools []string
@@ -139,12 +141,12 @@ func (p *SubagentPolicy) normalize() {
139 if p.StartupTimeout <= 0 {141 if p.StartupTimeout <= 0 {
140 p.StartupTimeout = time.Minute142 p.StartupTimeout = time.Minute
141 }143 }
142- if p.DefaultTimeout <= 0 {
143- p.DefaultTimeout = 20 * time.Minute
144- }
145 if p.MaxTurns <= 0 {144 if p.MaxTurns <= 0 {
146 p.MaxTurns = 3145 p.MaxTurns = 3
147 }146 }
147+ if p.MaxSteps <= 0 {
148+ p.MaxSteps = 128
149+ }
148 if p.MaxOutputBytes <= 0 {150 if p.MaxOutputBytes <= 0 {
149 p.MaxOutputBytes = 500_000151 p.MaxOutputBytes = 500_000
150 }152 }
M packages/agent/subagents/persist.go +52 -8
@@ -58,9 +58,11 @@ type agentMeta struct {
5858 WorkspaceBase string `json:"workspace_base,omitempty"`
5959 WorkspaceCapture CaptureMode `json:"workspace_capture,omitempty"`
6060 MaxTurns int `json:"max_turns,omitempty"`
61+ MaxSteps int `json:"max_steps,omitempty"`
6162 LifetimeTurns int `json:"lifetime_turns,omitempty"`
6263 CurrentRunTurns int `json:"current_run_turns,omitempty"`
6364 Timeout time.Duration `json:"timeout,omitempty"`
65+ TurnTimeoutMode turnTimeoutMode `json:"turn_timeout_mode,omitempty"`
6466 Tools []string `json:"tools,omitempty"`
6567 WebSearchPolicy WebSearchPolicy `json:"web_search_policy,omitempty"`
6668 Status Status `json:"status,omitempty"`
@@ -96,6 +98,43 @@ type agentMeta struct {
9698 SessionID string `json:"session_id,omitempty"`
9799 }
98100
101+type turnTimeoutMode string
102+
103+const (
104+ turnTimeoutUnlimited turnTimeoutMode = "unlimited"
105+ turnTimeoutLimited turnTimeoutMode = "limited"
106+)
107+
108+func timeoutModeFor(timeout time.Duration) turnTimeoutMode {
109+ if timeout > 0 {
110+ return turnTimeoutLimited
111+ }
112+ return turnTimeoutUnlimited
113+}
114+
115+func resolvePersistedTurnTimeout(m agentMeta, fallback time.Duration) time.Duration {
116+ switch m.TurnTimeoutMode {
117+ case turnTimeoutUnlimited:
118+ return 0
119+ case turnTimeoutLimited:
120+ return m.Timeout
121+ default:
122+ // Legacy metadata stored the effective positive timeout. A missing
123+ // value inherited the then-current supervisor policy.
124+ if m.Timeout > 0 {
125+ return m.Timeout
126+ }
127+ return fallback
128+ }
129+}
130+
131+func resolvePersistedMaxSteps(value, fallback int) int {
132+ if value > 0 {
133+ return value
134+ }
135+ return fallback
136+}
137+
99138 func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.json") }
100139
101140 // writeAgentMeta serialises a's identity into stateDir/meta.json. The
@@ -160,9 +199,11 @@ func writeAgentMeta(stateDir string, a *Agent) error {
160199 WorkspaceBase: a.WorkspaceBase,
161200 WorkspaceCapture: a.WorkspaceCapture,
162201 MaxTurns: a.MaxTurns,
202+ MaxSteps: a.MaxSteps,
163203 LifetimeTurns: lifetimeTurns,
164204 CurrentRunTurns: currentRunTurns,
165205 Timeout: a.Timeout,
206+ TurnTimeoutMode: timeoutModeFor(a.Timeout),
166207 Tools: append([]string(nil), a.Tools...),
167208 WebSearchPolicy: webSearchPolicy,
168209 Status: status,
@@ -251,6 +292,12 @@ func readAgentMeta(stateDir string) (agentMeta, error) {
251292 if m.ID == "" {
252293 return m, fmt.Errorf("subagent meta %s: missing id", stateDir)
253294 }
295+ if m.TurnTimeoutMode != "" && m.TurnTimeoutMode != turnTimeoutUnlimited && m.TurnTimeoutMode != turnTimeoutLimited {
296+ return m, fmt.Errorf("subagent meta %s: invalid turn_timeout_mode %q", stateDir, m.TurnTimeoutMode)
297+ }
298+ if m.TurnTimeoutMode == turnTimeoutLimited && m.Timeout <= 0 {
299+ return m, fmt.Errorf("subagent meta %s: limited turn timeout must be positive", stateDir)
300+ }
254301 return m, nil
255302 }
256303
@@ -496,9 +543,10 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) (*Agent, bool) {
496543 WorkspaceBase: m.WorkspaceBase,
497544 WorkspaceCapture: m.WorkspaceCapture,
498545 MaxTurns: m.MaxTurns,
546+ MaxSteps: resolvePersistedMaxSteps(m.MaxSteps, f.cfg.Policy.MaxSteps),
499547 LifetimeTurns: m.LifetimeTurns,
500548 CurrentRunTurns: m.CurrentRunTurns,
501- Timeout: m.Timeout,
549+ Timeout: resolvePersistedTurnTimeout(m, f.cfg.Policy.DefaultTimeout),
502550 Tools: append([]string(nil), m.Tools...),
503551 WebSearchPolicy: childWebSearchPolicy(m.WebSearchPolicy, m.Subagent, m.Tools),
504552 Attempt: m.Attempt,
@@ -530,7 +578,6 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) (*Agent, bool) {
530578 maxOutputBytes: f.cfg.Policy.MaxOutputBytes,
531579 maxOutputLines: f.cfg.Policy.MaxOutputLines,
532580 done: make(chan struct{}),
533- turnResults: make(chan *TurnResult, 16),
534581 persistFn: f.persistAgent,
535582 }
536583 if a.updatedAt.IsZero() {
@@ -1193,7 +1240,7 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
11931240 Subagent: existing.Subagent,
11941241 WorkspaceMode: existing.WorkspaceMode, WorkspacePath: existing.WorkspacePath,
11951242 WorkspaceBase: existing.WorkspaceBase, WorkspaceCapture: existing.WorkspaceCapture,
1196- MaxTurns: existing.MaxTurns, LifetimeTurns: lifetimeTurns, CurrentRunTurns: currentRunTurns, Timeout: existing.Timeout, Tools: append([]string(nil), existing.Tools...),
1243+ MaxTurns: existing.MaxTurns, MaxSteps: existing.MaxSteps, LifetimeTurns: lifetimeTurns, CurrentRunTurns: currentRunTurns, Timeout: existing.Timeout, TurnTimeoutMode: timeoutModeFor(existing.Timeout), Tools: append([]string(nil), existing.Tools...),
11971244 WebSearchPolicy: childWebSearchPolicy(existing.WebSearchPolicy, existing.Subagent, existing.Tools),
11981245 CurrentTurnID: existingSnapshot.CurrentTurnID, Attempt: existingSnapshot.Attempt,
11991246 Requirement: existingSnapshot.Requirement,
@@ -1254,10 +1301,7 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
12541301 m.RepositoryRoot = workspace.RepositoryRoot()
12551302 m.WorkspacePath = workspace.Dir()
12561303 m.WorkspaceMode = workspace.Mode()
1257- timeout := m.Timeout
1258- if timeout <= 0 {
1259- timeout = f.cfg.Policy.DefaultTimeout
1260- }
1304+ timeout := resolvePersistedTurnTimeout(m, f.cfg.Policy.DefaultTimeout)
12611305 runCtx, cancel := f.workerContext()
12621306 a := &Agent{
12631307 ID: m.ID,
@@ -1282,6 +1326,7 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
12821326 WorkspaceBase: m.WorkspaceBase,
12831327 WorkspaceCapture: m.WorkspaceCapture,
12841328 MaxTurns: maxTurns,
1329+ MaxSteps: resolvePersistedMaxSteps(m.MaxSteps, f.cfg.Policy.MaxSteps),
12851330 LifetimeTurns: m.LifetimeTurns,
12861331 CurrentRunTurns: m.CurrentRunTurns,
12871332 Timeout: timeout,
@@ -1312,7 +1357,6 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
13121357 maxOutputBytes: f.cfg.Policy.MaxOutputBytes,
13131358 maxOutputLines: f.cfg.Policy.MaxOutputLines,
13141359 done: make(chan struct{}),
1315- turnResults: make(chan *TurnResult, 16),
13161360 }
13171361 // Carry the previous transcript forward so the dashboard doesn't
13181362 // flash empty between resume and the first new event.
@@ -58,9 +58,11 @@ type agentMeta struct {
58 WorkspaceBase string `json:"workspace_base,omitempty"`58 WorkspaceBase string `json:"workspace_base,omitempty"`
59 WorkspaceCapture CaptureMode `json:"workspace_capture,omitempty"`59 WorkspaceCapture CaptureMode `json:"workspace_capture,omitempty"`
60 MaxTurns int `json:"max_turns,omitempty"`60 MaxTurns int `json:"max_turns,omitempty"`
61+ MaxSteps int `json:"max_steps,omitempty"`
61 LifetimeTurns int `json:"lifetime_turns,omitempty"`62 LifetimeTurns int `json:"lifetime_turns,omitempty"`
62 CurrentRunTurns int `json:"current_run_turns,omitempty"`63 CurrentRunTurns int `json:"current_run_turns,omitempty"`
63 Timeout time.Duration `json:"timeout,omitempty"`64 Timeout time.Duration `json:"timeout,omitempty"`
65+ TurnTimeoutMode turnTimeoutMode `json:"turn_timeout_mode,omitempty"`
64 Tools []string `json:"tools,omitempty"`66 Tools []string `json:"tools,omitempty"`
65 WebSearchPolicy WebSearchPolicy `json:"web_search_policy,omitempty"`67 WebSearchPolicy WebSearchPolicy `json:"web_search_policy,omitempty"`
66 Status Status `json:"status,omitempty"`68 Status Status `json:"status,omitempty"`
@@ -96,6 +98,43 @@ type agentMeta struct {
96 SessionID string `json:"session_id,omitempty"`98 SessionID string `json:"session_id,omitempty"`
97 }99 }
98 100
101+type turnTimeoutMode string
102+
103+const (
104+ turnTimeoutUnlimited turnTimeoutMode = "unlimited"
105+ turnTimeoutLimited turnTimeoutMode = "limited"
106+)
107+
108+func timeoutModeFor(timeout time.Duration) turnTimeoutMode {
109+ if timeout > 0 {
110+ return turnTimeoutLimited
111+ }
112+ return turnTimeoutUnlimited
113+}
114+
115+func resolvePersistedTurnTimeout(m agentMeta, fallback time.Duration) time.Duration {
116+ switch m.TurnTimeoutMode {
117+ case turnTimeoutUnlimited:
118+ return 0
119+ case turnTimeoutLimited:
120+ return m.Timeout
121+ default:
122+ // Legacy metadata stored the effective positive timeout. A missing
123+ // value inherited the then-current supervisor policy.
124+ if m.Timeout > 0 {
125+ return m.Timeout
126+ }
127+ return fallback
128+ }
129+}
130+
131+func resolvePersistedMaxSteps(value, fallback int) int {
132+ if value > 0 {
133+ return value
134+ }
135+ return fallback
136+}
137+
99 func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.json") }138 func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.json") }
100 139
101 // writeAgentMeta serialises a's identity into stateDir/meta.json. The140 // writeAgentMeta serialises a's identity into stateDir/meta.json. The
@@ -160,9 +199,11 @@ func writeAgentMeta(stateDir string, a *Agent) error {
160 WorkspaceBase: a.WorkspaceBase,199 WorkspaceBase: a.WorkspaceBase,
161 WorkspaceCapture: a.WorkspaceCapture,200 WorkspaceCapture: a.WorkspaceCapture,
162 MaxTurns: a.MaxTurns,201 MaxTurns: a.MaxTurns,
202+ MaxSteps: a.MaxSteps,
163 LifetimeTurns: lifetimeTurns,203 LifetimeTurns: lifetimeTurns,
164 CurrentRunTurns: currentRunTurns,204 CurrentRunTurns: currentRunTurns,
165 Timeout: a.Timeout,205 Timeout: a.Timeout,
206+ TurnTimeoutMode: timeoutModeFor(a.Timeout),
166 Tools: append([]string(nil), a.Tools...),207 Tools: append([]string(nil), a.Tools...),
167 WebSearchPolicy: webSearchPolicy,208 WebSearchPolicy: webSearchPolicy,
168 Status: status,209 Status: status,
@@ -251,6 +292,12 @@ func readAgentMeta(stateDir string) (agentMeta, error) {
251 if m.ID == "" {292 if m.ID == "" {
252 return m, fmt.Errorf("subagent meta %s: missing id", stateDir)293 return m, fmt.Errorf("subagent meta %s: missing id", stateDir)
253 }294 }
295+ if m.TurnTimeoutMode != "" && m.TurnTimeoutMode != turnTimeoutUnlimited && m.TurnTimeoutMode != turnTimeoutLimited {
296+ return m, fmt.Errorf("subagent meta %s: invalid turn_timeout_mode %q", stateDir, m.TurnTimeoutMode)
297+ }
298+ if m.TurnTimeoutMode == turnTimeoutLimited && m.Timeout <= 0 {
299+ return m, fmt.Errorf("subagent meta %s: limited turn timeout must be positive", stateDir)
300+ }
254 return m, nil301 return m, nil
255 }302 }
256 303
@@ -496,9 +543,10 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) (*Agent, bool) {
496 WorkspaceBase: m.WorkspaceBase,543 WorkspaceBase: m.WorkspaceBase,
497 WorkspaceCapture: m.WorkspaceCapture,544 WorkspaceCapture: m.WorkspaceCapture,
498 MaxTurns: m.MaxTurns,545 MaxTurns: m.MaxTurns,
546+ MaxSteps: resolvePersistedMaxSteps(m.MaxSteps, f.cfg.Policy.MaxSteps),
499 LifetimeTurns: m.LifetimeTurns,547 LifetimeTurns: m.LifetimeTurns,
500 CurrentRunTurns: m.CurrentRunTurns,548 CurrentRunTurns: m.CurrentRunTurns,
501- Timeout: m.Timeout,549+ Timeout: resolvePersistedTurnTimeout(m, f.cfg.Policy.DefaultTimeout),
502 Tools: append([]string(nil), m.Tools...),550 Tools: append([]string(nil), m.Tools...),
503 WebSearchPolicy: childWebSearchPolicy(m.WebSearchPolicy, m.Subagent, m.Tools),551 WebSearchPolicy: childWebSearchPolicy(m.WebSearchPolicy, m.Subagent, m.Tools),
504 Attempt: m.Attempt,552 Attempt: m.Attempt,
@@ -530,7 +578,6 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) (*Agent, bool) {
530 maxOutputBytes: f.cfg.Policy.MaxOutputBytes,578 maxOutputBytes: f.cfg.Policy.MaxOutputBytes,
531 maxOutputLines: f.cfg.Policy.MaxOutputLines,579 maxOutputLines: f.cfg.Policy.MaxOutputLines,
532 done: make(chan struct{}),580 done: make(chan struct{}),
533- turnResults: make(chan *TurnResult, 16),
534 persistFn: f.persistAgent,581 persistFn: f.persistAgent,
535 }582 }
536 if a.updatedAt.IsZero() {583 if a.updatedAt.IsZero() {
@@ -1193,7 +1240,7 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
1193 Subagent: existing.Subagent,1240 Subagent: existing.Subagent,
1194 WorkspaceMode: existing.WorkspaceMode, WorkspacePath: existing.WorkspacePath,1241 WorkspaceMode: existing.WorkspaceMode, WorkspacePath: existing.WorkspacePath,
1195 WorkspaceBase: existing.WorkspaceBase, WorkspaceCapture: existing.WorkspaceCapture,1242 WorkspaceBase: existing.WorkspaceBase, WorkspaceCapture: existing.WorkspaceCapture,
1196- MaxTurns: existing.MaxTurns, LifetimeTurns: lifetimeTurns, CurrentRunTurns: currentRunTurns, Timeout: existing.Timeout, Tools: append([]string(nil), existing.Tools...),1243+ MaxTurns: existing.MaxTurns, MaxSteps: existing.MaxSteps, LifetimeTurns: lifetimeTurns, CurrentRunTurns: currentRunTurns, Timeout: existing.Timeout, TurnTimeoutMode: timeoutModeFor(existing.Timeout), Tools: append([]string(nil), existing.Tools...),
1197 WebSearchPolicy: childWebSearchPolicy(existing.WebSearchPolicy, existing.Subagent, existing.Tools),1244 WebSearchPolicy: childWebSearchPolicy(existing.WebSearchPolicy, existing.Subagent, existing.Tools),
1198 CurrentTurnID: existingSnapshot.CurrentTurnID, Attempt: existingSnapshot.Attempt,1245 CurrentTurnID: existingSnapshot.CurrentTurnID, Attempt: existingSnapshot.Attempt,
1199 Requirement: existingSnapshot.Requirement,1246 Requirement: existingSnapshot.Requirement,
@@ -1254,10 +1301,7 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
1254 m.RepositoryRoot = workspace.RepositoryRoot()1301 m.RepositoryRoot = workspace.RepositoryRoot()
1255 m.WorkspacePath = workspace.Dir()1302 m.WorkspacePath = workspace.Dir()
1256 m.WorkspaceMode = workspace.Mode()1303 m.WorkspaceMode = workspace.Mode()
1257- timeout := m.Timeout1304+ timeout := resolvePersistedTurnTimeout(m, f.cfg.Policy.DefaultTimeout)
1258- if timeout <= 0 {
1259- timeout = f.cfg.Policy.DefaultTimeout
1260- }
1261 runCtx, cancel := f.workerContext()1305 runCtx, cancel := f.workerContext()
1262 a := &Agent{1306 a := &Agent{
1263 ID: m.ID,1307 ID: m.ID,
@@ -1282,6 +1326,7 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
1282 WorkspaceBase: m.WorkspaceBase,1326 WorkspaceBase: m.WorkspaceBase,
1283 WorkspaceCapture: m.WorkspaceCapture,1327 WorkspaceCapture: m.WorkspaceCapture,
1284 MaxTurns: maxTurns,1328 MaxTurns: maxTurns,
1329+ MaxSteps: resolvePersistedMaxSteps(m.MaxSteps, f.cfg.Policy.MaxSteps),
1285 LifetimeTurns: m.LifetimeTurns,1330 LifetimeTurns: m.LifetimeTurns,
1286 CurrentRunTurns: m.CurrentRunTurns,1331 CurrentRunTurns: m.CurrentRunTurns,
1287 Timeout: timeout,1332 Timeout: timeout,
@@ -1312,7 +1357,6 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
1312 maxOutputBytes: f.cfg.Policy.MaxOutputBytes,1357 maxOutputBytes: f.cfg.Policy.MaxOutputBytes,
1313 maxOutputLines: f.cfg.Policy.MaxOutputLines,1358 maxOutputLines: f.cfg.Policy.MaxOutputLines,
1314 done: make(chan struct{}),1359 done: make(chan struct{}),
1315- turnResults: make(chan *TurnResult, 16),
1316 }1360 }
1317 // Carry the previous transcript forward so the dashboard doesn't1361 // Carry the previous transcript forward so the dashboard doesn't
1318 // flash empty between resume and the first new event.1362 // flash empty between resume and the first new event.
M packages/agent/subagents/persist_test.go +49 -0
@@ -218,6 +218,55 @@ func TestResumePreservesPerAgentTimeout(t *testing.T) {
218218 resumed.Wait()
219219 }
220220
221+func TestResumePreservesUnlimitedTurnTimeout(t *testing.T) {
222+ root := t.TempDir()
223+ first := New(Config{
224+ Root: root, RepoRoot: root,
225+ NewRunner: func(*Agent) Runner {
226+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
227+ <-ctx.Done()
228+ return ctx.Err()
229+ })
230+ },
231+ })
232+ a, err := first.Spawn(context.Background(), "unlimited timeout test")
233+ if err != nil {
234+ t.Fatal(err)
235+ }
236+ first.StopAll()
237+ a.Wait()
238+
239+ observedCh := make(chan time.Duration, 1)
240+ second := New(Config{
241+ Root: root, RepoRoot: root,
242+ Policy: SubagentPolicy{DefaultTimeout: time.Hour},
243+ NewRunner: func(a *Agent) Runner {
244+ observedCh <- a.Timeout
245+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
246+ <-ctx.Done()
247+ return ctx.Err()
248+ })
249+ },
250+ })
251+ if loaded, errs := second.Reload(); loaded != 1 || len(errs) != 0 {
252+ t.Fatalf("reload loaded=%d errs=%v", loaded, errs)
253+ }
254+ resumed, err := second.ResumeSession(context.Background(), a.ID)
255+ if err != nil {
256+ t.Fatal(err)
257+ }
258+ select {
259+ case observed := <-observedCh:
260+ if resumed.Timeout != 0 || observed != 0 {
261+ t.Fatalf("resumed timeout = %s, runner observed %s; want unlimited", resumed.Timeout, observed)
262+ }
263+ case <-time.After(time.Second):
264+ t.Fatal("timed out waiting for resumed runner")
265+ }
266+ second.StopAll()
267+ resumed.Wait()
268+}
269+
221270 func TestReloadRejectsMetadataPathsOutsideState(t *testing.T) {
222271 root := t.TempDir()
223272 outside := filepath.Join(t.TempDir(), "outside.json")
@@ -218,6 +218,55 @@ func TestResumePreservesPerAgentTimeout(t *testing.T) {
218 resumed.Wait()218 resumed.Wait()
219 }219 }
220 220
221+func TestResumePreservesUnlimitedTurnTimeout(t *testing.T) {
222+ root := t.TempDir()
223+ first := New(Config{
224+ Root: root, RepoRoot: root,
225+ NewRunner: func(*Agent) Runner {
226+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
227+ <-ctx.Done()
228+ return ctx.Err()
229+ })
230+ },
231+ })
232+ a, err := first.Spawn(context.Background(), "unlimited timeout test")
233+ if err != nil {
234+ t.Fatal(err)
235+ }
236+ first.StopAll()
237+ a.Wait()
238+
239+ observedCh := make(chan time.Duration, 1)
240+ second := New(Config{
241+ Root: root, RepoRoot: root,
242+ Policy: SubagentPolicy{DefaultTimeout: time.Hour},
243+ NewRunner: func(a *Agent) Runner {
244+ observedCh <- a.Timeout
245+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
246+ <-ctx.Done()
247+ return ctx.Err()
248+ })
249+ },
250+ })
251+ if loaded, errs := second.Reload(); loaded != 1 || len(errs) != 0 {
252+ t.Fatalf("reload loaded=%d errs=%v", loaded, errs)
253+ }
254+ resumed, err := second.ResumeSession(context.Background(), a.ID)
255+ if err != nil {
256+ t.Fatal(err)
257+ }
258+ select {
259+ case observed := <-observedCh:
260+ if resumed.Timeout != 0 || observed != 0 {
261+ t.Fatalf("resumed timeout = %s, runner observed %s; want unlimited", resumed.Timeout, observed)
262+ }
263+ case <-time.After(time.Second):
264+ t.Fatal("timed out waiting for resumed runner")
265+ }
266+ second.StopAll()
267+ resumed.Wait()
268+}
269+
221 func TestReloadRejectsMetadataPathsOutsideState(t *testing.T) {270 func TestReloadRejectsMetadataPathsOutsideState(t *testing.T) {
222 root := t.TempDir()271 root := t.TempDir()
223 outside := filepath.Join(t.TempDir(), "outside.json")272 outside := filepath.Join(t.TempDir(), "outside.json")
M packages/agent/subagents/projection.go +79 -17
@@ -53,8 +53,9 @@ func (o Operation) Label() string {
5353 }
5454 }
5555
56-// LiveObservation is the last safe, factual signal received from a worker.
57-// It intentionally contains no worker text, tool arguments, or output.
56+// LiveObservation is the last safe, user-facing signal received from a
57+// worker. It intentionally contains no worker text, tool arguments, output,
58+// or low-level protocol event names.
5859 type LiveObservation struct {
5960 Type string
6061 Source string
@@ -73,14 +74,14 @@ func (o LiveObservation) Label() string {
7374 return "reasoning stream observed"
7475 case "tool.output.observed":
7576 if o.Name != "" {
76- return "tool output observed " + o.Name
77+ return "output from " + o.Name
7778 }
78- return "tool output observed"
79- case "worker.protocol.observed":
80- if o.Source != "" {
81- return "worker " + o.Source
79+ return "tool output"
80+ case "tool.finished":
81+ if o.Name != "" {
82+ return "finished " + o.Name
8283 }
83- return "worker protocol event"
84+ return "tool finished"
8485 default:
8586 return strings.ReplaceAll(o.Type, ".", " ")
8687 }
@@ -168,6 +169,11 @@ func ProjectTrace(events []TraceEvent) map[string]AgentTraceView {
168169 view.AgentID = event.AgentID
169170 view.LastEvent = event
170171 key, starts, ends, terminal := traceBoundary(event)
172+ var endedOperation Operation
173+ var endedOperationFound bool
174+ if ends {
175+ endedOperation, endedOperationFound = open[event.AgentID][key]
176+ }
171177 if starts {
172178 if event.Type == "provider.request.started" {
173179 view.LastRequest = requestFact(event, "open")
@@ -197,7 +203,19 @@ func ProjectTrace(events []TraceEvent) map[string]AgentTraceView {
197203 delete(open[event.AgentID], key)
198204 }
199205 if observation := traceObservation(event); observation != nil {
200- view.LastObservation = observation
206+ if observation.Type == "tool.finished" && observation.Name == "" {
207+ // tool_result carries only the call ID. Recover the safe tool name
208+ // from its matching open execution boundary; an unscoped generic
209+ // "tool finished" fact is too low-value for the live indicator.
210+ if endedOperationFound && endedOperation.Type == "tool.started" && endedOperation.Name != "" {
211+ observation.Name = endedOperation.Name
212+ } else {
213+ observation = nil
214+ }
215+ }
216+ if observation != nil {
217+ view.LastObservation = observation
218+ }
201219 }
202220 if result := traceResultFact(event, view.Result); result != nil {
203221 view.Result = result
@@ -238,17 +256,61 @@ func ProjectTrace(events []TraceEvent) map[string]AgentTraceView {
238256 return views
239257 }
240258
241-// ObservationFor returns the last observation only when it belongs to this
242-// operation's turn and, when identified, its tool call. A prior turn's stream
243-// or another tool's output is not live activity for this operation.
259+// TurnStartedAt returns the factual start of the open delegated turn owning
260+// operation. It is stable across nested provider requests and tool calls.
261+func (v AgentTraceView) TurnStartedAt(operation Operation) time.Time {
262+ if operation.TurnID == "" {
263+ return time.Time{}
264+ }
265+ for _, candidate := range v.OpenOperations {
266+ if candidate.Type == "turn.started" && candidate.TurnID == operation.TurnID {
267+ return candidate.StartedAt
268+ }
269+ }
270+ return time.Time{}
271+}
272+
273+// ObservationFor returns the last user-facing observation only when it belongs
274+// to this operation's turn and scope. Tool observations are shown for their
275+// matching open tool and may remain as a factual recent fact under the next
276+// provider request in that turn. Provider-stream observations never decorate
277+// a tool operation, and another tool's observation never crosses call scope.
244278 func (v AgentTraceView) ObservationFor(operation Operation) *LiveObservation {
245- if v.LastObservation == nil || operation.TurnID == "" || v.LastObservation.TurnID == "" || operation.TurnID != v.LastObservation.TurnID {
279+ observation := v.LastObservation
280+ if observation == nil || operation.TurnID == "" || observation.TurnID == "" || operation.TurnID != observation.TurnID {
246281 return nil
247282 }
248- if v.LastObservation.CallID != "" && v.LastObservation.CallID != operation.CallID {
249- return nil
283+ switch operation.Type {
284+ case "tool.started":
285+ if !isToolObservation(observation.Type) || operation.CallID == "" || observation.CallID == "" || operation.CallID != observation.CallID {
286+ return nil
287+ }
288+ case "provider.request.started":
289+ // The provider request is the current primary operation. A completed
290+ // tool from this turn remains useful context without pretending the
291+ // closed tool is still running.
292+ case "turn.started":
293+ // Keep even a very short tool perceptible after its factual end boundary
294+ // and before the next provider request boundary arrives. Other call-scoped
295+ // facts do not decorate the broader turn operation.
296+ if observation.CallID != "" && observation.Type != "tool.finished" {
297+ return nil
298+ }
299+ default:
300+ if observation.CallID != "" {
301+ return nil
302+ }
303+ }
304+ return observation
305+}
306+
307+func isToolObservation(typeName string) bool {
308+ switch typeName {
309+ case "tool.output.observed", "tool.finished":
310+ return true
311+ default:
312+ return false
250313 }
251- return v.LastObservation
252314 }
253315
254316 func operationPriority(o Operation) int {
@@ -273,7 +335,7 @@ func traceObservation(event TraceEvent) *LiveObservation {
273335 name, _ := event.Data["name"].(string)
274336 callID, _ := event.Data["call_id"].(string)
275337 switch typeName {
276- case "assistant.stream.observed", "reasoning.stream.observed", "tool.output.observed", "worker.protocol.observed":
338+ case "assistant.stream.observed", "reasoning.stream.observed", "tool.output.observed", "tool.finished":
277339 return &LiveObservation{Type: typeName, Source: source, At: event.Timestamp, TurnID: event.TurnID, CallID: callID, Name: name}
278340 }
279341 return nil
@@ -53,8 +53,9 @@ func (o Operation) Label() string {
53 }53 }
54 }54 }
55 55
56-// LiveObservation is the last safe, factual signal received from a worker.56+// LiveObservation is the last safe, user-facing signal received from a
57-// It intentionally contains no worker text, tool arguments, or output.57+// worker. It intentionally contains no worker text, tool arguments, output,
58+// or low-level protocol event names.
58 type LiveObservation struct {59 type LiveObservation struct {
59 Type string60 Type string
60 Source string61 Source string
@@ -73,14 +74,14 @@ func (o LiveObservation) Label() string {
73 return "reasoning stream observed"74 return "reasoning stream observed"
74 case "tool.output.observed":75 case "tool.output.observed":
75 if o.Name != "" {76 if o.Name != "" {
76- return "tool output observed " + o.Name77+ return "output from " + o.Name
77 }78 }
78- return "tool output observed"79+ return "tool output"
79- case "worker.protocol.observed":80+ case "tool.finished":
80- if o.Source != "" {81+ if o.Name != "" {
81- return "worker " + o.Source82+ return "finished " + o.Name
82 }83 }
83- return "worker protocol event"84+ return "tool finished"
84 default:85 default:
85 return strings.ReplaceAll(o.Type, ".", " ")86 return strings.ReplaceAll(o.Type, ".", " ")
86 }87 }
@@ -168,6 +169,11 @@ func ProjectTrace(events []TraceEvent) map[string]AgentTraceView {
168 view.AgentID = event.AgentID169 view.AgentID = event.AgentID
169 view.LastEvent = event170 view.LastEvent = event
170 key, starts, ends, terminal := traceBoundary(event)171 key, starts, ends, terminal := traceBoundary(event)
172+ var endedOperation Operation
173+ var endedOperationFound bool
174+ if ends {
175+ endedOperation, endedOperationFound = open[event.AgentID][key]
176+ }
171 if starts {177 if starts {
172 if event.Type == "provider.request.started" {178 if event.Type == "provider.request.started" {
173 view.LastRequest = requestFact(event, "open")179 view.LastRequest = requestFact(event, "open")
@@ -197,7 +203,19 @@ func ProjectTrace(events []TraceEvent) map[string]AgentTraceView {
197 delete(open[event.AgentID], key)203 delete(open[event.AgentID], key)
198 }204 }
199 if observation := traceObservation(event); observation != nil {205 if observation := traceObservation(event); observation != nil {
200- view.LastObservation = observation206+ if observation.Type == "tool.finished" && observation.Name == "" {
207+ // tool_result carries only the call ID. Recover the safe tool name
208+ // from its matching open execution boundary; an unscoped generic
209+ // "tool finished" fact is too low-value for the live indicator.
210+ if endedOperationFound && endedOperation.Type == "tool.started" && endedOperation.Name != "" {
211+ observation.Name = endedOperation.Name
212+ } else {
213+ observation = nil
214+ }
215+ }
216+ if observation != nil {
217+ view.LastObservation = observation
218+ }
201 }219 }
202 if result := traceResultFact(event, view.Result); result != nil {220 if result := traceResultFact(event, view.Result); result != nil {
203 view.Result = result221 view.Result = result
@@ -238,17 +256,61 @@ func ProjectTrace(events []TraceEvent) map[string]AgentTraceView {
238 return views256 return views
239 }257 }
240 258
241-// ObservationFor returns the last observation only when it belongs to this259+// TurnStartedAt returns the factual start of the open delegated turn owning
242-// operation's turn and, when identified, its tool call. A prior turn's stream260+// operation. It is stable across nested provider requests and tool calls.
243-// or another tool's output is not live activity for this operation.261+func (v AgentTraceView) TurnStartedAt(operation Operation) time.Time {
262+ if operation.TurnID == "" {
263+ return time.Time{}
264+ }
265+ for _, candidate := range v.OpenOperations {
266+ if candidate.Type == "turn.started" && candidate.TurnID == operation.TurnID {
267+ return candidate.StartedAt
268+ }
269+ }
270+ return time.Time{}
271+}
272+
273+// ObservationFor returns the last user-facing observation only when it belongs
274+// to this operation's turn and scope. Tool observations are shown for their
275+// matching open tool and may remain as a factual recent fact under the next
276+// provider request in that turn. Provider-stream observations never decorate
277+// a tool operation, and another tool's observation never crosses call scope.
244 func (v AgentTraceView) ObservationFor(operation Operation) *LiveObservation {278 func (v AgentTraceView) ObservationFor(operation Operation) *LiveObservation {
245- if v.LastObservation == nil || operation.TurnID == "" || v.LastObservation.TurnID == "" || operation.TurnID != v.LastObservation.TurnID {279+ observation := v.LastObservation
280+ if observation == nil || operation.TurnID == "" || observation.TurnID == "" || operation.TurnID != observation.TurnID {
246 return nil281 return nil
247 }282 }
248- if v.LastObservation.CallID != "" && v.LastObservation.CallID != operation.CallID {283+ switch operation.Type {
249- return nil284+ case "tool.started":
285+ if !isToolObservation(observation.Type) || operation.CallID == "" || observation.CallID == "" || operation.CallID != observation.CallID {
286+ return nil
287+ }
288+ case "provider.request.started":
289+ // The provider request is the current primary operation. A completed
290+ // tool from this turn remains useful context without pretending the
291+ // closed tool is still running.
292+ case "turn.started":
293+ // Keep even a very short tool perceptible after its factual end boundary
294+ // and before the next provider request boundary arrives. Other call-scoped
295+ // facts do not decorate the broader turn operation.
296+ if observation.CallID != "" && observation.Type != "tool.finished" {
297+ return nil
298+ }
299+ default:
300+ if observation.CallID != "" {
301+ return nil
302+ }
303+ }
304+ return observation
305+}
306+
307+func isToolObservation(typeName string) bool {
308+ switch typeName {
309+ case "tool.output.observed", "tool.finished":
310+ return true
311+ default:
312+ return false
250 }313 }
251- return v.LastObservation
252 }314 }
253 315
254 func operationPriority(o Operation) int {316 func operationPriority(o Operation) int {
@@ -273,7 +335,7 @@ func traceObservation(event TraceEvent) *LiveObservation {
273 name, _ := event.Data["name"].(string)335 name, _ := event.Data["name"].(string)
274 callID, _ := event.Data["call_id"].(string)336 callID, _ := event.Data["call_id"].(string)
275 switch typeName {337 switch typeName {
276- case "assistant.stream.observed", "reasoning.stream.observed", "tool.output.observed", "worker.protocol.observed":338+ case "assistant.stream.observed", "reasoning.stream.observed", "tool.output.observed", "tool.finished":
277 return &LiveObservation{Type: typeName, Source: source, At: event.Timestamp, TurnID: event.TurnID, CallID: callID, Name: name}339 return &LiveObservation{Type: typeName, Source: source, At: event.Timestamp, TurnID: event.TurnID, CallID: callID, Name: name}
278 }340 }
279 return nil341 return nil
M packages/agent/subagents/projection_test.go +75 -1
@@ -27,6 +27,80 @@ func TestProjectTraceRetainsJSONDecodedRequestAttempts(t *testing.T) {
2727 }
2828 }
2929
30+func TestProjectTraceIgnoresProtocolFactsAndShowsCompletedToolDuringNextProviderRequest(t *testing.T) {
31+ started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
32+ view := ProjectTrace([]TraceEvent{
33+ {Timestamp: started, Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"},
34+ {Timestamp: started.Add(time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "call-1", "name": "read"}},
35+ {Timestamp: started.Add(2 * time.Second), Type: "tool.finished", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "call-1", "name": "read"}},
36+ {Timestamp: started.Add(2500 * time.Millisecond), Type: "worker.protocol.observed", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"source_event": "tool.use.args"}},
37+ {Timestamp: started.Add(3 * time.Second), Type: "provider.request.started", AgentID: "agent-1", TurnID: "turn-1"},
38+ })["agent-1"]
39+ if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "provider.request.started" {
40+ t.Fatalf("primary operation = %#v", view.PrimaryOperation)
41+ }
42+ observation := view.ObservationFor(*view.PrimaryOperation)
43+ if observation == nil || observation.Label() != "finished read" {
44+ t.Fatalf("observation = %#v, want finished read", observation)
45+ }
46+ if view.LastObservation != observation {
47+ t.Fatalf("protocol fact replaced user-facing observation: %#v", view.LastObservation)
48+ }
49+}
50+
51+func TestProjectTraceSuppressesGenericUnmatchedToolFinished(t *testing.T) {
52+ view := ProjectTrace([]TraceEvent{
53+ {Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"},
54+ {Type: "tool.finished", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "unknown"}},
55+ })["agent-1"]
56+ if view.LastObservation != nil {
57+ t.Fatalf("generic tool-finished observation = %#v, want omitted", view.LastObservation)
58+ }
59+}
60+
61+func TestProjectTraceKeepsShortCompletedToolVisibleBeforeNextRequest(t *testing.T) {
62+ started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
63+ view := ProjectTrace([]TraceEvent{
64+ {Timestamp: started, Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"},
65+ {Timestamp: started.Add(time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "call-1", "name": "read"}},
66+ {Timestamp: started.Add(time.Second + time.Millisecond), Type: "tool.finished", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "call-1", "name": "read"}},
67+ })["agent-1"]
68+ if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "turn.started" {
69+ t.Fatalf("primary operation = %#v, want turn", view.PrimaryOperation)
70+ }
71+ if observation := view.ObservationFor(*view.PrimaryOperation); observation == nil || observation.Label() != "finished read" {
72+ t.Fatalf("completed tool observation = %#v, want finished read", observation)
73+ }
74+}
75+
76+func TestProjectTraceDoesNotDecorateToolWithProviderStream(t *testing.T) {
77+ started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
78+ view := ProjectTrace([]TraceEvent{
79+ {Timestamp: started, Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"},
80+ {Timestamp: started.Add(time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "call-1", "name": "read"}},
81+ {Timestamp: started.Add(2 * time.Second), Type: "assistant.stream.observed", AgentID: "agent-1", TurnID: "turn-1"},
82+ })["agent-1"]
83+ if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "tool.started" {
84+ t.Fatalf("primary operation = %#v, want tool", view.PrimaryOperation)
85+ }
86+ if observation := view.ObservationFor(*view.PrimaryOperation); observation != nil {
87+ t.Fatalf("provider stream decorated tool operation: %#v", observation)
88+ }
89+}
90+
91+func TestAgentTraceViewTurnStartedAtMatchesOwningTurn(t *testing.T) {
92+ first := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
93+ second := first.Add(time.Minute)
94+ view := AgentTraceView{OpenOperations: []Operation{
95+ {Type: "turn.started", TurnID: "turn-1", StartedAt: first},
96+ {Type: "turn.started", TurnID: "turn-2", StartedAt: second},
97+ }}
98+ operation := Operation{Type: "provider.request.started", TurnID: "turn-2", StartedAt: second.Add(time.Second)}
99+ if got := view.TurnStartedAt(operation); !got.Equal(second) {
100+ t.Fatalf("turn start = %v, want %v", got, second)
101+ }
102+}
103+
30104 func TestProjectTraceReportsOpenOperationInsteadOfLifecycleState(t *testing.T) {
31105 started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
32106 views := ProjectTrace([]TraceEvent{
@@ -96,7 +170,7 @@ func TestProjectTraceSelectsSpecificActivityAndProjectsResultDelivery(t *testing
96170 if view.Result == nil || !view.Result.Available || !view.Result.Delivered || view.Result.Ref != "subagent://agent-1/result" {
97171 t.Fatalf("result = %#v", view.Result)
98172 }
99- if got, want := view.Summary(), "running bash · assistant streaming"; got != want {
173+ if got, want := view.Summary(), "running bash"; got != want {
100174 t.Fatalf("summary = %q, want %q", got, want)
101175 }
102176 }
@@ -27,6 +27,80 @@ func TestProjectTraceRetainsJSONDecodedRequestAttempts(t *testing.T) {
27 }27 }
28 }28 }
29 29
30+func TestProjectTraceIgnoresProtocolFactsAndShowsCompletedToolDuringNextProviderRequest(t *testing.T) {
31+ started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
32+ view := ProjectTrace([]TraceEvent{
33+ {Timestamp: started, Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"},
34+ {Timestamp: started.Add(time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "call-1", "name": "read"}},
35+ {Timestamp: started.Add(2 * time.Second), Type: "tool.finished", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "call-1", "name": "read"}},
36+ {Timestamp: started.Add(2500 * time.Millisecond), Type: "worker.protocol.observed", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"source_event": "tool.use.args"}},
37+ {Timestamp: started.Add(3 * time.Second), Type: "provider.request.started", AgentID: "agent-1", TurnID: "turn-1"},
38+ })["agent-1"]
39+ if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "provider.request.started" {
40+ t.Fatalf("primary operation = %#v", view.PrimaryOperation)
41+ }
42+ observation := view.ObservationFor(*view.PrimaryOperation)
43+ if observation == nil || observation.Label() != "finished read" {
44+ t.Fatalf("observation = %#v, want finished read", observation)
45+ }
46+ if view.LastObservation != observation {
47+ t.Fatalf("protocol fact replaced user-facing observation: %#v", view.LastObservation)
48+ }
49+}
50+
51+func TestProjectTraceSuppressesGenericUnmatchedToolFinished(t *testing.T) {
52+ view := ProjectTrace([]TraceEvent{
53+ {Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"},
54+ {Type: "tool.finished", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "unknown"}},
55+ })["agent-1"]
56+ if view.LastObservation != nil {
57+ t.Fatalf("generic tool-finished observation = %#v, want omitted", view.LastObservation)
58+ }
59+}
60+
61+func TestProjectTraceKeepsShortCompletedToolVisibleBeforeNextRequest(t *testing.T) {
62+ started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
63+ view := ProjectTrace([]TraceEvent{
64+ {Timestamp: started, Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"},
65+ {Timestamp: started.Add(time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "call-1", "name": "read"}},
66+ {Timestamp: started.Add(time.Second + time.Millisecond), Type: "tool.finished", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "call-1", "name": "read"}},
67+ })["agent-1"]
68+ if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "turn.started" {
69+ t.Fatalf("primary operation = %#v, want turn", view.PrimaryOperation)
70+ }
71+ if observation := view.ObservationFor(*view.PrimaryOperation); observation == nil || observation.Label() != "finished read" {
72+ t.Fatalf("completed tool observation = %#v, want finished read", observation)
73+ }
74+}
75+
76+func TestProjectTraceDoesNotDecorateToolWithProviderStream(t *testing.T) {
77+ started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
78+ view := ProjectTrace([]TraceEvent{
79+ {Timestamp: started, Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"},
80+ {Timestamp: started.Add(time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "call-1", "name": "read"}},
81+ {Timestamp: started.Add(2 * time.Second), Type: "assistant.stream.observed", AgentID: "agent-1", TurnID: "turn-1"},
82+ })["agent-1"]
83+ if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "tool.started" {
84+ t.Fatalf("primary operation = %#v, want tool", view.PrimaryOperation)
85+ }
86+ if observation := view.ObservationFor(*view.PrimaryOperation); observation != nil {
87+ t.Fatalf("provider stream decorated tool operation: %#v", observation)
88+ }
89+}
90+
91+func TestAgentTraceViewTurnStartedAtMatchesOwningTurn(t *testing.T) {
92+ first := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
93+ second := first.Add(time.Minute)
94+ view := AgentTraceView{OpenOperations: []Operation{
95+ {Type: "turn.started", TurnID: "turn-1", StartedAt: first},
96+ {Type: "turn.started", TurnID: "turn-2", StartedAt: second},
97+ }}
98+ operation := Operation{Type: "provider.request.started", TurnID: "turn-2", StartedAt: second.Add(time.Second)}
99+ if got := view.TurnStartedAt(operation); !got.Equal(second) {
100+ t.Fatalf("turn start = %v, want %v", got, second)
101+ }
102+}
103+
30 func TestProjectTraceReportsOpenOperationInsteadOfLifecycleState(t *testing.T) {104 func TestProjectTraceReportsOpenOperationInsteadOfLifecycleState(t *testing.T) {
31 started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)105 started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
32 views := ProjectTrace([]TraceEvent{106 views := ProjectTrace([]TraceEvent{
@@ -96,7 +170,7 @@ func TestProjectTraceSelectsSpecificActivityAndProjectsResultDelivery(t *testing
96 if view.Result == nil || !view.Result.Available || !view.Result.Delivered || view.Result.Ref != "subagent://agent-1/result" {170 if view.Result == nil || !view.Result.Available || !view.Result.Delivered || view.Result.Ref != "subagent://agent-1/result" {
97 t.Fatalf("result = %#v", view.Result)171 t.Fatalf("result = %#v", view.Result)
98 }172 }
99- if got, want := view.Summary(), "running bash · assistant streaming"; got != want {173+ if got, want := view.Summary(), "running bash"; got != want {
100 t.Fatalf("summary = %q, want %q", got, want)174 t.Fatalf("summary = %q, want %q", got, want)
101 }175 }
102 }176 }
M packages/agent/subagents/runner.go +23 -11
@@ -79,6 +79,7 @@ type subagentWorkerArgsOpts struct {
7979 FastModeSet bool
8080 Subagent string
8181 MaxTurns int
82+ MaxSteps int
8283 TurnTimeout time.Duration
8384 LifetimeTurns int
8485 RunTurns int
@@ -118,6 +119,7 @@ func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []str
118119 FastModeSet: true,
119120 Subagent: a.Subagent,
120121 MaxTurns: a.MaxTurns,
122+ MaxSteps: a.MaxSteps,
121123 TurnTimeout: a.Timeout,
122124 LifetimeTurns: a.LifetimeTurnsValue(),
123125 RunTurns: a.CurrentRunTurnsValue(),
@@ -165,6 +167,9 @@ func subagentWorkerArgs(opts subagentWorkerArgsOpts) []string {
165167 if opts.MaxTurns > 0 {
166168 args = append(args, "--max-turns", fmt.Sprint(opts.MaxTurns))
167169 }
170+ if opts.MaxSteps > 0 {
171+ args = append(args, "--max-steps", fmt.Sprint(opts.MaxSteps))
172+ }
168173 if opts.TurnTimeout > 0 {
169174 args = append(args, "--subagent-turn-timeout", opts.TurnTimeout.String())
170175 }
@@ -708,6 +713,7 @@ func updateAgentFromEvent(a *Agent, ev Event) error {
708713 a.markActivity(now)
709714 persist := false
710715 notifyIdle := false
716+ var eventErr error
711717 switch ev.Type {
712718 case EventAgentReady, "agent_ready":
713719 a.setProcessState(ProcessAlive)
@@ -759,20 +765,26 @@ func updateAgentFromEvent(a *Agent, ev Event) error {
759765 a.setProcessState(ProcessAlive)
760766 case EventTurnResult, "turn_result":
761767 if result, err := decodeTurnResultEvent(ev, a.ID, a.maxOutputBytes, a.maxOutputLines); err == nil {
762- a.setResult(result)
763- if a.turnResults != nil {
764- select {
765- case a.turnResults <- result:
766- default:
767- }
768- }
769768 if a.stateDir != "" {
770- if err := writeTurnResult(a.stateDir, result); err == nil {
769+ if err := writeTurnResult(a.stateDir, result); err != nil {
770+ eventErr = fmt.Errorf("write turn result: %w", err)
771+ result = cloneTurnResult(result)
772+ result.Status = ResultFailed
773+ result.Error = &ResultError{
774+ Code: "result_persistence_failed",
775+ Message: "subagent result could not be persisted; inspect the live session before retrying",
776+ }
777+ a.lifecycleMu.Lock()
778+ a.resultRef = ""
779+ a.lifecycleMu.Unlock()
780+ a.recordPersistenceError(eventErr)
781+ } else {
771782 a.lifecycleMu.Lock()
772783 a.resultRef = ResultRef(a.ID)
773784 a.lifecycleMu.Unlock()
774785 }
775786 }
787+ a.setResult(result)
776788 switch result.Status {
777789 case ResultCanceled:
778790 a.setTurnState(TurnCanceled, result.TurnID)
@@ -850,13 +862,13 @@ func updateAgentFromEvent(a *Agent, ev Event) error {
850862 if persist && a.persistFn != nil {
851863 if err := a.persistFn(a); err != nil {
852864 a.recordPersistenceError(err)
853- return err
865+ eventErr = errors.Join(eventErr, err)
854866 }
855867 }
856868 if notifyIdle {
857869 a.notifyTurnIdle()
858870 }
859- return nil
871+ return eventErr
860872 }
861873
862874 // notifyPromptTurnEnd calls Agent.OnTurnEnd only for the subagent
@@ -955,7 +967,7 @@ func recordWorkerTrace(agent *Agent, ev Event) {
955967 case "error":
956968 // Error events do not necessarily belong to a provider request; the
957969 // following terminal turn event closes any request factually.
958- case "tool_call", EventToolStarted:
970+ case "tool_execution_started", EventToolStarted:
959971 traceType = "tool.started"
960972 case "tool_result", EventToolFinished:
961973 traceType = "tool.finished"
@@ -79,6 +79,7 @@ type subagentWorkerArgsOpts struct {
79 FastModeSet bool79 FastModeSet bool
80 Subagent string80 Subagent string
81 MaxTurns int81 MaxTurns int
82+ MaxSteps int
82 TurnTimeout time.Duration83 TurnTimeout time.Duration
83 LifetimeTurns int84 LifetimeTurns int
84 RunTurns int85 RunTurns int
@@ -118,6 +119,7 @@ func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []str
118 FastModeSet: true,119 FastModeSet: true,
119 Subagent: a.Subagent,120 Subagent: a.Subagent,
120 MaxTurns: a.MaxTurns,121 MaxTurns: a.MaxTurns,
122+ MaxSteps: a.MaxSteps,
121 TurnTimeout: a.Timeout,123 TurnTimeout: a.Timeout,
122 LifetimeTurns: a.LifetimeTurnsValue(),124 LifetimeTurns: a.LifetimeTurnsValue(),
123 RunTurns: a.CurrentRunTurnsValue(),125 RunTurns: a.CurrentRunTurnsValue(),
@@ -165,6 +167,9 @@ func subagentWorkerArgs(opts subagentWorkerArgsOpts) []string {
165 if opts.MaxTurns > 0 {167 if opts.MaxTurns > 0 {
166 args = append(args, "--max-turns", fmt.Sprint(opts.MaxTurns))168 args = append(args, "--max-turns", fmt.Sprint(opts.MaxTurns))
167 }169 }
170+ if opts.MaxSteps > 0 {
171+ args = append(args, "--max-steps", fmt.Sprint(opts.MaxSteps))
172+ }
168 if opts.TurnTimeout > 0 {173 if opts.TurnTimeout > 0 {
169 args = append(args, "--subagent-turn-timeout", opts.TurnTimeout.String())174 args = append(args, "--subagent-turn-timeout", opts.TurnTimeout.String())
170 }175 }
@@ -708,6 +713,7 @@ func updateAgentFromEvent(a *Agent, ev Event) error {
708 a.markActivity(now)713 a.markActivity(now)
709 persist := false714 persist := false
710 notifyIdle := false715 notifyIdle := false
716+ var eventErr error
711 switch ev.Type {717 switch ev.Type {
712 case EventAgentReady, "agent_ready":718 case EventAgentReady, "agent_ready":
713 a.setProcessState(ProcessAlive)719 a.setProcessState(ProcessAlive)
@@ -759,20 +765,26 @@ func updateAgentFromEvent(a *Agent, ev Event) error {
759 a.setProcessState(ProcessAlive)765 a.setProcessState(ProcessAlive)
760 case EventTurnResult, "turn_result":766 case EventTurnResult, "turn_result":
761 if result, err := decodeTurnResultEvent(ev, a.ID, a.maxOutputBytes, a.maxOutputLines); err == nil {767 if result, err := decodeTurnResultEvent(ev, a.ID, a.maxOutputBytes, a.maxOutputLines); err == nil {
762- a.setResult(result)
763- if a.turnResults != nil {
764- select {
765- case a.turnResults <- result:
766- default:
767- }
768- }
769 if a.stateDir != "" {768 if a.stateDir != "" {
770- if err := writeTurnResult(a.stateDir, result); err == nil {769+ if err := writeTurnResult(a.stateDir, result); err != nil {
770+ eventErr = fmt.Errorf("write turn result: %w", err)
771+ result = cloneTurnResult(result)
772+ result.Status = ResultFailed
773+ result.Error = &ResultError{
774+ Code: "result_persistence_failed",
775+ Message: "subagent result could not be persisted; inspect the live session before retrying",
776+ }
777+ a.lifecycleMu.Lock()
778+ a.resultRef = ""
779+ a.lifecycleMu.Unlock()
780+ a.recordPersistenceError(eventErr)
781+ } else {
771 a.lifecycleMu.Lock()782 a.lifecycleMu.Lock()
772 a.resultRef = ResultRef(a.ID)783 a.resultRef = ResultRef(a.ID)
773 a.lifecycleMu.Unlock()784 a.lifecycleMu.Unlock()
774 }785 }
775 }786 }
787+ a.setResult(result)
776 switch result.Status {788 switch result.Status {
777 case ResultCanceled:789 case ResultCanceled:
778 a.setTurnState(TurnCanceled, result.TurnID)790 a.setTurnState(TurnCanceled, result.TurnID)
@@ -850,13 +862,13 @@ func updateAgentFromEvent(a *Agent, ev Event) error {
850 if persist && a.persistFn != nil {862 if persist && a.persistFn != nil {
851 if err := a.persistFn(a); err != nil {863 if err := a.persistFn(a); err != nil {
852 a.recordPersistenceError(err)864 a.recordPersistenceError(err)
853- return err865+ eventErr = errors.Join(eventErr, err)
854 }866 }
855 }867 }
856 if notifyIdle {868 if notifyIdle {
857 a.notifyTurnIdle()869 a.notifyTurnIdle()
858 }870 }
859- return nil871+ return eventErr
860 }872 }
861 873
862 // notifyPromptTurnEnd calls Agent.OnTurnEnd only for the subagent874 // notifyPromptTurnEnd calls Agent.OnTurnEnd only for the subagent
@@ -955,7 +967,7 @@ func recordWorkerTrace(agent *Agent, ev Event) {
955 case "error":967 case "error":
956 // Error events do not necessarily belong to a provider request; the968 // Error events do not necessarily belong to a provider request; the
957 // following terminal turn event closes any request factually.969 // following terminal turn event closes any request factually.
958- case "tool_call", EventToolStarted:970+ case "tool_execution_started", EventToolStarted:
959 traceType = "tool.started"971 traceType = "tool.started"
960 case "tool_result", EventToolFinished:972 case "tool_result", EventToolFinished:
961 traceType = "tool.finished"973 traceType = "tool.finished"
M packages/agent/subagents/runner_test.go +49 -13
@@ -360,6 +360,13 @@ func TestDefaultChildArgsPropagatesWebSearchPolicy(t *testing.T) {
360360 }
361361 }
362362
363+func TestDefaultChildArgsPropagatesModelStepBudget(t *testing.T) {
364+ args := defaultChildArgs("zut", &Agent{Task: "task", MaxSteps: 128}, "/session", "/inbox")
365+ if i := indexOf(args, "--max-steps"); i < 0 || safeAt(args, i+1) != "128" {
366+ t.Fatalf("argv = %v, want --max-steps 128", args)
367+ }
368+}
369+
363370 func TestResolveSubagentExecutableUsesCurrentWhenAvailable(t *testing.T) {
364371 got, err := resolveSubagentExecutable(context.Background(), "/current/bin/zut", "/old/bin/zut", func(_ context.Context, candidate string) (string, error) {
365372 if candidate == "/current/bin/zut" {
@@ -754,6 +761,28 @@ func TestRecordPersistenceErrorPreservesFirstFailure(t *testing.T) {
754761 }
755762 }
756763
764+func TestUpdateAgentFromEventSurfacesTurnResultPersistenceFailure(t *testing.T) {
765+ blockedStateDir := filepath.Join(t.TempDir(), "not-a-directory")
766+ if err := os.WriteFile(blockedStateDir, []byte("blocked"), 0o600); err != nil {
767+ t.Fatal(err)
768+ }
769+ a := &Agent{ID: "agent-1", stateDir: blockedStateDir}
770+ err := updateAgentFromEvent(a, Event{Type: EventTurnResult, TurnID: "turn-1", Data: map[string]any{
771+ "status": "succeeded",
772+ "output": "review complete",
773+ }})
774+ if err == nil {
775+ t.Fatal("turn-result persistence failure was ignored")
776+ }
777+ result := a.Result()
778+ if result == nil || result.Status != ResultFailed || result.Error == nil || result.Error.Code != "result_persistence_failed" {
779+ t.Fatalf("in-memory result = %#v, want explicit persistence failure", result)
780+ }
781+ if result.Output != "review complete" {
782+ t.Fatalf("failure output = %q, want preserved partial output", result.Output)
783+ }
784+}
785+
757786 func TestRestoreResumePromptPreservesCommandIdentity(t *testing.T) {
758787 a := &Agent{}
759788 a.setResumePrompt("old prompt", time.Now())
@@ -814,12 +843,14 @@ func TestRecordWorkerTraceNormalizesSafeLiveObservations(t *testing.T) {
814843 recordWorkerTrace(agent, Event{Type: EventTurnStarted, TurnID: "turn-1", Data: map[string]any{"nested_turn": true}})
815844 recordWorkerTrace(agent, Event{Type: "turn_end", TurnID: "turn-1", Data: map[string]any{"nested_turn": true}})
816845 recordWorkerTrace(agent, Event{Type: "tool_call", TurnID: "turn-1", Data: map[string]any{"id": "call-1", "name": "bash", "args": "sensitive arguments"}})
846+ recordWorkerTrace(agent, Event{Type: "tool_execution_started", TurnID: "turn-1", Data: map[string]any{"id": "call-1", "name": "bash"}})
847+ recordWorkerTrace(agent, Event{Type: "tool_use_args", TurnID: "turn-1", Data: map[string]any{"id": "call-1", "delta": "sensitive argument fragment"}})
817848 recordWorkerTrace(agent, Event{Type: "future_event", TurnID: "turn-1", Data: map[string]any{"secret": "hidden"}})
818849 if err := trace.Flush(); err != nil {
819850 t.Fatal(err)
820851 }
821852 events := trace.Events()
822- if len(events) != 6 {
853+ if len(events) != 8 {
823854 t.Fatalf("events = %#v", events)
824855 }
825856 if got, want := events[0].Type, "assistant.stream.observed"; got != want {
@@ -837,23 +868,28 @@ func TestRecordWorkerTraceNormalizesSafeLiveObservations(t *testing.T) {
837868 if got, want := events[3].Type, "provider.request.finished"; got != want {
838869 t.Fatalf("type = %q, want %q", got, want)
839870 }
840- if got, want := events[4].Type, "tool.started"; got != want {
871+ if got, want := events[4].Type, "worker.protocol.observed"; got != want {
872+ t.Fatalf("tool_call type = %q, want %q", got, want)
873+ }
874+ if got, want := events[5].Type, "tool.started"; got != want {
841875 t.Fatalf("type = %q, want %q", got, want)
842876 }
843- if got, _ := events[4].Data["call_id"].(string); got != "call-1" {
877+ if got, _ := events[5].Data["call_id"].(string); got != "call-1" {
844878 t.Fatalf("tool call_id = %q", got)
845879 }
846- if _, found := events[4].Data["args"]; found {
847- t.Fatalf("tool arguments leaked: %#v", events[2].Data)
848- }
849- if got, want := events[5].Type, "worker.protocol.observed"; got != want {
850- t.Fatalf("type = %q, want %q", got, want)
851- }
852- if got, _ := events[5].Data["source_event"].(string); got != "future.event" {
853- t.Fatalf("source_event = %q", got)
880+ if _, found := events[5].Data["args"]; found {
881+ t.Fatalf("tool arguments leaked: %#v", events[5].Data)
854882 }
855- if _, found := events[5].Data["secret"]; found {
856- t.Fatalf("worker payload leaked: %#v", events[3].Data)
883+ for _, index := range []int{6, 7} {
884+ if got, want := events[index].Type, "worker.protocol.observed"; got != want {
885+ t.Fatalf("events[%d].type = %q, want %q", index, got, want)
886+ }
887+ if _, found := events[index].Data["delta"]; found {
888+ t.Fatalf("worker payload leaked: %#v", events[index].Data)
889+ }
890+ if _, found := events[index].Data["secret"]; found {
891+ t.Fatalf("worker payload leaked: %#v", events[index].Data)
892+ }
857893 }
858894 view := ProjectTrace(append([]TraceEvent{{Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"}}, events...))["agent-1"]
859895 if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "tool.started" || view.PrimaryOperation.Name != "bash" {
@@ -360,6 +360,13 @@ func TestDefaultChildArgsPropagatesWebSearchPolicy(t *testing.T) {
360 }360 }
361 }361 }
362 362
363+func TestDefaultChildArgsPropagatesModelStepBudget(t *testing.T) {
364+ args := defaultChildArgs("zut", &Agent{Task: "task", MaxSteps: 128}, "/session", "/inbox")
365+ if i := indexOf(args, "--max-steps"); i < 0 || safeAt(args, i+1) != "128" {
366+ t.Fatalf("argv = %v, want --max-steps 128", args)
367+ }
368+}
369+
363 func TestResolveSubagentExecutableUsesCurrentWhenAvailable(t *testing.T) {370 func TestResolveSubagentExecutableUsesCurrentWhenAvailable(t *testing.T) {
364 got, err := resolveSubagentExecutable(context.Background(), "/current/bin/zut", "/old/bin/zut", func(_ context.Context, candidate string) (string, error) {371 got, err := resolveSubagentExecutable(context.Background(), "/current/bin/zut", "/old/bin/zut", func(_ context.Context, candidate string) (string, error) {
365 if candidate == "/current/bin/zut" {372 if candidate == "/current/bin/zut" {
@@ -754,6 +761,28 @@ func TestRecordPersistenceErrorPreservesFirstFailure(t *testing.T) {
754 }761 }
755 }762 }
756 763
764+func TestUpdateAgentFromEventSurfacesTurnResultPersistenceFailure(t *testing.T) {
765+ blockedStateDir := filepath.Join(t.TempDir(), "not-a-directory")
766+ if err := os.WriteFile(blockedStateDir, []byte("blocked"), 0o600); err != nil {
767+ t.Fatal(err)
768+ }
769+ a := &Agent{ID: "agent-1", stateDir: blockedStateDir}
770+ err := updateAgentFromEvent(a, Event{Type: EventTurnResult, TurnID: "turn-1", Data: map[string]any{
771+ "status": "succeeded",
772+ "output": "review complete",
773+ }})
774+ if err == nil {
775+ t.Fatal("turn-result persistence failure was ignored")
776+ }
777+ result := a.Result()
778+ if result == nil || result.Status != ResultFailed || result.Error == nil || result.Error.Code != "result_persistence_failed" {
779+ t.Fatalf("in-memory result = %#v, want explicit persistence failure", result)
780+ }
781+ if result.Output != "review complete" {
782+ t.Fatalf("failure output = %q, want preserved partial output", result.Output)
783+ }
784+}
785+
757 func TestRestoreResumePromptPreservesCommandIdentity(t *testing.T) {786 func TestRestoreResumePromptPreservesCommandIdentity(t *testing.T) {
758 a := &Agent{}787 a := &Agent{}
759 a.setResumePrompt("old prompt", time.Now())788 a.setResumePrompt("old prompt", time.Now())
@@ -814,12 +843,14 @@ func TestRecordWorkerTraceNormalizesSafeLiveObservations(t *testing.T) {
814 recordWorkerTrace(agent, Event{Type: EventTurnStarted, TurnID: "turn-1", Data: map[string]any{"nested_turn": true}})843 recordWorkerTrace(agent, Event{Type: EventTurnStarted, TurnID: "turn-1", Data: map[string]any{"nested_turn": true}})
815 recordWorkerTrace(agent, Event{Type: "turn_end", TurnID: "turn-1", Data: map[string]any{"nested_turn": true}})844 recordWorkerTrace(agent, Event{Type: "turn_end", TurnID: "turn-1", Data: map[string]any{"nested_turn": true}})
816 recordWorkerTrace(agent, Event{Type: "tool_call", TurnID: "turn-1", Data: map[string]any{"id": "call-1", "name": "bash", "args": "sensitive arguments"}})845 recordWorkerTrace(agent, Event{Type: "tool_call", TurnID: "turn-1", Data: map[string]any{"id": "call-1", "name": "bash", "args": "sensitive arguments"}})
846+ recordWorkerTrace(agent, Event{Type: "tool_execution_started", TurnID: "turn-1", Data: map[string]any{"id": "call-1", "name": "bash"}})
847+ recordWorkerTrace(agent, Event{Type: "tool_use_args", TurnID: "turn-1", Data: map[string]any{"id": "call-1", "delta": "sensitive argument fragment"}})
817 recordWorkerTrace(agent, Event{Type: "future_event", TurnID: "turn-1", Data: map[string]any{"secret": "hidden"}})848 recordWorkerTrace(agent, Event{Type: "future_event", TurnID: "turn-1", Data: map[string]any{"secret": "hidden"}})
818 if err := trace.Flush(); err != nil {849 if err := trace.Flush(); err != nil {
819 t.Fatal(err)850 t.Fatal(err)
820 }851 }
821 events := trace.Events()852 events := trace.Events()
822- if len(events) != 6 {853+ if len(events) != 8 {
823 t.Fatalf("events = %#v", events)854 t.Fatalf("events = %#v", events)
824 }855 }
825 if got, want := events[0].Type, "assistant.stream.observed"; got != want {856 if got, want := events[0].Type, "assistant.stream.observed"; got != want {
@@ -837,23 +868,28 @@ func TestRecordWorkerTraceNormalizesSafeLiveObservations(t *testing.T) {
837 if got, want := events[3].Type, "provider.request.finished"; got != want {868 if got, want := events[3].Type, "provider.request.finished"; got != want {
838 t.Fatalf("type = %q, want %q", got, want)869 t.Fatalf("type = %q, want %q", got, want)
839 }870 }
840- if got, want := events[4].Type, "tool.started"; got != want {871+ if got, want := events[4].Type, "worker.protocol.observed"; got != want {
872+ t.Fatalf("tool_call type = %q, want %q", got, want)
873+ }
874+ if got, want := events[5].Type, "tool.started"; got != want {
841 t.Fatalf("type = %q, want %q", got, want)875 t.Fatalf("type = %q, want %q", got, want)
842 }876 }
843- if got, _ := events[4].Data["call_id"].(string); got != "call-1" {877+ if got, _ := events[5].Data["call_id"].(string); got != "call-1" {
844 t.Fatalf("tool call_id = %q", got)878 t.Fatalf("tool call_id = %q", got)
845 }879 }
846- if _, found := events[4].Data["args"]; found {880+ if _, found := events[5].Data["args"]; found {
847- t.Fatalf("tool arguments leaked: %#v", events[2].Data)881+ t.Fatalf("tool arguments leaked: %#v", events[5].Data)
848- }
849- if got, want := events[5].Type, "worker.protocol.observed"; got != want {
850- t.Fatalf("type = %q, want %q", got, want)
851- }
852- if got, _ := events[5].Data["source_event"].(string); got != "future.event" {
853- t.Fatalf("source_event = %q", got)
854 }882 }
855- if _, found := events[5].Data["secret"]; found {883+ for _, index := range []int{6, 7} {
856- t.Fatalf("worker payload leaked: %#v", events[3].Data)884+ if got, want := events[index].Type, "worker.protocol.observed"; got != want {
885+ t.Fatalf("events[%d].type = %q, want %q", index, got, want)
886+ }
887+ if _, found := events[index].Data["delta"]; found {
888+ t.Fatalf("worker payload leaked: %#v", events[index].Data)
889+ }
890+ if _, found := events[index].Data["secret"]; found {
891+ t.Fatalf("worker payload leaked: %#v", events[index].Data)
892+ }
857 }893 }
858 view := ProjectTrace(append([]TraceEvent{{Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"}}, events...))["agent-1"]894 view := ProjectTrace(append([]TraceEvent{{Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"}}, events...))["agent-1"]
859 if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "tool.started" || view.PrimaryOperation.Name != "bash" {895 if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "tool.started" || view.PrimaryOperation.Name != "bash" {
M packages/agent/subagents/supervisor.go +1 -1
@@ -540,6 +540,7 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
540540 WorkspaceBase: strings.TrimSpace(req.WorkspaceBase),
541541 WorkspaceCapture: req.WorkspaceCapture,
542542 MaxTurns: maxTurns,
543+ MaxSteps: f.cfg.Policy.MaxSteps,
543544 Timeout: timeout,
544545 HeartbeatInterval: f.cfg.Policy.HeartbeatInterval,
545546 Tools: tools,
@@ -561,7 +562,6 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
561562 maxOutputBytes: f.cfg.Policy.MaxOutputBytes,
562563 maxOutputLines: f.cfg.Policy.MaxOutputLines,
563564 done: make(chan struct{}),
564- turnResults: make(chan *TurnResult, 16),
565565 }
566566 a.fastModeOverridesHost = fastModeOverridesHost
567567 if a.RootSessionID == "" {
@@ -540,6 +540,7 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
540 WorkspaceBase: strings.TrimSpace(req.WorkspaceBase),540 WorkspaceBase: strings.TrimSpace(req.WorkspaceBase),
541 WorkspaceCapture: req.WorkspaceCapture,541 WorkspaceCapture: req.WorkspaceCapture,
542 MaxTurns: maxTurns,542 MaxTurns: maxTurns,
543+ MaxSteps: f.cfg.Policy.MaxSteps,
543 Timeout: timeout,544 Timeout: timeout,
544 HeartbeatInterval: f.cfg.Policy.HeartbeatInterval,545 HeartbeatInterval: f.cfg.Policy.HeartbeatInterval,
545 Tools: tools,546 Tools: tools,
@@ -561,7 +562,6 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
561 maxOutputBytes: f.cfg.Policy.MaxOutputBytes,562 maxOutputBytes: f.cfg.Policy.MaxOutputBytes,
562 maxOutputLines: f.cfg.Policy.MaxOutputLines,563 maxOutputLines: f.cfg.Policy.MaxOutputLines,
563 done: make(chan struct{}),564 done: make(chan struct{}),
564- turnResults: make(chan *TurnResult, 16),
565 }565 }
566 a.fastModeOverridesHost = fastModeOverridesHost566 a.fastModeOverridesHost = fastModeOverridesHost
567 if a.RootSessionID == "" {567 if a.RootSessionID == "" {
M packages/agent/subagents/supervisor_test.go +28 -0
@@ -72,6 +72,34 @@ func TestSpawnTimeoutDoesNotLimitResumableWorkerLifetime(t *testing.T) {
7272 a.Wait()
7373 }
7474
75+func TestSpawnWithoutTimeoutUsesUnlimitedTurns(t *testing.T) {
76+ f := newTestSupervisor(t, func(*Agent) Runner {
77+ return RunnerFunc(func(context.Context, Sink) error { return nil })
78+ })
79+ a, err := f.Spawn(context.Background(), "unlimited turn")
80+ if err != nil {
81+ t.Fatal(err)
82+ }
83+ if a.Timeout != 0 {
84+ t.Fatalf("turn timeout = %s, want unlimited", a.Timeout)
85+ }
86+ a.Wait()
87+}
88+
89+func TestSpawnUsesDefaultModelStepBudget(t *testing.T) {
90+ f := newTestSupervisor(t, func(*Agent) Runner {
91+ return RunnerFunc(func(context.Context, Sink) error { return nil })
92+ })
93+ a, err := f.Spawn(context.Background(), "bounded model loop")
94+ if err != nil {
95+ t.Fatal(err)
96+ }
97+ if a.MaxSteps != 128 {
98+ t.Fatalf("max steps = %d, want 128", a.MaxSteps)
99+ }
100+ a.Wait()
101+}
102+
75103 func TestSpawnRunsAndCompletes(t *testing.T) {
76104 ran := make(chan string, 1)
77105 f := newTestSupervisor(t, func(a *Agent) Runner {
@@ -72,6 +72,34 @@ func TestSpawnTimeoutDoesNotLimitResumableWorkerLifetime(t *testing.T) {
72 a.Wait()72 a.Wait()
73 }73 }
74 74
75+func TestSpawnWithoutTimeoutUsesUnlimitedTurns(t *testing.T) {
76+ f := newTestSupervisor(t, func(*Agent) Runner {
77+ return RunnerFunc(func(context.Context, Sink) error { return nil })
78+ })
79+ a, err := f.Spawn(context.Background(), "unlimited turn")
80+ if err != nil {
81+ t.Fatal(err)
82+ }
83+ if a.Timeout != 0 {
84+ t.Fatalf("turn timeout = %s, want unlimited", a.Timeout)
85+ }
86+ a.Wait()
87+}
88+
89+func TestSpawnUsesDefaultModelStepBudget(t *testing.T) {
90+ f := newTestSupervisor(t, func(*Agent) Runner {
91+ return RunnerFunc(func(context.Context, Sink) error { return nil })
92+ })
93+ a, err := f.Spawn(context.Background(), "bounded model loop")
94+ if err != nil {
95+ t.Fatal(err)
96+ }
97+ if a.MaxSteps != 128 {
98+ t.Fatalf("max steps = %d, want 128", a.MaxSteps)
99+ }
100+ a.Wait()
101+}
102+
75 func TestSpawnRunsAndCompletes(t *testing.T) {103 func TestSpawnRunsAndCompletes(t *testing.T) {
76 ran := make(chan string, 1)104 ran := make(chan string, 1)
77 f := newTestSupervisor(t, func(a *Agent) Runner {105 f := newTestSupervisor(t, func(a *Agent) Runner {
M packages/agent/tools/subagent_spawn.go +10 -17
@@ -5,7 +5,6 @@ import (
55 "encoding/json"
66 "fmt"
77 "strings"
8- "time"
98
109 "github.com/bnema/zut/packages/agent/subagents"
1110 "github.com/bnema/zut/packages/core"
@@ -62,8 +61,10 @@ type subagentSpawnArgs struct {
6261 FastMode *bool `json:"fast_mode,omitempty"`
6362 Required bool `json:"required,omitempty"`
6463 Isolation string `json:"isolation,omitempty"`
65- Timeout string `json:"timeout,omitempty"`
66- MaxTurns *int `json:"max_turns,omitempty"`
64+ // Timeout remains decode-only for calls produced from an older schema.
65+ // Deadline policy is manager-owned, so Execute deliberately ignores it.
66+ Timeout string `json:"timeout,omitempty"`
67+ MaxTurns *int `json:"max_turns,omitempty"`
6768 }
6869
6970 const subagentSpawnSchemaTemplate = `{
@@ -103,10 +104,6 @@ const subagentSpawnSchemaTemplate = `{
103104 "enum": ["shared", "worktree"],
104105 "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it."
105106 },
106- "timeout": {
107- "type": "string",
108- "description": "Optional Go duration such as 20m."
109- },
110107 "max_turns": {
111108 "type": "integer",
112109 "minimum": 1,
@@ -155,14 +152,6 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
155152 return protocolToolError(prefix + ": isolation must be shared or worktree")
156153 }
157154 }
158- var timeout time.Duration
159- if value := strings.TrimSpace(a.Timeout); value != "" {
160- parsed, parseErr := time.ParseDuration(value)
161- if parseErr != nil || parsed <= 0 {
162- return protocolToolError(prefix + ": timeout must be a positive duration")
163- }
164- timeout = parsed
165- }
166155 if a.MaxTurns != nil {
167156 if *a.MaxTurns < 1 {
168157 return protocolToolError(prefix + ": max_turns must be positive")
@@ -254,7 +243,6 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
254243 FastMode: fastModeOverride,
255244 Subagent: agentName,
256245 Required: a.Required,
257- Timeout: timeout,
258246 MaxTurns: maxTurns,
259247 WorkspaceMode: workspaceMode,
260248 Tools: profileTools,
@@ -294,6 +282,10 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
294282 sb.WriteString("\nThe sub-agent is running in the background. Completion is host-event-driven through [auto-subagents update], the only completion signal; the manager remains free for independent work. ")
295283 sb.WriteString("Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. ")
296284 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.")
285+ timeoutDetail := "unlimited"
286+ if agent.Timeout > 0 {
287+ timeoutDetail = agent.Timeout.String()
288+ }
297289 details := map[string]any{
298290 "agent_id": agent.ID,
299291 "task": task,
@@ -304,8 +296,9 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
304296 "fast_mode": agent.FastMode,
305297 "required": a.Required,
306298 "isolation": string(workspaceMode),
307- "timeout": agent.Timeout.String(),
299+ "timeout": timeoutDetail,
308300 "max_turns": agent.MaxTurns,
301+ "max_steps": agent.MaxSteps,
309302 "state": agent.Status(),
310303 "process_state": string(agent.ProcessState()),
311304 "turn_state": string(agent.TurnState()),
@@ -5,7 +5,6 @@ import (
5 "encoding/json"5 "encoding/json"
6 "fmt"6 "fmt"
7 "strings"7 "strings"
8- "time"
9 8
10 "github.com/bnema/zut/packages/agent/subagents"9 "github.com/bnema/zut/packages/agent/subagents"
11 "github.com/bnema/zut/packages/core"10 "github.com/bnema/zut/packages/core"
@@ -62,8 +61,10 @@ type subagentSpawnArgs struct {
62 FastMode *bool `json:"fast_mode,omitempty"`61 FastMode *bool `json:"fast_mode,omitempty"`
63 Required bool `json:"required,omitempty"`62 Required bool `json:"required,omitempty"`
64 Isolation string `json:"isolation,omitempty"`63 Isolation string `json:"isolation,omitempty"`
65- Timeout string `json:"timeout,omitempty"`64+ // Timeout remains decode-only for calls produced from an older schema.
66- MaxTurns *int `json:"max_turns,omitempty"`65+ // Deadline policy is manager-owned, so Execute deliberately ignores it.
66+ Timeout string `json:"timeout,omitempty"`
67+ MaxTurns *int `json:"max_turns,omitempty"`
67 }68 }
68 69
69 const subagentSpawnSchemaTemplate = `{70 const subagentSpawnSchemaTemplate = `{
@@ -103,10 +104,6 @@ const subagentSpawnSchemaTemplate = `{
103 "enum": ["shared", "worktree"],104 "enum": ["shared", "worktree"],
104 "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it."105 "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it."
105 },106 },
106- "timeout": {
107- "type": "string",
108- "description": "Optional Go duration such as 20m."
109- },
110 "max_turns": {107 "max_turns": {
111 "type": "integer",108 "type": "integer",
112 "minimum": 1,109 "minimum": 1,
@@ -155,14 +152,6 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
155 return protocolToolError(prefix + ": isolation must be shared or worktree")152 return protocolToolError(prefix + ": isolation must be shared or worktree")
156 }153 }
157 }154 }
158- var timeout time.Duration
159- if value := strings.TrimSpace(a.Timeout); value != "" {
160- parsed, parseErr := time.ParseDuration(value)
161- if parseErr != nil || parsed <= 0 {
162- return protocolToolError(prefix + ": timeout must be a positive duration")
163- }
164- timeout = parsed
165- }
166 if a.MaxTurns != nil {155 if a.MaxTurns != nil {
167 if *a.MaxTurns < 1 {156 if *a.MaxTurns < 1 {
168 return protocolToolError(prefix + ": max_turns must be positive")157 return protocolToolError(prefix + ": max_turns must be positive")
@@ -254,7 +243,6 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
254 FastMode: fastModeOverride,243 FastMode: fastModeOverride,
255 Subagent: agentName,244 Subagent: agentName,
256 Required: a.Required,245 Required: a.Required,
257- Timeout: timeout,
258 MaxTurns: maxTurns,246 MaxTurns: maxTurns,
259 WorkspaceMode: workspaceMode,247 WorkspaceMode: workspaceMode,
260 Tools: profileTools,248 Tools: profileTools,
@@ -294,6 +282,10 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
294 sb.WriteString("\nThe sub-agent is running in the background. Completion is host-event-driven through [auto-subagents update], the only completion signal; the manager remains free for independent work. ")282 sb.WriteString("\nThe sub-agent is running in the background. Completion is host-event-driven through [auto-subagents update], the only completion signal; the manager remains free for independent work. ")
295 sb.WriteString("Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. ")283 sb.WriteString("Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. ")
296 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.")284 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.")
285+ timeoutDetail := "unlimited"
286+ if agent.Timeout > 0 {
287+ timeoutDetail = agent.Timeout.String()
288+ }
297 details := map[string]any{289 details := map[string]any{
298 "agent_id": agent.ID,290 "agent_id": agent.ID,
299 "task": task,291 "task": task,
@@ -304,8 +296,9 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
304 "fast_mode": agent.FastMode,296 "fast_mode": agent.FastMode,
305 "required": a.Required,297 "required": a.Required,
306 "isolation": string(workspaceMode),298 "isolation": string(workspaceMode),
307- "timeout": agent.Timeout.String(),299+ "timeout": timeoutDetail,
308 "max_turns": agent.MaxTurns,300 "max_turns": agent.MaxTurns,
301+ "max_steps": agent.MaxSteps,
309 "state": agent.Status(),302 "state": agent.Status(),
310 "process_state": string(agent.ProcessState()),303 "process_state": string(agent.ProcessState()),
311 "turn_state": string(agent.TurnState()),304 "turn_state": string(agent.TurnState()),
M packages/agent/tools/subagent_spawn_test.go +29 -0
@@ -159,6 +159,12 @@ func TestSubagentSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) {
159159 if got := details["reasoning"]; got != "medium" {
160160 t.Fatalf("reasoning detail = %v, want medium", got)
161161 }
162+ if got := details["timeout"]; got != "unlimited" {
163+ t.Fatalf("timeout detail = %v, want unlimited", got)
164+ }
165+ if got := details["max_steps"]; got != 128 {
166+ t.Fatalf("max_steps detail = %v, want 128", got)
167+ }
162168 text := textResult(res.Content)
163169 if !strings.Contains(text, "model: gpt-5") || !strings.Contains(text, "provider: openai-codex") || !strings.Contains(text, "reasoning: medium") {
164170 t.Fatalf("result text missing inherited model/provider:\n%s", text)
@@ -184,6 +190,7 @@ func TestSubagentSpawnDetailsUseEffectiveTimeoutAndTurns(t *testing.T) {
184190 Policy: subagents.SubagentPolicy{
185191 DefaultTimeout: 37 * time.Minute,
186192 MaxTurns: 7,
193+ MaxSteps: 99,
187194 },
188195 NewRunner: func(*subagents.Agent) subagents.Runner {
189196 return noopSupervisorRunner{}
@@ -212,6 +219,25 @@ func TestSubagentSpawnDetailsUseEffectiveTimeoutAndTurns(t *testing.T) {
212219 if got := details["max_turns"]; got != 7 {
213220 t.Fatalf("max_turns detail = %v, want 7", got)
214221 }
222+ if got := details["max_steps"]; got != 99 {
223+ t.Fatalf("max_steps detail = %v, want 99", got)
224+ }
225+}
226+
227+func TestSubagentSpawnIgnoresLegacyModelTimeoutOverride(t *testing.T) {
228+ manager := newTestSupervisor(t)
229+ tool := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }}
230+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs","timeout":"20m"}`), nil)
231+ if err != nil {
232+ t.Fatal(err)
233+ }
234+ if res.IsError {
235+ t.Fatalf("unexpected tool error: %s", textResult(res.Content))
236+ }
237+ agents := manager.List()
238+ if len(agents) != 1 || agents[0].Timeout != 0 {
239+ t.Fatalf("legacy model timeout changed manager policy: %#v", agents)
240+ }
215241 }
216242
217243 func TestSubagentSpawnRejectsExplicitZeroMaxTurns(t *testing.T) {
@@ -366,6 +392,9 @@ func TestSubagentSpawnSchemaUsesReasoningAndKeepsProfileThinkingInternal(t *test
366392 if _, ok := schema.Properties["thinking"]; ok {
367393 t.Fatal("subagent_spawn schema exposes profile-only thinking")
368394 }
395+ if _, ok := schema.Properties["timeout"]; ok {
396+ t.Fatal("subagent_spawn schema exposes manager-owned turn timeout")
397+ }
369398 fastMode, ok := schema.Properties["fast_mode"]
370399 if !ok {
371400 t.Fatal("subagent_spawn schema does not advertise fast_mode")
@@ -159,6 +159,12 @@ func TestSubagentSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) {
159 if got := details["reasoning"]; got != "medium" {159 if got := details["reasoning"]; got != "medium" {
160 t.Fatalf("reasoning detail = %v, want medium", got)160 t.Fatalf("reasoning detail = %v, want medium", got)
161 }161 }
162+ if got := details["timeout"]; got != "unlimited" {
163+ t.Fatalf("timeout detail = %v, want unlimited", got)
164+ }
165+ if got := details["max_steps"]; got != 128 {
166+ t.Fatalf("max_steps detail = %v, want 128", got)
167+ }
162 text := textResult(res.Content)168 text := textResult(res.Content)
163 if !strings.Contains(text, "model: gpt-5") || !strings.Contains(text, "provider: openai-codex") || !strings.Contains(text, "reasoning: medium") {169 if !strings.Contains(text, "model: gpt-5") || !strings.Contains(text, "provider: openai-codex") || !strings.Contains(text, "reasoning: medium") {
164 t.Fatalf("result text missing inherited model/provider:\n%s", text)170 t.Fatalf("result text missing inherited model/provider:\n%s", text)
@@ -184,6 +190,7 @@ func TestSubagentSpawnDetailsUseEffectiveTimeoutAndTurns(t *testing.T) {
184 Policy: subagents.SubagentPolicy{190 Policy: subagents.SubagentPolicy{
185 DefaultTimeout: 37 * time.Minute,191 DefaultTimeout: 37 * time.Minute,
186 MaxTurns: 7,192 MaxTurns: 7,
193+ MaxSteps: 99,
187 },194 },
188 NewRunner: func(*subagents.Agent) subagents.Runner {195 NewRunner: func(*subagents.Agent) subagents.Runner {
189 return noopSupervisorRunner{}196 return noopSupervisorRunner{}
@@ -212,6 +219,25 @@ func TestSubagentSpawnDetailsUseEffectiveTimeoutAndTurns(t *testing.T) {
212 if got := details["max_turns"]; got != 7 {219 if got := details["max_turns"]; got != 7 {
213 t.Fatalf("max_turns detail = %v, want 7", got)220 t.Fatalf("max_turns detail = %v, want 7", got)
214 }221 }
222+ if got := details["max_steps"]; got != 99 {
223+ t.Fatalf("max_steps detail = %v, want 99", got)
224+ }
225+}
226+
227+func TestSubagentSpawnIgnoresLegacyModelTimeoutOverride(t *testing.T) {
228+ manager := newTestSupervisor(t)
229+ tool := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }}
230+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs","timeout":"20m"}`), nil)
231+ if err != nil {
232+ t.Fatal(err)
233+ }
234+ if res.IsError {
235+ t.Fatalf("unexpected tool error: %s", textResult(res.Content))
236+ }
237+ agents := manager.List()
238+ if len(agents) != 1 || agents[0].Timeout != 0 {
239+ t.Fatalf("legacy model timeout changed manager policy: %#v", agents)
240+ }
215 }241 }
216 242
217 func TestSubagentSpawnRejectsExplicitZeroMaxTurns(t *testing.T) {243 func TestSubagentSpawnRejectsExplicitZeroMaxTurns(t *testing.T) {
@@ -366,6 +392,9 @@ func TestSubagentSpawnSchemaUsesReasoningAndKeepsProfileThinkingInternal(t *test
366 if _, ok := schema.Properties["thinking"]; ok {392 if _, ok := schema.Properties["thinking"]; ok {
367 t.Fatal("subagent_spawn schema exposes profile-only thinking")393 t.Fatal("subagent_spawn schema exposes profile-only thinking")
368 }394 }
395+ if _, ok := schema.Properties["timeout"]; ok {
396+ t.Fatal("subagent_spawn schema exposes manager-owned turn timeout")
397+ }
369 fastMode, ok := schema.Properties["fast_mode"]398 fastMode, ok := schema.Properties["fast_mode"]
370 if !ok {399 if !ok {
371 t.Fatal("subagent_spawn schema does not advertise fast_mode")400 t.Fatal("subagent_spawn schema does not advertise fast_mode")
M packages/core/agent.go +5 -2
@@ -13,7 +13,10 @@ import (
1313 "github.com/bnema/zut/packages/provider"
1414 )
1515
16-var ErrStreamIdleTimeout = errors.New("provider stream idle timeout")
16+var (
17+ ErrStreamIdleTimeout = errors.New("provider stream idle timeout")
18+ ErrMaxSteps = errors.New("agent model step limit exceeded")
19+)
1720
1821 type queuedMessage struct {
1922 text string
@@ -651,7 +654,7 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error {
651654 }
652655 if a.MaxSteps > 0 {
653656 sink(EvDone{})
654- return fmt.Errorf("max steps (%d) exceeded", a.MaxSteps)
657+ return fmt.Errorf("%w: %d", ErrMaxSteps, a.MaxSteps)
655658 }
656659 return nil
657660 }
@@ -13,7 +13,10 @@ import (
13 "github.com/bnema/zut/packages/provider"13 "github.com/bnema/zut/packages/provider"
14 )14 )
15 15
16-var ErrStreamIdleTimeout = errors.New("provider stream idle timeout")16+var (
17+ ErrStreamIdleTimeout = errors.New("provider stream idle timeout")
18+ ErrMaxSteps = errors.New("agent model step limit exceeded")
19+)
17 20
18 type queuedMessage struct {21 type queuedMessage struct {
19 text string22 text string
@@ -651,7 +654,7 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error {
651 }654 }
652 if a.MaxSteps > 0 {655 if a.MaxSteps > 0 {
653 sink(EvDone{})656 sink(EvDone{})
654- return fmt.Errorf("max steps (%d) exceeded", a.MaxSteps)657+ return fmt.Errorf("%w: %d", ErrMaxSteps, a.MaxSteps)
655 }658 }
656 return nil659 return nil
657 }660 }
A packages/core/agent_step_limit_test.go +49 -0
@@ -0,0 +1,49 @@
1+package core
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "sync/atomic"
9+ "testing"
10+
11+ "github.com/bnema/zut/packages/provider"
12+)
13+
14+type loopingToolClient struct {
15+ calls int32
16+}
17+
18+func (c *loopingToolClient) Name() string { return "looping-tool" }
19+
20+func (c *loopingToolClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
21+ call := atomic.AddInt32(&c.calls, 1)
22+ callID := fmt.Sprintf("call-%d", call)
23+ out := make(chan provider.Event, 4)
24+ out <- provider.EventStart{Provider: c.Name(), Model: req.Model}
25+ out <- provider.EventToolStart{ID: callID, Name: "echo"}
26+ out <- provider.EventToolEnd{ID: callID}
27+ out <- provider.EventDone{Stop: provider.StopToolUse, Message: provider.Message{
28+ Role: provider.RoleAssistant,
29+ Content: []provider.Content{provider.ToolCallBlock{
30+ ID: callID, Name: "echo", Arguments: json.RawMessage(`{}`),
31+ }},
32+ }}
33+ close(out)
34+ return out, nil
35+}
36+
37+func TestAgentReturnsTypedStepLimitAfterModelLoopBudget(t *testing.T) {
38+ client := &loopingToolClient{}
39+ agent := NewAgent(client, "model", "system", Registry{"echo": &recordingTool{}})
40+ agent.MaxSteps = 2
41+
42+ err := agent.Prompt(context.Background(), "keep using tools", nil, nil)
43+ if !errors.Is(err, ErrMaxSteps) {
44+ t.Fatalf("prompt error = %v, want ErrMaxSteps", err)
45+ }
46+ if got := atomic.LoadInt32(&client.calls); got != 2 {
47+ t.Fatalf("provider calls = %d, want 2", got)
48+ }
49+}
@@ -0,0 +1,49 @@
1+package core
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "sync/atomic"
9+ "testing"
10+
11+ "github.com/bnema/zut/packages/provider"
12+)
13+
14+type loopingToolClient struct {
15+ calls int32
16+}
17+
18+func (c *loopingToolClient) Name() string { return "looping-tool" }
19+
20+func (c *loopingToolClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
21+ call := atomic.AddInt32(&c.calls, 1)
22+ callID := fmt.Sprintf("call-%d", call)
23+ out := make(chan provider.Event, 4)
24+ out <- provider.EventStart{Provider: c.Name(), Model: req.Model}
25+ out <- provider.EventToolStart{ID: callID, Name: "echo"}
26+ out <- provider.EventToolEnd{ID: callID}
27+ out <- provider.EventDone{Stop: provider.StopToolUse, Message: provider.Message{
28+ Role: provider.RoleAssistant,
29+ Content: []provider.Content{provider.ToolCallBlock{
30+ ID: callID, Name: "echo", Arguments: json.RawMessage(`{}`),
31+ }},
32+ }}
33+ close(out)
34+ return out, nil
35+}
36+
37+func TestAgentReturnsTypedStepLimitAfterModelLoopBudget(t *testing.T) {
38+ client := &loopingToolClient{}
39+ agent := NewAgent(client, "model", "system", Registry{"echo": &recordingTool{}})
40+ agent.MaxSteps = 2
41+
42+ err := agent.Prompt(context.Background(), "keep using tools", nil, nil)
43+ if !errors.Is(err, ErrMaxSteps) {
44+ t.Fatalf("prompt error = %v, want ErrMaxSteps", err)
45+ }
46+ if got := atomic.LoadInt32(&client.calls); got != 2 {
47+ t.Fatalf("provider calls = %d, want 2", got)
48+ }
49+}

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close