refactor(subagents): separate turn limits from observation
- commit
- 2ae56a3920706ce42f0488512b42ad74c7360e44
- author
- brice · · 2026-08-12 16:44 UTC
- parent
- 4ed3d6d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 425 | 425 | ||
| 426 | 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 | 430 | ### Named subagent profiles | |
| 431 | 431 | ||
| @@ -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 profiles | 430 | ### 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 | |||
| 139 | 139 | ||
| 140 | 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 | 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 | 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 | 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 | 179 | ## 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. | |
| @@ -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 policy | 179 | ## 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 { | |||
| 31 | 31 | StartupTimeout string `json:"startup_timeout,omitempty"` | |
| 32 | 32 | DefaultTimeout string `json:"default_timeout,omitempty"` | |
| 33 | 33 | MaxTurns int `json:"max_turns,omitempty"` | |
| 34 | + | MaxSteps int `json:"max_steps,omitempty"` | |
| 34 | 35 | MaxOutputBytes int `json:"max_output_bytes,omitempty"` | |
| 35 | 36 | MaxOutputLines int `json:"max_output_lines,omitempty"` | |
| 36 | 37 | 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 [] | |||
| 24 | 24 | if operation == nil { | |
| 25 | 25 | 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 | 28 | if line != "" { | |
| 29 | 29 | lines = append(lines, line) | |
| 30 | 30 | } | |
| @@ -32,7 +32,7 @@ func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots [] | |||
| 32 | 32 | 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 | 36 | name := sanitizeSubagentIndicatorText(snapshot.Subagent) | |
| 37 | 37 | if name == "" { | |
| 38 | 38 | name = sanitizeSubagentIndicatorText(snapshot.ID) | |
| @@ -48,7 +48,13 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba | |||
| 48 | 48 | if observation != nil { | |
| 49 | 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 | 58 | plain, layout := fitSubagentActivityLine(spinnerGlyph, name, activity, age, width-2) | |
| 53 | 59 | if plain == "" { | |
| 54 | 60 | return "" | |
| @@ -63,7 +69,7 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba | |||
| 63 | 69 | name = strings.TrimPrefix(parts[0], spinnerGlyph+" ") | |
| 64 | 70 | return " " + th.FGColor(th.Spinner, spinnerGlyph) + " " + | |
| 65 | 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 | 73 | case subagentActivityNameAge: | |
| 68 | 74 | parts := strings.SplitN(plain, " · ", 2) | |
| 69 | 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 | 97 | type subagentActivityLayout uint8 | |
| 81 | 98 | ||
| 82 | 99 | const ( | |
| @@ -24,7 +24,7 @@ func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots [] | |||||
| 24 | if operation == nil { | 24 | if operation == nil { | ||
| 25 | continue | 25 | 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 lines | 32 | 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 uint8 | 97 | 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) { | |||
| 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 | 60 | func TestRenderSubagentActivityLinesOmitsPriorTurnObservation(t *testing.T) { | |
| 41 | 61 | now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) | |
| 42 | 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 | 114 | func TestFormatSubagentActivityAgeUsesOperationStart(t *testing.T) { | |
| 82 | 115 | now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) | |
| 83 | 116 | 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) { | |||
| 261 | 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 | 265 | return | |
| 266 | 266 | } | |
| 267 | 267 | // 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 | return | 265 | return | ||
| 266 | } | 266 | } | ||
| 267 | // If cancellation became observable at the same time as | 267 | // 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 { | |||
| 500 | 500 | StartupTimeout: parseDuration(cfg.StartupTimeout), | |
| 501 | 501 | DefaultTimeout: parseDuration(cfg.DefaultTimeout), | |
| 502 | 502 | MaxTurns: cfg.MaxTurns, | |
| 503 | + | MaxSteps: cfg.MaxSteps, | |
| 503 | 504 | MaxOutputBytes: cfg.MaxOutputBytes, | |
| 504 | 505 | MaxOutputLines: cfg.MaxOutputLines, | |
| 505 | 506 | 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 { | |||
| 557 | 557 | return subagents.EventTurnStarted | |
| 558 | 558 | case "turn_progress": | |
| 559 | 559 | return subagents.EventTurnProgress | |
| 560 | - | case "tool_call": | |
| 560 | + | case "tool_execution_started": | |
| 561 | 561 | return subagents.EventToolStarted | |
| 562 | 562 | case "tool_result": | |
| 563 | 563 | return subagents.EventToolFinished | |
| @@ -712,6 +712,9 @@ func resultErrorPayload(err error, shutdownOrigin subagents.ShutdownOrigin) map[ | |||
| 712 | 712 | if errors.Is(err, core.ErrStreamIdleTimeout) { | |
| 713 | 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 | 718 | if errors.Is(err, context.DeadlineExceeded) || shutdownOrigin == subagents.ShutdownOriginDeadline { | |
| 716 | 719 | return map[string]any{"code": "deadline_exceeded", "message": "subagent turn deadline exceeded; partial output is preserved in the result and history"} | |
| 717 | 720 | } | |
| @@ -557,7 +557,7 @@ func canonicalWorkerEvent(typ string) string { | |||||
| 557 | return subagents.EventTurnStarted | 557 | return subagents.EventTurnStarted | ||
| 558 | case "turn_progress": | 558 | case "turn_progress": | ||
| 559 | return subagents.EventTurnProgress | 559 | return subagents.EventTurnProgress | ||
| 560 | - | case "tool_call": | 560 | + | case "tool_execution_started": |
| 561 | return subagents.EventToolStarted | 561 | return subagents.EventToolStarted | ||
| 562 | case "tool_result": | 562 | case "tool_result": | ||
| 563 | return subagents.EventToolFinished | 563 | 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) { | |||
| 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 | 76 | func TestResultErrorPayloadAttributesShutdownCancellation(t *testing.T) { | |
| 70 | 77 | payload := resultErrorPayload(context.Canceled, subagents.ShutdownOriginSession) | |
| 71 | 78 | 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 { | |||
| 50 | 50 | WorkspaceBase string | |
| 51 | 51 | WorkspaceCapture CaptureMode | |
| 52 | 52 | MaxTurns int | |
| 53 | + | MaxSteps int | |
| 53 | 54 | ||
| 54 | 55 | // LifetimeTurns counts every accepted message turn for this worker across | |
| 55 | 56 | // all explicit runs. CurrentRunTurns resets only when an explicit | |
| @@ -58,8 +59,8 @@ type Agent struct { | |||
| 58 | 59 | LifetimeTurns int | |
| 59 | 60 | CurrentRunTurns int | |
| 60 | 61 | ||
| 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. | |
| 63 | 64 | Timeout time.Duration | |
| 64 | 65 | HeartbeatInterval time.Duration | |
| 65 | 66 | Tools []string | |
| @@ -209,9 +210,9 @@ type Agent struct { | |||
| 209 | 210 | // done closes when the run goroutine finalises the agent's | |
| 210 | 211 | // status (done / failed / killed). Wait blocks on this so | |
| 211 | 212 | // 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 | |
| 215 | 216 | } | |
| 216 | 217 | ||
| 217 | 218 | // Inbox exposes the supervisor-side socket handle. Returns nil for | |
| @@ -270,6 +271,19 @@ func (a *Agent) CurrentTurnID() string { | |||
| 270 | 271 | 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 | 287 | // AttemptValue returns the current process attempt number. | |
| 274 | 288 | func (a *Agent) AttemptValue() int { | |
| 275 | 289 | a.lifecycleMu.Lock() | |
| @@ -447,6 +461,10 @@ func (a *Agent) setResult(result *TurnResult) { | |||
| 447 | 461 | a.lifecycleMu.Lock() | |
| 448 | 462 | a.result = cloneTurnResult(result) | |
| 449 | 463 | a.updatedAt = time.Now() | |
| 464 | + | if a.resultChanged != nil { | |
| 465 | + | close(a.resultChanged) | |
| 466 | + | } | |
| 467 | + | a.resultChanged = make(chan struct{}) | |
| 450 | 468 | a.lifecycleMu.Unlock() | |
| 451 | 469 | } | |
| 452 | 470 | ||
| @@ -492,8 +510,7 @@ func (a *Agent) Err() error { | |||
| 492 | 510 | return a.lastErr | |
| 493 | 511 | } | |
| 494 | 512 | ||
| 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. | |
| 497 | 514 | func (a *Agent) Wait() { <-a.done } | |
| 498 | 515 | ||
| 499 | 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 | 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 | 537 | if ctx == nil { | |
| 514 | 538 | ctx = context.Background() | |
| 515 | 539 | } | |
| 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() | |
| 524 | 545 | return result, nil | |
| 525 | 546 | } | |
| 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 | + | } | |
| 529 | 565 | } | |
| 530 | 566 | } | |
| 531 | 567 | ||
| @@ -50,6 +50,7 @@ type Agent struct { | |||||
| 50 | WorkspaceBase string | 50 | WorkspaceBase string | ||
| 51 | WorkspaceCapture CaptureMode | 51 | WorkspaceCapture CaptureMode | ||
| 52 | MaxTurns int | 52 | MaxTurns int | ||
| 53 | + | MaxSteps int | |||
| 53 | 54 | ||||
| 54 | // LifetimeTurns counts every accepted message turn for this worker across | 55 | // LifetimeTurns counts every accepted message turn for this worker across | ||
| 55 | // all explicit runs. CurrentRunTurns resets only when an explicit | 56 | // all explicit runs. CurrentRunTurns resets only when an explicit | ||
| @@ -58,8 +59,8 @@ type Agent struct { | |||||
| 58 | LifetimeTurns int | 59 | LifetimeTurns int | ||
| 59 | CurrentRunTurns int | 60 | CurrentRunTurns int | ||
| 60 | 61 | ||||
| 61 | - | // Timeout is the effective per-agent lifetime, persisted so a resumed | 62 | + | // 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.Duration | 64 | Timeout time.Duration | ||
| 64 | HeartbeatInterval time.Duration | 65 | HeartbeatInterval time.Duration | ||
| 65 | Tools []string | 66 | Tools []string | ||
| @@ -209,9 +210,9 @@ type Agent struct { | |||||
| 209 | // done closes when the run goroutine finalises the agent's | 210 | // done closes when the run goroutine finalises the agent's | ||
| 210 | // status (done / failed / killed). Wait blocks on this so | 211 | // 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 *TurnResult | 214 | + | resultChanged chan struct{} |
| 214 | - | doneOnce sync.Once | 215 | + | doneOnce sync.Once |
| 215 | } | 216 | } | ||
| 216 | 217 | ||||
| 217 | // Inbox exposes the supervisor-side socket handle. Returns nil for | 218 | // Inbox exposes the supervisor-side socket handle. Returns nil for | ||
| @@ -270,6 +271,19 @@ func (a *Agent) CurrentTurnID() string { | |||||
| 270 | return a.currentTurnID | 271 | 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.lastErr | 510 | return a.lastErr | ||
| 493 | } | 511 | } | ||
| 494 | 512 | ||||
| 495 | - | // Wait blocks until the agent reaches a terminal state. Used by tests | 513 | + | // 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, nil | 545 | 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 @@ | |||
| 1 | 1 | package subagents | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "sync" | |
| 5 | 6 | "testing" | |
| 7 | + | "time" | |
| 6 | 8 | ) | |
| 7 | 9 | ||
| 8 | 10 | func TestAgentAttemptValueAndTranscriptAccounting(t *testing.T) { | |
| @@ -77,3 +79,36 @@ func TestAgentSnapshotConcurrentTranscriptTruncation(t *testing.T) { | |||
| 77 | 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 | + | } | |
| @@ -1,8 +1,10 @@ | |||||
| 1 | package subagents | 1 | 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 { | |||
| 76 | 76 | func (b *Batch) Wait() { <-b.done } | |
| 77 | 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 | 91 | func (f *Supervisor) SpawnBatch(ctx context.Context, req BatchRequest) (*Batch, error) { | |
| 80 | 92 | if len(req.Tasks) == 0 { | |
| 81 | 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 | 199 | continue | |
| 188 | 200 | } | |
| 189 | - | result, err := f.waitForBatchResult(a) | |
| 201 | + | result, err := f.waitForBatchResult(f.lifetimeCtx, a) | |
| 190 | 202 | if err == nil { | |
| 191 | 203 | results[id] = result | |
| 192 | 204 | switch result.Status { | |
| @@ -222,15 +234,18 @@ func (f *Supervisor) collectBatch(batch *Batch) { | |||
| 222 | 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 | 238 | if a == nil { | |
| 227 | 239 | return nil, errors.New("subagents: missing batch child") | |
| 228 | 240 | } | |
| 241 | + | if ctx == nil { | |
| 242 | + | ctx = context.Background() | |
| 243 | + | } | |
| 229 | 244 | if result := a.Result(); result != nil { | |
| 230 | 245 | return result, nil | |
| 231 | 246 | } | |
| 232 | 247 | if a.Status() != StatusDetached { | |
| 233 | - | return a.waitForTurnResult(context.Background()) | |
| 248 | + | return a.waitForTurnResult(ctx) | |
| 234 | 249 | } | |
| 235 | 250 | ||
| 236 | 251 | // 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 | 285 | interval := 100 * time.Millisecond | |
| 271 | - | deadline := time.NewTimer(f.cfg.Policy.DefaultTimeout) | |
| 272 | - | defer deadline.Stop() | |
| 273 | 286 | ticker := time.NewTicker(interval) | |
| 274 | 287 | defer ticker.Stop() | |
| 275 | 288 | for { | |
| @@ -281,20 +294,28 @@ func (f *Supervisor) waitForBatchResult(a *Agent) (*TurnResult, error) { | |||
| 281 | 294 | if !inboxLive(a.InboxPath) { | |
| 282 | 295 | return nil, fmt.Errorf("subagents: agent %s exited without a turn result", a.ID) | |
| 283 | 296 | } | |
| 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() | |
| 286 | 299 | } | |
| 287 | 300 | } | |
| 288 | 301 | } | |
| 289 | 302 | ||
| 290 | 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 | 310 | f.mu.Lock() | |
| 292 | 311 | batch := f.batches[id] | |
| 293 | 312 | f.mu.Unlock() | |
| 294 | 313 | if batch == nil { | |
| 295 | 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 | 319 | return batch.Result(), nil | |
| 299 | 320 | } | |
| 300 | 321 | ||
| @@ -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 | continue | 199 | 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] = result | 203 | 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, nil | 245 | 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 when | 251 | // 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.Millisecond | 285 | 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(), nil | 319 | return batch.Result(), nil | ||
| 299 | } | 320 | } | ||
| 300 | 321 | ||||
M packages/agent/subagents/batch_test.go +22 -0
| @@ -3,6 +3,7 @@ package subagents | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | + | "errors" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "os" | |
| 8 | 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 | 162 | func TestBatchAllowsMoreThanFormerLifetimeLimit(t *testing.T) { | |
| 141 | 163 | const workersBeyondFormerLifetimeLimit = 33 | |
| 142 | 164 | 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 = 33 | 163 | 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 ( | |||
| 106 | 106 | ) | |
| 107 | 107 | ||
| 108 | 108 | // 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. | |
| 112 | 113 | type SubagentPolicy struct { | |
| 113 | 114 | MaxConcurrent int | |
| 114 | 115 | MaxConcurrentPerParent int | |
| @@ -116,6 +117,7 @@ type SubagentPolicy struct { | |||
| 116 | 117 | StartupTimeout time.Duration | |
| 117 | 118 | DefaultTimeout time.Duration | |
| 118 | 119 | MaxTurns int | |
| 120 | + | MaxSteps int | |
| 119 | 121 | MaxOutputBytes int | |
| 120 | 122 | MaxOutputLines int | |
| 121 | 123 | AllowedTools []string | |
| @@ -139,12 +141,12 @@ func (p *SubagentPolicy) normalize() { | |||
| 139 | 141 | if p.StartupTimeout <= 0 { | |
| 140 | 142 | p.StartupTimeout = time.Minute | |
| 141 | 143 | } | |
| 142 | - | if p.DefaultTimeout <= 0 { | |
| 143 | - | p.DefaultTimeout = 20 * time.Minute | |
| 144 | - | } | |
| 145 | 144 | if p.MaxTurns <= 0 { | |
| 146 | 145 | p.MaxTurns = 3 | |
| 147 | 146 | } | |
| 147 | + | if p.MaxSteps <= 0 { | |
| 148 | + | p.MaxSteps = 128 | |
| 149 | + | } | |
| 148 | 150 | if p.MaxOutputBytes <= 0 { | |
| 149 | 151 | p.MaxOutputBytes = 500_000 | |
| 150 | 152 | } | |
| @@ -106,9 +106,10 @@ const ( | |||||
| 106 | ) | 106 | ) | ||
| 107 | 107 | ||||
| 108 | // SubagentPolicy contains manager-owned safety and resource limits. normalize | 108 | // 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 values | 110 | + | // 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 int | 114 | MaxConcurrent int | ||
| 114 | MaxConcurrentPerParent int | 115 | MaxConcurrentPerParent int | ||
| @@ -116,6 +117,7 @@ type SubagentPolicy struct { | |||||
| 116 | StartupTimeout time.Duration | 117 | StartupTimeout time.Duration | ||
| 117 | DefaultTimeout time.Duration | 118 | DefaultTimeout time.Duration | ||
| 118 | MaxTurns int | 119 | MaxTurns int | ||
| 120 | + | MaxSteps int | |||
| 119 | MaxOutputBytes int | 121 | MaxOutputBytes int | ||
| 120 | MaxOutputLines int | 122 | MaxOutputLines int | ||
| 121 | AllowedTools []string | 123 | AllowedTools []string | ||
| @@ -139,12 +141,12 @@ func (p *SubagentPolicy) normalize() { | |||||
| 139 | if p.StartupTimeout <= 0 { | 141 | if p.StartupTimeout <= 0 { | ||
| 140 | p.StartupTimeout = time.Minute | 142 | 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 = 3 | 145 | 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_000 | 151 | p.MaxOutputBytes = 500_000 | ||
| 150 | } | 152 | } | ||
M packages/agent/subagents/persist.go +52 -8
| @@ -58,9 +58,11 @@ type agentMeta struct { | |||
| 58 | 58 | WorkspaceBase string `json:"workspace_base,omitempty"` | |
| 59 | 59 | WorkspaceCapture CaptureMode `json:"workspace_capture,omitempty"` | |
| 60 | 60 | MaxTurns int `json:"max_turns,omitempty"` | |
| 61 | + | MaxSteps int `json:"max_steps,omitempty"` | |
| 61 | 62 | LifetimeTurns int `json:"lifetime_turns,omitempty"` | |
| 62 | 63 | CurrentRunTurns int `json:"current_run_turns,omitempty"` | |
| 63 | 64 | Timeout time.Duration `json:"timeout,omitempty"` | |
| 65 | + | TurnTimeoutMode turnTimeoutMode `json:"turn_timeout_mode,omitempty"` | |
| 64 | 66 | Tools []string `json:"tools,omitempty"` | |
| 65 | 67 | WebSearchPolicy WebSearchPolicy `json:"web_search_policy,omitempty"` | |
| 66 | 68 | Status Status `json:"status,omitempty"` | |
| @@ -96,6 +98,43 @@ type agentMeta struct { | |||
| 96 | 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 | 138 | func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.json") } | |
| 100 | 139 | ||
| 101 | 140 | // writeAgentMeta serialises a's identity into stateDir/meta.json. The | |
| @@ -160,9 +199,11 @@ func writeAgentMeta(stateDir string, a *Agent) error { | |||
| 160 | 199 | WorkspaceBase: a.WorkspaceBase, | |
| 161 | 200 | WorkspaceCapture: a.WorkspaceCapture, | |
| 162 | 201 | MaxTurns: a.MaxTurns, | |
| 202 | + | MaxSteps: a.MaxSteps, | |
| 163 | 203 | LifetimeTurns: lifetimeTurns, | |
| 164 | 204 | CurrentRunTurns: currentRunTurns, | |
| 165 | 205 | Timeout: a.Timeout, | |
| 206 | + | TurnTimeoutMode: timeoutModeFor(a.Timeout), | |
| 166 | 207 | Tools: append([]string(nil), a.Tools...), | |
| 167 | 208 | WebSearchPolicy: webSearchPolicy, | |
| 168 | 209 | Status: status, | |
| @@ -251,6 +292,12 @@ func readAgentMeta(stateDir string) (agentMeta, error) { | |||
| 251 | 292 | if m.ID == "" { | |
| 252 | 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 | 301 | return m, nil | |
| 255 | 302 | } | |
| 256 | 303 | ||
| @@ -496,9 +543,10 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) (*Agent, bool) { | |||
| 496 | 543 | WorkspaceBase: m.WorkspaceBase, | |
| 497 | 544 | WorkspaceCapture: m.WorkspaceCapture, | |
| 498 | 545 | MaxTurns: m.MaxTurns, | |
| 546 | + | MaxSteps: resolvePersistedMaxSteps(m.MaxSteps, f.cfg.Policy.MaxSteps), | |
| 499 | 547 | LifetimeTurns: m.LifetimeTurns, | |
| 500 | 548 | CurrentRunTurns: m.CurrentRunTurns, | |
| 501 | - | Timeout: m.Timeout, | |
| 549 | + | Timeout: resolvePersistedTurnTimeout(m, f.cfg.Policy.DefaultTimeout), | |
| 502 | 550 | Tools: append([]string(nil), m.Tools...), | |
| 503 | 551 | WebSearchPolicy: childWebSearchPolicy(m.WebSearchPolicy, m.Subagent, m.Tools), | |
| 504 | 552 | Attempt: m.Attempt, | |
| @@ -530,7 +578,6 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) (*Agent, bool) { | |||
| 530 | 578 | maxOutputBytes: f.cfg.Policy.MaxOutputBytes, | |
| 531 | 579 | maxOutputLines: f.cfg.Policy.MaxOutputLines, | |
| 532 | 580 | done: make(chan struct{}), | |
| 533 | - | turnResults: make(chan *TurnResult, 16), | |
| 534 | 581 | persistFn: f.persistAgent, | |
| 535 | 582 | } | |
| 536 | 583 | if a.updatedAt.IsZero() { | |
| @@ -1193,7 +1240,7 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo | |||
| 1193 | 1240 | Subagent: existing.Subagent, | |
| 1194 | 1241 | WorkspaceMode: existing.WorkspaceMode, WorkspacePath: existing.WorkspacePath, | |
| 1195 | 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 | 1244 | WebSearchPolicy: childWebSearchPolicy(existing.WebSearchPolicy, existing.Subagent, existing.Tools), | |
| 1198 | 1245 | CurrentTurnID: existingSnapshot.CurrentTurnID, Attempt: existingSnapshot.Attempt, | |
| 1199 | 1246 | Requirement: existingSnapshot.Requirement, | |
| @@ -1254,10 +1301,7 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo | |||
| 1254 | 1301 | m.RepositoryRoot = workspace.RepositoryRoot() | |
| 1255 | 1302 | m.WorkspacePath = workspace.Dir() | |
| 1256 | 1303 | 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) | |
| 1261 | 1305 | runCtx, cancel := f.workerContext() | |
| 1262 | 1306 | a := &Agent{ | |
| 1263 | 1307 | ID: m.ID, | |
| @@ -1282,6 +1326,7 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo | |||
| 1282 | 1326 | WorkspaceBase: m.WorkspaceBase, | |
| 1283 | 1327 | WorkspaceCapture: m.WorkspaceCapture, | |
| 1284 | 1328 | MaxTurns: maxTurns, | |
| 1329 | + | MaxSteps: resolvePersistedMaxSteps(m.MaxSteps, f.cfg.Policy.MaxSteps), | |
| 1285 | 1330 | LifetimeTurns: m.LifetimeTurns, | |
| 1286 | 1331 | CurrentRunTurns: m.CurrentRunTurns, | |
| 1287 | 1332 | Timeout: timeout, | |
| @@ -1312,7 +1357,6 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo | |||
| 1312 | 1357 | maxOutputBytes: f.cfg.Policy.MaxOutputBytes, | |
| 1313 | 1358 | maxOutputLines: f.cfg.Policy.MaxOutputLines, | |
| 1314 | 1359 | done: make(chan struct{}), | |
| 1315 | - | turnResults: make(chan *TurnResult, 16), | |
| 1316 | 1360 | } | |
| 1317 | 1361 | // Carry the previous transcript forward so the dashboard doesn't | |
| 1318 | 1362 | // 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. The | 140 | // 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, nil | 301 | 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.Timeout | 1304 | + | 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't | 1361 | // 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) { | |||
| 218 | 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 | 270 | func TestReloadRejectsMetadataPathsOutsideState(t *testing.T) { | |
| 222 | 271 | root := t.TempDir() | |
| 223 | 272 | 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 { | |||
| 53 | 53 | } | |
| 54 | 54 | } | |
| 55 | 55 | ||
| 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. | |
| 58 | 59 | type LiveObservation struct { | |
| 59 | 60 | Type string | |
| 60 | 61 | Source string | |
| @@ -73,14 +74,14 @@ func (o LiveObservation) Label() string { | |||
| 73 | 74 | return "reasoning stream observed" | |
| 74 | 75 | case "tool.output.observed": | |
| 75 | 76 | if o.Name != "" { | |
| 76 | - | return "tool output observed " + o.Name | |
| 77 | + | return "output from " + o.Name | |
| 77 | 78 | } | |
| 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 | |
| 82 | 83 | } | |
| 83 | - | return "worker protocol event" | |
| 84 | + | return "tool finished" | |
| 84 | 85 | default: | |
| 85 | 86 | return strings.ReplaceAll(o.Type, ".", " ") | |
| 86 | 87 | } | |
| @@ -168,6 +169,11 @@ func ProjectTrace(events []TraceEvent) map[string]AgentTraceView { | |||
| 168 | 169 | view.AgentID = event.AgentID | |
| 169 | 170 | view.LastEvent = event | |
| 170 | 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 | 177 | if starts { | |
| 172 | 178 | if event.Type == "provider.request.started" { | |
| 173 | 179 | view.LastRequest = requestFact(event, "open") | |
| @@ -197,7 +203,19 @@ func ProjectTrace(events []TraceEvent) map[string]AgentTraceView { | |||
| 197 | 203 | delete(open[event.AgentID], key) | |
| 198 | 204 | } | |
| 199 | 205 | 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 | + | } | |
| 201 | 219 | } | |
| 202 | 220 | if result := traceResultFact(event, view.Result); result != nil { | |
| 203 | 221 | view.Result = result | |
| @@ -238,17 +256,61 @@ func ProjectTrace(events []TraceEvent) map[string]AgentTraceView { | |||
| 238 | 256 | return views | |
| 239 | 257 | } | |
| 240 | 258 | ||
| 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. | |
| 244 | 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 | 281 | return nil | |
| 247 | 282 | } | |
| 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 | |
| 250 | 313 | } | |
| 251 | - | return v.LastObservation | |
| 252 | 314 | } | |
| 253 | 315 | ||
| 254 | 316 | func operationPriority(o Operation) int { | |
| @@ -273,7 +335,7 @@ func traceObservation(event TraceEvent) *LiveObservation { | |||
| 273 | 335 | name, _ := event.Data["name"].(string) | |
| 274 | 336 | callID, _ := event.Data["call_id"].(string) | |
| 275 | 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 | 339 | return &LiveObservation{Type: typeName, Source: source, At: event.Timestamp, TurnID: event.TurnID, CallID: callID, Name: name} | |
| 278 | 340 | } | |
| 279 | 341 | 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 string | 60 | Type string | ||
| 60 | Source string | 61 | 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.Name | 77 | + | 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.Source | 82 | + | 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.AgentID | 169 | view.AgentID = event.AgentID | ||
| 169 | view.LastEvent = event | 170 | 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 = 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 | + | } | |||
| 201 | } | 219 | } | ||
| 202 | if result := traceResultFact(event, view.Result); result != nil { | 220 | if result := traceResultFact(event, view.Result); result != nil { | ||
| 203 | view.Result = result | 221 | view.Result = result | ||
| @@ -238,17 +256,61 @@ func ProjectTrace(events []TraceEvent) map[string]AgentTraceView { | |||||
| 238 | return views | 256 | return views | ||
| 239 | } | 257 | } | ||
| 240 | 258 | ||||
| 241 | - | // ObservationFor returns the last observation only when it belongs to this | 259 | + | // 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 stream | 260 | + | // 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 nil | 281 | return nil | ||
| 247 | } | 282 | } | ||
| 248 | - | if v.LastObservation.CallID != "" && v.LastObservation.CallID != operation.CallID { | 283 | + | switch operation.Type { |
| 249 | - | return nil | 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 | |||
| 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 nil | 341 | return nil | ||
M packages/agent/subagents/projection_test.go +75 -1
| @@ -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 | 104 | func TestProjectTraceReportsOpenOperationInsteadOfLifecycleState(t *testing.T) { | |
| 31 | 105 | started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC) | |
| 32 | 106 | views := ProjectTrace([]TraceEvent{ | |
| @@ -96,7 +170,7 @@ func TestProjectTraceSelectsSpecificActivityAndProjectsResultDelivery(t *testing | |||
| 96 | 170 | if view.Result == nil || !view.Result.Available || !view.Result.Delivered || view.Result.Ref != "subagent://agent-1/result" { | |
| 97 | 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 | 174 | t.Fatalf("summary = %q, want %q", got, want) | |
| 101 | 175 | } | |
| 102 | 176 | } | |
| @@ -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 { | |||
| 79 | 79 | FastModeSet bool | |
| 80 | 80 | Subagent string | |
| 81 | 81 | MaxTurns int | |
| 82 | + | MaxSteps int | |
| 82 | 83 | TurnTimeout time.Duration | |
| 83 | 84 | LifetimeTurns int | |
| 84 | 85 | RunTurns int | |
| @@ -118,6 +119,7 @@ func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []str | |||
| 118 | 119 | FastModeSet: true, | |
| 119 | 120 | Subagent: a.Subagent, | |
| 120 | 121 | MaxTurns: a.MaxTurns, | |
| 122 | + | MaxSteps: a.MaxSteps, | |
| 121 | 123 | TurnTimeout: a.Timeout, | |
| 122 | 124 | LifetimeTurns: a.LifetimeTurnsValue(), | |
| 123 | 125 | RunTurns: a.CurrentRunTurnsValue(), | |
| @@ -165,6 +167,9 @@ func subagentWorkerArgs(opts subagentWorkerArgsOpts) []string { | |||
| 165 | 167 | if opts.MaxTurns > 0 { | |
| 166 | 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 | 173 | if opts.TurnTimeout > 0 { | |
| 169 | 174 | args = append(args, "--subagent-turn-timeout", opts.TurnTimeout.String()) | |
| 170 | 175 | } | |
| @@ -708,6 +713,7 @@ func updateAgentFromEvent(a *Agent, ev Event) error { | |||
| 708 | 713 | a.markActivity(now) | |
| 709 | 714 | persist := false | |
| 710 | 715 | notifyIdle := false | |
| 716 | + | var eventErr error | |
| 711 | 717 | switch ev.Type { | |
| 712 | 718 | case EventAgentReady, "agent_ready": | |
| 713 | 719 | a.setProcessState(ProcessAlive) | |
| @@ -759,20 +765,26 @@ func updateAgentFromEvent(a *Agent, ev Event) error { | |||
| 759 | 765 | a.setProcessState(ProcessAlive) | |
| 760 | 766 | case EventTurnResult, "turn_result": | |
| 761 | 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 | 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 | 782 | a.lifecycleMu.Lock() | |
| 772 | 783 | a.resultRef = ResultRef(a.ID) | |
| 773 | 784 | a.lifecycleMu.Unlock() | |
| 774 | 785 | } | |
| 775 | 786 | } | |
| 787 | + | a.setResult(result) | |
| 776 | 788 | switch result.Status { | |
| 777 | 789 | case ResultCanceled: | |
| 778 | 790 | a.setTurnState(TurnCanceled, result.TurnID) | |
| @@ -850,13 +862,13 @@ func updateAgentFromEvent(a *Agent, ev Event) error { | |||
| 850 | 862 | if persist && a.persistFn != nil { | |
| 851 | 863 | if err := a.persistFn(a); err != nil { | |
| 852 | 864 | a.recordPersistenceError(err) | |
| 853 | - | return err | |
| 865 | + | eventErr = errors.Join(eventErr, err) | |
| 854 | 866 | } | |
| 855 | 867 | } | |
| 856 | 868 | if notifyIdle { | |
| 857 | 869 | a.notifyTurnIdle() | |
| 858 | 870 | } | |
| 859 | - | return nil | |
| 871 | + | return eventErr | |
| 860 | 872 | } | |
| 861 | 873 | ||
| 862 | 874 | // notifyPromptTurnEnd calls Agent.OnTurnEnd only for the subagent | |
| @@ -955,7 +967,7 @@ func recordWorkerTrace(agent *Agent, ev Event) { | |||
| 955 | 967 | case "error": | |
| 956 | 968 | // Error events do not necessarily belong to a provider request; the | |
| 957 | 969 | // following terminal turn event closes any request factually. | |
| 958 | - | case "tool_call", EventToolStarted: | |
| 970 | + | case "tool_execution_started", EventToolStarted: | |
| 959 | 971 | traceType = "tool.started" | |
| 960 | 972 | case "tool_result", EventToolFinished: | |
| 961 | 973 | traceType = "tool.finished" | |
| @@ -79,6 +79,7 @@ type subagentWorkerArgsOpts struct { | |||||
| 79 | FastModeSet bool | 79 | FastModeSet bool | ||
| 80 | Subagent string | 80 | Subagent string | ||
| 81 | MaxTurns int | 81 | MaxTurns int | ||
| 82 | + | MaxSteps int | |||
| 82 | TurnTimeout time.Duration | 83 | TurnTimeout time.Duration | ||
| 83 | LifetimeTurns int | 84 | LifetimeTurns int | ||
| 84 | RunTurns int | 85 | 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 := false | 714 | persist := false | ||
| 710 | notifyIdle := false | 715 | 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 err | 865 | + | 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 nil | 871 | + | return eventErr |
| 860 | } | 872 | } | ||
| 861 | 873 | ||||
| 862 | // notifyPromptTurnEnd calls Agent.OnTurnEnd only for the subagent | 874 | // 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; the | 968 | // 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) { | |||
| 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 | 370 | func TestResolveSubagentExecutableUsesCurrentWhenAvailable(t *testing.T) { | |
| 364 | 371 | got, err := resolveSubagentExecutable(context.Background(), "/current/bin/zut", "/old/bin/zut", func(_ context.Context, candidate string) (string, error) { | |
| 365 | 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 | 786 | func TestRestoreResumePromptPreservesCommandIdentity(t *testing.T) { | |
| 758 | 787 | a := &Agent{} | |
| 759 | 788 | a.setResumePrompt("old prompt", time.Now()) | |
| @@ -814,12 +843,14 @@ func TestRecordWorkerTraceNormalizesSafeLiveObservations(t *testing.T) { | |||
| 814 | 843 | recordWorkerTrace(agent, Event{Type: EventTurnStarted, TurnID: "turn-1", Data: map[string]any{"nested_turn": true}}) | |
| 815 | 844 | recordWorkerTrace(agent, Event{Type: "turn_end", TurnID: "turn-1", Data: map[string]any{"nested_turn": true}}) | |
| 816 | 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 | 848 | recordWorkerTrace(agent, Event{Type: "future_event", TurnID: "turn-1", Data: map[string]any{"secret": "hidden"}}) | |
| 818 | 849 | if err := trace.Flush(); err != nil { | |
| 819 | 850 | t.Fatal(err) | |
| 820 | 851 | } | |
| 821 | 852 | events := trace.Events() | |
| 822 | - | if len(events) != 6 { | |
| 853 | + | if len(events) != 8 { | |
| 823 | 854 | t.Fatalf("events = %#v", events) | |
| 824 | 855 | } | |
| 825 | 856 | if got, want := events[0].Type, "assistant.stream.observed"; got != want { | |
| @@ -837,23 +868,28 @@ func TestRecordWorkerTraceNormalizesSafeLiveObservations(t *testing.T) { | |||
| 837 | 868 | if got, want := events[3].Type, "provider.request.finished"; got != want { | |
| 838 | 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 | 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 | 878 | t.Fatalf("tool call_id = %q", got) | |
| 845 | 879 | } | |
| 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) | |
| 854 | 882 | } | |
| 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 | + | } | |
| 857 | 893 | } | |
| 858 | 894 | view := ProjectTrace(append([]TraceEvent{{Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"}}, events...))["agent-1"] | |
| 859 | 895 | 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 | |||
| 540 | 540 | WorkspaceBase: strings.TrimSpace(req.WorkspaceBase), | |
| 541 | 541 | WorkspaceCapture: req.WorkspaceCapture, | |
| 542 | 542 | MaxTurns: maxTurns, | |
| 543 | + | MaxSteps: f.cfg.Policy.MaxSteps, | |
| 543 | 544 | Timeout: timeout, | |
| 544 | 545 | HeartbeatInterval: f.cfg.Policy.HeartbeatInterval, | |
| 545 | 546 | Tools: tools, | |
| @@ -561,7 +562,6 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er | |||
| 561 | 562 | maxOutputBytes: f.cfg.Policy.MaxOutputBytes, | |
| 562 | 563 | maxOutputLines: f.cfg.Policy.MaxOutputLines, | |
| 563 | 564 | done: make(chan struct{}), | |
| 564 | - | turnResults: make(chan *TurnResult, 16), | |
| 565 | 565 | } | |
| 566 | 566 | a.fastModeOverridesHost = fastModeOverridesHost | |
| 567 | 567 | 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 = fastModeOverridesHost | 566 | 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) { | |||
| 72 | 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 | 103 | func TestSpawnRunsAndCompletes(t *testing.T) { | |
| 76 | 104 | ran := make(chan string, 1) | |
| 77 | 105 | 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 ( | |||
| 5 | 5 | "encoding/json" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "strings" | |
| 8 | - | "time" | |
| 9 | 8 | ||
| 10 | 9 | "github.com/bnema/zut/packages/agent/subagents" | |
| 11 | 10 | "github.com/bnema/zut/packages/core" | |
| @@ -62,8 +61,10 @@ type subagentSpawnArgs struct { | |||
| 62 | 61 | FastMode *bool `json:"fast_mode,omitempty"` | |
| 63 | 62 | Required bool `json:"required,omitempty"` | |
| 64 | 63 | 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"` | |
| 67 | 68 | } | |
| 68 | 69 | ||
| 69 | 70 | const subagentSpawnSchemaTemplate = `{ | |
| @@ -103,10 +104,6 @@ const subagentSpawnSchemaTemplate = `{ | |||
| 103 | 104 | "enum": ["shared", "worktree"], | |
| 104 | 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 | 107 | "max_turns": { | |
| 111 | 108 | "type": "integer", | |
| 112 | 109 | "minimum": 1, | |
| @@ -155,14 +152,6 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 155 | 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 | 155 | if a.MaxTurns != nil { | |
| 167 | 156 | if *a.MaxTurns < 1 { | |
| 168 | 157 | return protocolToolError(prefix + ": max_turns must be positive") | |
| @@ -254,7 +243,6 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 254 | 243 | FastMode: fastModeOverride, | |
| 255 | 244 | Subagent: agentName, | |
| 256 | 245 | Required: a.Required, | |
| 257 | - | Timeout: timeout, | |
| 258 | 246 | MaxTurns: maxTurns, | |
| 259 | 247 | WorkspaceMode: workspaceMode, | |
| 260 | 248 | Tools: profileTools, | |
| @@ -294,6 +282,10 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 294 | 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 | 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 | 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 | 289 | details := map[string]any{ | |
| 298 | 290 | "agent_id": agent.ID, | |
| 299 | 291 | "task": task, | |
| @@ -304,8 +296,9 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 304 | 296 | "fast_mode": agent.FastMode, | |
| 305 | 297 | "required": a.Required, | |
| 306 | 298 | "isolation": string(workspaceMode), | |
| 307 | - | "timeout": agent.Timeout.String(), | |
| 299 | + | "timeout": timeoutDetail, | |
| 308 | 300 | "max_turns": agent.MaxTurns, | |
| 301 | + | "max_steps": agent.MaxSteps, | |
| 309 | 302 | "state": agent.Status(), | |
| 310 | 303 | "process_state": string(agent.ProcessState()), | |
| 311 | 304 | "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) { | |||
| 159 | 159 | if got := details["reasoning"]; got != "medium" { | |
| 160 | 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 | 168 | text := textResult(res.Content) | |
| 163 | 169 | if !strings.Contains(text, "model: gpt-5") || !strings.Contains(text, "provider: openai-codex") || !strings.Contains(text, "reasoning: medium") { | |
| 164 | 170 | t.Fatalf("result text missing inherited model/provider:\n%s", text) | |
| @@ -184,6 +190,7 @@ func TestSubagentSpawnDetailsUseEffectiveTimeoutAndTurns(t *testing.T) { | |||
| 184 | 190 | Policy: subagents.SubagentPolicy{ | |
| 185 | 191 | DefaultTimeout: 37 * time.Minute, | |
| 186 | 192 | MaxTurns: 7, | |
| 193 | + | MaxSteps: 99, | |
| 187 | 194 | }, | |
| 188 | 195 | NewRunner: func(*subagents.Agent) subagents.Runner { | |
| 189 | 196 | return noopSupervisorRunner{} | |
| @@ -212,6 +219,25 @@ func TestSubagentSpawnDetailsUseEffectiveTimeoutAndTurns(t *testing.T) { | |||
| 212 | 219 | if got := details["max_turns"]; got != 7 { | |
| 213 | 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 | 243 | func TestSubagentSpawnRejectsExplicitZeroMaxTurns(t *testing.T) { | |
| @@ -366,6 +392,9 @@ func TestSubagentSpawnSchemaUsesReasoningAndKeepsProfileThinkingInternal(t *test | |||
| 366 | 392 | if _, ok := schema.Properties["thinking"]; ok { | |
| 367 | 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 | 398 | fastMode, ok := schema.Properties["fast_mode"] | |
| 370 | 399 | if !ok { | |
| 371 | 400 | 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 ( | |||
| 13 | 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 | 21 | type queuedMessage struct { | |
| 19 | 22 | text string | |
| @@ -651,7 +654,7 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error { | |||
| 651 | 654 | } | |
| 652 | 655 | if a.MaxSteps > 0 { | |
| 653 | 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 | 659 | return nil | |
| 657 | 660 | } | |
| @@ -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 string | 22 | 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 nil | 659 | 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 | + | } | |||