fix(subagents): finish observable unlimited execution
- commit
- f87ad92006e33a1e2f6bde35a6f019fea8f24e81
- author
- brice · · 2026-08-13 03:32 UTC
- parent
- 3e8568c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
38 files · +478 -333
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 and shared/worktree isolation; deadline, turn, and step budgets are manager-owned and are not exposed in its schema. Delegated turns, run turns, and model/tool loops are unlimited by default unless positive `subagents.default_timeout`, `subagents.max_turns`, or `subagents.max_steps` values are configured. 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 factual metadata: the worker id, current primary operation, last safe observation/event, terminal fact, timestamps, turn counters, a bounded first-line task summary, requirement state, and terminal-result delivery metadata. 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 and shared/worktree isolation. Model steps and delegated turns are counted for observability but are never capped; only an optional manager-owned `subagents.default_timeout` bounds active turn duration. 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 and shared/worktree isolation; deadline, turn, and step budgets are manager-owned and are not exposed in its schema. Delegated turns, run turns, and model/tool loops are unlimited by default unless positive `subagents.default_timeout`, `subagents.max_turns`, or `subagents.max_steps` values are configured. 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 factual metadata: the worker id, current primary operation, last safe observation/event, terminal fact, timestamps, turn counters, a bounded first-line task summary, requirement state, and terminal-result delivery metadata. 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 and shared/worktree isolation. Model steps and delegated turns are counted for observability but are never capped; only an optional manager-owned `subagents.default_timeout` bounds active turn duration. 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 +1 -1
| @@ -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`, `max_steps`, output caps, allowed tools/roots, heartbeat and idle timeouts. Duration strings use Go duration syntax and malformed values fail configuration loading with the field name. 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`, `max_turns`, and `max_steps` leave delegated turns, active-run turns, and provider/model/tool-loop iterations unlimited. Positive values opt into those manager-owned budgets; they are not fields in the model-facing `subagent_spawn` schema, and stale calls that still send them are rejected explicitly. Exhausting `max_steps` 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. Concurrency limits apply to slash commands, `subagent_spawn`, and batch operations. A child cannot create descendants in v1. Per-worker deadline mode and explicitly configured limits are retained across reload/resume. The shared HTTP streaming providers retry transient request-open failures four times; after opening, a stream may reconnect five times and is canceled after five minutes without an event. 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`, output caps, allowed tools/roots, heartbeat and idle timeouts. Duration strings use Go duration syntax and malformed values fail configuration loading with the field name. 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. Model steps and delegated turns are counted for observability but never capped. A missing or non-positive `default_timeout` leaves each active delegated turn unlimited; a positive value opts into a manager-owned deadline retained across reload and resume. An explicit `subagent_resume` starts a new observable run without replaying the original task or losing session context. Previously saved `max_total_spawned` values are accepted but ignored. Concurrency limits apply to slash commands, `subagent_spawn`, and batch operations. A child cannot create descendants in v1. The shared HTTP streaming providers retry transient request-open failures four times; after opening, a stream may reconnect five times and is canceled after five minutes without an event. 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. | |
| @@ -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`, `max_steps`, output caps, allowed tools/roots, heartbeat and idle timeouts. Duration strings use Go duration syntax and malformed values fail configuration loading with the field name. 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`, `max_turns`, and `max_steps` leave delegated turns, active-run turns, and provider/model/tool-loop iterations unlimited. Positive values opt into those manager-owned budgets; they are not fields in the model-facing `subagent_spawn` schema, and stale calls that still send them are rejected explicitly. Exhausting `max_steps` 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. Concurrency limits apply to slash commands, `subagent_spawn`, and batch operations. A child cannot create descendants in v1. Per-worker deadline mode and explicitly configured limits are retained across reload/resume. The shared HTTP streaming providers retry transient request-open failures four times; after opening, a stream may reconnect five times and is canceled after five minutes without an event. 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`, output caps, allowed tools/roots, heartbeat and idle timeouts. Duration strings use Go duration syntax and malformed values fail configuration loading with the field name. 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. Model steps and delegated turns are counted for observability but never capped. A missing or non-positive `default_timeout` leaves each active delegated turn unlimited; a positive value opts into a manager-owned deadline retained across reload and resume. An explicit `subagent_resume` starts a new observable run without replaying the original task or losing session context. Previously saved `max_total_spawned` values are accepted but ignored. Concurrency limits apply to slash commands, `subagent_spawn`, and batch operations. A child cannot create descendants in v1. The shared HTTP streaming providers retry transient request-open failures four times; after opening, a stream may reconnect five times and is canceled after five minutes without an event. 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/args.go +0 -12
| @@ -147,8 +147,6 @@ type Args struct { | |||
| 147 | 147 | // --subagent-worker. | |
| 148 | 148 | SubagentWorker string | |
| 149 | 149 | ||
| 150 | - | // SubagentMaxTurns limits message turns in one worker run. | |
| 151 | - | SubagentMaxTurns int | |
| 152 | 150 | // SubagentTurnTimeout limits each active delegated turn while leaving the | |
| 153 | 151 | // worker process alive between turns for explicit follow-ups. | |
| 154 | 152 | SubagentTurnTimeout time.Duration | |
| @@ -387,16 +385,6 @@ func ParseArgs(in []string) (Args, error) { | |||
| 387 | 385 | return a, fmt.Errorf("--max-steps must be a positive integer") | |
| 388 | 386 | } | |
| 389 | 387 | a.MaxSteps = n | |
| 390 | - | case "--max-turns": | |
| 391 | - | v, err := want(&i, arg) | |
| 392 | - | if err != nil { | |
| 393 | - | return a, err | |
| 394 | - | } | |
| 395 | - | var n int | |
| 396 | - | if _, err := fmt.Sscanf(v, "%d", &n); err != nil || n <= 0 { | |
| 397 | - | return a, fmt.Errorf("--max-turns must be a positive integer") | |
| 398 | - | } | |
| 399 | - | a.SubagentMaxTurns = n | |
| 400 | 388 | case "--subagent-turn-timeout": | |
| 401 | 389 | v, err := want(&i, arg) | |
| 402 | 390 | if err != nil { | |
| @@ -147,8 +147,6 @@ type Args struct { | |||||
| 147 | // --subagent-worker. | 147 | // --subagent-worker. | ||
| 148 | SubagentWorker string | 148 | SubagentWorker string | ||
| 149 | 149 | ||||
| 150 | - | // SubagentMaxTurns limits message turns in one worker run. | |||
| 151 | - | SubagentMaxTurns int | |||
| 152 | // SubagentTurnTimeout limits each active delegated turn while leaving the | 150 | // SubagentTurnTimeout limits each active delegated turn while leaving the | ||
| 153 | // worker process alive between turns for explicit follow-ups. | 151 | // worker process alive between turns for explicit follow-ups. | ||
| 154 | SubagentTurnTimeout time.Duration | 152 | SubagentTurnTimeout time.Duration | ||
| @@ -387,16 +385,6 @@ func ParseArgs(in []string) (Args, error) { | |||||
| 387 | return a, fmt.Errorf("--max-steps must be a positive integer") | 385 | return a, fmt.Errorf("--max-steps must be a positive integer") | ||
| 388 | } | 386 | } | ||
| 389 | a.MaxSteps = n | 387 | a.MaxSteps = n | ||
| 390 | - | case "--max-turns": | |||
| 391 | - | v, err := want(&i, arg) | |||
| 392 | - | if err != nil { | |||
| 393 | - | return a, err | |||
| 394 | - | } | |||
| 395 | - | var n int | |||
| 396 | - | if _, err := fmt.Sscanf(v, "%d", &n); err != nil || n <= 0 { | |||
| 397 | - | return a, fmt.Errorf("--max-turns must be a positive integer") | |||
| 398 | - | } | |||
| 399 | - | a.SubagentMaxTurns = n | |||
| 400 | case "--subagent-turn-timeout": | 388 | case "--subagent-turn-timeout": | ||
| 401 | v, err := want(&i, arg) | 389 | v, err := want(&i, arg) | ||
| 402 | if err != nil { | 390 | if err != nil { | ||
M packages/agent/config.go +0 -2
| @@ -30,8 +30,6 @@ type SubagentsConfig struct { | |||
| 30 | 30 | QueueTimeout string `json:"queue_timeout,omitempty"` | |
| 31 | 31 | StartupTimeout string `json:"startup_timeout,omitempty"` | |
| 32 | 32 | DefaultTimeout string `json:"default_timeout,omitempty"` | |
| 33 | - | MaxTurns int `json:"max_turns,omitempty"` | |
| 34 | - | MaxSteps int `json:"max_steps,omitempty"` | |
| 35 | 33 | MaxOutputBytes int `json:"max_output_bytes,omitempty"` | |
| 36 | 34 | MaxOutputLines int `json:"max_output_lines,omitempty"` | |
| 37 | 35 | AllowedTools []string `json:"allowed_tools,omitempty"` | |
| @@ -30,8 +30,6 @@ type SubagentsConfig struct { | |||||
| 30 | QueueTimeout string `json:"queue_timeout,omitempty"` | 30 | QueueTimeout string `json:"queue_timeout,omitempty"` | ||
| 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"` | |||
| 34 | - | MaxSteps int `json:"max_steps,omitempty"` | |||
| 35 | MaxOutputBytes int `json:"max_output_bytes,omitempty"` | 33 | MaxOutputBytes int `json:"max_output_bytes,omitempty"` | ||
| 36 | MaxOutputLines int `json:"max_output_lines,omitempty"` | 34 | MaxOutputLines int `json:"max_output_lines,omitempty"` | ||
| 37 | AllowedTools []string `json:"allowed_tools,omitempty"` | 35 | AllowedTools []string `json:"allowed_tools,omitempty"` | ||
M packages/agent/modes/subagent_indicator.go +24 -8
| @@ -10,6 +10,11 @@ import ( | |||
| 10 | 10 | "github.com/mattn/go-runewidth" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | + | var providerWaitingSpinner = tui.NewStringSpinner( | |
| 14 | + | []string{"Zzzz", "zZzz", "zzZz", "zzzZ"}, | |
| 15 | + | time.Second, | |
| 16 | + | ) | |
| 17 | + | ||
| 13 | 18 | // renderSubagentActivityLines renders only trace-observed open operations. | |
| 14 | 19 | // A living process, generic lifecycle state, or heartbeat never starts a | |
| 15 | 20 | // spinner because none proves that an operation is progressing. | |
| @@ -17,14 +22,11 @@ func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots [] | |||
| 17 | 22 | lines := make([]string, 0, len(snapshots)) | |
| 18 | 23 | for _, snapshot := range snapshots { | |
| 19 | 24 | view := views[snapshot.ID] | |
| 20 | - | operation := view.PrimaryOperation | |
| 21 | - | if operation == nil && len(view.OpenOperations) != 0 { | |
| 22 | - | operation = &view.OpenOperations[0] | |
| 23 | - | } | |
| 24 | - | if operation == nil { | |
| 25 | + | operation, ok := view.Primary() | |
| 26 | + | if !ok { | |
| 25 | 27 | continue | |
| 26 | 28 | } | |
| 27 | - | line := renderSubagentActivityLine(th, spinnerGlyph, snapshot, *operation, view.TurnStartedAt(*operation), view.ObservationFor(*operation), width, now) | |
| 29 | + | line := renderSubagentActivityLine(th, spinnerGlyph, snapshot, operation, view.TurnStartedAt(operation), view.ObservationFor(operation), width, now) | |
| 28 | 30 | if line != "" { | |
| 29 | 31 | lines = append(lines, line) | |
| 30 | 32 | } | |
| @@ -44,7 +46,7 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba | |||
| 44 | 46 | if spinnerGlyph == "" { | |
| 45 | 47 | spinnerGlyph = "." | |
| 46 | 48 | } | |
| 47 | - | activity := operation.Label() | |
| 49 | + | activity := subagentOperationActivity(operation, now) | |
| 48 | 50 | if observation != nil { | |
| 49 | 51 | activity += " · " + observation.Label() + " " + formatSubagentActivityAge(observation.At, now) + " ago" | |
| 50 | 52 | } | |
| @@ -55,6 +57,13 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba | |||
| 55 | 57 | turnStartedAt = operation.StartedAt | |
| 56 | 58 | } | |
| 57 | 59 | age := formatSubagentActivityAge(turnStartedAt, now) | |
| 60 | + | if operation.Step > 0 { | |
| 61 | + | label := "steps" | |
| 62 | + | if operation.Step == 1 { | |
| 63 | + | label = "step" | |
| 64 | + | } | |
| 65 | + | age = fmt.Sprintf("%d %s · %s", operation.Step, label, age) | |
| 66 | + | } | |
| 58 | 67 | plain, layout := fitSubagentActivityLine(spinnerGlyph, name, activity, age, width-2) | |
| 59 | 68 | if plain == "" { | |
| 60 | 69 | return "" | |
| @@ -83,6 +92,13 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba | |||
| 83 | 92 | } | |
| 84 | 93 | } | |
| 85 | 94 | ||
| 95 | + | func subagentOperationActivity(operation subagents.Operation, now time.Time) string { | |
| 96 | + | if operation.Type == "provider.request.started" { | |
| 97 | + | return providerWaitingSpinner.FrameAt(operation.StartedAt, now) | |
| 98 | + | } | |
| 99 | + | return operation.Label() | |
| 100 | + | } | |
| 101 | + | ||
| 86 | 102 | func subagentOperationColor(th tui.Theme, operation subagents.Operation) tui.TerminalColor { | |
| 87 | 103 | switch operation.Type { | |
| 88 | 104 | case "provider.request.started": | |
| @@ -192,7 +208,7 @@ func (i *Interactive) activeSubagentActivitySnapshots() ([]subagents.AgentSnapsh | |||
| 192 | 208 | continue | |
| 193 | 209 | } | |
| 194 | 210 | view := views[agent.ID] | |
| 195 | - | if view.PrimaryOperation == nil && len(view.OpenOperations) == 0 { | |
| 211 | + | if _, ok := view.Primary(); !ok { | |
| 196 | 212 | continue | |
| 197 | 213 | } | |
| 198 | 214 | out = append(out, subagents.AgentSnapshot{ID: agent.ID, Started: agent.Started, Subagent: agent.Subagent}) | |
| @@ -10,6 +10,11 @@ import ( | |||||
| 10 | "github.com/mattn/go-runewidth" | 10 | "github.com/mattn/go-runewidth" | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | + | var providerWaitingSpinner = tui.NewStringSpinner( | |||
| 14 | + | []string{"Zzzz", "zZzz", "zzZz", "zzzZ"}, | |||
| 15 | + | time.Second, | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 13 | // renderSubagentActivityLines renders only trace-observed open operations. | 18 | // renderSubagentActivityLines renders only trace-observed open operations. | ||
| 14 | // A living process, generic lifecycle state, or heartbeat never starts a | 19 | // A living process, generic lifecycle state, or heartbeat never starts a | ||
| 15 | // spinner because none proves that an operation is progressing. | 20 | // spinner because none proves that an operation is progressing. | ||
| @@ -17,14 +22,11 @@ func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots [] | |||||
| 17 | lines := make([]string, 0, len(snapshots)) | 22 | lines := make([]string, 0, len(snapshots)) | ||
| 18 | for _, snapshot := range snapshots { | 23 | for _, snapshot := range snapshots { | ||
| 19 | view := views[snapshot.ID] | 24 | view := views[snapshot.ID] | ||
| 20 | - | operation := view.PrimaryOperation | 25 | + | operation, ok := view.Primary() |
| 21 | - | if operation == nil && len(view.OpenOperations) != 0 { | 26 | + | if !ok { |
| 22 | - | operation = &view.OpenOperations[0] | |||
| 23 | - | } | |||
| 24 | - | if operation == nil { | |||
| 25 | continue | 27 | continue | ||
| 26 | } | 28 | } | ||
| 27 | - | line := renderSubagentActivityLine(th, spinnerGlyph, snapshot, *operation, view.TurnStartedAt(*operation), view.ObservationFor(*operation), width, now) | 29 | + | line := renderSubagentActivityLine(th, spinnerGlyph, snapshot, operation, view.TurnStartedAt(operation), view.ObservationFor(operation), width, now) |
| 28 | if line != "" { | 30 | if line != "" { | ||
| 29 | lines = append(lines, line) | 31 | lines = append(lines, line) | ||
| 30 | } | 32 | } | ||
| @@ -44,7 +46,7 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba | |||||
| 44 | if spinnerGlyph == "" { | 46 | if spinnerGlyph == "" { | ||
| 45 | spinnerGlyph = "." | 47 | spinnerGlyph = "." | ||
| 46 | } | 48 | } | ||
| 47 | - | activity := operation.Label() | 49 | + | activity := subagentOperationActivity(operation, now) |
| 48 | if observation != nil { | 50 | if observation != nil { | ||
| 49 | activity += " · " + observation.Label() + " " + formatSubagentActivityAge(observation.At, now) + " ago" | 51 | activity += " · " + observation.Label() + " " + formatSubagentActivityAge(observation.At, now) + " ago" | ||
| 50 | } | 52 | } | ||
| @@ -55,6 +57,13 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba | |||||
| 55 | turnStartedAt = operation.StartedAt | 57 | turnStartedAt = operation.StartedAt | ||
| 56 | } | 58 | } | ||
| 57 | age := formatSubagentActivityAge(turnStartedAt, now) | 59 | age := formatSubagentActivityAge(turnStartedAt, now) | ||
| 60 | + | if operation.Step > 0 { | |||
| 61 | + | label := "steps" | |||
| 62 | + | if operation.Step == 1 { | |||
| 63 | + | label = "step" | |||
| 64 | + | } | |||
| 65 | + | age = fmt.Sprintf("%d %s · %s", operation.Step, label, age) | |||
| 66 | + | } | |||
| 58 | plain, layout := fitSubagentActivityLine(spinnerGlyph, name, activity, age, width-2) | 67 | plain, layout := fitSubagentActivityLine(spinnerGlyph, name, activity, age, width-2) | ||
| 59 | if plain == "" { | 68 | if plain == "" { | ||
| 60 | return "" | 69 | return "" | ||
| @@ -83,6 +92,13 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba | |||||
| 83 | } | 92 | } | ||
| 84 | } | 93 | } | ||
| 85 | 94 | ||||
| 95 | + | func subagentOperationActivity(operation subagents.Operation, now time.Time) string { | |||
| 96 | + | if operation.Type == "provider.request.started" { | |||
| 97 | + | return providerWaitingSpinner.FrameAt(operation.StartedAt, now) | |||
| 98 | + | } | |||
| 99 | + | return operation.Label() | |||
| 100 | + | } | |||
| 101 | + | ||||
| 86 | func subagentOperationColor(th tui.Theme, operation subagents.Operation) tui.TerminalColor { | 102 | func subagentOperationColor(th tui.Theme, operation subagents.Operation) tui.TerminalColor { | ||
| 87 | switch operation.Type { | 103 | switch operation.Type { | ||
| 88 | case "provider.request.started": | 104 | case "provider.request.started": | ||
| @@ -192,7 +208,7 @@ func (i *Interactive) activeSubagentActivitySnapshots() ([]subagents.AgentSnapsh | |||||
| 192 | continue | 208 | continue | ||
| 193 | } | 209 | } | ||
| 194 | view := views[agent.ID] | 210 | view := views[agent.ID] | ||
| 195 | - | if view.PrimaryOperation == nil && len(view.OpenOperations) == 0 { | 211 | + | if _, ok := view.Primary(); !ok { |
| 196 | continue | 212 | continue | ||
| 197 | } | 213 | } | ||
| 198 | out = append(out, subagents.AgentSnapshot{ID: agent.ID, Started: agent.Started, Subagent: agent.Subagent}) | 214 | out = append(out, subagents.AgentSnapshot{ID: agent.ID, Started: agent.Started, Subagent: agent.Subagent}) | ||
M packages/agent/modes/subagent_indicator_test.go +32 -7
| @@ -30,7 +30,7 @@ func TestRenderSubagentActivityLinesShowsOnlyTraceOpenOperations(t *testing.T) { | |||
| 30 | 30 | func TestRenderSubagentActivityLinesShowsSafeLastObservation(t *testing.T) { | |
| 31 | 31 | now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) | |
| 32 | 32 | lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{ | |
| 33 | - | "review-123": {PrimaryOperation: &subagents.Operation{Type: "turn.started", TurnID: "turn-1", StartedAt: now.Add(-time.Minute)}, LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)}}, | |
| 33 | + | "review-123": {OpenOperations: []subagents.Operation{{Type: "turn.started", TurnID: "turn-1", StartedAt: now.Add(-time.Minute)}}, LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)}}, | |
| 34 | 34 | }, 100, now)) | |
| 35 | 35 | if got := strings.Join(lines, "\n"); !strings.Contains(got, "processing turn · assistant streaming 1s ago") || strings.Contains(got, "delta") { | |
| 36 | 36 | t.Fatalf("activity lines = %#v", lines) | |
| @@ -40,10 +40,10 @@ func TestRenderSubagentActivityLinesShowsSafeLastObservation(t *testing.T) { | |||
| 40 | 40 | func TestRenderSubagentActivityLinesShowsRecentCompletedToolUnderProviderRequest(t *testing.T) { | |
| 41 | 41 | now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) | |
| 42 | 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)}}, | |
| 43 | + | "review-123": {OpenOperations: []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 | 44 | }, 100, now)) | |
| 45 | 45 | got := strings.Join(lines, "\n") | |
| 46 | - | if !strings.Contains(got, "waiting for model response · finished read 3s ago · 2s") { | |
| 46 | + | if !strings.Contains(got, "zzZz · finished read 3s ago · 2s") { | |
| 47 | 47 | t.Fatalf("activity lines = %#v", lines) | |
| 48 | 48 | } | |
| 49 | 49 | } | |
| @@ -52,15 +52,40 @@ func TestRenderSubagentActivityLineColorsProviderWaitAsActive(t *testing.T) { | |||
| 52 | 52 | now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) | |
| 53 | 53 | operation := subagents.Operation{Type: "provider.request.started", TurnID: "turn-1", StartedAt: now.Add(-2 * time.Second)} | |
| 54 | 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")) { | |
| 55 | + | if !strings.Contains(line, tui.Dark.FGColor(tui.Dark.Spinner, "zzZz")) { | |
| 56 | 56 | t.Fatalf("provider wait is not rendered with the active color: %q", line) | |
| 57 | 57 | } | |
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | + | func TestRenderSubagentActivityLineAnimatesProviderWaitAtOneHertz(t *testing.T) { | |
| 61 | + | started := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) | |
| 62 | + | operation := subagents.Operation{Type: "provider.request.started", TurnID: "turn-1", StartedAt: started} | |
| 63 | + | snapshot := subagents.AgentSnapshot{ID: "review-123", Subagent: "reviewer"} | |
| 64 | + | ||
| 65 | + | wants := []string{"Zzzz", "zZzz", "zzZz", "zzzZ", "Zzzz"} | |
| 66 | + | for second, want := range wants { | |
| 67 | + | line := stripANSIBytes(renderSubagentActivityLine(tui.Dark, "/", snapshot, operation, started, nil, 100, started.Add(time.Duration(second)*time.Second))) | |
| 68 | + | if !strings.Contains(line, "reviewer · "+want+" · ") { | |
| 69 | + | t.Fatalf("provider wait at %ds = %q, want frame %q", second, line, want) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestRenderSubagentActivityLineShowsModelStepBesideElapsedTurnTime(t *testing.T) { | |
| 75 | + | started := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) | |
| 76 | + | operation := subagents.Operation{Type: "provider.request.started", TurnID: "turn-1", Step: 3, StartedAt: started.Add(8 * time.Second)} | |
| 77 | + | snapshot := subagents.AgentSnapshot{ID: "review-123", Subagent: "reviewer"} | |
| 78 | + | ||
| 79 | + | line := stripANSIBytes(renderSubagentActivityLine(tui.Dark, "/", snapshot, operation, started, nil, 100, started.Add(12*time.Second))) | |
| 80 | + | if !strings.Contains(line, "reviewer · Zzzz · 3 steps · 12s") { | |
| 81 | + | t.Fatalf("provider wait line = %q, want model step beside delegated-turn elapsed time", line) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 60 | 85 | func TestRenderSubagentActivityLinesOmitsPriorTurnObservation(t *testing.T) { | |
| 61 | 86 | now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) | |
| 62 | 87 | lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{ | |
| 63 | - | "review-123": {PrimaryOperation: &subagents.Operation{Type: "tool.started", TurnID: "turn-2", StartedAt: now.Add(-time.Minute)}, LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)}}, | |
| 88 | + | "review-123": {OpenOperations: []subagents.Operation{{Type: "tool.started", TurnID: "turn-2", StartedAt: now.Add(-time.Minute)}}, LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)}}, | |
| 64 | 89 | }, 100, now)) | |
| 65 | 90 | if got := strings.Join(lines, "\n"); strings.Contains(got, "assistant streaming") || !strings.Contains(got, "running tool") || !strings.Contains(got, "1m") { | |
| 66 | 91 | t.Fatalf("activity lines = %#v", lines) | |
| @@ -103,10 +128,10 @@ func TestRenderSubagentActivityLinesUsesStableDelegatedTurnElapsedTime(t *testin | |||
| 103 | 128 | turn := subagents.Operation{Type: "turn.started", TurnID: "turn-1", StartedAt: now.Add(-12 * time.Second)} | |
| 104 | 129 | request := subagents.Operation{Type: "provider.request.started", TurnID: "turn-1", StartedAt: now.Add(-time.Second)} | |
| 105 | 130 | 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}}, | |
| 131 | + | "review-123": {OpenOperations: []subagents.Operation{request, turn}}, | |
| 107 | 132 | }, 100, now)) | |
| 108 | 133 | got := strings.Join(lines, "\n") | |
| 109 | - | if !strings.Contains(got, "waiting for model response · 12s") || strings.Contains(got, "5h16m") { | |
| 134 | + | if !strings.Contains(got, "zZzz · 12s") || strings.Contains(got, "5h16m") { | |
| 110 | 135 | t.Fatalf("activity lines = %#v, want current delegated-turn elapsed time", lines) | |
| 111 | 136 | } | |
| 112 | 137 | } | |
| @@ -30,7 +30,7 @@ func TestRenderSubagentActivityLinesShowsOnlyTraceOpenOperations(t *testing.T) { | |||||
| 30 | func TestRenderSubagentActivityLinesShowsSafeLastObservation(t *testing.T) { | 30 | func TestRenderSubagentActivityLinesShowsSafeLastObservation(t *testing.T) { | ||
| 31 | now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) | 31 | now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) | ||
| 32 | lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{ | 32 | lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{ | ||
| 33 | - | "review-123": {PrimaryOperation: &subagents.Operation{Type: "turn.started", TurnID: "turn-1", StartedAt: now.Add(-time.Minute)}, LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)}}, | 33 | + | "review-123": {OpenOperations: []subagents.Operation{{Type: "turn.started", TurnID: "turn-1", StartedAt: now.Add(-time.Minute)}}, LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)}}, |
| 34 | }, 100, now)) | 34 | }, 100, now)) | ||
| 35 | if got := strings.Join(lines, "\n"); !strings.Contains(got, "processing turn · assistant streaming 1s ago") || strings.Contains(got, "delta") { | 35 | if got := strings.Join(lines, "\n"); !strings.Contains(got, "processing turn · assistant streaming 1s ago") || strings.Contains(got, "delta") { | ||
| 36 | t.Fatalf("activity lines = %#v", lines) | 36 | t.Fatalf("activity lines = %#v", lines) | ||
| @@ -40,10 +40,10 @@ func TestRenderSubagentActivityLinesShowsSafeLastObservation(t *testing.T) { | |||||
| 40 | func TestRenderSubagentActivityLinesShowsRecentCompletedToolUnderProviderRequest(t *testing.T) { | 40 | func TestRenderSubagentActivityLinesShowsRecentCompletedToolUnderProviderRequest(t *testing.T) { | ||
| 41 | now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) | 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{ | 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)}}, | 43 | + | "review-123": {OpenOperations: []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)) | 44 | }, 100, now)) | ||
| 45 | got := strings.Join(lines, "\n") | 45 | got := strings.Join(lines, "\n") | ||
| 46 | - | if !strings.Contains(got, "waiting for model response · finished read 3s ago · 2s") { | 46 | + | if !strings.Contains(got, "zzZz · finished read 3s ago · 2s") { |
| 47 | t.Fatalf("activity lines = %#v", lines) | 47 | t.Fatalf("activity lines = %#v", lines) | ||
| 48 | } | 48 | } | ||
| 49 | } | 49 | } | ||
| @@ -52,15 +52,40 @@ func TestRenderSubagentActivityLineColorsProviderWaitAsActive(t *testing.T) { | |||||
| 52 | now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) | 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)} | 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) | 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")) { | 55 | + | if !strings.Contains(line, tui.Dark.FGColor(tui.Dark.Spinner, "zzZz")) { |
| 56 | t.Fatalf("provider wait is not rendered with the active color: %q", line) | 56 | t.Fatalf("provider wait is not rendered with the active color: %q", line) | ||
| 57 | } | 57 | } | ||
| 58 | } | 58 | } | ||
| 59 | 59 | ||||
| 60 | + | func TestRenderSubagentActivityLineAnimatesProviderWaitAtOneHertz(t *testing.T) { | |||
| 61 | + | started := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) | |||
| 62 | + | operation := subagents.Operation{Type: "provider.request.started", TurnID: "turn-1", StartedAt: started} | |||
| 63 | + | snapshot := subagents.AgentSnapshot{ID: "review-123", Subagent: "reviewer"} | |||
| 64 | + | ||||
| 65 | + | wants := []string{"Zzzz", "zZzz", "zzZz", "zzzZ", "Zzzz"} | |||
| 66 | + | for second, want := range wants { | |||
| 67 | + | line := stripANSIBytes(renderSubagentActivityLine(tui.Dark, "/", snapshot, operation, started, nil, 100, started.Add(time.Duration(second)*time.Second))) | |||
| 68 | + | if !strings.Contains(line, "reviewer · "+want+" · ") { | |||
| 69 | + | t.Fatalf("provider wait at %ds = %q, want frame %q", second, line, want) | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestRenderSubagentActivityLineShowsModelStepBesideElapsedTurnTime(t *testing.T) { | |||
| 75 | + | started := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) | |||
| 76 | + | operation := subagents.Operation{Type: "provider.request.started", TurnID: "turn-1", Step: 3, StartedAt: started.Add(8 * time.Second)} | |||
| 77 | + | snapshot := subagents.AgentSnapshot{ID: "review-123", Subagent: "reviewer"} | |||
| 78 | + | ||||
| 79 | + | line := stripANSIBytes(renderSubagentActivityLine(tui.Dark, "/", snapshot, operation, started, nil, 100, started.Add(12*time.Second))) | |||
| 80 | + | if !strings.Contains(line, "reviewer · Zzzz · 3 steps · 12s") { | |||
| 81 | + | t.Fatalf("provider wait line = %q, want model step beside delegated-turn elapsed time", line) | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 60 | func TestRenderSubagentActivityLinesOmitsPriorTurnObservation(t *testing.T) { | 85 | func TestRenderSubagentActivityLinesOmitsPriorTurnObservation(t *testing.T) { | ||
| 61 | now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) | 86 | now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) | ||
| 62 | lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{ | 87 | lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{ | ||
| 63 | - | "review-123": {PrimaryOperation: &subagents.Operation{Type: "tool.started", TurnID: "turn-2", StartedAt: now.Add(-time.Minute)}, LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)}}, | 88 | + | "review-123": {OpenOperations: []subagents.Operation{{Type: "tool.started", TurnID: "turn-2", StartedAt: now.Add(-time.Minute)}}, LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)}}, |
| 64 | }, 100, now)) | 89 | }, 100, now)) | ||
| 65 | if got := strings.Join(lines, "\n"); strings.Contains(got, "assistant streaming") || !strings.Contains(got, "running tool") || !strings.Contains(got, "1m") { | 90 | if got := strings.Join(lines, "\n"); strings.Contains(got, "assistant streaming") || !strings.Contains(got, "running tool") || !strings.Contains(got, "1m") { | ||
| 66 | t.Fatalf("activity lines = %#v", lines) | 91 | t.Fatalf("activity lines = %#v", lines) | ||
| @@ -103,10 +128,10 @@ func TestRenderSubagentActivityLinesUsesStableDelegatedTurnElapsedTime(t *testin | |||||
| 103 | turn := subagents.Operation{Type: "turn.started", TurnID: "turn-1", StartedAt: now.Add(-12 * time.Second)} | 128 | 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)} | 129 | 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{ | 130 | 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}}, | 131 | + | "review-123": {OpenOperations: []subagents.Operation{request, turn}}, |
| 107 | }, 100, now)) | 132 | }, 100, now)) | ||
| 108 | got := strings.Join(lines, "\n") | 133 | got := strings.Join(lines, "\n") | ||
| 109 | - | if !strings.Contains(got, "waiting for model response · 12s") || strings.Contains(got, "5h16m") { | 134 | + | if !strings.Contains(got, "zZzz · 12s") || strings.Contains(got, "5h16m") { |
| 110 | t.Fatalf("activity lines = %#v, want current delegated-turn elapsed time", lines) | 135 | t.Fatalf("activity lines = %#v, want current delegated-turn elapsed time", lines) | ||
| 111 | } | 136 | } | ||
| 112 | } | 137 | } | ||
M packages/agent/modes/subagents_dialog.go +2 -6
| @@ -1641,12 +1641,8 @@ func (d *subagentsDialog) traceView(id string) subagents.AgentTraceView { | |||
| 1641 | 1641 | // completed write-tests-67890 1h add coverage | |
| 1642 | 1642 | func formatSupervisorRow(r subagents.AgentSnapshot, view subagents.AgentTraceView, maxWidth int) string { | |
| 1643 | 1643 | fact := view.Summary() | |
| 1644 | - | primary := view.PrimaryOperation | |
| 1645 | - | if primary == nil && len(view.OpenOperations) != 0 { | |
| 1646 | - | primary = &view.OpenOperations[0] | |
| 1647 | - | } | |
| 1648 | - | if primary != nil { | |
| 1649 | - | if observation := view.ObservationFor(*primary); observation != nil { | |
| 1644 | + | if primary, ok := view.Primary(); ok { | |
| 1645 | + | if observation := view.ObservationFor(primary); observation != nil { | |
| 1650 | 1646 | fact += " " + formatAge(observation.At) | |
| 1651 | 1647 | } else { | |
| 1652 | 1648 | fact += " " + formatAge(primary.StartedAt) | |
| @@ -1641,12 +1641,8 @@ func (d *subagentsDialog) traceView(id string) subagents.AgentTraceView { | |||||
| 1641 | // completed write-tests-67890 1h add coverage | 1641 | // completed write-tests-67890 1h add coverage | ||
| 1642 | func formatSupervisorRow(r subagents.AgentSnapshot, view subagents.AgentTraceView, maxWidth int) string { | 1642 | func formatSupervisorRow(r subagents.AgentSnapshot, view subagents.AgentTraceView, maxWidth int) string { | ||
| 1643 | fact := view.Summary() | 1643 | fact := view.Summary() | ||
| 1644 | - | primary := view.PrimaryOperation | 1644 | + | if primary, ok := view.Primary(); ok { |
| 1645 | - | if primary == nil && len(view.OpenOperations) != 0 { | 1645 | + | if observation := view.ObservationFor(primary); observation != nil { |
| 1646 | - | primary = &view.OpenOperations[0] | |||
| 1647 | - | } | |||
| 1648 | - | if primary != nil { | |||
| 1649 | - | if observation := view.ObservationFor(*primary); observation != nil { | |||
| 1650 | fact += " " + formatAge(observation.At) | 1646 | fact += " " + formatAge(observation.At) | ||
| 1651 | } else { | 1647 | } else { | ||
| 1652 | fact += " " + formatAge(primary.StartedAt) | 1648 | fact += " " + formatAge(primary.StartedAt) | ||
M packages/agent/modes/subagents_dialog_test.go +2 -2
| @@ -38,8 +38,8 @@ func TestFormatSupervisorRowShowsTerminalResultDelivery(t *testing.T) { | |||
| 38 | 38 | func TestFormatSupervisorRowOmitsPriorTurnObservationAge(t *testing.T) { | |
| 39 | 39 | now := time.Now() | |
| 40 | 40 | got := formatSupervisorRow(subagents.AgentSnapshot{ID: "review-1", Started: now.Add(-time.Hour)}, subagents.AgentTraceView{ | |
| 41 | - | PrimaryOperation: &subagents.Operation{Type: "tool.started", TurnID: "turn-2", StartedAt: now.Add(-time.Minute)}, | |
| 42 | - | LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)}, | |
| 41 | + | OpenOperations: []subagents.Operation{{Type: "tool.started", TurnID: "turn-2", StartedAt: now.Add(-time.Minute)}}, | |
| 42 | + | LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)}, | |
| 43 | 43 | }, 120) | |
| 44 | 44 | if strings.Contains(got, "assistant streaming") || !strings.Contains(got, "running tool") || !strings.Contains(got, "1m") { | |
| 45 | 45 | t.Fatalf("row = %q", got) | |
| @@ -38,8 +38,8 @@ func TestFormatSupervisorRowShowsTerminalResultDelivery(t *testing.T) { | |||||
| 38 | func TestFormatSupervisorRowOmitsPriorTurnObservationAge(t *testing.T) { | 38 | func TestFormatSupervisorRowOmitsPriorTurnObservationAge(t *testing.T) { | ||
| 39 | now := time.Now() | 39 | now := time.Now() | ||
| 40 | got := formatSupervisorRow(subagents.AgentSnapshot{ID: "review-1", Started: now.Add(-time.Hour)}, subagents.AgentTraceView{ | 40 | got := formatSupervisorRow(subagents.AgentSnapshot{ID: "review-1", Started: now.Add(-time.Hour)}, subagents.AgentTraceView{ | ||
| 41 | - | PrimaryOperation: &subagents.Operation{Type: "tool.started", TurnID: "turn-2", StartedAt: now.Add(-time.Minute)}, | 41 | + | OpenOperations: []subagents.Operation{{Type: "tool.started", TurnID: "turn-2", StartedAt: now.Add(-time.Minute)}}, |
| 42 | - | LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)}, | 42 | + | LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)}, |
| 43 | }, 120) | 43 | }, 120) | ||
| 44 | if strings.Contains(got, "assistant streaming") || !strings.Contains(got, "running tool") || !strings.Contains(got, "1m") { | 44 | if strings.Contains(got, "assistant streaming") || !strings.Contains(got, "running tool") || !strings.Contains(got, "1m") { | ||
| 45 | t.Fatalf("row = %q", got) | 45 | t.Fatalf("row = %q", got) | ||
M packages/agent/subagent_runtime.go +0 -2
| @@ -499,8 +499,6 @@ func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy { | |||
| 499 | 499 | QueueTimeout: parseDuration(cfg.QueueTimeout), | |
| 500 | 500 | StartupTimeout: parseDuration(cfg.StartupTimeout), | |
| 501 | 501 | DefaultTimeout: parseDuration(cfg.DefaultTimeout), | |
| 502 | - | MaxTurns: cfg.MaxTurns, | |
| 503 | - | MaxSteps: cfg.MaxSteps, | |
| 504 | 502 | MaxOutputBytes: cfg.MaxOutputBytes, | |
| 505 | 503 | MaxOutputLines: cfg.MaxOutputLines, | |
| 506 | 504 | AllowedTools: append([]string(nil), cfg.AllowedTools...), | |
| @@ -499,8 +499,6 @@ func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy { | |||||
| 499 | QueueTimeout: parseDuration(cfg.QueueTimeout), | 499 | QueueTimeout: parseDuration(cfg.QueueTimeout), | ||
| 500 | StartupTimeout: parseDuration(cfg.StartupTimeout), | 500 | StartupTimeout: parseDuration(cfg.StartupTimeout), | ||
| 501 | DefaultTimeout: parseDuration(cfg.DefaultTimeout), | 501 | DefaultTimeout: parseDuration(cfg.DefaultTimeout), | ||
| 502 | - | MaxTurns: cfg.MaxTurns, | |||
| 503 | - | MaxSteps: cfg.MaxSteps, | |||
| 504 | MaxOutputBytes: cfg.MaxOutputBytes, | 502 | MaxOutputBytes: cfg.MaxOutputBytes, | ||
| 505 | MaxOutputLines: cfg.MaxOutputLines, | 503 | MaxOutputLines: cfg.MaxOutputLines, | ||
| 506 | AllowedTools: append([]string(nil), cfg.AllowedTools...), | 504 | AllowedTools: append([]string(nil), cfg.AllowedTools...), | ||
M packages/agent/subagent_runtime_test.go +0 -1
| @@ -59,7 +59,6 @@ func newRuntimeTestConfig(root, repoRoot string, runner func(*subagents.Agent) s | |||
| 59 | 59 | FastMode: true, | |
| 60 | 60 | Policy: subagents.SubagentPolicy{ | |
| 61 | 61 | MaxConcurrent: 2, | |
| 62 | - | MaxTurns: 4, | |
| 63 | 62 | DefaultTimeout: time.Minute, | |
| 64 | 63 | CancelGracePeriod: time.Second, | |
| 65 | 64 | }, | |
| @@ -59,7 +59,6 @@ func newRuntimeTestConfig(root, repoRoot string, runner func(*subagents.Agent) s | |||||
| 59 | FastMode: true, | 59 | FastMode: true, | ||
| 60 | Policy: subagents.SubagentPolicy{ | 60 | Policy: subagents.SubagentPolicy{ | ||
| 61 | MaxConcurrent: 2, | 61 | MaxConcurrent: 2, | ||
| 62 | - | MaxTurns: 4, | |||
| 63 | DefaultTimeout: time.Minute, | 62 | DefaultTimeout: time.Minute, | ||
| 64 | CancelGracePeriod: time.Second, | 63 | CancelGracePeriod: time.Second, | ||
| 65 | }, | 64 | }, | ||
M packages/agent/subagent_worker.go +8 -33
| @@ -34,10 +34,9 @@ import ( | |||
| 34 | 34 | // production; tests use the stubchild binary under | |
| 35 | 35 | // packages/agent/subagents/testdata/cmd/stubchild instead of the real model | |
| 36 | 36 | // loop. | |
| 37 | - | // workerTurnBudget is deliberately independent of the model loop: one | |
| 38 | - | // admitted message turn consumes one current-run and lifetime turn, while | |
| 39 | - | // retries, compaction, and provider/tool loops remain inside that turn. | |
| 40 | - | type workerTurnBudget struct { | |
| 37 | + | // workerTurnCounters track stable delegated-turn identity and observability. | |
| 38 | + | // Retries, compaction, and provider/tool loops remain inside one message turn. | |
| 39 | + | type workerTurnCounters struct { | |
| 41 | 40 | sequence int | |
| 42 | 41 | lifetime int | |
| 43 | 42 | current int | |
| @@ -71,17 +70,14 @@ func (s *sessionPersistenceState) err() error { | |||
| 71 | 70 | return s.errValue | |
| 72 | 71 | } | |
| 73 | 72 | ||
| 74 | - | func (b *workerTurnBudget) start(maxTurns int, newRun bool) (step, lifetime, current int, admitted bool) { | |
| 73 | + | func (b *workerTurnCounters) start(newRun bool) (step, lifetime, current int) { | |
| 75 | 74 | if newRun { | |
| 76 | 75 | b.current = 0 | |
| 77 | 76 | } | |
| 78 | 77 | b.sequence++ | |
| 79 | - | if maxTurns > 0 && b.current >= maxTurns { | |
| 80 | - | return b.sequence, b.lifetime, b.current, false | |
| 81 | - | } | |
| 82 | 78 | b.lifetime++ | |
| 83 | 79 | b.current++ | |
| 84 | - | return b.sequence, b.lifetime, b.current, true | |
| 80 | + | return b.sequence, b.lifetime, b.current | |
| 85 | 81 | } | |
| 86 | 82 | ||
| 87 | 83 | func subagentTurnContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { | |
| @@ -188,7 +184,6 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE | |||
| 188 | 184 | "model": r.Model, | |
| 189 | 185 | "lifetime_turns": args.SubagentLifetimeTurns, | |
| 190 | 186 | "current_run_turns": args.SubagentRunTurns, | |
| 191 | - | "max_turns": args.SubagentMaxTurns, | |
| 192 | 187 | }) | |
| 193 | 188 | ||
| 194 | 189 | // Keep a per-turn cancel so the "cancel" inbox message can interrupt | |
| @@ -197,7 +192,8 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE | |||
| 197 | 192 | mu sync.Mutex | |
| 198 | 193 | cancelFn context.CancelFunc | |
| 199 | 194 | busyTurn bool | |
| 200 | - | budget = workerTurnBudget{ | |
| 195 | + | counters = workerTurnCounters{ | |
| 196 | + | sequence: args.SubagentLifetimeTurns, | |
| 201 | 197 | lifetime: args.SubagentLifetimeTurns, | |
| 202 | 198 | current: args.SubagentRunTurns, | |
| 203 | 199 | } | |
| @@ -225,25 +221,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE | |||
| 225 | 221 | } | |
| 226 | 222 | busyTurn = true | |
| 227 | 223 | turnPending = false | |
| 228 | - | step, lifetime, currentRun, admitted := budget.start(args.SubagentMaxTurns, newRun) | |
| 229 | - | if !admitted { | |
| 230 | - | busyTurn = false | |
| 231 | - | mu.Unlock() | |
| 232 | - | turnID := fmt.Sprintf("turn-%d", step) | |
| 233 | - | errPayload := map[string]any{"code": "turn_rejected", "reason": "max_turns", "command_id": commandID, "message": "maximum subagent turns reached"} | |
| 234 | - | em.emit("error", errPayload) | |
| 235 | - | em.emit("turn.result", map[string]any{"status": "failed", "turn_id": turnID, "error": errPayload}) | |
| 236 | - | em.emit("turn.failed", map[string]any{"turn_id": turnID, "error": errPayload}) | |
| 237 | - | em.emit("turn_end", map[string]any{"step": step, "turn_id": turnID, "error": errPayload["message"]}) | |
| 238 | - | // Max-turn rejection ends the current run, but the worker remains | |
| 239 | - | // alive and can accept a queued follow-up as a fresh run. | |
| 240 | - | em.emit("agent.idle", map[string]any{ | |
| 241 | - | "turn_id": turnID, | |
| 242 | - | "lifetime_turns": lifetime, | |
| 243 | - | "current_run_turns": currentRun, | |
| 244 | - | }) | |
| 245 | - | return | |
| 246 | - | } | |
| 224 | + | step, lifetime, currentRun := counters.start(newRun) | |
| 247 | 225 | turnID := fmt.Sprintf("turn-%d", step) | |
| 248 | 226 | em.setTurnID(turnID) | |
| 249 | 227 | c, cancel := subagentTurnContext(ctx, args.SubagentTurnTimeout) | |
| @@ -707,9 +685,6 @@ func resultErrorPayload(err error, shutdownOrigin subagents.ShutdownOrigin) map[ | |||
| 707 | 685 | if errors.Is(err, core.ErrStreamIdleTimeout) { | |
| 708 | 686 | return map[string]any{"code": "stream_idle_timeout", "message": "provider stream produced no events for five minutes; the delegated turn stopped safely"} | |
| 709 | 687 | } | |
| 710 | - | if errors.Is(err, core.ErrMaxSteps) { | |
| 711 | - | return map[string]any{"code": "step_limit", "message": "subagent model-step limit reached; partial output is preserved and the worker can be resumed"} | |
| 712 | - | } | |
| 713 | 688 | if errors.Is(err, context.DeadlineExceeded) || shutdownOrigin == subagents.ShutdownOriginDeadline { | |
| 714 | 689 | return map[string]any{"code": "deadline_exceeded", "message": "subagent turn deadline exceeded; partial output is preserved in the result and history"} | |
| 715 | 690 | } | |
| @@ -34,10 +34,9 @@ import ( | |||||
| 34 | // production; tests use the stubchild binary under | 34 | // production; tests use the stubchild binary under | ||
| 35 | // packages/agent/subagents/testdata/cmd/stubchild instead of the real model | 35 | // packages/agent/subagents/testdata/cmd/stubchild instead of the real model | ||
| 36 | // loop. | 36 | // loop. | ||
| 37 | - | // workerTurnBudget is deliberately independent of the model loop: one | 37 | + | // workerTurnCounters track stable delegated-turn identity and observability. |
| 38 | - | // admitted message turn consumes one current-run and lifetime turn, while | 38 | + | // Retries, compaction, and provider/tool loops remain inside one message turn. |
| 39 | - | // retries, compaction, and provider/tool loops remain inside that turn. | 39 | + | type workerTurnCounters struct { |
| 40 | - | type workerTurnBudget struct { | |||
| 41 | sequence int | 40 | sequence int | ||
| 42 | lifetime int | 41 | lifetime int | ||
| 43 | current int | 42 | current int | ||
| @@ -71,17 +70,14 @@ func (s *sessionPersistenceState) err() error { | |||||
| 71 | return s.errValue | 70 | return s.errValue | ||
| 72 | } | 71 | } | ||
| 73 | 72 | ||||
| 74 | - | func (b *workerTurnBudget) start(maxTurns int, newRun bool) (step, lifetime, current int, admitted bool) { | 73 | + | func (b *workerTurnCounters) start(newRun bool) (step, lifetime, current int) { |
| 75 | if newRun { | 74 | if newRun { | ||
| 76 | b.current = 0 | 75 | b.current = 0 | ||
| 77 | } | 76 | } | ||
| 78 | b.sequence++ | 77 | b.sequence++ | ||
| 79 | - | if maxTurns > 0 && b.current >= maxTurns { | |||
| 80 | - | return b.sequence, b.lifetime, b.current, false | |||
| 81 | - | } | |||
| 82 | b.lifetime++ | 78 | b.lifetime++ | ||
| 83 | b.current++ | 79 | b.current++ | ||
| 84 | - | return b.sequence, b.lifetime, b.current, true | 80 | + | return b.sequence, b.lifetime, b.current |
| 85 | } | 81 | } | ||
| 86 | 82 | ||||
| 87 | func subagentTurnContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { | 83 | func subagentTurnContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { | ||
| @@ -188,7 +184,6 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE | |||||
| 188 | "model": r.Model, | 184 | "model": r.Model, | ||
| 189 | "lifetime_turns": args.SubagentLifetimeTurns, | 185 | "lifetime_turns": args.SubagentLifetimeTurns, | ||
| 190 | "current_run_turns": args.SubagentRunTurns, | 186 | "current_run_turns": args.SubagentRunTurns, | ||
| 191 | - | "max_turns": args.SubagentMaxTurns, | |||
| 192 | }) | 187 | }) | ||
| 193 | 188 | ||||
| 194 | // Keep a per-turn cancel so the "cancel" inbox message can interrupt | 189 | // Keep a per-turn cancel so the "cancel" inbox message can interrupt | ||
| @@ -197,7 +192,8 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE | |||||
| 197 | mu sync.Mutex | 192 | mu sync.Mutex | ||
| 198 | cancelFn context.CancelFunc | 193 | cancelFn context.CancelFunc | ||
| 199 | busyTurn bool | 194 | busyTurn bool | ||
| 200 | - | budget = workerTurnBudget{ | 195 | + | counters = workerTurnCounters{ |
| 196 | + | sequence: args.SubagentLifetimeTurns, | |||
| 201 | lifetime: args.SubagentLifetimeTurns, | 197 | lifetime: args.SubagentLifetimeTurns, | ||
| 202 | current: args.SubagentRunTurns, | 198 | current: args.SubagentRunTurns, | ||
| 203 | } | 199 | } | ||
| @@ -225,25 +221,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE | |||||
| 225 | } | 221 | } | ||
| 226 | busyTurn = true | 222 | busyTurn = true | ||
| 227 | turnPending = false | 223 | turnPending = false | ||
| 228 | - | step, lifetime, currentRun, admitted := budget.start(args.SubagentMaxTurns, newRun) | 224 | + | step, lifetime, currentRun := counters.start(newRun) |
| 229 | - | if !admitted { | |||
| 230 | - | busyTurn = false | |||
| 231 | - | mu.Unlock() | |||
| 232 | - | turnID := fmt.Sprintf("turn-%d", step) | |||
| 233 | - | errPayload := map[string]any{"code": "turn_rejected", "reason": "max_turns", "command_id": commandID, "message": "maximum subagent turns reached"} | |||
| 234 | - | em.emit("error", errPayload) | |||
| 235 | - | em.emit("turn.result", map[string]any{"status": "failed", "turn_id": turnID, "error": errPayload}) | |||
| 236 | - | em.emit("turn.failed", map[string]any{"turn_id": turnID, "error": errPayload}) | |||
| 237 | - | em.emit("turn_end", map[string]any{"step": step, "turn_id": turnID, "error": errPayload["message"]}) | |||
| 238 | - | // Max-turn rejection ends the current run, but the worker remains | |||
| 239 | - | // alive and can accept a queued follow-up as a fresh run. | |||
| 240 | - | em.emit("agent.idle", map[string]any{ | |||
| 241 | - | "turn_id": turnID, | |||
| 242 | - | "lifetime_turns": lifetime, | |||
| 243 | - | "current_run_turns": currentRun, | |||
| 244 | - | }) | |||
| 245 | - | return | |||
| 246 | - | } | |||
| 247 | turnID := fmt.Sprintf("turn-%d", step) | 225 | turnID := fmt.Sprintf("turn-%d", step) | ||
| 248 | em.setTurnID(turnID) | 226 | em.setTurnID(turnID) | ||
| 249 | c, cancel := subagentTurnContext(ctx, args.SubagentTurnTimeout) | 227 | c, cancel := subagentTurnContext(ctx, args.SubagentTurnTimeout) | ||
| @@ -707,9 +685,6 @@ func resultErrorPayload(err error, shutdownOrigin subagents.ShutdownOrigin) map[ | |||||
| 707 | if errors.Is(err, core.ErrStreamIdleTimeout) { | 685 | if errors.Is(err, core.ErrStreamIdleTimeout) { | ||
| 708 | return map[string]any{"code": "stream_idle_timeout", "message": "provider stream produced no events for five minutes; the delegated turn stopped safely"} | 686 | return map[string]any{"code": "stream_idle_timeout", "message": "provider stream produced no events for five minutes; the delegated turn stopped safely"} | ||
| 709 | } | 687 | } | ||
| 710 | - | if errors.Is(err, core.ErrMaxSteps) { | |||
| 711 | - | return map[string]any{"code": "step_limit", "message": "subagent model-step limit reached; partial output is preserved and the worker can be resumed"} | |||
| 712 | - | } | |||
| 713 | if errors.Is(err, context.DeadlineExceeded) || shutdownOrigin == subagents.ShutdownOriginDeadline { | 688 | if errors.Is(err, context.DeadlineExceeded) || shutdownOrigin == subagents.ShutdownOriginDeadline { | ||
| 714 | return map[string]any{"code": "deadline_exceeded", "message": "subagent turn deadline exceeded; partial output is preserved in the result and history"} | 689 | return map[string]any{"code": "deadline_exceeded", "message": "subagent turn deadline exceeded; partial output is preserved in the result and history"} | ||
| 715 | } | 690 | } | ||
M packages/agent/subagent_worker_test.go +14 -21
| @@ -66,13 +66,6 @@ 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 | - | ||
| 76 | 69 | func TestResultErrorPayloadAttributesShutdownCancellation(t *testing.T) { | |
| 77 | 70 | payload := resultErrorPayload(context.Canceled, subagents.ShutdownOriginSession) | |
| 78 | 71 | if payload["code"] != "shutdown" || payload["message"] != "subagent stopped during session shutdown" { | |
| @@ -865,24 +858,24 @@ func TestSupervisorEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) { | |||
| 865 | 858 | } | |
| 866 | 859 | } | |
| 867 | 860 | ||
| 868 | - | func TestWorkerTurnBudgetFreshAllowanceOnlyForExplicitRun(t *testing.T) { | |
| 869 | - | var budget workerTurnBudget | |
| 861 | + | func TestWorkerTurnCountersRemainObservableWithoutAdmissionLimit(t *testing.T) { | |
| 862 | + | var counters workerTurnCounters | |
| 870 | 863 | ||
| 871 | - | if _, lifetime, current, admitted := budget.start(2, false); !admitted || lifetime != 1 || current != 1 { | |
| 872 | - | t.Fatalf("first turn = (%d, %d, %v), want (1, 1, true)", lifetime, current, admitted) | |
| 864 | + | if step, lifetime, current := counters.start(false); step != 1 || lifetime != 1 || current != 1 { | |
| 865 | + | t.Fatalf("first turn = (%d, %d, %d), want (1, 1, 1)", step, lifetime, current) | |
| 873 | 866 | } | |
| 874 | - | // Provider retries, model loops, and compaction stay inside the admitted | |
| 875 | - | // turn and therefore do not call start or consume another allowance. | |
| 876 | - | if budget.lifetime != 1 || budget.current != 1 { | |
| 877 | - | t.Fatalf("nested work changed budget to (lifetime=%d, current=%d)", budget.lifetime, budget.current) | |
| 867 | + | // Provider retries, model loops, and compaction stay inside the delegated | |
| 868 | + | // turn and therefore do not advance the message-turn counters. | |
| 869 | + | if counters.lifetime != 1 || counters.current != 1 { | |
| 870 | + | t.Fatalf("nested work changed counters to (lifetime=%d, current=%d)", counters.lifetime, counters.current) | |
| 878 | 871 | } | |
| 879 | - | if _, lifetime, current, admitted := budget.start(2, false); !admitted || lifetime != 2 || current != 2 { | |
| 880 | - | t.Fatalf("second turn = (%d, %d, %v), want (2, 2, true)", lifetime, current, admitted) | |
| 872 | + | if step, lifetime, current := counters.start(false); step != 2 || lifetime != 2 || current != 2 { | |
| 873 | + | t.Fatalf("second turn = (%d, %d, %d), want (2, 2, 2)", step, lifetime, current) | |
| 881 | 874 | } | |
| 882 | - | if _, lifetime, current, admitted := budget.start(2, false); admitted || lifetime != 2 || current != 2 { | |
| 883 | - | t.Fatalf("exhausted turn = (%d, %d, %v), want (2, 2, false)", lifetime, current, admitted) | |
| 875 | + | if step, lifetime, current := counters.start(false); step != 3 || lifetime != 3 || current != 3 { | |
| 876 | + | t.Fatalf("third turn = (%d, %d, %d), want (3, 3, 3)", step, lifetime, current) | |
| 884 | 877 | } | |
| 885 | - | if _, lifetime, current, admitted := budget.start(2, true); !admitted || lifetime != 3 || current != 1 { | |
| 886 | - | t.Fatalf("explicit resumed turn = (%d, %d, %v), want (3, 1, true)", lifetime, current, admitted) | |
| 878 | + | if step, lifetime, current := counters.start(true); step != 4 || lifetime != 4 || current != 1 { | |
| 879 | + | t.Fatalf("explicit resumed turn = (%d, %d, %d), want (4, 4, 1)", step, lifetime, current) | |
| 887 | 880 | } | |
| 888 | 881 | } | |
| @@ -66,13 +66,6 @@ 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 | - | ||||
| 76 | func TestResultErrorPayloadAttributesShutdownCancellation(t *testing.T) { | 69 | func TestResultErrorPayloadAttributesShutdownCancellation(t *testing.T) { | ||
| 77 | payload := resultErrorPayload(context.Canceled, subagents.ShutdownOriginSession) | 70 | payload := resultErrorPayload(context.Canceled, subagents.ShutdownOriginSession) | ||
| 78 | if payload["code"] != "shutdown" || payload["message"] != "subagent stopped during session shutdown" { | 71 | if payload["code"] != "shutdown" || payload["message"] != "subagent stopped during session shutdown" { | ||
| @@ -865,24 +858,24 @@ func TestSupervisorEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) { | |||||
| 865 | } | 858 | } | ||
| 866 | } | 859 | } | ||
| 867 | 860 | ||||
| 868 | - | func TestWorkerTurnBudgetFreshAllowanceOnlyForExplicitRun(t *testing.T) { | 861 | + | func TestWorkerTurnCountersRemainObservableWithoutAdmissionLimit(t *testing.T) { |
| 869 | - | var budget workerTurnBudget | 862 | + | var counters workerTurnCounters |
| 870 | 863 | ||||
| 871 | - | if _, lifetime, current, admitted := budget.start(2, false); !admitted || lifetime != 1 || current != 1 { | 864 | + | if step, lifetime, current := counters.start(false); step != 1 || lifetime != 1 || current != 1 { |
| 872 | - | t.Fatalf("first turn = (%d, %d, %v), want (1, 1, true)", lifetime, current, admitted) | 865 | + | t.Fatalf("first turn = (%d, %d, %d), want (1, 1, 1)", step, lifetime, current) |
| 873 | } | 866 | } | ||
| 874 | - | // Provider retries, model loops, and compaction stay inside the admitted | 867 | + | // Provider retries, model loops, and compaction stay inside the delegated |
| 875 | - | // turn and therefore do not call start or consume another allowance. | 868 | + | // turn and therefore do not advance the message-turn counters. |
| 876 | - | if budget.lifetime != 1 || budget.current != 1 { | 869 | + | if counters.lifetime != 1 || counters.current != 1 { |
| 877 | - | t.Fatalf("nested work changed budget to (lifetime=%d, current=%d)", budget.lifetime, budget.current) | 870 | + | t.Fatalf("nested work changed counters to (lifetime=%d, current=%d)", counters.lifetime, counters.current) |
| 878 | } | 871 | } | ||
| 879 | - | if _, lifetime, current, admitted := budget.start(2, false); !admitted || lifetime != 2 || current != 2 { | 872 | + | if step, lifetime, current := counters.start(false); step != 2 || lifetime != 2 || current != 2 { |
| 880 | - | t.Fatalf("second turn = (%d, %d, %v), want (2, 2, true)", lifetime, current, admitted) | 873 | + | t.Fatalf("second turn = (%d, %d, %d), want (2, 2, 2)", step, lifetime, current) |
| 881 | } | 874 | } | ||
| 882 | - | if _, lifetime, current, admitted := budget.start(2, false); admitted || lifetime != 2 || current != 2 { | 875 | + | if step, lifetime, current := counters.start(false); step != 3 || lifetime != 3 || current != 3 { |
| 883 | - | t.Fatalf("exhausted turn = (%d, %d, %v), want (2, 2, false)", lifetime, current, admitted) | 876 | + | t.Fatalf("third turn = (%d, %d, %d), want (3, 3, 3)", step, lifetime, current) |
| 884 | } | 877 | } | ||
| 885 | - | if _, lifetime, current, admitted := budget.start(2, true); !admitted || lifetime != 3 || current != 1 { | 878 | + | if step, lifetime, current := counters.start(true); step != 4 || lifetime != 4 || current != 1 { |
| 886 | - | t.Fatalf("explicit resumed turn = (%d, %d, %v), want (3, 1, true)", lifetime, current, admitted) | 879 | + | t.Fatalf("explicit resumed turn = (%d, %d, %d), want (4, 4, 1)", step, lifetime, current) |
| 887 | } | 880 | } | ||
| 888 | } | 881 | } | ||
M packages/agent/subagents/agent.go +28 -2
| @@ -49,8 +49,6 @@ type Agent struct { | |||
| 49 | 49 | WorkspacePath string | |
| 50 | 50 | WorkspaceBase string | |
| 51 | 51 | WorkspaceCapture CaptureMode | |
| 52 | - | MaxTurns int | |
| 53 | - | MaxSteps int | |
| 54 | 52 | ||
| 55 | 53 | // LifetimeTurns counts every accepted message turn for this worker across | |
| 56 | 54 | // all explicit runs. CurrentRunTurns resets only when an explicit | |
| @@ -211,6 +209,8 @@ type Agent struct { | |||
| 211 | 209 | // status (done / failed / killed). Wait blocks on this so | |
| 212 | 210 | // callers don't have to poll. | |
| 213 | 211 | done chan struct{} | |
| 212 | + | results map[string]*TurnResult | |
| 213 | + | resultWaiters map[string]int | |
| 214 | 214 | resultChanged chan struct{} | |
| 215 | 215 | doneOnce sync.Once | |
| 216 | 216 | } | |
| @@ -460,6 +460,12 @@ func (a *Agent) markActivity(now time.Time) { | |||
| 460 | 460 | func (a *Agent) setResult(result *TurnResult) { | |
| 461 | 461 | a.lifecycleMu.Lock() | |
| 462 | 462 | a.result = cloneTurnResult(result) | |
| 463 | + | if result != nil && result.TurnID != "" && a.resultWaiters[result.TurnID] > 0 { | |
| 464 | + | if a.results == nil { | |
| 465 | + | a.results = make(map[string]*TurnResult) | |
| 466 | + | } | |
| 467 | + | a.results[result.TurnID] = cloneTurnResult(result) | |
| 468 | + | } | |
| 463 | 469 | a.updatedAt = time.Now() | |
| 464 | 470 | if a.resultChanged != nil { | |
| 465 | 471 | close(a.resultChanged) | |
| @@ -537,9 +543,29 @@ func (a *Agent) WaitTurnResult(ctx context.Context, turnID string) (*TurnResult, | |||
| 537 | 543 | if ctx == nil { | |
| 538 | 544 | ctx = context.Background() | |
| 539 | 545 | } | |
| 546 | + | if turnID != "" { | |
| 547 | + | a.lifecycleMu.Lock() | |
| 548 | + | if a.resultWaiters == nil { | |
| 549 | + | a.resultWaiters = make(map[string]int) | |
| 550 | + | } | |
| 551 | + | a.resultWaiters[turnID]++ | |
| 552 | + | a.lifecycleMu.Unlock() | |
| 553 | + | defer func() { | |
| 554 | + | a.lifecycleMu.Lock() | |
| 555 | + | a.resultWaiters[turnID]-- | |
| 556 | + | if a.resultWaiters[turnID] == 0 { | |
| 557 | + | delete(a.resultWaiters, turnID) | |
| 558 | + | delete(a.results, turnID) | |
| 559 | + | } | |
| 560 | + | a.lifecycleMu.Unlock() | |
| 561 | + | }() | |
| 562 | + | } | |
| 540 | 563 | for { | |
| 541 | 564 | a.lifecycleMu.Lock() | |
| 542 | 565 | result := cloneTurnResult(a.result) | |
| 566 | + | if turnID != "" && a.results != nil { | |
| 567 | + | result = cloneTurnResult(a.results[turnID]) | |
| 568 | + | } | |
| 543 | 569 | if result != nil && (turnID == "" || result.TurnID == turnID) { | |
| 544 | 570 | a.lifecycleMu.Unlock() | |
| 545 | 571 | return result, nil | |
| @@ -49,8 +49,6 @@ type Agent struct { | |||||
| 49 | WorkspacePath string | 49 | WorkspacePath string | ||
| 50 | WorkspaceBase string | 50 | WorkspaceBase string | ||
| 51 | WorkspaceCapture CaptureMode | 51 | WorkspaceCapture CaptureMode | ||
| 52 | - | MaxTurns int | |||
| 53 | - | MaxSteps int | |||
| 54 | 52 | ||||
| 55 | // LifetimeTurns counts every accepted message turn for this worker across | 53 | // LifetimeTurns counts every accepted message turn for this worker across | ||
| 56 | // all explicit runs. CurrentRunTurns resets only when an explicit | 54 | // all explicit runs. CurrentRunTurns resets only when an explicit | ||
| @@ -211,6 +209,8 @@ type Agent struct { | |||||
| 211 | // status (done / failed / killed). Wait blocks on this so | 209 | // status (done / failed / killed). Wait blocks on this so | ||
| 212 | // callers don't have to poll. | 210 | // callers don't have to poll. | ||
| 213 | done chan struct{} | 211 | done chan struct{} | ||
| 212 | + | results map[string]*TurnResult | |||
| 213 | + | resultWaiters map[string]int | |||
| 214 | resultChanged chan struct{} | 214 | resultChanged chan struct{} | ||
| 215 | doneOnce sync.Once | 215 | doneOnce sync.Once | ||
| 216 | } | 216 | } | ||
| @@ -460,6 +460,12 @@ func (a *Agent) markActivity(now time.Time) { | |||||
| 460 | func (a *Agent) setResult(result *TurnResult) { | 460 | func (a *Agent) setResult(result *TurnResult) { | ||
| 461 | a.lifecycleMu.Lock() | 461 | a.lifecycleMu.Lock() | ||
| 462 | a.result = cloneTurnResult(result) | 462 | a.result = cloneTurnResult(result) | ||
| 463 | + | if result != nil && result.TurnID != "" && a.resultWaiters[result.TurnID] > 0 { | |||
| 464 | + | if a.results == nil { | |||
| 465 | + | a.results = make(map[string]*TurnResult) | |||
| 466 | + | } | |||
| 467 | + | a.results[result.TurnID] = cloneTurnResult(result) | |||
| 468 | + | } | |||
| 463 | a.updatedAt = time.Now() | 469 | a.updatedAt = time.Now() | ||
| 464 | if a.resultChanged != nil { | 470 | if a.resultChanged != nil { | ||
| 465 | close(a.resultChanged) | 471 | close(a.resultChanged) | ||
| @@ -537,9 +543,29 @@ func (a *Agent) WaitTurnResult(ctx context.Context, turnID string) (*TurnResult, | |||||
| 537 | if ctx == nil { | 543 | if ctx == nil { | ||
| 538 | ctx = context.Background() | 544 | ctx = context.Background() | ||
| 539 | } | 545 | } | ||
| 546 | + | if turnID != "" { | |||
| 547 | + | a.lifecycleMu.Lock() | |||
| 548 | + | if a.resultWaiters == nil { | |||
| 549 | + | a.resultWaiters = make(map[string]int) | |||
| 550 | + | } | |||
| 551 | + | a.resultWaiters[turnID]++ | |||
| 552 | + | a.lifecycleMu.Unlock() | |||
| 553 | + | defer func() { | |||
| 554 | + | a.lifecycleMu.Lock() | |||
| 555 | + | a.resultWaiters[turnID]-- | |||
| 556 | + | if a.resultWaiters[turnID] == 0 { | |||
| 557 | + | delete(a.resultWaiters, turnID) | |||
| 558 | + | delete(a.results, turnID) | |||
| 559 | + | } | |||
| 560 | + | a.lifecycleMu.Unlock() | |||
| 561 | + | }() | |||
| 562 | + | } | |||
| 540 | for { | 563 | for { | ||
| 541 | a.lifecycleMu.Lock() | 564 | a.lifecycleMu.Lock() | ||
| 542 | result := cloneTurnResult(a.result) | 565 | result := cloneTurnResult(a.result) | ||
| 566 | + | if turnID != "" && a.results != nil { | |||
| 567 | + | result = cloneTurnResult(a.results[turnID]) | |||
| 568 | + | } | |||
| 543 | if result != nil && (turnID == "" || result.TurnID == turnID) { | 569 | if result != nil && (turnID == "" || result.TurnID == turnID) { | ||
| 544 | a.lifecycleMu.Unlock() | 570 | a.lifecycleMu.Unlock() | ||
| 545 | return result, nil | 571 | return result, nil | ||
M packages/agent/subagents/agent_test.go +40 -0
| @@ -2,6 +2,7 @@ package subagents | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "runtime" | |
| 5 | 6 | "sync" | |
| 6 | 7 | "testing" | |
| 7 | 8 | "time" | |
| @@ -106,6 +107,45 @@ func TestWaitTurnResultBroadcastsToMultipleObservers(t *testing.T) { | |||
| 106 | 107 | } | |
| 107 | 108 | } | |
| 108 | 109 | ||
| 110 | + | func TestWaitTurnResultRetainsEarlierCompletedTurn(t *testing.T) { | |
| 111 | + | a := &Agent{ID: "worker-1", done: make(chan struct{})} | |
| 112 | + | resultReady := make(chan *TurnResult, 1) | |
| 113 | + | errReady := make(chan error, 1) | |
| 114 | + | go func() { | |
| 115 | + | result, err := a.WaitTurnResult(context.Background(), "turn-1") | |
| 116 | + | resultReady <- result | |
| 117 | + | errReady <- err | |
| 118 | + | }() | |
| 119 | + | deadline := time.Now().Add(time.Second) | |
| 120 | + | for { | |
| 121 | + | a.lifecycleMu.Lock() | |
| 122 | + | registered := a.resultWaiters["turn-1"] > 0 | |
| 123 | + | a.lifecycleMu.Unlock() | |
| 124 | + | if registered { | |
| 125 | + | break | |
| 126 | + | } | |
| 127 | + | if time.Now().After(deadline) { | |
| 128 | + | t.Fatal("turn-1 waiter did not register") | |
| 129 | + | } | |
| 130 | + | runtime.Gosched() | |
| 131 | + | } | |
| 132 | + | a.setResult(&TurnResult{Version: ProtocolVersion, AgentID: a.ID, TurnID: "turn-1", Status: ResultSucceeded}) | |
| 133 | + | a.setResult(&TurnResult{Version: ProtocolVersion, AgentID: a.ID, TurnID: "turn-2", Status: ResultSucceeded}) | |
| 134 | + | ||
| 135 | + | if err := <-errReady; err != nil { | |
| 136 | + | t.Fatal(err) | |
| 137 | + | } | |
| 138 | + | result := <-resultReady | |
| 139 | + | if result == nil || result.TurnID != "turn-1" { | |
| 140 | + | t.Fatalf("wait result = %#v, want retained turn-1", result) | |
| 141 | + | } | |
| 142 | + | a.lifecycleMu.Lock() | |
| 143 | + | defer a.lifecycleMu.Unlock() | |
| 144 | + | if len(a.results) != 0 || len(a.resultWaiters) != 0 { | |
| 145 | + | t.Fatalf("completed waiter state leaked: results=%d waiters=%d", len(a.results), len(a.resultWaiters)) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 109 | 149 | func TestWaitTargetTurnIDAdvancesQueuedResume(t *testing.T) { | |
| 110 | 150 | a := &Agent{LifetimeTurns: 1, turnState: TurnQueued, currentTurnID: "turn-1"} | |
| 111 | 151 | if got := a.WaitTargetTurnID(); got != "turn-2" { | |
| @@ -2,6 +2,7 @@ package subagents | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "runtime" | |||
| 5 | "sync" | 6 | "sync" | ||
| 6 | "testing" | 7 | "testing" | ||
| 7 | "time" | 8 | "time" | ||
| @@ -106,6 +107,45 @@ func TestWaitTurnResultBroadcastsToMultipleObservers(t *testing.T) { | |||||
| 106 | } | 107 | } | ||
| 107 | } | 108 | } | ||
| 108 | 109 | ||||
| 110 | + | func TestWaitTurnResultRetainsEarlierCompletedTurn(t *testing.T) { | |||
| 111 | + | a := &Agent{ID: "worker-1", done: make(chan struct{})} | |||
| 112 | + | resultReady := make(chan *TurnResult, 1) | |||
| 113 | + | errReady := make(chan error, 1) | |||
| 114 | + | go func() { | |||
| 115 | + | result, err := a.WaitTurnResult(context.Background(), "turn-1") | |||
| 116 | + | resultReady <- result | |||
| 117 | + | errReady <- err | |||
| 118 | + | }() | |||
| 119 | + | deadline := time.Now().Add(time.Second) | |||
| 120 | + | for { | |||
| 121 | + | a.lifecycleMu.Lock() | |||
| 122 | + | registered := a.resultWaiters["turn-1"] > 0 | |||
| 123 | + | a.lifecycleMu.Unlock() | |||
| 124 | + | if registered { | |||
| 125 | + | break | |||
| 126 | + | } | |||
| 127 | + | if time.Now().After(deadline) { | |||
| 128 | + | t.Fatal("turn-1 waiter did not register") | |||
| 129 | + | } | |||
| 130 | + | runtime.Gosched() | |||
| 131 | + | } | |||
| 132 | + | a.setResult(&TurnResult{Version: ProtocolVersion, AgentID: a.ID, TurnID: "turn-1", Status: ResultSucceeded}) | |||
| 133 | + | a.setResult(&TurnResult{Version: ProtocolVersion, AgentID: a.ID, TurnID: "turn-2", Status: ResultSucceeded}) | |||
| 134 | + | ||||
| 135 | + | if err := <-errReady; err != nil { | |||
| 136 | + | t.Fatal(err) | |||
| 137 | + | } | |||
| 138 | + | result := <-resultReady | |||
| 139 | + | if result == nil || result.TurnID != "turn-1" { | |||
| 140 | + | t.Fatalf("wait result = %#v, want retained turn-1", result) | |||
| 141 | + | } | |||
| 142 | + | a.lifecycleMu.Lock() | |||
| 143 | + | defer a.lifecycleMu.Unlock() | |||
| 144 | + | if len(a.results) != 0 || len(a.resultWaiters) != 0 { | |||
| 145 | + | t.Fatalf("completed waiter state leaked: results=%d waiters=%d", len(a.results), len(a.resultWaiters)) | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | ||||
| 109 | func TestWaitTargetTurnIDAdvancesQueuedResume(t *testing.T) { | 149 | func TestWaitTargetTurnIDAdvancesQueuedResume(t *testing.T) { | ||
| 110 | a := &Agent{LifetimeTurns: 1, turnState: TurnQueued, currentTurnID: "turn-1"} | 150 | a := &Agent{LifetimeTurns: 1, turnState: TurnQueued, currentTurnID: "turn-1"} | ||
| 111 | if got := a.WaitTargetTurnID(); got != "turn-2" { | 151 | if got := a.WaitTargetTurnID(); got != "turn-2" { | ||
M packages/agent/subagents/batch_test.go +29 -7
| @@ -139,23 +139,45 @@ func TestBatchSpawnWaitAndCollect(t *testing.T) { | |||
| 139 | 139 | } | |
| 140 | 140 | ||
| 141 | 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, | |
| 142 | + | started := make(chan struct{}) | |
| 143 | + | release := make(chan struct{}) | |
| 144 | + | f := New(Config{ | |
| 145 | + | Root: t.TempDir(), RepoRoot: t.TempDir(), | |
| 146 | + | NewRunner: func(*Agent) Runner { | |
| 147 | + | return RunnerFunc(func(context.Context, Sink) error { | |
| 148 | + | close(started) | |
| 149 | + | <-release | |
| 150 | + | return nil | |
| 151 | + | }) | |
| 152 | + | }, | |
| 153 | + | }) | |
| 154 | + | t.Cleanup(f.StopAll) | |
| 155 | + | batch, err := f.SpawnBatch(context.Background(), BatchRequest{Tasks: []string{"one"}}) | |
| 156 | + | if err != nil { | |
| 157 | + | t.Fatal(err) | |
| 146 | 158 | } | |
| 159 | + | <-started | |
| 147 | 160 | ctx, cancel := context.WithCancel(context.Background()) | |
| 148 | 161 | cancel() | |
| 149 | 162 | ||
| 150 | - | result, err := f.waitForBatchResult(ctx, a) | |
| 163 | + | result, err := f.WaitBatchContext(ctx, batch.ID) | |
| 151 | 164 | if !errors.Is(err, context.Canceled) { | |
| 152 | 165 | t.Fatalf("wait error = %v, want context.Canceled", err) | |
| 153 | 166 | } | |
| 154 | 167 | if result != nil { | |
| 155 | 168 | t.Fatalf("wait result = %#v, want nil", result) | |
| 156 | 169 | } | |
| 157 | - | if got := a.Status(); got != StatusRunning { | |
| 158 | - | t.Fatalf("worker status = %s, want running", got) | |
| 170 | + | child := f.Get(batch.ChildIDs[0]) | |
| 171 | + | if child == nil || child.Status() != StatusRunning { | |
| 172 | + | t.Fatalf("worker after observer cancellation = %#v, want running", child) | |
| 173 | + | } | |
| 174 | + | close(release) | |
| 175 | + | completed, err := f.WaitBatch(batch.ID) | |
| 176 | + | if err != nil { | |
| 177 | + | t.Fatal(err) | |
| 178 | + | } | |
| 179 | + | if completed.Status != BatchSucceeded || completed.Results[child.ID] == nil { | |
| 180 | + | t.Fatalf("completed batch = %#v, want successful retained worker result", completed) | |
| 159 | 181 | } | |
| 160 | 182 | } | |
| 161 | 183 | ||
| @@ -139,23 +139,45 @@ func TestBatchSpawnWaitAndCollect(t *testing.T) { | |||||
| 139 | } | 139 | } | ||
| 140 | 140 | ||||
| 141 | func TestBatchResultWaitCancellationDoesNotCancelWorker(t *testing.T) { | 141 | func TestBatchResultWaitCancellationDoesNotCancelWorker(t *testing.T) { | ||
| 142 | - | f := New(Config{Root: t.TempDir(), RepoRoot: t.TempDir()}) | 142 | + | started := make(chan struct{}) |
| 143 | - | a := &Agent{ | 143 | + | release := make(chan struct{}) |
| 144 | - | ID: "worker-1", | 144 | + | f := New(Config{ |
| 145 | - | status: StatusRunning, | 145 | + | Root: t.TempDir(), RepoRoot: t.TempDir(), |
| 146 | + | NewRunner: func(*Agent) Runner { | |||
| 147 | + | return RunnerFunc(func(context.Context, Sink) error { | |||
| 148 | + | close(started) | |||
| 149 | + | <-release | |||
| 150 | + | return nil | |||
| 151 | + | }) | |||
| 152 | + | }, | |||
| 153 | + | }) | |||
| 154 | + | t.Cleanup(f.StopAll) | |||
| 155 | + | batch, err := f.SpawnBatch(context.Background(), BatchRequest{Tasks: []string{"one"}}) | |||
| 156 | + | if err != nil { | |||
| 157 | + | t.Fatal(err) | |||
| 146 | } | 158 | } | ||
| 159 | + | <-started | |||
| 147 | ctx, cancel := context.WithCancel(context.Background()) | 160 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 148 | cancel() | 161 | cancel() | ||
| 149 | 162 | ||||
| 150 | - | result, err := f.waitForBatchResult(ctx, a) | 163 | + | result, err := f.WaitBatchContext(ctx, batch.ID) |
| 151 | if !errors.Is(err, context.Canceled) { | 164 | if !errors.Is(err, context.Canceled) { | ||
| 152 | t.Fatalf("wait error = %v, want context.Canceled", err) | 165 | t.Fatalf("wait error = %v, want context.Canceled", err) | ||
| 153 | } | 166 | } | ||
| 154 | if result != nil { | 167 | if result != nil { | ||
| 155 | t.Fatalf("wait result = %#v, want nil", result) | 168 | t.Fatalf("wait result = %#v, want nil", result) | ||
| 156 | } | 169 | } | ||
| 157 | - | if got := a.Status(); got != StatusRunning { | 170 | + | child := f.Get(batch.ChildIDs[0]) |
| 158 | - | t.Fatalf("worker status = %s, want running", got) | 171 | + | if child == nil || child.Status() != StatusRunning { |
| 172 | + | t.Fatalf("worker after observer cancellation = %#v, want running", child) | |||
| 173 | + | } | |||
| 174 | + | close(release) | |||
| 175 | + | completed, err := f.WaitBatch(batch.ID) | |||
| 176 | + | if err != nil { | |||
| 177 | + | t.Fatal(err) | |||
| 178 | + | } | |||
| 179 | + | if completed.Status != BatchSucceeded || completed.Results[child.ID] == nil { | |||
| 180 | + | t.Fatalf("completed batch = %#v, want successful retained worker result", completed) | |||
| 159 | } | 181 | } | ||
| 160 | } | 182 | } | ||
| 161 | 183 | ||||
M packages/agent/subagents/model.go +3 -11
| @@ -107,17 +107,15 @@ const ( | |||
| 107 | 107 | ||
| 108 | 108 | // SubagentPolicy contains manager-owned safety and resource limits. normalize | |
| 109 | 109 | // replaces zero MaxOutputBytes, MaxOutputLines, QueueTimeout, and StartupTimeout | |
| 110 | - | // values with positive manager defaults. Zero DefaultTimeout, MaxTurns, and | |
| 111 | - | // MaxSteps leave delegated turns and model loops unlimited; queue, startup, | |
| 112 | - | // stream-idle, and idle-process limits remain independently bounded. | |
| 110 | + | // values with positive manager defaults. Zero DefaultTimeout leaves delegated | |
| 111 | + | // turns unlimited; queue, startup, stream-idle, and idle-process limits remain | |
| 112 | + | // independently bounded. | |
| 113 | 113 | type SubagentPolicy struct { | |
| 114 | 114 | MaxConcurrent int | |
| 115 | 115 | MaxConcurrentPerParent int | |
| 116 | 116 | QueueTimeout time.Duration | |
| 117 | 117 | StartupTimeout time.Duration | |
| 118 | 118 | DefaultTimeout time.Duration | |
| 119 | - | MaxTurns int | |
| 120 | - | MaxSteps int | |
| 121 | 119 | MaxOutputBytes int | |
| 122 | 120 | MaxOutputLines int | |
| 123 | 121 | AllowedTools []string | |
| @@ -132,12 +130,6 @@ func (p *SubagentPolicy) normalize() { | |||
| 132 | 130 | if p.DefaultTimeout < 0 { | |
| 133 | 131 | p.DefaultTimeout = 0 | |
| 134 | 132 | } | |
| 135 | - | if p.MaxTurns < 0 { | |
| 136 | - | p.MaxTurns = 0 | |
| 137 | - | } | |
| 138 | - | if p.MaxSteps < 0 { | |
| 139 | - | p.MaxSteps = 0 | |
| 140 | - | } | |
| 141 | 133 | if p.MaxConcurrent <= 0 { | |
| 142 | 134 | p.MaxConcurrent = 8 | |
| 143 | 135 | } | |
| @@ -107,17 +107,15 @@ const ( | |||||
| 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, and StartupTimeout | 109 | // replaces zero MaxOutputBytes, MaxOutputLines, QueueTimeout, and StartupTimeout | ||
| 110 | - | // values with positive manager defaults. Zero DefaultTimeout, MaxTurns, and | 110 | + | // values with positive manager defaults. Zero DefaultTimeout leaves delegated |
| 111 | - | // MaxSteps leave delegated turns and model loops unlimited; queue, startup, | 111 | + | // turns unlimited; queue, startup, stream-idle, and idle-process limits remain |
| 112 | - | // stream-idle, and idle-process limits remain independently bounded. | 112 | + | // independently bounded. |
| 113 | type SubagentPolicy struct { | 113 | type SubagentPolicy struct { | ||
| 114 | MaxConcurrent int | 114 | MaxConcurrent int | ||
| 115 | MaxConcurrentPerParent int | 115 | MaxConcurrentPerParent int | ||
| 116 | QueueTimeout time.Duration | 116 | QueueTimeout time.Duration | ||
| 117 | StartupTimeout time.Duration | 117 | StartupTimeout time.Duration | ||
| 118 | DefaultTimeout time.Duration | 118 | DefaultTimeout time.Duration | ||
| 119 | - | MaxTurns int | |||
| 120 | - | MaxSteps int | |||
| 121 | MaxOutputBytes int | 119 | MaxOutputBytes int | ||
| 122 | MaxOutputLines int | 120 | MaxOutputLines int | ||
| 123 | AllowedTools []string | 121 | AllowedTools []string | ||
| @@ -132,12 +130,6 @@ func (p *SubagentPolicy) normalize() { | |||||
| 132 | if p.DefaultTimeout < 0 { | 130 | if p.DefaultTimeout < 0 { | ||
| 133 | p.DefaultTimeout = 0 | 131 | p.DefaultTimeout = 0 | ||
| 134 | } | 132 | } | ||
| 135 | - | if p.MaxTurns < 0 { | |||
| 136 | - | p.MaxTurns = 0 | |||
| 137 | - | } | |||
| 138 | - | if p.MaxSteps < 0 { | |||
| 139 | - | p.MaxSteps = 0 | |||
| 140 | - | } | |||
| 141 | if p.MaxConcurrent <= 0 { | 133 | if p.MaxConcurrent <= 0 { | ||
| 142 | p.MaxConcurrent = 8 | 134 | p.MaxConcurrent = 8 | ||
| 143 | } | 135 | } | ||
M packages/agent/subagents/persist.go +2 -21
| @@ -57,8 +57,6 @@ type agentMeta struct { | |||
| 57 | 57 | WorkspacePath string `json:"workspace_path,omitempty"` | |
| 58 | 58 | WorkspaceBase string `json:"workspace_base,omitempty"` | |
| 59 | 59 | WorkspaceCapture CaptureMode `json:"workspace_capture,omitempty"` | |
| 60 | - | MaxTurns int `json:"max_turns,omitempty"` | |
| 61 | - | MaxSteps int `json:"max_steps,omitempty"` | |
| 62 | 60 | LifetimeTurns int `json:"lifetime_turns,omitempty"` | |
| 63 | 61 | CurrentRunTurns int `json:"current_run_turns,omitempty"` | |
| 64 | 62 | Timeout time.Duration `json:"timeout,omitempty"` | |
| @@ -128,13 +126,6 @@ func resolvePersistedTurnTimeout(m agentMeta, fallback time.Duration) time.Durat | |||
| 128 | 126 | } | |
| 129 | 127 | } | |
| 130 | 128 | ||
| 131 | - | func resolvePersistedMaxSteps(value, fallback int) int { | |
| 132 | - | if value > 0 { | |
| 133 | - | return value | |
| 134 | - | } | |
| 135 | - | return fallback | |
| 136 | - | } | |
| 137 | - | ||
| 138 | 129 | func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.json") } | |
| 139 | 130 | ||
| 140 | 131 | // writeAgentMeta serialises a's identity into stateDir/meta.json. The | |
| @@ -198,8 +189,6 @@ func writeAgentMeta(stateDir string, a *Agent) error { | |||
| 198 | 189 | WorkspacePath: a.WorkspacePath, | |
| 199 | 190 | WorkspaceBase: a.WorkspaceBase, | |
| 200 | 191 | WorkspaceCapture: a.WorkspaceCapture, | |
| 201 | - | MaxTurns: a.MaxTurns, | |
| 202 | - | MaxSteps: a.MaxSteps, | |
| 203 | 192 | LifetimeTurns: lifetimeTurns, | |
| 204 | 193 | CurrentRunTurns: currentRunTurns, | |
| 205 | 194 | Timeout: a.Timeout, | |
| @@ -542,8 +531,6 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) (*Agent, bool) { | |||
| 542 | 531 | WorkspacePath: m.WorkspacePath, | |
| 543 | 532 | WorkspaceBase: m.WorkspaceBase, | |
| 544 | 533 | WorkspaceCapture: m.WorkspaceCapture, | |
| 545 | - | MaxTurns: m.MaxTurns, | |
| 546 | - | MaxSteps: resolvePersistedMaxSteps(m.MaxSteps, f.cfg.Policy.MaxSteps), | |
| 547 | 534 | LifetimeTurns: m.LifetimeTurns, | |
| 548 | 535 | CurrentRunTurns: m.CurrentRunTurns, | |
| 549 | 536 | Timeout: resolvePersistedTurnTimeout(m, f.cfg.Policy.DefaultTimeout), | |
| @@ -1240,7 +1227,7 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo | |||
| 1240 | 1227 | Subagent: existing.Subagent, | |
| 1241 | 1228 | WorkspaceMode: existing.WorkspaceMode, WorkspacePath: existing.WorkspacePath, | |
| 1242 | 1229 | WorkspaceBase: existing.WorkspaceBase, WorkspaceCapture: existing.WorkspaceCapture, | |
| 1243 | - | MaxTurns: existing.MaxTurns, MaxSteps: existing.MaxSteps, LifetimeTurns: lifetimeTurns, CurrentRunTurns: currentRunTurns, Timeout: existing.Timeout, TurnTimeoutMode: timeoutModeFor(existing.Timeout), Tools: append([]string(nil), existing.Tools...), | |
| 1230 | + | LifetimeTurns: lifetimeTurns, CurrentRunTurns: currentRunTurns, Timeout: existing.Timeout, TurnTimeoutMode: timeoutModeFor(existing.Timeout), Tools: append([]string(nil), existing.Tools...), | |
| 1244 | 1231 | WebSearchPolicy: childWebSearchPolicy(existing.WebSearchPolicy, existing.Subagent, existing.Tools), | |
| 1245 | 1232 | CurrentTurnID: existingSnapshot.CurrentTurnID, Attempt: existingSnapshot.Attempt, | |
| 1246 | 1233 | Requirement: existingSnapshot.Requirement, | |
| @@ -1256,7 +1243,7 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo | |||
| 1256 | 1243 | if len(m.Tools) == 0 && len(f.cfg.Policy.AllowedTools) > 0 { | |
| 1257 | 1244 | m.Tools = append([]string(nil), f.cfg.Policy.AllowedTools...) | |
| 1258 | 1245 | } | |
| 1259 | - | if err := f.validateSpawnOptions(SpawnRequest{MaxTurns: m.MaxTurns, Tools: m.Tools}); err != nil { | |
| 1246 | + | if err := f.validateSpawnOptions(SpawnRequest{Tools: m.Tools}); err != nil { | |
| 1260 | 1247 | return nil, err | |
| 1261 | 1248 | } | |
| 1262 | 1249 | workspaceMode := m.WorkspaceMode | |
| @@ -1276,10 +1263,6 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo | |||
| 1276 | 1263 | if err := validateWorkspaceRoot(repositoryRoot, workspaceMode, allowedRoots); err != nil { | |
| 1277 | 1264 | return nil, err | |
| 1278 | 1265 | } | |
| 1279 | - | maxTurns := m.MaxTurns | |
| 1280 | - | if maxTurns <= 0 { | |
| 1281 | - | maxTurns = f.cfg.Policy.MaxTurns | |
| 1282 | - | } | |
| 1283 | 1266 | existingWorkspacePath := "" | |
| 1284 | 1267 | if workspaceMode == WorkspaceWorktree { | |
| 1285 | 1268 | existingWorkspacePath = m.WorkspacePath | |
| @@ -1325,8 +1308,6 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo | |||
| 1325 | 1308 | WorkspacePath: m.WorkspacePath, | |
| 1326 | 1309 | WorkspaceBase: m.WorkspaceBase, | |
| 1327 | 1310 | WorkspaceCapture: m.WorkspaceCapture, | |
| 1328 | - | MaxTurns: maxTurns, | |
| 1329 | - | MaxSteps: resolvePersistedMaxSteps(m.MaxSteps, f.cfg.Policy.MaxSteps), | |
| 1330 | 1311 | LifetimeTurns: m.LifetimeTurns, | |
| 1331 | 1312 | CurrentRunTurns: m.CurrentRunTurns, | |
| 1332 | 1313 | Timeout: timeout, | |
| @@ -57,8 +57,6 @@ type agentMeta struct { | |||||
| 57 | WorkspacePath string `json:"workspace_path,omitempty"` | 57 | WorkspacePath string `json:"workspace_path,omitempty"` | ||
| 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"` | |||
| 61 | - | MaxSteps int `json:"max_steps,omitempty"` | |||
| 62 | LifetimeTurns int `json:"lifetime_turns,omitempty"` | 60 | LifetimeTurns int `json:"lifetime_turns,omitempty"` | ||
| 63 | CurrentRunTurns int `json:"current_run_turns,omitempty"` | 61 | CurrentRunTurns int `json:"current_run_turns,omitempty"` | ||
| 64 | Timeout time.Duration `json:"timeout,omitempty"` | 62 | Timeout time.Duration `json:"timeout,omitempty"` | ||
| @@ -128,13 +126,6 @@ func resolvePersistedTurnTimeout(m agentMeta, fallback time.Duration) time.Durat | |||||
| 128 | } | 126 | } | ||
| 129 | } | 127 | } | ||
| 130 | 128 | ||||
| 131 | - | func resolvePersistedMaxSteps(value, fallback int) int { | |||
| 132 | - | if value > 0 { | |||
| 133 | - | return value | |||
| 134 | - | } | |||
| 135 | - | return fallback | |||
| 136 | - | } | |||
| 137 | - | ||||
| 138 | func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.json") } | 129 | func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.json") } | ||
| 139 | 130 | ||||
| 140 | // writeAgentMeta serialises a's identity into stateDir/meta.json. The | 131 | // writeAgentMeta serialises a's identity into stateDir/meta.json. The | ||
| @@ -198,8 +189,6 @@ func writeAgentMeta(stateDir string, a *Agent) error { | |||||
| 198 | WorkspacePath: a.WorkspacePath, | 189 | WorkspacePath: a.WorkspacePath, | ||
| 199 | WorkspaceBase: a.WorkspaceBase, | 190 | WorkspaceBase: a.WorkspaceBase, | ||
| 200 | WorkspaceCapture: a.WorkspaceCapture, | 191 | WorkspaceCapture: a.WorkspaceCapture, | ||
| 201 | - | MaxTurns: a.MaxTurns, | |||
| 202 | - | MaxSteps: a.MaxSteps, | |||
| 203 | LifetimeTurns: lifetimeTurns, | 192 | LifetimeTurns: lifetimeTurns, | ||
| 204 | CurrentRunTurns: currentRunTurns, | 193 | CurrentRunTurns: currentRunTurns, | ||
| 205 | Timeout: a.Timeout, | 194 | Timeout: a.Timeout, | ||
| @@ -542,8 +531,6 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) (*Agent, bool) { | |||||
| 542 | WorkspacePath: m.WorkspacePath, | 531 | WorkspacePath: m.WorkspacePath, | ||
| 543 | WorkspaceBase: m.WorkspaceBase, | 532 | WorkspaceBase: m.WorkspaceBase, | ||
| 544 | WorkspaceCapture: m.WorkspaceCapture, | 533 | WorkspaceCapture: m.WorkspaceCapture, | ||
| 545 | - | MaxTurns: m.MaxTurns, | |||
| 546 | - | MaxSteps: resolvePersistedMaxSteps(m.MaxSteps, f.cfg.Policy.MaxSteps), | |||
| 547 | LifetimeTurns: m.LifetimeTurns, | 534 | LifetimeTurns: m.LifetimeTurns, | ||
| 548 | CurrentRunTurns: m.CurrentRunTurns, | 535 | CurrentRunTurns: m.CurrentRunTurns, | ||
| 549 | Timeout: resolvePersistedTurnTimeout(m, f.cfg.Policy.DefaultTimeout), | 536 | Timeout: resolvePersistedTurnTimeout(m, f.cfg.Policy.DefaultTimeout), | ||
| @@ -1240,7 +1227,7 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo | |||||
| 1240 | Subagent: existing.Subagent, | 1227 | Subagent: existing.Subagent, | ||
| 1241 | WorkspaceMode: existing.WorkspaceMode, WorkspacePath: existing.WorkspacePath, | 1228 | WorkspaceMode: existing.WorkspaceMode, WorkspacePath: existing.WorkspacePath, | ||
| 1242 | WorkspaceBase: existing.WorkspaceBase, WorkspaceCapture: existing.WorkspaceCapture, | 1229 | WorkspaceBase: existing.WorkspaceBase, WorkspaceCapture: existing.WorkspaceCapture, | ||
| 1243 | - | MaxTurns: existing.MaxTurns, MaxSteps: existing.MaxSteps, LifetimeTurns: lifetimeTurns, CurrentRunTurns: currentRunTurns, Timeout: existing.Timeout, TurnTimeoutMode: timeoutModeFor(existing.Timeout), Tools: append([]string(nil), existing.Tools...), | 1230 | + | LifetimeTurns: lifetimeTurns, CurrentRunTurns: currentRunTurns, Timeout: existing.Timeout, TurnTimeoutMode: timeoutModeFor(existing.Timeout), Tools: append([]string(nil), existing.Tools...), |
| 1244 | WebSearchPolicy: childWebSearchPolicy(existing.WebSearchPolicy, existing.Subagent, existing.Tools), | 1231 | WebSearchPolicy: childWebSearchPolicy(existing.WebSearchPolicy, existing.Subagent, existing.Tools), | ||
| 1245 | CurrentTurnID: existingSnapshot.CurrentTurnID, Attempt: existingSnapshot.Attempt, | 1232 | CurrentTurnID: existingSnapshot.CurrentTurnID, Attempt: existingSnapshot.Attempt, | ||
| 1246 | Requirement: existingSnapshot.Requirement, | 1233 | Requirement: existingSnapshot.Requirement, | ||
| @@ -1256,7 +1243,7 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo | |||||
| 1256 | if len(m.Tools) == 0 && len(f.cfg.Policy.AllowedTools) > 0 { | 1243 | if len(m.Tools) == 0 && len(f.cfg.Policy.AllowedTools) > 0 { | ||
| 1257 | m.Tools = append([]string(nil), f.cfg.Policy.AllowedTools...) | 1244 | m.Tools = append([]string(nil), f.cfg.Policy.AllowedTools...) | ||
| 1258 | } | 1245 | } | ||
| 1259 | - | if err := f.validateSpawnOptions(SpawnRequest{MaxTurns: m.MaxTurns, Tools: m.Tools}); err != nil { | 1246 | + | if err := f.validateSpawnOptions(SpawnRequest{Tools: m.Tools}); err != nil { |
| 1260 | return nil, err | 1247 | return nil, err | ||
| 1261 | } | 1248 | } | ||
| 1262 | workspaceMode := m.WorkspaceMode | 1249 | workspaceMode := m.WorkspaceMode | ||
| @@ -1276,10 +1263,6 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo | |||||
| 1276 | if err := validateWorkspaceRoot(repositoryRoot, workspaceMode, allowedRoots); err != nil { | 1263 | if err := validateWorkspaceRoot(repositoryRoot, workspaceMode, allowedRoots); err != nil { | ||
| 1277 | return nil, err | 1264 | return nil, err | ||
| 1278 | } | 1265 | } | ||
| 1279 | - | maxTurns := m.MaxTurns | |||
| 1280 | - | if maxTurns <= 0 { | |||
| 1281 | - | maxTurns = f.cfg.Policy.MaxTurns | |||
| 1282 | - | } | |||
| 1283 | existingWorkspacePath := "" | 1266 | existingWorkspacePath := "" | ||
| 1284 | if workspaceMode == WorkspaceWorktree { | 1267 | if workspaceMode == WorkspaceWorktree { | ||
| 1285 | existingWorkspacePath = m.WorkspacePath | 1268 | existingWorkspacePath = m.WorkspacePath | ||
| @@ -1325,8 +1308,6 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo | |||||
| 1325 | WorkspacePath: m.WorkspacePath, | 1308 | WorkspacePath: m.WorkspacePath, | ||
| 1326 | WorkspaceBase: m.WorkspaceBase, | 1309 | WorkspaceBase: m.WorkspaceBase, | ||
| 1327 | WorkspaceCapture: m.WorkspaceCapture, | 1310 | WorkspaceCapture: m.WorkspaceCapture, | ||
| 1328 | - | MaxTurns: maxTurns, | |||
| 1329 | - | MaxSteps: resolvePersistedMaxSteps(m.MaxSteps, f.cfg.Policy.MaxSteps), | |||
| 1330 | LifetimeTurns: m.LifetimeTurns, | 1311 | LifetimeTurns: m.LifetimeTurns, | ||
| 1331 | CurrentRunTurns: m.CurrentRunTurns, | 1312 | CurrentRunTurns: m.CurrentRunTurns, | ||
| 1332 | Timeout: timeout, | 1313 | Timeout: timeout, | ||
M packages/agent/subagents/projection.go +21 -20
| @@ -17,6 +17,7 @@ type Operation struct { | |||
| 17 | 17 | Name string | |
| 18 | 18 | Provider string | |
| 19 | 19 | Model string | |
| 20 | + | Step int | |
| 20 | 21 | Attempt int | |
| 21 | 22 | MaxAttempts int | |
| 22 | 23 | StartedAt time.Time | |
| @@ -115,14 +116,13 @@ type RequestFact struct { | |||
| 115 | 116 | // contains operations and terminal facts rather than lifecycle guesses such as | |
| 116 | 117 | // working, idle, or alive. | |
| 117 | 118 | type AgentTraceView struct { | |
| 118 | - | AgentID string | |
| 119 | - | LastEvent TraceEvent | |
| 120 | - | LastObservation *LiveObservation | |
| 121 | - | OpenOperations []Operation | |
| 122 | - | PrimaryOperation *Operation | |
| 123 | - | Terminal string | |
| 124 | - | Result *ResultFact | |
| 125 | - | LastRequest *RequestFact | |
| 119 | + | AgentID string | |
| 120 | + | LastEvent TraceEvent | |
| 121 | + | LastObservation *LiveObservation | |
| 122 | + | OpenOperations []Operation | |
| 123 | + | Terminal string | |
| 124 | + | Result *ResultFact | |
| 125 | + | LastRequest *RequestFact | |
| 126 | 126 | } | |
| 127 | 127 | ||
| 128 | 128 | // Summary returns the most specific factual activity or terminal result fact. | |
| @@ -139,12 +139,8 @@ func (v AgentTraceView) Summary() string { | |||
| 139 | 139 | } | |
| 140 | 140 | return v.Terminal | |
| 141 | 141 | } | |
| 142 | - | primary := v.PrimaryOperation | |
| 143 | - | if primary == nil && len(v.OpenOperations) != 0 { | |
| 144 | - | primary = &v.OpenOperations[0] | |
| 145 | - | } | |
| 146 | - | if primary != nil { | |
| 147 | - | if observation := v.ObservationFor(*primary); observation != nil { | |
| 142 | + | if primary, ok := v.Primary(); ok { | |
| 143 | + | if observation := v.ObservationFor(primary); observation != nil { | |
| 148 | 144 | return primary.Label() + " · " + observation.Label() | |
| 149 | 145 | } | |
| 150 | 146 | return primary.Label() | |
| @@ -208,7 +204,8 @@ func (p *traceProjection) apply(event TraceEvent) { | |||
| 208 | 204 | model, _ := event.Data["model"].(string) | |
| 209 | 205 | attempt := traceInt(event.Data["attempt"]) | |
| 210 | 206 | maxAttempts := traceInt(event.Data["max_attempts"]) | |
| 211 | - | p.open[event.AgentID][key] = Operation{Type: event.Type, AgentID: event.AgentID, TurnID: event.TurnID, CallID: callID, Name: name, Provider: provider, Model: model, Attempt: attempt, MaxAttempts: maxAttempts, StartedAt: event.Timestamp} | |
| 207 | + | step := traceInt(event.Data["step"]) | |
| 208 | + | p.open[event.AgentID][key] = Operation{Type: event.Type, AgentID: event.AgentID, TurnID: event.TurnID, CallID: callID, Name: name, Provider: provider, Model: model, Step: step, Attempt: attempt, MaxAttempts: maxAttempts, StartedAt: event.Timestamp} | |
| 212 | 209 | } | |
| 213 | 210 | if ends { | |
| 214 | 211 | if event.Type == "provider.request.finished" || event.Type == "provider.request.failed" || event.Type == "provider.request.cancelled" || event.Type == "provider.request.retry.scheduled" { | |
| @@ -272,10 +269,6 @@ func (p *traceProjection) snapshot() map[string]AgentTraceView { | |||
| 272 | 269 | } | |
| 273 | 270 | return left.CallID < right.CallID | |
| 274 | 271 | }) | |
| 275 | - | if len(view.OpenOperations) != 0 { | |
| 276 | - | primary := view.OpenOperations[0] | |
| 277 | - | view.PrimaryOperation = &primary | |
| 278 | - | } | |
| 279 | 272 | views[agentID] = view | |
| 280 | 273 | } | |
| 281 | 274 | return views | |
| @@ -283,7 +276,6 @@ func (p *traceProjection) snapshot() map[string]AgentTraceView { | |||
| 283 | 276 | ||
| 284 | 277 | func cloneAgentTraceView(view AgentTraceView) AgentTraceView { | |
| 285 | 278 | view.OpenOperations = nil | |
| 286 | - | view.PrimaryOperation = nil | |
| 287 | 279 | if view.LastObservation != nil { | |
| 288 | 280 | observation := *view.LastObservation | |
| 289 | 281 | view.LastObservation = &observation | |
| @@ -299,6 +291,15 @@ func cloneAgentTraceView(view AgentTraceView) AgentTraceView { | |||
| 299 | 291 | return view | |
| 300 | 292 | } | |
| 301 | 293 | ||
| 294 | + | // Primary returns the highest-priority factual operation from the ordered | |
| 295 | + | // open-operation projection. | |
| 296 | + | func (v AgentTraceView) Primary() (Operation, bool) { | |
| 297 | + | if len(v.OpenOperations) == 0 { | |
| 298 | + | return Operation{}, false | |
| 299 | + | } | |
| 300 | + | return v.OpenOperations[0], true | |
| 301 | + | } | |
| 302 | + | ||
| 302 | 303 | // TurnStartedAt returns the factual start of the open delegated turn owning | |
| 303 | 304 | // operation. It is stable across nested provider requests and tool calls. | |
| 304 | 305 | func (v AgentTraceView) TurnStartedAt(operation Operation) time.Time { | |
| @@ -17,6 +17,7 @@ type Operation struct { | |||||
| 17 | Name string | 17 | Name string | ||
| 18 | Provider string | 18 | Provider string | ||
| 19 | Model string | 19 | Model string | ||
| 20 | + | Step int | |||
| 20 | Attempt int | 21 | Attempt int | ||
| 21 | MaxAttempts int | 22 | MaxAttempts int | ||
| 22 | StartedAt time.Time | 23 | StartedAt time.Time | ||
| @@ -115,14 +116,13 @@ type RequestFact struct { | |||||
| 115 | // contains operations and terminal facts rather than lifecycle guesses such as | 116 | // contains operations and terminal facts rather than lifecycle guesses such as | ||
| 116 | // working, idle, or alive. | 117 | // working, idle, or alive. | ||
| 117 | type AgentTraceView struct { | 118 | type AgentTraceView struct { | ||
| 118 | - | AgentID string | 119 | + | AgentID string |
| 119 | - | LastEvent TraceEvent | 120 | + | LastEvent TraceEvent |
| 120 | - | LastObservation *LiveObservation | 121 | + | LastObservation *LiveObservation |
| 121 | - | OpenOperations []Operation | 122 | + | OpenOperations []Operation |
| 122 | - | PrimaryOperation *Operation | 123 | + | Terminal string |
| 123 | - | Terminal string | 124 | + | Result *ResultFact |
| 124 | - | Result *ResultFact | 125 | + | LastRequest *RequestFact |
| 125 | - | LastRequest *RequestFact | |||
| 126 | } | 126 | } | ||
| 127 | 127 | ||||
| 128 | // Summary returns the most specific factual activity or terminal result fact. | 128 | // Summary returns the most specific factual activity or terminal result fact. | ||
| @@ -139,12 +139,8 @@ func (v AgentTraceView) Summary() string { | |||||
| 139 | } | 139 | } | ||
| 140 | return v.Terminal | 140 | return v.Terminal | ||
| 141 | } | 141 | } | ||
| 142 | - | primary := v.PrimaryOperation | 142 | + | if primary, ok := v.Primary(); ok { |
| 143 | - | if primary == nil && len(v.OpenOperations) != 0 { | 143 | + | if observation := v.ObservationFor(primary); observation != nil { |
| 144 | - | primary = &v.OpenOperations[0] | |||
| 145 | - | } | |||
| 146 | - | if primary != nil { | |||
| 147 | - | if observation := v.ObservationFor(*primary); observation != nil { | |||
| 148 | return primary.Label() + " · " + observation.Label() | 144 | return primary.Label() + " · " + observation.Label() | ||
| 149 | } | 145 | } | ||
| 150 | return primary.Label() | 146 | return primary.Label() | ||
| @@ -208,7 +204,8 @@ func (p *traceProjection) apply(event TraceEvent) { | |||||
| 208 | model, _ := event.Data["model"].(string) | 204 | model, _ := event.Data["model"].(string) | ||
| 209 | attempt := traceInt(event.Data["attempt"]) | 205 | attempt := traceInt(event.Data["attempt"]) | ||
| 210 | maxAttempts := traceInt(event.Data["max_attempts"]) | 206 | maxAttempts := traceInt(event.Data["max_attempts"]) | ||
| 211 | - | p.open[event.AgentID][key] = Operation{Type: event.Type, AgentID: event.AgentID, TurnID: event.TurnID, CallID: callID, Name: name, Provider: provider, Model: model, Attempt: attempt, MaxAttempts: maxAttempts, StartedAt: event.Timestamp} | 207 | + | step := traceInt(event.Data["step"]) |
| 208 | + | p.open[event.AgentID][key] = Operation{Type: event.Type, AgentID: event.AgentID, TurnID: event.TurnID, CallID: callID, Name: name, Provider: provider, Model: model, Step: step, Attempt: attempt, MaxAttempts: maxAttempts, StartedAt: event.Timestamp} | |||
| 212 | } | 209 | } | ||
| 213 | if ends { | 210 | if ends { | ||
| 214 | if event.Type == "provider.request.finished" || event.Type == "provider.request.failed" || event.Type == "provider.request.cancelled" || event.Type == "provider.request.retry.scheduled" { | 211 | if event.Type == "provider.request.finished" || event.Type == "provider.request.failed" || event.Type == "provider.request.cancelled" || event.Type == "provider.request.retry.scheduled" { | ||
| @@ -272,10 +269,6 @@ func (p *traceProjection) snapshot() map[string]AgentTraceView { | |||||
| 272 | } | 269 | } | ||
| 273 | return left.CallID < right.CallID | 270 | return left.CallID < right.CallID | ||
| 274 | }) | 271 | }) | ||
| 275 | - | if len(view.OpenOperations) != 0 { | |||
| 276 | - | primary := view.OpenOperations[0] | |||
| 277 | - | view.PrimaryOperation = &primary | |||
| 278 | - | } | |||
| 279 | views[agentID] = view | 272 | views[agentID] = view | ||
| 280 | } | 273 | } | ||
| 281 | return views | 274 | return views | ||
| @@ -283,7 +276,6 @@ func (p *traceProjection) snapshot() map[string]AgentTraceView { | |||||
| 283 | 276 | ||||
| 284 | func cloneAgentTraceView(view AgentTraceView) AgentTraceView { | 277 | func cloneAgentTraceView(view AgentTraceView) AgentTraceView { | ||
| 285 | view.OpenOperations = nil | 278 | view.OpenOperations = nil | ||
| 286 | - | view.PrimaryOperation = nil | |||
| 287 | if view.LastObservation != nil { | 279 | if view.LastObservation != nil { | ||
| 288 | observation := *view.LastObservation | 280 | observation := *view.LastObservation | ||
| 289 | view.LastObservation = &observation | 281 | view.LastObservation = &observation | ||
| @@ -299,6 +291,15 @@ func cloneAgentTraceView(view AgentTraceView) AgentTraceView { | |||||
| 299 | return view | 291 | return view | ||
| 300 | } | 292 | } | ||
| 301 | 293 | ||||
| 294 | + | // Primary returns the highest-priority factual operation from the ordered | |||
| 295 | + | // open-operation projection. | |||
| 296 | + | func (v AgentTraceView) Primary() (Operation, bool) { | |||
| 297 | + | if len(v.OpenOperations) == 0 { | |||
| 298 | + | return Operation{}, false | |||
| 299 | + | } | |||
| 300 | + | return v.OpenOperations[0], true | |||
| 301 | + | } | |||
| 302 | + | ||||
| 302 | // TurnStartedAt returns the factual start of the open delegated turn owning | 303 | // TurnStartedAt returns the factual start of the open delegated turn owning | ||
| 303 | // operation. It is stable across nested provider requests and tool calls. | 304 | // operation. It is stable across nested provider requests and tool calls. | ||
| 304 | func (v AgentTraceView) TurnStartedAt(operation Operation) time.Time { | 305 | func (v AgentTraceView) TurnStartedAt(operation Operation) time.Time { | ||
M packages/agent/subagents/projection_test.go +26 -17
| @@ -8,6 +8,15 @@ import ( | |||
| 8 | 8 | "time" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | + | func primaryOperation(t testing.TB, view AgentTraceView) *Operation { | |
| 12 | + | t.Helper() | |
| 13 | + | operation, ok := view.Primary() | |
| 14 | + | if !ok { | |
| 15 | + | t.Fatal("trace view has no primary operation") | |
| 16 | + | } | |
| 17 | + | return &operation | |
| 18 | + | } | |
| 19 | + | ||
| 11 | 20 | func TestProjectTraceRetainsJSONDecodedRequestAttempts(t *testing.T) { | |
| 12 | 21 | bundle := t.TempDir() | |
| 13 | 22 | if err := os.WriteFile(filepath.Join(bundle, "manifest.json"), []byte(`{"version":1,"trace_file":"trace.jsonl"}`), 0o600); err != nil { | |
| @@ -21,7 +30,7 @@ func TestProjectTraceRetainsJSONDecodedRequestAttempts(t *testing.T) { | |||
| 21 | 30 | if err != nil { | |
| 22 | 31 | t.Fatal(err) | |
| 23 | 32 | } | |
| 24 | - | operation := ProjectTrace(events)["agent-1"].PrimaryOperation | |
| 33 | + | operation := primaryOperation(t, ProjectTrace(events)["agent-1"]) | |
| 25 | 34 | if operation == nil || operation.Attempt != 2 || operation.MaxAttempts != 6 { | |
| 26 | 35 | t.Fatalf("projected operation = %#v, want attempt 2/6", operation) | |
| 27 | 36 | } | |
| @@ -36,10 +45,10 @@ func TestProjectTraceIgnoresProtocolFactsAndShowsCompletedToolDuringNextProvider | |||
| 36 | 45 | {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 | 46 | {Timestamp: started.Add(3 * time.Second), Type: "provider.request.started", AgentID: "agent-1", TurnID: "turn-1"}, | |
| 38 | 47 | })["agent-1"] | |
| 39 | - | if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "provider.request.started" { | |
| 40 | - | t.Fatalf("primary operation = %#v", view.PrimaryOperation) | |
| 48 | + | if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "provider.request.started" { | |
| 49 | + | t.Fatalf("primary operation = %#v", primaryOperation(t, view)) | |
| 41 | 50 | } | |
| 42 | - | observation := view.ObservationFor(*view.PrimaryOperation) | |
| 51 | + | observation := view.ObservationFor(*primaryOperation(t, view)) | |
| 43 | 52 | if observation == nil || observation.Label() != "finished read" { | |
| 44 | 53 | t.Fatalf("observation = %#v, want finished read", observation) | |
| 45 | 54 | } | |
| @@ -65,10 +74,10 @@ func TestProjectTraceKeepsShortCompletedToolVisibleBeforeNextRequest(t *testing. | |||
| 65 | 74 | {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 | 75 | {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 | 76 | })["agent-1"] | |
| 68 | - | if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "turn.started" { | |
| 69 | - | t.Fatalf("primary operation = %#v, want turn", view.PrimaryOperation) | |
| 77 | + | if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "turn.started" { | |
| 78 | + | t.Fatalf("primary operation = %#v, want turn", primaryOperation(t, view)) | |
| 70 | 79 | } | |
| 71 | - | if observation := view.ObservationFor(*view.PrimaryOperation); observation == nil || observation.Label() != "finished read" { | |
| 80 | + | if observation := view.ObservationFor(*primaryOperation(t, view)); observation == nil || observation.Label() != "finished read" { | |
| 72 | 81 | t.Fatalf("completed tool observation = %#v, want finished read", observation) | |
| 73 | 82 | } | |
| 74 | 83 | } | |
| @@ -80,10 +89,10 @@ func TestProjectTraceDoesNotDecorateToolWithProviderStream(t *testing.T) { | |||
| 80 | 89 | {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 | 90 | {Timestamp: started.Add(2 * time.Second), Type: "assistant.stream.observed", AgentID: "agent-1", TurnID: "turn-1"}, | |
| 82 | 91 | })["agent-1"] | |
| 83 | - | if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "tool.started" { | |
| 84 | - | t.Fatalf("primary operation = %#v, want tool", view.PrimaryOperation) | |
| 92 | + | if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "tool.started" { | |
| 93 | + | t.Fatalf("primary operation = %#v, want tool", primaryOperation(t, view)) | |
| 85 | 94 | } | |
| 86 | - | if observation := view.ObservationFor(*view.PrimaryOperation); observation != nil { | |
| 95 | + | if observation := view.ObservationFor(*primaryOperation(t, view)); observation != nil { | |
| 87 | 96 | t.Fatalf("provider stream decorated tool operation: %#v", observation) | |
| 88 | 97 | } | |
| 89 | 98 | } | |
| @@ -161,8 +170,8 @@ func TestProjectTraceSelectsSpecificActivityAndProjectsResultDelivery(t *testing | |||
| 161 | 170 | {Timestamp: started.Add(3 * time.Second), Type: "result.available", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"ref": "subagent://agent-1/result"}}, | |
| 162 | 171 | {Timestamp: started.Add(4 * time.Second), Type: "result.delivered", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"ref": "subagent://agent-1/result"}}, | |
| 163 | 172 | })["agent-1"] | |
| 164 | - | if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "tool.started" || view.PrimaryOperation.Name != "bash" { | |
| 165 | - | t.Fatalf("primary operation = %#v, want bash tool", view.PrimaryOperation) | |
| 173 | + | if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "tool.started" || primaryOperation(t, view).Name != "bash" { | |
| 174 | + | t.Fatalf("primary operation = %#v, want bash tool", primaryOperation(t, view)) | |
| 166 | 175 | } | |
| 167 | 176 | if view.LastObservation == nil || view.LastObservation.Type != "assistant.stream.observed" || view.LastObservation.Label() != "assistant streaming" { | |
| 168 | 177 | t.Fatalf("last observation = %#v", view.LastObservation) | |
| @@ -213,10 +222,10 @@ func TestProjectTraceOmitsObservationForAnotherToolInSameTurn(t *testing.T) { | |||
| 213 | 222 | {Timestamp: started.Add(2 * time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "two", "name": "read"}}, | |
| 214 | 223 | {Timestamp: started.Add(3 * time.Second), Type: "tool.output.observed", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "two", "name": "read"}}, | |
| 215 | 224 | })["agent-1"] | |
| 216 | - | if view.PrimaryOperation == nil || view.PrimaryOperation.CallID != "one" { | |
| 217 | - | t.Fatalf("primary operation = %#v, want call one", view.PrimaryOperation) | |
| 225 | + | if primaryOperation(t, view) == nil || primaryOperation(t, view).CallID != "one" { | |
| 226 | + | t.Fatalf("primary operation = %#v, want call one", primaryOperation(t, view)) | |
| 218 | 227 | } | |
| 219 | - | if observation := view.ObservationFor(*view.PrimaryOperation); observation != nil { | |
| 228 | + | if observation := view.ObservationFor(*primaryOperation(t, view)); observation != nil { | |
| 220 | 229 | t.Fatalf("another tool's observation = %#v, want omitted", observation) | |
| 221 | 230 | } | |
| 222 | 231 | if got, want := view.Summary(), "running bash"; got != want { | |
| @@ -233,10 +242,10 @@ func TestProjectTraceOmitsPriorTurnObservation(t *testing.T) { | |||
| 233 | 242 | if view.LastObservation == nil { | |
| 234 | 243 | t.Fatal("last observation missing") | |
| 235 | 244 | } | |
| 236 | - | if view.PrimaryOperation == nil { | |
| 245 | + | if primaryOperation(t, view) == nil { | |
| 237 | 246 | t.Fatal("primary operation missing") | |
| 238 | 247 | } | |
| 239 | - | if observation := view.ObservationFor(*view.PrimaryOperation); observation != nil { | |
| 248 | + | if observation := view.ObservationFor(*primaryOperation(t, view)); observation != nil { | |
| 240 | 249 | t.Fatalf("prior turn observation = %#v, want omitted", observation) | |
| 241 | 250 | } | |
| 242 | 251 | if got, want := view.Summary(), "processing turn"; got != want { | |
| @@ -8,6 +8,15 @@ import ( | |||||
| 8 | "time" | 8 | "time" | ||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | + | func primaryOperation(t testing.TB, view AgentTraceView) *Operation { | |||
| 12 | + | t.Helper() | |||
| 13 | + | operation, ok := view.Primary() | |||
| 14 | + | if !ok { | |||
| 15 | + | t.Fatal("trace view has no primary operation") | |||
| 16 | + | } | |||
| 17 | + | return &operation | |||
| 18 | + | } | |||
| 19 | + | ||||
| 11 | func TestProjectTraceRetainsJSONDecodedRequestAttempts(t *testing.T) { | 20 | func TestProjectTraceRetainsJSONDecodedRequestAttempts(t *testing.T) { | ||
| 12 | bundle := t.TempDir() | 21 | bundle := t.TempDir() | ||
| 13 | if err := os.WriteFile(filepath.Join(bundle, "manifest.json"), []byte(`{"version":1,"trace_file":"trace.jsonl"}`), 0o600); err != nil { | 22 | if err := os.WriteFile(filepath.Join(bundle, "manifest.json"), []byte(`{"version":1,"trace_file":"trace.jsonl"}`), 0o600); err != nil { | ||
| @@ -21,7 +30,7 @@ func TestProjectTraceRetainsJSONDecodedRequestAttempts(t *testing.T) { | |||||
| 21 | if err != nil { | 30 | if err != nil { | ||
| 22 | t.Fatal(err) | 31 | t.Fatal(err) | ||
| 23 | } | 32 | } | ||
| 24 | - | operation := ProjectTrace(events)["agent-1"].PrimaryOperation | 33 | + | operation := primaryOperation(t, ProjectTrace(events)["agent-1"]) |
| 25 | if operation == nil || operation.Attempt != 2 || operation.MaxAttempts != 6 { | 34 | if operation == nil || operation.Attempt != 2 || operation.MaxAttempts != 6 { | ||
| 26 | t.Fatalf("projected operation = %#v, want attempt 2/6", operation) | 35 | t.Fatalf("projected operation = %#v, want attempt 2/6", operation) | ||
| 27 | } | 36 | } | ||
| @@ -36,10 +45,10 @@ func TestProjectTraceIgnoresProtocolFactsAndShowsCompletedToolDuringNextProvider | |||||
| 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"}}, | 45 | {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"}, | 46 | {Timestamp: started.Add(3 * time.Second), Type: "provider.request.started", AgentID: "agent-1", TurnID: "turn-1"}, | ||
| 38 | })["agent-1"] | 47 | })["agent-1"] | ||
| 39 | - | if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "provider.request.started" { | 48 | + | if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "provider.request.started" { |
| 40 | - | t.Fatalf("primary operation = %#v", view.PrimaryOperation) | 49 | + | t.Fatalf("primary operation = %#v", primaryOperation(t, view)) |
| 41 | } | 50 | } | ||
| 42 | - | observation := view.ObservationFor(*view.PrimaryOperation) | 51 | + | observation := view.ObservationFor(*primaryOperation(t, view)) |
| 43 | if observation == nil || observation.Label() != "finished read" { | 52 | if observation == nil || observation.Label() != "finished read" { | ||
| 44 | t.Fatalf("observation = %#v, want finished read", observation) | 53 | t.Fatalf("observation = %#v, want finished read", observation) | ||
| 45 | } | 54 | } | ||
| @@ -65,10 +74,10 @@ func TestProjectTraceKeepsShortCompletedToolVisibleBeforeNextRequest(t *testing. | |||||
| 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"}}, | 74 | {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"}}, | 75 | {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"] | 76 | })["agent-1"] | ||
| 68 | - | if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "turn.started" { | 77 | + | if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "turn.started" { |
| 69 | - | t.Fatalf("primary operation = %#v, want turn", view.PrimaryOperation) | 78 | + | t.Fatalf("primary operation = %#v, want turn", primaryOperation(t, view)) |
| 70 | } | 79 | } | ||
| 71 | - | if observation := view.ObservationFor(*view.PrimaryOperation); observation == nil || observation.Label() != "finished read" { | 80 | + | if observation := view.ObservationFor(*primaryOperation(t, view)); observation == nil || observation.Label() != "finished read" { |
| 72 | t.Fatalf("completed tool observation = %#v, want finished read", observation) | 81 | t.Fatalf("completed tool observation = %#v, want finished read", observation) | ||
| 73 | } | 82 | } | ||
| 74 | } | 83 | } | ||
| @@ -80,10 +89,10 @@ func TestProjectTraceDoesNotDecorateToolWithProviderStream(t *testing.T) { | |||||
| 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"}}, | 89 | {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"}, | 90 | {Timestamp: started.Add(2 * time.Second), Type: "assistant.stream.observed", AgentID: "agent-1", TurnID: "turn-1"}, | ||
| 82 | })["agent-1"] | 91 | })["agent-1"] | ||
| 83 | - | if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "tool.started" { | 92 | + | if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "tool.started" { |
| 84 | - | t.Fatalf("primary operation = %#v, want tool", view.PrimaryOperation) | 93 | + | t.Fatalf("primary operation = %#v, want tool", primaryOperation(t, view)) |
| 85 | } | 94 | } | ||
| 86 | - | if observation := view.ObservationFor(*view.PrimaryOperation); observation != nil { | 95 | + | if observation := view.ObservationFor(*primaryOperation(t, view)); observation != nil { |
| 87 | t.Fatalf("provider stream decorated tool operation: %#v", observation) | 96 | t.Fatalf("provider stream decorated tool operation: %#v", observation) | ||
| 88 | } | 97 | } | ||
| 89 | } | 98 | } | ||
| @@ -161,8 +170,8 @@ func TestProjectTraceSelectsSpecificActivityAndProjectsResultDelivery(t *testing | |||||
| 161 | {Timestamp: started.Add(3 * time.Second), Type: "result.available", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"ref": "subagent://agent-1/result"}}, | 170 | {Timestamp: started.Add(3 * time.Second), Type: "result.available", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"ref": "subagent://agent-1/result"}}, | ||
| 162 | {Timestamp: started.Add(4 * time.Second), Type: "result.delivered", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"ref": "subagent://agent-1/result"}}, | 171 | {Timestamp: started.Add(4 * time.Second), Type: "result.delivered", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"ref": "subagent://agent-1/result"}}, | ||
| 163 | })["agent-1"] | 172 | })["agent-1"] | ||
| 164 | - | if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "tool.started" || view.PrimaryOperation.Name != "bash" { | 173 | + | if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "tool.started" || primaryOperation(t, view).Name != "bash" { |
| 165 | - | t.Fatalf("primary operation = %#v, want bash tool", view.PrimaryOperation) | 174 | + | t.Fatalf("primary operation = %#v, want bash tool", primaryOperation(t, view)) |
| 166 | } | 175 | } | ||
| 167 | if view.LastObservation == nil || view.LastObservation.Type != "assistant.stream.observed" || view.LastObservation.Label() != "assistant streaming" { | 176 | if view.LastObservation == nil || view.LastObservation.Type != "assistant.stream.observed" || view.LastObservation.Label() != "assistant streaming" { | ||
| 168 | t.Fatalf("last observation = %#v", view.LastObservation) | 177 | t.Fatalf("last observation = %#v", view.LastObservation) | ||
| @@ -213,10 +222,10 @@ func TestProjectTraceOmitsObservationForAnotherToolInSameTurn(t *testing.T) { | |||||
| 213 | {Timestamp: started.Add(2 * time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "two", "name": "read"}}, | 222 | {Timestamp: started.Add(2 * time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "two", "name": "read"}}, | ||
| 214 | {Timestamp: started.Add(3 * time.Second), Type: "tool.output.observed", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "two", "name": "read"}}, | 223 | {Timestamp: started.Add(3 * time.Second), Type: "tool.output.observed", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "two", "name": "read"}}, | ||
| 215 | })["agent-1"] | 224 | })["agent-1"] | ||
| 216 | - | if view.PrimaryOperation == nil || view.PrimaryOperation.CallID != "one" { | 225 | + | if primaryOperation(t, view) == nil || primaryOperation(t, view).CallID != "one" { |
| 217 | - | t.Fatalf("primary operation = %#v, want call one", view.PrimaryOperation) | 226 | + | t.Fatalf("primary operation = %#v, want call one", primaryOperation(t, view)) |
| 218 | } | 227 | } | ||
| 219 | - | if observation := view.ObservationFor(*view.PrimaryOperation); observation != nil { | 228 | + | if observation := view.ObservationFor(*primaryOperation(t, view)); observation != nil { |
| 220 | t.Fatalf("another tool's observation = %#v, want omitted", observation) | 229 | t.Fatalf("another tool's observation = %#v, want omitted", observation) | ||
| 221 | } | 230 | } | ||
| 222 | if got, want := view.Summary(), "running bash"; got != want { | 231 | if got, want := view.Summary(), "running bash"; got != want { | ||
| @@ -233,10 +242,10 @@ func TestProjectTraceOmitsPriorTurnObservation(t *testing.T) { | |||||
| 233 | if view.LastObservation == nil { | 242 | if view.LastObservation == nil { | ||
| 234 | t.Fatal("last observation missing") | 243 | t.Fatal("last observation missing") | ||
| 235 | } | 244 | } | ||
| 236 | - | if view.PrimaryOperation == nil { | 245 | + | if primaryOperation(t, view) == nil { |
| 237 | t.Fatal("primary operation missing") | 246 | t.Fatal("primary operation missing") | ||
| 238 | } | 247 | } | ||
| 239 | - | if observation := view.ObservationFor(*view.PrimaryOperation); observation != nil { | 248 | + | if observation := view.ObservationFor(*primaryOperation(t, view)); observation != nil { |
| 240 | t.Fatalf("prior turn observation = %#v, want omitted", observation) | 249 | t.Fatalf("prior turn observation = %#v, want omitted", observation) | ||
| 241 | } | 250 | } | ||
| 242 | if got, want := view.Summary(), "processing turn"; got != want { | 251 | if got, want := view.Summary(), "processing turn"; got != want { | ||
M packages/agent/subagents/protocol.go +1 -1
| @@ -600,7 +600,7 @@ type AgentHeartbeatPayload struct { | |||
| 600 | 600 | } | |
| 601 | 601 | type TurnStartPayload struct { | |
| 602 | 602 | Prompt string `json:"prompt"` | |
| 603 | - | // NewRun starts a fresh max_turns budget while retaining the worker's | |
| 603 | + | // NewRun starts a fresh observable run while retaining the worker's | |
| 604 | 604 | // existing session and transcript. It is set only for explicit | |
| 605 | 605 | // subagent_resume follow-ups. | |
| 606 | 606 | NewRun bool `json:"new_run,omitempty"` | |
| @@ -600,7 +600,7 @@ type AgentHeartbeatPayload struct { | |||||
| 600 | } | 600 | } | ||
| 601 | type TurnStartPayload struct { | 601 | type TurnStartPayload struct { | ||
| 602 | Prompt string `json:"prompt"` | 602 | Prompt string `json:"prompt"` | ||
| 603 | - | // NewRun starts a fresh max_turns budget while retaining the worker's | 603 | + | // NewRun starts a fresh observable run while retaining the worker's |
| 604 | // existing session and transcript. It is set only for explicit | 604 | // existing session and transcript. It is set only for explicit | ||
| 605 | // subagent_resume follow-ups. | 605 | // subagent_resume follow-ups. | ||
| 606 | NewRun bool `json:"new_run,omitempty"` | 606 | NewRun bool `json:"new_run,omitempty"` | ||
M packages/agent/subagents/result.go +5 -1
| @@ -571,13 +571,17 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) { | |||
| 571 | 571 | result.Summary = strings.Split(strings.TrimSpace(result.Output), "\n")[0] | |
| 572 | 572 | } | |
| 573 | 573 | result = result.Bounded(a.maxOutputBytes, a.maxOutputLines) | |
| 574 | - | a.setResult(result) | |
| 575 | 574 | stateDir := a.stateDirectory(f.cfg.Root) | |
| 576 | 575 | if err := writeTurnResult(stateDir, result); err != nil { | |
| 577 | 576 | a.recordPersistenceError(fmt.Errorf("write turn result: %w", err)) | |
| 577 | + | failed := cloneTurnResult(result) | |
| 578 | + | failed.Status = ResultFailed | |
| 579 | + | failed.Error = &ResultError{Code: "result_persistence_failed", Message: "failed to persist delegated turn result"} | |
| 580 | + | a.setResult(failed) | |
| 578 | 581 | a.resolveRequirement(0, nil, fmt.Sprintf("write turn result: %v", err), true) | |
| 579 | 582 | return | |
| 580 | 583 | } | |
| 584 | + | a.setResult(result) | |
| 581 | 585 | a.lifecycleMu.Lock() | |
| 582 | 586 | a.resultRef = ResultRef(a.ID) | |
| 583 | 587 | a.lifecycleMu.Unlock() | |
| @@ -571,13 +571,17 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) { | |||||
| 571 | result.Summary = strings.Split(strings.TrimSpace(result.Output), "\n")[0] | 571 | result.Summary = strings.Split(strings.TrimSpace(result.Output), "\n")[0] | ||
| 572 | } | 572 | } | ||
| 573 | result = result.Bounded(a.maxOutputBytes, a.maxOutputLines) | 573 | result = result.Bounded(a.maxOutputBytes, a.maxOutputLines) | ||
| 574 | - | a.setResult(result) | |||
| 575 | stateDir := a.stateDirectory(f.cfg.Root) | 574 | stateDir := a.stateDirectory(f.cfg.Root) | ||
| 576 | if err := writeTurnResult(stateDir, result); err != nil { | 575 | if err := writeTurnResult(stateDir, result); err != nil { | ||
| 577 | a.recordPersistenceError(fmt.Errorf("write turn result: %w", err)) | 576 | a.recordPersistenceError(fmt.Errorf("write turn result: %w", err)) | ||
| 577 | + | failed := cloneTurnResult(result) | |||
| 578 | + | failed.Status = ResultFailed | |||
| 579 | + | failed.Error = &ResultError{Code: "result_persistence_failed", Message: "failed to persist delegated turn result"} | |||
| 580 | + | a.setResult(failed) | |||
| 578 | a.resolveRequirement(0, nil, fmt.Sprintf("write turn result: %v", err), true) | 581 | a.resolveRequirement(0, nil, fmt.Sprintf("write turn result: %v", err), true) | ||
| 579 | return | 582 | return | ||
| 580 | } | 583 | } | ||
| 584 | + | a.setResult(result) | |||
| 581 | a.lifecycleMu.Lock() | 585 | a.lifecycleMu.Lock() | ||
| 582 | a.resultRef = ResultRef(a.ID) | 586 | a.resultRef = ResultRef(a.ID) | ||
| 583 | a.lifecycleMu.Unlock() | 587 | a.lifecycleMu.Unlock() | ||
M packages/agent/subagents/runner.go +4 -28
| @@ -78,8 +78,6 @@ type subagentWorkerArgsOpts struct { | |||
| 78 | 78 | FastMode bool | |
| 79 | 79 | FastModeSet bool | |
| 80 | 80 | Subagent string | |
| 81 | - | MaxTurns int | |
| 82 | - | MaxSteps int | |
| 83 | 81 | TurnTimeout time.Duration | |
| 84 | 82 | LifetimeTurns int | |
| 85 | 83 | RunTurns int | |
| @@ -118,8 +116,6 @@ func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []str | |||
| 118 | 116 | FastMode: a.FastMode, | |
| 119 | 117 | FastModeSet: true, | |
| 120 | 118 | Subagent: a.Subagent, | |
| 121 | - | MaxTurns: a.MaxTurns, | |
| 122 | - | MaxSteps: a.MaxSteps, | |
| 123 | 119 | TurnTimeout: a.Timeout, | |
| 124 | 120 | LifetimeTurns: a.LifetimeTurnsValue(), | |
| 125 | 121 | RunTurns: a.CurrentRunTurnsValue(), | |
| @@ -164,12 +160,6 @@ func subagentWorkerArgs(opts subagentWorkerArgsOpts) []string { | |||
| 164 | 160 | if opts.Subagent != "" { | |
| 165 | 161 | args = append(args, "--subagent", opts.Subagent) | |
| 166 | 162 | } | |
| 167 | - | if opts.MaxTurns > 0 { | |
| 168 | - | args = append(args, "--max-turns", fmt.Sprint(opts.MaxTurns)) | |
| 169 | - | } | |
| 170 | - | if opts.MaxSteps > 0 { | |
| 171 | - | args = append(args, "--max-steps", fmt.Sprint(opts.MaxSteps)) | |
| 172 | - | } | |
| 173 | 163 | if opts.TurnTimeout > 0 { | |
| 174 | 164 | args = append(args, "--subagent-turn-timeout", opts.TurnTimeout.String()) | |
| 175 | 165 | } | |
| @@ -814,13 +804,8 @@ func updateAgentFromEvent(a *Agent, ev Event) error { | |||
| 814 | 804 | case "error": | |
| 815 | 805 | if code, _ := ev.Data["code"].(string); code == "turn_rejected" && eventMatchesPendingResume(a, ev) { | |
| 816 | 806 | if a.rejectActiveResumePrompt() { | |
| 817 | - | reason, _ := ev.Data["reason"].(string) | |
| 818 | - | if reason == "max_turns" { | |
| 819 | - | a.setTurnState(TurnFailed, ev.TurnID) | |
| 820 | - | } else { | |
| 821 | - | a.setTurnState(TurnIdle, ev.TurnID) | |
| 822 | - | notifyIdle = true | |
| 823 | - | } | |
| 807 | + | a.setTurnState(TurnIdle, ev.TurnID) | |
| 808 | + | notifyIdle = true | |
| 824 | 809 | persist = true | |
| 825 | 810 | } | |
| 826 | 811 | } | |
| @@ -926,7 +911,7 @@ func recordWorkerTrace(agent *Agent, ev Event) { | |||
| 926 | 911 | traceType := "worker.protocol.observed" | |
| 927 | 912 | nestedTurn, _ := ev.Data["nested_turn"].(bool) | |
| 928 | 913 | switch ev.Type { | |
| 929 | - | case "turn_start": | |
| 914 | + | case "turn_start", EventTurnStarted: | |
| 930 | 915 | if nestedTurn { | |
| 931 | 916 | traceType = "provider.request.started" | |
| 932 | 917 | } else { | |
| @@ -941,15 +926,6 @@ func recordWorkerTrace(agent *Agent, ev Event) { | |||
| 941 | 926 | } else { | |
| 942 | 927 | traceType = "provider.request.finished" | |
| 943 | 928 | } | |
| 944 | - | case EventTurnStarted: | |
| 945 | - | // The worker canonicalizes core turn_start to turn.started. Its | |
| 946 | - | // nested_turn marker keeps that provider-loop boundary distinct from | |
| 947 | - | // the delegated worker turn, which also uses turn.started. | |
| 948 | - | if nestedTurn { | |
| 949 | - | traceType = "provider.request.started" | |
| 950 | - | } else { | |
| 951 | - | traceType = "turn.started" | |
| 952 | - | } | |
| 953 | 929 | case EventTurnResult: | |
| 954 | 930 | traceType = "turn.finished" | |
| 955 | 931 | if status, _ := ev.Data["status"].(string); status == "failed" { | |
| @@ -991,7 +967,7 @@ func recordWorkerTrace(agent *Agent, ev Event) { | |||
| 991 | 967 | data[key] = value | |
| 992 | 968 | } | |
| 993 | 969 | } | |
| 994 | - | for _, key := range []string{"attempt", "max_attempts"} { | |
| 970 | + | for _, key := range []string{"step", "attempt", "max_attempts"} { | |
| 995 | 971 | if value, ok := ev.Data[key].(float64); ok && value > 0 { | |
| 996 | 972 | data[key] = int(value) | |
| 997 | 973 | } else if value, ok := ev.Data[key].(int); ok && value > 0 { | |
| @@ -78,8 +78,6 @@ type subagentWorkerArgsOpts struct { | |||||
| 78 | FastMode bool | 78 | FastMode bool | ||
| 79 | FastModeSet bool | 79 | FastModeSet bool | ||
| 80 | Subagent string | 80 | Subagent string | ||
| 81 | - | MaxTurns int | |||
| 82 | - | MaxSteps int | |||
| 83 | TurnTimeout time.Duration | 81 | TurnTimeout time.Duration | ||
| 84 | LifetimeTurns int | 82 | LifetimeTurns int | ||
| 85 | RunTurns int | 83 | RunTurns int | ||
| @@ -118,8 +116,6 @@ func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []str | |||||
| 118 | FastMode: a.FastMode, | 116 | FastMode: a.FastMode, | ||
| 119 | FastModeSet: true, | 117 | FastModeSet: true, | ||
| 120 | Subagent: a.Subagent, | 118 | Subagent: a.Subagent, | ||
| 121 | - | MaxTurns: a.MaxTurns, | |||
| 122 | - | MaxSteps: a.MaxSteps, | |||
| 123 | TurnTimeout: a.Timeout, | 119 | TurnTimeout: a.Timeout, | ||
| 124 | LifetimeTurns: a.LifetimeTurnsValue(), | 120 | LifetimeTurns: a.LifetimeTurnsValue(), | ||
| 125 | RunTurns: a.CurrentRunTurnsValue(), | 121 | RunTurns: a.CurrentRunTurnsValue(), | ||
| @@ -164,12 +160,6 @@ func subagentWorkerArgs(opts subagentWorkerArgsOpts) []string { | |||||
| 164 | if opts.Subagent != "" { | 160 | if opts.Subagent != "" { | ||
| 165 | args = append(args, "--subagent", opts.Subagent) | 161 | args = append(args, "--subagent", opts.Subagent) | ||
| 166 | } | 162 | } | ||
| 167 | - | if opts.MaxTurns > 0 { | |||
| 168 | - | args = append(args, "--max-turns", fmt.Sprint(opts.MaxTurns)) | |||
| 169 | - | } | |||
| 170 | - | if opts.MaxSteps > 0 { | |||
| 171 | - | args = append(args, "--max-steps", fmt.Sprint(opts.MaxSteps)) | |||
| 172 | - | } | |||
| 173 | if opts.TurnTimeout > 0 { | 163 | if opts.TurnTimeout > 0 { | ||
| 174 | args = append(args, "--subagent-turn-timeout", opts.TurnTimeout.String()) | 164 | args = append(args, "--subagent-turn-timeout", opts.TurnTimeout.String()) | ||
| 175 | } | 165 | } | ||
| @@ -814,13 +804,8 @@ func updateAgentFromEvent(a *Agent, ev Event) error { | |||||
| 814 | case "error": | 804 | case "error": | ||
| 815 | if code, _ := ev.Data["code"].(string); code == "turn_rejected" && eventMatchesPendingResume(a, ev) { | 805 | if code, _ := ev.Data["code"].(string); code == "turn_rejected" && eventMatchesPendingResume(a, ev) { | ||
| 816 | if a.rejectActiveResumePrompt() { | 806 | if a.rejectActiveResumePrompt() { | ||
| 817 | - | reason, _ := ev.Data["reason"].(string) | 807 | + | a.setTurnState(TurnIdle, ev.TurnID) |
| 818 | - | if reason == "max_turns" { | 808 | + | notifyIdle = true |
| 819 | - | a.setTurnState(TurnFailed, ev.TurnID) | |||
| 820 | - | } else { | |||
| 821 | - | a.setTurnState(TurnIdle, ev.TurnID) | |||
| 822 | - | notifyIdle = true | |||
| 823 | - | } | |||
| 824 | persist = true | 809 | persist = true | ||
| 825 | } | 810 | } | ||
| 826 | } | 811 | } | ||
| @@ -926,7 +911,7 @@ func recordWorkerTrace(agent *Agent, ev Event) { | |||||
| 926 | traceType := "worker.protocol.observed" | 911 | traceType := "worker.protocol.observed" | ||
| 927 | nestedTurn, _ := ev.Data["nested_turn"].(bool) | 912 | nestedTurn, _ := ev.Data["nested_turn"].(bool) | ||
| 928 | switch ev.Type { | 913 | switch ev.Type { | ||
| 929 | - | case "turn_start": | 914 | + | case "turn_start", EventTurnStarted: |
| 930 | if nestedTurn { | 915 | if nestedTurn { | ||
| 931 | traceType = "provider.request.started" | 916 | traceType = "provider.request.started" | ||
| 932 | } else { | 917 | } else { | ||
| @@ -941,15 +926,6 @@ func recordWorkerTrace(agent *Agent, ev Event) { | |||||
| 941 | } else { | 926 | } else { | ||
| 942 | traceType = "provider.request.finished" | 927 | traceType = "provider.request.finished" | ||
| 943 | } | 928 | } | ||
| 944 | - | case EventTurnStarted: | |||
| 945 | - | // The worker canonicalizes core turn_start to turn.started. Its | |||
| 946 | - | // nested_turn marker keeps that provider-loop boundary distinct from | |||
| 947 | - | // the delegated worker turn, which also uses turn.started. | |||
| 948 | - | if nestedTurn { | |||
| 949 | - | traceType = "provider.request.started" | |||
| 950 | - | } else { | |||
| 951 | - | traceType = "turn.started" | |||
| 952 | - | } | |||
| 953 | case EventTurnResult: | 929 | case EventTurnResult: | ||
| 954 | traceType = "turn.finished" | 930 | traceType = "turn.finished" | ||
| 955 | if status, _ := ev.Data["status"].(string); status == "failed" { | 931 | if status, _ := ev.Data["status"].(string); status == "failed" { | ||
| @@ -991,7 +967,7 @@ func recordWorkerTrace(agent *Agent, ev Event) { | |||||
| 991 | data[key] = value | 967 | data[key] = value | ||
| 992 | } | 968 | } | ||
| 993 | } | 969 | } | ||
| 994 | - | for _, key := range []string{"attempt", "max_attempts"} { | 970 | + | for _, key := range []string{"step", "attempt", "max_attempts"} { |
| 995 | if value, ok := ev.Data[key].(float64); ok && value > 0 { | 971 | if value, ok := ev.Data[key].(float64); ok && value > 0 { | ||
| 996 | data[key] = int(value) | 972 | data[key] = int(value) | ||
| 997 | } else if value, ok := ev.Data[key].(int); ok && value > 0 { | 973 | } else if value, ok := ev.Data[key].(int); ok && value > 0 { | ||
M packages/agent/subagents/runner_test.go +25 -5
| @@ -360,10 +360,12 @@ 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) | |
| 363 | + | func TestDefaultChildArgsDoNotImposeModelStepOrTurnLimits(t *testing.T) { | |
| 364 | + | args := defaultChildArgs("zut", &Agent{Task: "task"}, "/session", "/inbox") | |
| 365 | + | for _, flag := range []string{"--max-steps", "--max-turns"} { | |
| 366 | + | if indexOf(args, flag) >= 0 { | |
| 367 | + | t.Fatalf("argv = %v, contains removed subagent limit %s", args, flag) | |
| 368 | + | } | |
| 367 | 369 | } | |
| 368 | 370 | } | |
| 369 | 371 | ||
| @@ -911,7 +913,7 @@ func TestRecordWorkerTraceNormalizesSafeLiveObservations(t *testing.T) { | |||
| 911 | 913 | } | |
| 912 | 914 | } | |
| 913 | 915 | view := ProjectTrace(append([]TraceEvent{{Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"}}, events...))["agent-1"] | |
| 914 | - | if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "tool.started" || view.PrimaryOperation.Name != "bash" { | |
| 916 | + | if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "tool.started" || primaryOperation(t, view).Name != "bash" { | |
| 915 | 917 | t.Fatalf("current-turn tool activity was not projected: %#v", view) | |
| 916 | 918 | } | |
| 917 | 919 | } | |
| @@ -934,6 +936,24 @@ func TestRecordWorkerTraceClosesProviderRequestWhenTurnEnds(t *testing.T) { | |||
| 934 | 936 | } | |
| 935 | 937 | } | |
| 936 | 938 | ||
| 939 | + | func TestRecordWorkerTraceProjectsCurrentModelStep(t *testing.T) { | |
| 940 | + | trace := NewMemoryTraceWriter() | |
| 941 | + | t.Cleanup(func() { _ = trace.Close() }) | |
| 942 | + | agent := &Agent{ID: "agent-1", trace: trace} | |
| 943 | + | recordWorkerTrace(agent, Event{ | |
| 944 | + | Type: EventTurnStarted, | |
| 945 | + | TurnID: "turn-1", | |
| 946 | + | Data: map[string]any{"nested_turn": true, "step": float64(3)}, | |
| 947 | + | }) | |
| 948 | + | if err := trace.Flush(); err != nil { | |
| 949 | + | t.Fatal(err) | |
| 950 | + | } | |
| 951 | + | operation := primaryOperation(t, ProjectTrace(trace.Events())["agent-1"]) | |
| 952 | + | if operation.Type != "provider.request.started" || operation.Step != 3 { | |
| 953 | + | t.Fatalf("provider operation = %#v, want model step 3", operation) | |
| 954 | + | } | |
| 955 | + | } | |
| 956 | + | ||
| 937 | 957 | func TestApplyEventToSinkProjectsToolActivityIntoTranscript(t *testing.T) { | |
| 938 | 958 | events := []Event{ | |
| 939 | 959 | {Type: EventToolStarted, Data: map[string]any{"name": "bash"}}, | |
| @@ -360,10 +360,12 @@ func TestDefaultChildArgsPropagatesWebSearchPolicy(t *testing.T) { | |||||
| 360 | } | 360 | } | ||
| 361 | } | 361 | } | ||
| 362 | 362 | ||||
| 363 | - | func TestDefaultChildArgsPropagatesModelStepBudget(t *testing.T) { | 363 | + | func TestDefaultChildArgsDoNotImposeModelStepOrTurnLimits(t *testing.T) { |
| 364 | - | args := defaultChildArgs("zut", &Agent{Task: "task", MaxSteps: 128}, "/session", "/inbox") | 364 | + | args := defaultChildArgs("zut", &Agent{Task: "task"}, "/session", "/inbox") |
| 365 | - | if i := indexOf(args, "--max-steps"); i < 0 || safeAt(args, i+1) != "128" { | 365 | + | for _, flag := range []string{"--max-steps", "--max-turns"} { |
| 366 | - | t.Fatalf("argv = %v, want --max-steps 128", args) | 366 | + | if indexOf(args, flag) >= 0 { |
| 367 | + | t.Fatalf("argv = %v, contains removed subagent limit %s", args, flag) | |||
| 368 | + | } | |||
| 367 | } | 369 | } | ||
| 368 | } | 370 | } | ||
| 369 | 371 | ||||
| @@ -911,7 +913,7 @@ func TestRecordWorkerTraceNormalizesSafeLiveObservations(t *testing.T) { | |||||
| 911 | } | 913 | } | ||
| 912 | } | 914 | } | ||
| 913 | view := ProjectTrace(append([]TraceEvent{{Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"}}, events...))["agent-1"] | 915 | view := ProjectTrace(append([]TraceEvent{{Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"}}, events...))["agent-1"] | ||
| 914 | - | if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "tool.started" || view.PrimaryOperation.Name != "bash" { | 916 | + | if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "tool.started" || primaryOperation(t, view).Name != "bash" { |
| 915 | t.Fatalf("current-turn tool activity was not projected: %#v", view) | 917 | t.Fatalf("current-turn tool activity was not projected: %#v", view) | ||
| 916 | } | 918 | } | ||
| 917 | } | 919 | } | ||
| @@ -934,6 +936,24 @@ func TestRecordWorkerTraceClosesProviderRequestWhenTurnEnds(t *testing.T) { | |||||
| 934 | } | 936 | } | ||
| 935 | } | 937 | } | ||
| 936 | 938 | ||||
| 939 | + | func TestRecordWorkerTraceProjectsCurrentModelStep(t *testing.T) { | |||
| 940 | + | trace := NewMemoryTraceWriter() | |||
| 941 | + | t.Cleanup(func() { _ = trace.Close() }) | |||
| 942 | + | agent := &Agent{ID: "agent-1", trace: trace} | |||
| 943 | + | recordWorkerTrace(agent, Event{ | |||
| 944 | + | Type: EventTurnStarted, | |||
| 945 | + | TurnID: "turn-1", | |||
| 946 | + | Data: map[string]any{"nested_turn": true, "step": float64(3)}, | |||
| 947 | + | }) | |||
| 948 | + | if err := trace.Flush(); err != nil { | |||
| 949 | + | t.Fatal(err) | |||
| 950 | + | } | |||
| 951 | + | operation := primaryOperation(t, ProjectTrace(trace.Events())["agent-1"]) | |||
| 952 | + | if operation.Type != "provider.request.started" || operation.Step != 3 { | |||
| 953 | + | t.Fatalf("provider operation = %#v, want model step 3", operation) | |||
| 954 | + | } | |||
| 955 | + | } | |||
| 956 | + | ||||
| 937 | func TestApplyEventToSinkProjectsToolActivityIntoTranscript(t *testing.T) { | 957 | func TestApplyEventToSinkProjectsToolActivityIntoTranscript(t *testing.T) { | ||
| 938 | events := []Event{ | 958 | events := []Event{ | ||
| 939 | {Type: EventToolStarted, Data: map[string]any{"name": "bash"}}, | 959 | {Type: EventToolStarted, Data: map[string]any{"name": "bash"}}, | ||
M packages/agent/subagents/scheduler.go +0 -3
| @@ -63,9 +63,6 @@ func (f *Supervisor) validateSpawnRequest(req SpawnRequest) error { | |||
| 63 | 63 | ||
| 64 | 64 | func (f *Supervisor) validateSpawnOptions(req SpawnRequest) error { | |
| 65 | 65 | p := f.cfg.Policy | |
| 66 | - | if req.MaxTurns < 0 || (p.MaxTurns > 0 && req.MaxTurns > p.MaxTurns) { | |
| 67 | - | return fmt.Errorf("subagents: max_turns must be 0 through %d (zero uses the policy default)", p.MaxTurns) | |
| 68 | - | } | |
| 69 | 66 | if req.Timeout < 0 { | |
| 70 | 67 | return errorsInvalid("timeout must not be negative") | |
| 71 | 68 | } | |
| @@ -63,9 +63,6 @@ func (f *Supervisor) validateSpawnRequest(req SpawnRequest) error { | |||||
| 63 | 63 | ||||
| 64 | func (f *Supervisor) validateSpawnOptions(req SpawnRequest) error { | 64 | func (f *Supervisor) validateSpawnOptions(req SpawnRequest) error { | ||
| 65 | p := f.cfg.Policy | 65 | p := f.cfg.Policy | ||
| 66 | - | if req.MaxTurns < 0 || (p.MaxTurns > 0 && req.MaxTurns > p.MaxTurns) { | |||
| 67 | - | return fmt.Errorf("subagents: max_turns must be 0 through %d (zero uses the policy default)", p.MaxTurns) | |||
| 68 | - | } | |||
| 69 | if req.Timeout < 0 { | 66 | if req.Timeout < 0 { | ||
| 70 | return errorsInvalid("timeout must not be negative") | 67 | return errorsInvalid("timeout must not be negative") | ||
| 71 | } | 68 | } | ||
M packages/agent/subagents/supervisor.go +0 -15
| @@ -228,14 +228,6 @@ func (f *Supervisor) RepoRoot() string { | |||
| 228 | 228 | return f.cfg.RepoRoot | |
| 229 | 229 | } | |
| 230 | 230 | ||
| 231 | - | // MaxTurns returns the maximum prompt-level turns allowed for subsequently | |
| 232 | - | // spawned agents. | |
| 233 | - | func (f *Supervisor) MaxTurns() int { | |
| 234 | - | f.mu.Lock() | |
| 235 | - | defer f.mu.Unlock() | |
| 236 | - | return f.cfg.Policy.MaxTurns | |
| 237 | - | } | |
| 238 | - | ||
| 239 | 231 | // SetFastMode updates the fast-mode setting for subsequently spawned | |
| 240 | 232 | // children. Existing agents keep the setting captured at their spawn | |
| 241 | 233 | // boundary so resumes remain deterministic. | |
| @@ -382,7 +374,6 @@ type SpawnRequest struct { | |||
| 382 | 374 | // supervisor persists its outcome while the manager remains asynchronous. | |
| 383 | 375 | Required bool | |
| 384 | 376 | Timeout time.Duration | |
| 385 | - | MaxTurns int | |
| 386 | 377 | Tools []string | |
| 387 | 378 | WorkspaceMode WorkspaceMode | |
| 388 | 379 | WorkspaceBase string | |
| @@ -502,10 +493,6 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er | |||
| 502 | 493 | return nil, err | |
| 503 | 494 | } | |
| 504 | 495 | dir = workspace.Dir() | |
| 505 | - | maxTurns := req.MaxTurns | |
| 506 | - | if maxTurns <= 0 { | |
| 507 | - | maxTurns = f.cfg.Policy.MaxTurns | |
| 508 | - | } | |
| 509 | 496 | timeout := req.Timeout | |
| 510 | 497 | if timeout <= 0 { | |
| 511 | 498 | timeout = f.cfg.Policy.DefaultTimeout | |
| @@ -539,8 +526,6 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er | |||
| 539 | 526 | WorkspaceMode: workspaceMode, | |
| 540 | 527 | WorkspaceBase: strings.TrimSpace(req.WorkspaceBase), | |
| 541 | 528 | WorkspaceCapture: req.WorkspaceCapture, | |
| 542 | - | MaxTurns: maxTurns, | |
| 543 | - | MaxSteps: f.cfg.Policy.MaxSteps, | |
| 544 | 529 | Timeout: timeout, | |
| 545 | 530 | HeartbeatInterval: f.cfg.Policy.HeartbeatInterval, | |
| 546 | 531 | Tools: tools, | |
| @@ -228,14 +228,6 @@ func (f *Supervisor) RepoRoot() string { | |||||
| 228 | return f.cfg.RepoRoot | 228 | return f.cfg.RepoRoot | ||
| 229 | } | 229 | } | ||
| 230 | 230 | ||||
| 231 | - | // MaxTurns returns the maximum prompt-level turns allowed for subsequently | |||
| 232 | - | // spawned agents. | |||
| 233 | - | func (f *Supervisor) MaxTurns() int { | |||
| 234 | - | f.mu.Lock() | |||
| 235 | - | defer f.mu.Unlock() | |||
| 236 | - | return f.cfg.Policy.MaxTurns | |||
| 237 | - | } | |||
| 238 | - | ||||
| 239 | // SetFastMode updates the fast-mode setting for subsequently spawned | 231 | // SetFastMode updates the fast-mode setting for subsequently spawned | ||
| 240 | // children. Existing agents keep the setting captured at their spawn | 232 | // children. Existing agents keep the setting captured at their spawn | ||
| 241 | // boundary so resumes remain deterministic. | 233 | // boundary so resumes remain deterministic. | ||
| @@ -382,7 +374,6 @@ type SpawnRequest struct { | |||||
| 382 | // supervisor persists its outcome while the manager remains asynchronous. | 374 | // supervisor persists its outcome while the manager remains asynchronous. | ||
| 383 | Required bool | 375 | Required bool | ||
| 384 | Timeout time.Duration | 376 | Timeout time.Duration | ||
| 385 | - | MaxTurns int | |||
| 386 | Tools []string | 377 | Tools []string | ||
| 387 | WorkspaceMode WorkspaceMode | 378 | WorkspaceMode WorkspaceMode | ||
| 388 | WorkspaceBase string | 379 | WorkspaceBase string | ||
| @@ -502,10 +493,6 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er | |||||
| 502 | return nil, err | 493 | return nil, err | ||
| 503 | } | 494 | } | ||
| 504 | dir = workspace.Dir() | 495 | dir = workspace.Dir() | ||
| 505 | - | maxTurns := req.MaxTurns | |||
| 506 | - | if maxTurns <= 0 { | |||
| 507 | - | maxTurns = f.cfg.Policy.MaxTurns | |||
| 508 | - | } | |||
| 509 | timeout := req.Timeout | 496 | timeout := req.Timeout | ||
| 510 | if timeout <= 0 { | 497 | if timeout <= 0 { | ||
| 511 | timeout = f.cfg.Policy.DefaultTimeout | 498 | timeout = f.cfg.Policy.DefaultTimeout | ||
| @@ -539,8 +526,6 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er | |||||
| 539 | WorkspaceMode: workspaceMode, | 526 | WorkspaceMode: workspaceMode, | ||
| 540 | WorkspaceBase: strings.TrimSpace(req.WorkspaceBase), | 527 | WorkspaceBase: strings.TrimSpace(req.WorkspaceBase), | ||
| 541 | WorkspaceCapture: req.WorkspaceCapture, | 528 | WorkspaceCapture: req.WorkspaceCapture, | ||
| 542 | - | MaxTurns: maxTurns, | |||
| 543 | - | MaxSteps: f.cfg.Policy.MaxSteps, | |||
| 544 | Timeout: timeout, | 529 | Timeout: timeout, | ||
| 545 | HeartbeatInterval: f.cfg.Policy.HeartbeatInterval, | 530 | HeartbeatInterval: f.cfg.Policy.HeartbeatInterval, | ||
| 546 | Tools: tools, | 531 | Tools: tools, | ||
M packages/agent/subagents/supervisor_test.go +3 -19
| @@ -86,29 +86,13 @@ func TestSpawnWithoutTimeoutUsesUnlimitedTurns(t *testing.T) { | |||
| 86 | 86 | a.Wait() | |
| 87 | 87 | } | |
| 88 | 88 | ||
| 89 | - | func TestSpawnLeavesModelAndTurnBudgetsUnlimitedByDefault(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(), "unlimited model loop") | |
| 94 | - | if err != nil { | |
| 95 | - | t.Fatal(err) | |
| 96 | - | } | |
| 97 | - | if a.MaxSteps != 0 || a.MaxTurns != 0 { | |
| 98 | - | t.Fatalf("model/turn budgets = %d/%d, want unlimited", a.MaxSteps, a.MaxTurns) | |
| 99 | - | } | |
| 100 | - | a.Wait() | |
| 101 | - | } | |
| 102 | - | ||
| 103 | - | func TestSubagentPolicyCanonicalizesNegativeUnlimitedBudgets(t *testing.T) { | |
| 89 | + | func TestSubagentPolicyCanonicalizesNegativeUnlimitedTimeout(t *testing.T) { | |
| 104 | 90 | policy := SubagentPolicy{ | |
| 105 | 91 | DefaultTimeout: -time.Second, | |
| 106 | - | MaxTurns: -1, | |
| 107 | - | MaxSteps: -1, | |
| 108 | 92 | } | |
| 109 | 93 | policy.normalize() | |
| 110 | - | if policy.DefaultTimeout != 0 || policy.MaxTurns != 0 || policy.MaxSteps != 0 { | |
| 111 | - | t.Fatalf("normalized budgets = %s/%d/%d, want unlimited zeros", policy.DefaultTimeout, policy.MaxTurns, policy.MaxSteps) | |
| 94 | + | if policy.DefaultTimeout != 0 { | |
| 95 | + | t.Fatalf("normalized timeout = %s, want unlimited zero", policy.DefaultTimeout) | |
| 112 | 96 | } | |
| 113 | 97 | } | |
| 114 | 98 | ||
| @@ -86,29 +86,13 @@ func TestSpawnWithoutTimeoutUsesUnlimitedTurns(t *testing.T) { | |||||
| 86 | a.Wait() | 86 | a.Wait() | ||
| 87 | } | 87 | } | ||
| 88 | 88 | ||||
| 89 | - | func TestSpawnLeavesModelAndTurnBudgetsUnlimitedByDefault(t *testing.T) { | 89 | + | func TestSubagentPolicyCanonicalizesNegativeUnlimitedTimeout(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(), "unlimited model loop") | |||
| 94 | - | if err != nil { | |||
| 95 | - | t.Fatal(err) | |||
| 96 | - | } | |||
| 97 | - | if a.MaxSteps != 0 || a.MaxTurns != 0 { | |||
| 98 | - | t.Fatalf("model/turn budgets = %d/%d, want unlimited", a.MaxSteps, a.MaxTurns) | |||
| 99 | - | } | |||
| 100 | - | a.Wait() | |||
| 101 | - | } | |||
| 102 | - | ||||
| 103 | - | func TestSubagentPolicyCanonicalizesNegativeUnlimitedBudgets(t *testing.T) { | |||
| 104 | policy := SubagentPolicy{ | 90 | policy := SubagentPolicy{ | ||
| 105 | DefaultTimeout: -time.Second, | 91 | DefaultTimeout: -time.Second, | ||
| 106 | - | MaxTurns: -1, | |||
| 107 | - | MaxSteps: -1, | |||
| 108 | } | 92 | } | ||
| 109 | policy.normalize() | 93 | policy.normalize() | ||
| 110 | - | if policy.DefaultTimeout != 0 || policy.MaxTurns != 0 || policy.MaxSteps != 0 { | 94 | + | if policy.DefaultTimeout != 0 { |
| 111 | - | t.Fatalf("normalized budgets = %s/%d/%d, want unlimited zeros", policy.DefaultTimeout, policy.MaxTurns, policy.MaxSteps) | 95 | + | t.Fatalf("normalized timeout = %s, want unlimited zero", policy.DefaultTimeout) |
| 112 | } | 96 | } | ||
| 113 | } | 97 | } | ||
| 114 | 98 | ||||
M packages/agent/subagents/trace_test.go +9 -9
| @@ -144,11 +144,11 @@ func TestSupervisorTraceViewsRetainOpenTurnAcrossEventEviction(t *testing.T) { | |||
| 144 | 144 | } | |
| 145 | 145 | ||
| 146 | 146 | view := supervisor.TraceViews()["agent-1"] | |
| 147 | - | if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "turn.started" { | |
| 148 | - | t.Fatalf("primary operation after request = %#v, want open delegated turn", view.PrimaryOperation) | |
| 147 | + | if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "turn.started" { | |
| 148 | + | t.Fatalf("primary operation after request = %#v, want open delegated turn", primaryOperation(t, view)) | |
| 149 | 149 | } | |
| 150 | - | if !view.PrimaryOperation.StartedAt.Equal(started) { | |
| 151 | - | t.Fatalf("delegated turn started at %v, want %v", view.PrimaryOperation.StartedAt, started) | |
| 150 | + | if !primaryOperation(t, view).StartedAt.Equal(started) { | |
| 151 | + | t.Fatalf("delegated turn started at %v, want %v", primaryOperation(t, view).StartedAt, started) | |
| 152 | 152 | } | |
| 153 | 153 | ||
| 154 | 154 | nextRequestStarted := requestStarted.Add(2 * time.Second) | |
| @@ -162,10 +162,10 @@ func TestSupervisorTraceViewsRetainOpenTurnAcrossEventEviction(t *testing.T) { | |||
| 162 | 162 | t.Fatal(err) | |
| 163 | 163 | } | |
| 164 | 164 | view = supervisor.TraceViews()["agent-1"] | |
| 165 | - | if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "provider.request.started" { | |
| 166 | - | t.Fatalf("primary operation during next request = %#v, want provider request", view.PrimaryOperation) | |
| 165 | + | if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "provider.request.started" { | |
| 166 | + | t.Fatalf("primary operation during next request = %#v, want provider request", primaryOperation(t, view)) | |
| 167 | 167 | } | |
| 168 | - | if got := view.TurnStartedAt(*view.PrimaryOperation); !got.Equal(started) { | |
| 168 | + | if got := view.TurnStartedAt(*primaryOperation(t, view)); !got.Equal(started) { | |
| 169 | 169 | t.Fatalf("elapsed timer anchor = %v, want delegated turn start %v", got, started) | |
| 170 | 170 | } | |
| 171 | 171 | } | |
| @@ -183,8 +183,8 @@ func TestTraceViewsObserveIngestedBoundariesBeforeAsyncWrite(t *testing.T) { | |||
| 183 | 183 | }) | |
| 184 | 184 | ||
| 185 | 185 | view := writer.Views()["agent-1"] | |
| 186 | - | if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "turn.started" { | |
| 187 | - | t.Fatalf("primary operation before async write = %#v, want delegated turn", view.PrimaryOperation) | |
| 186 | + | if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "turn.started" { | |
| 187 | + | t.Fatalf("primary operation before async write = %#v, want delegated turn", primaryOperation(t, view)) | |
| 188 | 188 | } | |
| 189 | 189 | } | |
| 190 | 190 | ||
| @@ -144,11 +144,11 @@ func TestSupervisorTraceViewsRetainOpenTurnAcrossEventEviction(t *testing.T) { | |||||
| 144 | } | 144 | } | ||
| 145 | 145 | ||||
| 146 | view := supervisor.TraceViews()["agent-1"] | 146 | view := supervisor.TraceViews()["agent-1"] | ||
| 147 | - | if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "turn.started" { | 147 | + | if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "turn.started" { |
| 148 | - | t.Fatalf("primary operation after request = %#v, want open delegated turn", view.PrimaryOperation) | 148 | + | t.Fatalf("primary operation after request = %#v, want open delegated turn", primaryOperation(t, view)) |
| 149 | } | 149 | } | ||
| 150 | - | if !view.PrimaryOperation.StartedAt.Equal(started) { | 150 | + | if !primaryOperation(t, view).StartedAt.Equal(started) { |
| 151 | - | t.Fatalf("delegated turn started at %v, want %v", view.PrimaryOperation.StartedAt, started) | 151 | + | t.Fatalf("delegated turn started at %v, want %v", primaryOperation(t, view).StartedAt, started) |
| 152 | } | 152 | } | ||
| 153 | 153 | ||||
| 154 | nextRequestStarted := requestStarted.Add(2 * time.Second) | 154 | nextRequestStarted := requestStarted.Add(2 * time.Second) | ||
| @@ -162,10 +162,10 @@ func TestSupervisorTraceViewsRetainOpenTurnAcrossEventEviction(t *testing.T) { | |||||
| 162 | t.Fatal(err) | 162 | t.Fatal(err) | ||
| 163 | } | 163 | } | ||
| 164 | view = supervisor.TraceViews()["agent-1"] | 164 | view = supervisor.TraceViews()["agent-1"] | ||
| 165 | - | if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "provider.request.started" { | 165 | + | if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "provider.request.started" { |
| 166 | - | t.Fatalf("primary operation during next request = %#v, want provider request", view.PrimaryOperation) | 166 | + | t.Fatalf("primary operation during next request = %#v, want provider request", primaryOperation(t, view)) |
| 167 | } | 167 | } | ||
| 168 | - | if got := view.TurnStartedAt(*view.PrimaryOperation); !got.Equal(started) { | 168 | + | if got := view.TurnStartedAt(*primaryOperation(t, view)); !got.Equal(started) { |
| 169 | t.Fatalf("elapsed timer anchor = %v, want delegated turn start %v", got, started) | 169 | t.Fatalf("elapsed timer anchor = %v, want delegated turn start %v", got, started) | ||
| 170 | } | 170 | } | ||
| 171 | } | 171 | } | ||
| @@ -183,8 +183,8 @@ func TestTraceViewsObserveIngestedBoundariesBeforeAsyncWrite(t *testing.T) { | |||||
| 183 | }) | 183 | }) | ||
| 184 | 184 | ||||
| 185 | view := writer.Views()["agent-1"] | 185 | view := writer.Views()["agent-1"] | ||
| 186 | - | if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "turn.started" { | 186 | + | if primaryOperation(t, view) == nil || primaryOperation(t, view).Type != "turn.started" { |
| 187 | - | t.Fatalf("primary operation before async write = %#v, want delegated turn", view.PrimaryOperation) | 187 | + | t.Fatalf("primary operation before async write = %#v, want delegated turn", primaryOperation(t, view)) |
| 188 | } | 188 | } | ||
| 189 | } | 189 | } | ||
| 190 | 190 | ||||
M packages/agent/tools/subagent_resume.go +2 -2
| @@ -11,8 +11,8 @@ import ( | |||
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | // SubagentResumeTool gives a sub-agent a follow-up turn while preserving its | |
| 14 | - | // existing session context. Every explicit follow-up starts a fresh max_turns | |
| 15 | - | // budget. For an idle live worker, the follow-up is delivered directly; for an | |
| 14 | + | // existing session context. Every explicit follow-up starts a fresh observable | |
| 15 | + | // run. For an idle live worker, the follow-up is delivered directly; for an | |
| 16 | 16 | // active live worker, it is queued for the next available message turn; for a | |
| 17 | 17 | // terminal worker, the supervisor restarts the retained session with the | |
| 18 | 18 | // follow-up as its initial turn. | |
| @@ -11,8 +11,8 @@ import ( | |||||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | // SubagentResumeTool gives a sub-agent a follow-up turn while preserving its | 13 | // SubagentResumeTool gives a sub-agent a follow-up turn while preserving its | ||
| 14 | - | // existing session context. Every explicit follow-up starts a fresh max_turns | 14 | + | // existing session context. Every explicit follow-up starts a fresh observable |
| 15 | - | // budget. For an idle live worker, the follow-up is delivered directly; for an | 15 | + | // run. For an idle live worker, the follow-up is delivered directly; for an |
| 16 | // active live worker, it is queued for the next available message turn; for a | 16 | // active live worker, it is queued for the next available message turn; for a | ||
| 17 | // terminal worker, the supervisor restarts the retained session with the | 17 | // terminal worker, the supervisor restarts the retained session with the | ||
| 18 | // follow-up as its initial turn. | 18 | // follow-up as its initial turn. | ||
M packages/agent/tools/subagent_spawn.go +7 -10
| @@ -1,9 +1,11 @@ | |||
| 1 | 1 | package tools | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "context" | |
| 5 | 6 | "encoding/json" | |
| 6 | 7 | "fmt" | |
| 8 | + | "io" | |
| 7 | 9 | "strings" | |
| 8 | 10 | ||
| 9 | 11 | "github.com/bnema/zut/packages/agent/subagents" | |
| @@ -61,11 +63,6 @@ type subagentSpawnArgs struct { | |||
| 61 | 63 | FastMode *bool `json:"fast_mode,omitempty"` | |
| 62 | 64 | Required bool `json:"required,omitempty"` | |
| 63 | 65 | Isolation string `json:"isolation,omitempty"` | |
| 64 | - | // Decode removed fields so stale callers receive an explicit policy error | |
| 65 | - | // instead of silently spawning with a different, unlimited budget. | |
| 66 | - | Timeout json.RawMessage `json:"timeout,omitempty"` | |
| 67 | - | MaxTurns json.RawMessage `json:"max_turns,omitempty"` | |
| 68 | - | MaxSteps json.RawMessage `json:"max_steps,omitempty"` | |
| 69 | 66 | } | |
| 70 | 67 | ||
| 71 | 68 | const subagentSpawnSchemaTemplate = `{ | |
| @@ -126,11 +123,13 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 126 | 123 | return protocolToolError(prefix + ": subagent delegation is unavailable in this mode") | |
| 127 | 124 | } | |
| 128 | 125 | var a subagentSpawnArgs | |
| 129 | - | if err := json.Unmarshal(raw, &a); err != nil { | |
| 126 | + | decoder := json.NewDecoder(bytes.NewReader(raw)) | |
| 127 | + | decoder.DisallowUnknownFields() | |
| 128 | + | if err := decoder.Decode(&a); err != nil { | |
| 130 | 129 | return core.ToolResult{}, fmt.Errorf("invalid args: %w", err) | |
| 131 | 130 | } | |
| 132 | - | if len(a.Timeout) != 0 || len(a.MaxTurns) != 0 || len(a.MaxSteps) != 0 { | |
| 133 | - | return protocolToolError(prefix + ": timeout, max_turns, and max_steps are manager-owned budgets and cannot be set by this tool") | |
| 131 | + | if err := decoder.Decode(&struct{}{}); err != io.EOF { | |
| 132 | + | return core.ToolResult{}, fmt.Errorf("invalid args: trailing JSON value") | |
| 134 | 133 | } | |
| 135 | 134 | task := strings.TrimSpace(a.Task) | |
| 136 | 135 | if task == "" { | |
| @@ -275,8 +274,6 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||
| 275 | 274 | "required": a.Required, | |
| 276 | 275 | "isolation": string(workspaceMode), | |
| 277 | 276 | "timeout": timeoutDetail, | |
| 278 | - | "max_turns": agent.MaxTurns, | |
| 279 | - | "max_steps": agent.MaxSteps, | |
| 280 | 277 | "state": agent.Status(), | |
| 281 | 278 | "process_state": string(agent.ProcessState()), | |
| 282 | 279 | "turn_state": string(agent.TurnState()), | |
| @@ -1,9 +1,11 @@ | |||||
| 1 | package tools | 1 | package tools | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 4 | "context" | 5 | "context" | ||
| 5 | "encoding/json" | 6 | "encoding/json" | ||
| 6 | "fmt" | 7 | "fmt" | ||
| 8 | + | "io" | |||
| 7 | "strings" | 9 | "strings" | ||
| 8 | 10 | ||||
| 9 | "github.com/bnema/zut/packages/agent/subagents" | 11 | "github.com/bnema/zut/packages/agent/subagents" | ||
| @@ -61,11 +63,6 @@ type subagentSpawnArgs struct { | |||||
| 61 | FastMode *bool `json:"fast_mode,omitempty"` | 63 | FastMode *bool `json:"fast_mode,omitempty"` | ||
| 62 | Required bool `json:"required,omitempty"` | 64 | Required bool `json:"required,omitempty"` | ||
| 63 | Isolation string `json:"isolation,omitempty"` | 65 | Isolation string `json:"isolation,omitempty"` | ||
| 64 | - | // Decode removed fields so stale callers receive an explicit policy error | |||
| 65 | - | // instead of silently spawning with a different, unlimited budget. | |||
| 66 | - | Timeout json.RawMessage `json:"timeout,omitempty"` | |||
| 67 | - | MaxTurns json.RawMessage `json:"max_turns,omitempty"` | |||
| 68 | - | MaxSteps json.RawMessage `json:"max_steps,omitempty"` | |||
| 69 | } | 66 | } | ||
| 70 | 67 | ||||
| 71 | const subagentSpawnSchemaTemplate = `{ | 68 | const subagentSpawnSchemaTemplate = `{ | ||
| @@ -126,11 +123,13 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||||
| 126 | return protocolToolError(prefix + ": subagent delegation is unavailable in this mode") | 123 | return protocolToolError(prefix + ": subagent delegation is unavailable in this mode") | ||
| 127 | } | 124 | } | ||
| 128 | var a subagentSpawnArgs | 125 | var a subagentSpawnArgs | ||
| 129 | - | if err := json.Unmarshal(raw, &a); err != nil { | 126 | + | decoder := json.NewDecoder(bytes.NewReader(raw)) |
| 127 | + | decoder.DisallowUnknownFields() | |||
| 128 | + | if err := decoder.Decode(&a); err != nil { | |||
| 130 | return core.ToolResult{}, fmt.Errorf("invalid args: %w", err) | 129 | return core.ToolResult{}, fmt.Errorf("invalid args: %w", err) | ||
| 131 | } | 130 | } | ||
| 132 | - | if len(a.Timeout) != 0 || len(a.MaxTurns) != 0 || len(a.MaxSteps) != 0 { | 131 | + | if err := decoder.Decode(&struct{}{}); err != io.EOF { |
| 133 | - | return protocolToolError(prefix + ": timeout, max_turns, and max_steps are manager-owned budgets and cannot be set by this tool") | 132 | + | return core.ToolResult{}, fmt.Errorf("invalid args: trailing JSON value") |
| 134 | } | 133 | } | ||
| 135 | task := strings.TrimSpace(a.Task) | 134 | task := strings.TrimSpace(a.Task) | ||
| 136 | if task == "" { | 135 | if task == "" { | ||
| @@ -275,8 +274,6 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ | |||||
| 275 | "required": a.Required, | 274 | "required": a.Required, | ||
| 276 | "isolation": string(workspaceMode), | 275 | "isolation": string(workspaceMode), | ||
| 277 | "timeout": timeoutDetail, | 276 | "timeout": timeoutDetail, | ||
| 278 | - | "max_turns": agent.MaxTurns, | |||
| 279 | - | "max_steps": agent.MaxSteps, | |||
| 280 | "state": agent.Status(), | 277 | "state": agent.Status(), | ||
| 281 | "process_state": string(agent.ProcessState()), | 278 | "process_state": string(agent.ProcessState()), | ||
| 282 | "turn_state": string(agent.TurnState()), | 279 | "turn_state": string(agent.TurnState()), | ||
M packages/agent/tools/subagent_spawn_test.go +12 -36
| @@ -128,9 +128,6 @@ func TestSubagentSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) { | |||
| 128 | 128 | if got := details["timeout"]; got != "unlimited" { | |
| 129 | 129 | t.Fatalf("timeout detail = %v, want unlimited", got) | |
| 130 | 130 | } | |
| 131 | - | if got := details["max_steps"]; got != 0 { | |
| 132 | - | t.Fatalf("max_steps detail = %v, want unlimited", got) | |
| 133 | - | } | |
| 134 | 131 | text := textResult(res.Content) | |
| 135 | 132 | if !strings.Contains(text, "model: gpt-5") || !strings.Contains(text, "provider: openai-codex") || !strings.Contains(text, "reasoning: medium") { | |
| 136 | 133 | t.Fatalf("result text missing inherited model/provider:\n%s", text) | |
| @@ -143,20 +140,15 @@ func TestSubagentSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) { | |||
| 143 | 140 | if agents[0].Model != "gpt-5" || agents[0].Provider != "openai-codex" || agents[0].Reasoning != "medium" { | |
| 144 | 141 | t.Fatalf("agent model/provider/reasoning = %q/%q/%q, want gpt-5/openai-codex/medium", agents[0].Model, agents[0].Provider, agents[0].Reasoning) | |
| 145 | 142 | } | |
| 146 | - | if agents[0].MaxTurns != 0 { | |
| 147 | - | t.Fatalf("omitted max_turns = %d, want unlimited", agents[0].MaxTurns) | |
| 148 | - | } | |
| 149 | 143 | } | |
| 150 | 144 | ||
| 151 | - | func TestSubagentSpawnDetailsUseEffectiveTimeoutAndTurns(t *testing.T) { | |
| 145 | + | func TestSubagentSpawnDetailsUseEffectiveTimeout(t *testing.T) { | |
| 152 | 146 | root := t.TempDir() | |
| 153 | 147 | manager := subagents.New(subagents.Config{ | |
| 154 | 148 | Root: filepath.Join(root, "subagents"), | |
| 155 | 149 | RepoRoot: root, | |
| 156 | 150 | Policy: subagents.SubagentPolicy{ | |
| 157 | 151 | DefaultTimeout: 37 * time.Minute, | |
| 158 | - | MaxTurns: 7, | |
| 159 | - | MaxSteps: 99, | |
| 160 | 152 | }, | |
| 161 | 153 | NewRunner: func(*subagents.Agent) subagents.Runner { | |
| 162 | 154 | return noopSupervisorRunner{} | |
| @@ -182,41 +174,25 @@ func TestSubagentSpawnDetailsUseEffectiveTimeoutAndTurns(t *testing.T) { | |||
| 182 | 174 | if got := details["timeout"]; got != (37 * time.Minute).String() { | |
| 183 | 175 | t.Fatalf("omitted timeout detail = %v, want %s", got, (37 * time.Minute).String()) | |
| 184 | 176 | } | |
| 185 | - | if got := details["max_turns"]; got != 7 { | |
| 186 | - | t.Fatalf("max_turns detail = %v, want 7", got) | |
| 187 | - | } | |
| 188 | - | if got := details["max_steps"]; got != 99 { | |
| 189 | - | t.Fatalf("max_steps detail = %v, want 99", got) | |
| 190 | - | } | |
| 191 | - | } | |
| 192 | - | ||
| 193 | - | func TestSubagentSpawnRejectsLegacyModelTimeoutOverride(t *testing.T) { | |
| 194 | - | manager := newTestSupervisor(t) | |
| 195 | - | tool := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }} | |
| 196 | - | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs","timeout":"20m"}`), nil) | |
| 197 | - | if err != nil { | |
| 198 | - | t.Fatal(err) | |
| 199 | - | } | |
| 200 | - | if !res.IsError || !strings.Contains(textResult(res.Content), "manager-owned") { | |
| 201 | - | t.Fatalf("result = %#v, want manager-owned budget error", res) | |
| 177 | + | if _, found := details["max_turns"]; found { | |
| 178 | + | t.Fatalf("details retain removed max_turns: %#v", details) | |
| 202 | 179 | } | |
| 203 | - | if agents := manager.List(); len(agents) != 0 { | |
| 204 | - | t.Fatalf("legacy timeout spawned agents: %#v", agents) | |
| 180 | + | if _, found := details["max_steps"]; found { | |
| 181 | + | t.Fatalf("details retain removed max_steps: %#v", details) | |
| 205 | 182 | } | |
| 206 | 183 | } | |
| 207 | 184 | ||
| 208 | - | func TestSubagentSpawnRejectsLegacyModelMaxTurnsOverride(t *testing.T) { | |
| 185 | + | func TestSubagentSpawnRejectsUnknownExecutionLimitFields(t *testing.T) { | |
| 209 | 186 | manager := newTestSupervisor(t) | |
| 210 | 187 | tool := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }} | |
| 211 | - | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs","max_turns":1}`), nil) | |
| 212 | - | if err != nil { | |
| 213 | - | t.Fatal(err) | |
| 214 | - | } | |
| 215 | - | if !res.IsError || !strings.Contains(textResult(res.Content), "manager-owned") { | |
| 216 | - | t.Fatalf("result = %#v, want manager-owned budget error", res) | |
| 188 | + | for _, field := range []string{"timeout", "max_turns", "max_steps"} { | |
| 189 | + | _, err := tool.Execute(context.Background(), json.RawMessage(fmt.Sprintf(`{"task":"research docs",%q:1}`, field)), nil) | |
| 190 | + | if err == nil || !strings.Contains(err.Error(), "unknown field") { | |
| 191 | + | t.Fatalf("field %q error = %v, want unknown field", field, err) | |
| 192 | + | } | |
| 217 | 193 | } | |
| 218 | 194 | if agents := manager.List(); len(agents) != 0 { | |
| 219 | - | t.Fatalf("legacy max_turns spawned agents: %#v", agents) | |
| 195 | + | t.Fatalf("unknown execution limits spawned agents: %#v", agents) | |
| 220 | 196 | } | |
| 221 | 197 | } | |
| 222 | 198 | ||
| @@ -128,9 +128,6 @@ func TestSubagentSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) { | |||||
| 128 | if got := details["timeout"]; got != "unlimited" { | 128 | if got := details["timeout"]; got != "unlimited" { | ||
| 129 | t.Fatalf("timeout detail = %v, want unlimited", got) | 129 | t.Fatalf("timeout detail = %v, want unlimited", got) | ||
| 130 | } | 130 | } | ||
| 131 | - | if got := details["max_steps"]; got != 0 { | |||
| 132 | - | t.Fatalf("max_steps detail = %v, want unlimited", got) | |||
| 133 | - | } | |||
| 134 | text := textResult(res.Content) | 131 | text := textResult(res.Content) | ||
| 135 | if !strings.Contains(text, "model: gpt-5") || !strings.Contains(text, "provider: openai-codex") || !strings.Contains(text, "reasoning: medium") { | 132 | if !strings.Contains(text, "model: gpt-5") || !strings.Contains(text, "provider: openai-codex") || !strings.Contains(text, "reasoning: medium") { | ||
| 136 | t.Fatalf("result text missing inherited model/provider:\n%s", text) | 133 | t.Fatalf("result text missing inherited model/provider:\n%s", text) | ||
| @@ -143,20 +140,15 @@ func TestSubagentSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) { | |||||
| 143 | if agents[0].Model != "gpt-5" || agents[0].Provider != "openai-codex" || agents[0].Reasoning != "medium" { | 140 | if agents[0].Model != "gpt-5" || agents[0].Provider != "openai-codex" || agents[0].Reasoning != "medium" { | ||
| 144 | t.Fatalf("agent model/provider/reasoning = %q/%q/%q, want gpt-5/openai-codex/medium", agents[0].Model, agents[0].Provider, agents[0].Reasoning) | 141 | t.Fatalf("agent model/provider/reasoning = %q/%q/%q, want gpt-5/openai-codex/medium", agents[0].Model, agents[0].Provider, agents[0].Reasoning) | ||
| 145 | } | 142 | } | ||
| 146 | - | if agents[0].MaxTurns != 0 { | |||
| 147 | - | t.Fatalf("omitted max_turns = %d, want unlimited", agents[0].MaxTurns) | |||
| 148 | - | } | |||
| 149 | } | 143 | } | ||
| 150 | 144 | ||||
| 151 | - | func TestSubagentSpawnDetailsUseEffectiveTimeoutAndTurns(t *testing.T) { | 145 | + | func TestSubagentSpawnDetailsUseEffectiveTimeout(t *testing.T) { |
| 152 | root := t.TempDir() | 146 | root := t.TempDir() | ||
| 153 | manager := subagents.New(subagents.Config{ | 147 | manager := subagents.New(subagents.Config{ | ||
| 154 | Root: filepath.Join(root, "subagents"), | 148 | Root: filepath.Join(root, "subagents"), | ||
| 155 | RepoRoot: root, | 149 | RepoRoot: root, | ||
| 156 | Policy: subagents.SubagentPolicy{ | 150 | Policy: subagents.SubagentPolicy{ | ||
| 157 | DefaultTimeout: 37 * time.Minute, | 151 | DefaultTimeout: 37 * time.Minute, | ||
| 158 | - | MaxTurns: 7, | |||
| 159 | - | MaxSteps: 99, | |||
| 160 | }, | 152 | }, | ||
| 161 | NewRunner: func(*subagents.Agent) subagents.Runner { | 153 | NewRunner: func(*subagents.Agent) subagents.Runner { | ||
| 162 | return noopSupervisorRunner{} | 154 | return noopSupervisorRunner{} | ||
| @@ -182,41 +174,25 @@ func TestSubagentSpawnDetailsUseEffectiveTimeoutAndTurns(t *testing.T) { | |||||
| 182 | if got := details["timeout"]; got != (37 * time.Minute).String() { | 174 | if got := details["timeout"]; got != (37 * time.Minute).String() { | ||
| 183 | t.Fatalf("omitted timeout detail = %v, want %s", got, (37 * time.Minute).String()) | 175 | t.Fatalf("omitted timeout detail = %v, want %s", got, (37 * time.Minute).String()) | ||
| 184 | } | 176 | } | ||
| 185 | - | if got := details["max_turns"]; got != 7 { | 177 | + | if _, found := details["max_turns"]; found { |
| 186 | - | t.Fatalf("max_turns detail = %v, want 7", got) | 178 | + | t.Fatalf("details retain removed max_turns: %#v", details) |
| 187 | - | } | |||
| 188 | - | if got := details["max_steps"]; got != 99 { | |||
| 189 | - | t.Fatalf("max_steps detail = %v, want 99", got) | |||
| 190 | - | } | |||
| 191 | - | } | |||
| 192 | - | ||||
| 193 | - | func TestSubagentSpawnRejectsLegacyModelTimeoutOverride(t *testing.T) { | |||
| 194 | - | manager := newTestSupervisor(t) | |||
| 195 | - | tool := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }} | |||
| 196 | - | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs","timeout":"20m"}`), nil) | |||
| 197 | - | if err != nil { | |||
| 198 | - | t.Fatal(err) | |||
| 199 | - | } | |||
| 200 | - | if !res.IsError || !strings.Contains(textResult(res.Content), "manager-owned") { | |||
| 201 | - | t.Fatalf("result = %#v, want manager-owned budget error", res) | |||
| 202 | } | 179 | } | ||
| 203 | - | if agents := manager.List(); len(agents) != 0 { | 180 | + | if _, found := details["max_steps"]; found { |
| 204 | - | t.Fatalf("legacy timeout spawned agents: %#v", agents) | 181 | + | t.Fatalf("details retain removed max_steps: %#v", details) |
| 205 | } | 182 | } | ||
| 206 | } | 183 | } | ||
| 207 | 184 | ||||
| 208 | - | func TestSubagentSpawnRejectsLegacyModelMaxTurnsOverride(t *testing.T) { | 185 | + | func TestSubagentSpawnRejectsUnknownExecutionLimitFields(t *testing.T) { |
| 209 | manager := newTestSupervisor(t) | 186 | manager := newTestSupervisor(t) | ||
| 210 | tool := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }} | 187 | tool := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }} | ||
| 211 | - | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs","max_turns":1}`), nil) | 188 | + | for _, field := range []string{"timeout", "max_turns", "max_steps"} { |
| 212 | - | if err != nil { | 189 | + | _, err := tool.Execute(context.Background(), json.RawMessage(fmt.Sprintf(`{"task":"research docs",%q:1}`, field)), nil) |
| 213 | - | t.Fatal(err) | 190 | + | if err == nil || !strings.Contains(err.Error(), "unknown field") { |
| 214 | - | } | 191 | + | t.Fatalf("field %q error = %v, want unknown field", field, err) |
| 215 | - | if !res.IsError || !strings.Contains(textResult(res.Content), "manager-owned") { | 192 | + | } |
| 216 | - | t.Fatalf("result = %#v, want manager-owned budget error", res) | |||
| 217 | } | 193 | } | ||
| 218 | if agents := manager.List(); len(agents) != 0 { | 194 | if agents := manager.List(); len(agents) != 0 { | ||
| 219 | - | t.Fatalf("legacy max_turns spawned agents: %#v", agents) | 195 | + | t.Fatalf("unknown execution limits spawned agents: %#v", agents) |
| 220 | } | 196 | } | ||
| 221 | } | 197 | } | ||
| 222 | 198 | ||||
M packages/agent/tools/subagent_status.go +4 -7
| @@ -160,11 +160,7 @@ func publicSubagentStatus(snapshot subagents.AgentSnapshot, view subagents.Agent | |||
| 160 | 160 | CurrentRunTurns: snapshot.CurrentRunTurns, | |
| 161 | 161 | TaskSummary: summarizeSubagentTask(snapshot.Task), | |
| 162 | 162 | } | |
| 163 | - | primary := view.PrimaryOperation | |
| 164 | - | if primary == nil && len(view.OpenOperations) != 0 { | |
| 165 | - | primary = &view.OpenOperations[0] | |
| 166 | - | } | |
| 167 | - | if primary != nil { | |
| 163 | + | if primary, ok := view.Primary(); ok { | |
| 168 | 164 | entry.PrimaryOperation = &statusOperation{Type: primary.Type, StartedAt: primary.StartedAt} | |
| 169 | 165 | } | |
| 170 | 166 | if view.LastObservation != nil { | |
| @@ -186,6 +182,7 @@ func publicSubagentStatus(snapshot subagents.AgentSnapshot, view subagents.Agent | |||
| 186 | 182 | State: publicResultState(snapshot.Result.Status), | |
| 187 | 183 | Available: snapshot.ResultRef != "", | |
| 188 | 184 | Ref: snapshot.ResultRef, | |
| 185 | + | Delivered: snapshot.Requirement.Notified, | |
| 189 | 186 | } | |
| 190 | 187 | } | |
| 191 | 188 | if view.Result != nil { | |
| @@ -196,8 +193,8 @@ func publicSubagentStatus(snapshot subagents.AgentSnapshot, view subagents.Agent | |||
| 196 | 193 | if view.Result.Ref != "" { | |
| 197 | 194 | entry.Result.Ref = view.Result.Ref | |
| 198 | 195 | } | |
| 199 | - | entry.Result.Delivered = view.Result.Delivered | |
| 200 | - | entry.Result.DeliveryFailed = view.Result.Failed | |
| 196 | + | entry.Result.Delivered = entry.Result.Delivered || view.Result.Delivered | |
| 197 | + | entry.Result.DeliveryFailed = entry.Result.DeliveryFailed || view.Result.Failed | |
| 201 | 198 | } | |
| 202 | 199 | return entry | |
| 203 | 200 | } | |
| @@ -160,11 +160,7 @@ func publicSubagentStatus(snapshot subagents.AgentSnapshot, view subagents.Agent | |||||
| 160 | CurrentRunTurns: snapshot.CurrentRunTurns, | 160 | CurrentRunTurns: snapshot.CurrentRunTurns, | ||
| 161 | TaskSummary: summarizeSubagentTask(snapshot.Task), | 161 | TaskSummary: summarizeSubagentTask(snapshot.Task), | ||
| 162 | } | 162 | } | ||
| 163 | - | primary := view.PrimaryOperation | 163 | + | if primary, ok := view.Primary(); ok { |
| 164 | - | if primary == nil && len(view.OpenOperations) != 0 { | |||
| 165 | - | primary = &view.OpenOperations[0] | |||
| 166 | - | } | |||
| 167 | - | if primary != nil { | |||
| 168 | entry.PrimaryOperation = &statusOperation{Type: primary.Type, StartedAt: primary.StartedAt} | 164 | entry.PrimaryOperation = &statusOperation{Type: primary.Type, StartedAt: primary.StartedAt} | ||
| 169 | } | 165 | } | ||
| 170 | if view.LastObservation != nil { | 166 | if view.LastObservation != nil { | ||
| @@ -186,6 +182,7 @@ func publicSubagentStatus(snapshot subagents.AgentSnapshot, view subagents.Agent | |||||
| 186 | State: publicResultState(snapshot.Result.Status), | 182 | State: publicResultState(snapshot.Result.Status), | ||
| 187 | Available: snapshot.ResultRef != "", | 183 | Available: snapshot.ResultRef != "", | ||
| 188 | Ref: snapshot.ResultRef, | 184 | Ref: snapshot.ResultRef, | ||
| 185 | + | Delivered: snapshot.Requirement.Notified, | |||
| 189 | } | 186 | } | ||
| 190 | } | 187 | } | ||
| 191 | if view.Result != nil { | 188 | if view.Result != nil { | ||
| @@ -196,8 +193,8 @@ func publicSubagentStatus(snapshot subagents.AgentSnapshot, view subagents.Agent | |||||
| 196 | if view.Result.Ref != "" { | 193 | if view.Result.Ref != "" { | ||
| 197 | entry.Result.Ref = view.Result.Ref | 194 | entry.Result.Ref = view.Result.Ref | ||
| 198 | } | 195 | } | ||
| 199 | - | entry.Result.Delivered = view.Result.Delivered | 196 | + | entry.Result.Delivered = entry.Result.Delivered || view.Result.Delivered |
| 200 | - | entry.Result.DeliveryFailed = view.Result.Failed | 197 | + | entry.Result.DeliveryFailed = entry.Result.DeliveryFailed || view.Result.Failed |
| 201 | } | 198 | } | ||
| 202 | return entry | 199 | return entry | ||
| 203 | } | 200 | } | ||
M packages/agent/tools/subagent_status_test.go +17 -3
| @@ -61,11 +61,13 @@ func TestSubagentStatusIncludesTurnCounters(t *testing.T) { | |||
| 61 | 61 | } | |
| 62 | 62 | } | |
| 63 | 63 | ||
| 64 | - | func TestPublicSubagentStatusUsesOnlyPrimaryOperation(t *testing.T) { | |
| 64 | + | func TestPublicSubagentStatusUsesOrderedPrimaryOperation(t *testing.T) { | |
| 65 | 65 | at := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC) | |
| 66 | 66 | entry := publicSubagentStatus(subagents.AgentSnapshot{ID: "agent-1"}, subagents.AgentTraceView{ | |
| 67 | - | OpenOperations: []subagents.Operation{{Type: "turn.started", StartedAt: at}}, | |
| 68 | - | PrimaryOperation: &subagents.Operation{Type: "tool.started", StartedAt: at.Add(time.Second)}, | |
| 67 | + | OpenOperations: []subagents.Operation{ | |
| 68 | + | {Type: "tool.started", StartedAt: at.Add(time.Second)}, | |
| 69 | + | {Type: "turn.started", StartedAt: at}, | |
| 70 | + | }, | |
| 69 | 71 | }) | |
| 70 | 72 | if entry.PrimaryOperation == nil || entry.PrimaryOperation.Type != "tool.started" { | |
| 71 | 73 | t.Fatalf("primary operation = %#v", entry.PrimaryOperation) | |
| @@ -120,6 +122,18 @@ func TestPublicSubagentStatusIncludesResultDeliveryFacts(t *testing.T) { | |||
| 120 | 122 | } | |
| 121 | 123 | } | |
| 122 | 124 | ||
| 125 | + | func TestPublicSubagentStatusKeepsDurableDeliveryAfterTraceRestart(t *testing.T) { | |
| 126 | + | entry := publicSubagentStatus(subagents.AgentSnapshot{ | |
| 127 | + | ID: "agent-1", | |
| 128 | + | Result: &subagents.TurnResult{Status: subagents.ResultSucceeded}, | |
| 129 | + | ResultRef: "subagent://agent-1/result", | |
| 130 | + | Requirement: subagents.RequirementSnapshot{Required: true, Notified: true}, | |
| 131 | + | }, subagents.AgentTraceView{}) | |
| 132 | + | if entry.Result == nil || !entry.Result.Available || !entry.Result.Delivered { | |
| 133 | + | t.Fatalf("result = %#v, want durable delivered fact without transient trace", entry.Result) | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | ||
| 123 | 137 | func TestSubagentStatusSchemaHasOptionalAgentID(t *testing.T) { | |
| 124 | 138 | var schema struct { | |
| 125 | 139 | Type string `json:"type"` | |
| @@ -61,11 +61,13 @@ func TestSubagentStatusIncludesTurnCounters(t *testing.T) { | |||||
| 61 | } | 61 | } | ||
| 62 | } | 62 | } | ||
| 63 | 63 | ||||
| 64 | - | func TestPublicSubagentStatusUsesOnlyPrimaryOperation(t *testing.T) { | 64 | + | func TestPublicSubagentStatusUsesOrderedPrimaryOperation(t *testing.T) { |
| 65 | at := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC) | 65 | at := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC) | ||
| 66 | entry := publicSubagentStatus(subagents.AgentSnapshot{ID: "agent-1"}, subagents.AgentTraceView{ | 66 | entry := publicSubagentStatus(subagents.AgentSnapshot{ID: "agent-1"}, subagents.AgentTraceView{ | ||
| 67 | - | OpenOperations: []subagents.Operation{{Type: "turn.started", StartedAt: at}}, | 67 | + | OpenOperations: []subagents.Operation{ |
| 68 | - | PrimaryOperation: &subagents.Operation{Type: "tool.started", StartedAt: at.Add(time.Second)}, | 68 | + | {Type: "tool.started", StartedAt: at.Add(time.Second)}, |
| 69 | + | {Type: "turn.started", StartedAt: at}, | |||
| 70 | + | }, | |||
| 69 | }) | 71 | }) | ||
| 70 | if entry.PrimaryOperation == nil || entry.PrimaryOperation.Type != "tool.started" { | 72 | if entry.PrimaryOperation == nil || entry.PrimaryOperation.Type != "tool.started" { | ||
| 71 | t.Fatalf("primary operation = %#v", entry.PrimaryOperation) | 73 | t.Fatalf("primary operation = %#v", entry.PrimaryOperation) | ||
| @@ -120,6 +122,18 @@ func TestPublicSubagentStatusIncludesResultDeliveryFacts(t *testing.T) { | |||||
| 120 | } | 122 | } | ||
| 121 | } | 123 | } | ||
| 122 | 124 | ||||
| 125 | + | func TestPublicSubagentStatusKeepsDurableDeliveryAfterTraceRestart(t *testing.T) { | |||
| 126 | + | entry := publicSubagentStatus(subagents.AgentSnapshot{ | |||
| 127 | + | ID: "agent-1", | |||
| 128 | + | Result: &subagents.TurnResult{Status: subagents.ResultSucceeded}, | |||
| 129 | + | ResultRef: "subagent://agent-1/result", | |||
| 130 | + | Requirement: subagents.RequirementSnapshot{Required: true, Notified: true}, | |||
| 131 | + | }, subagents.AgentTraceView{}) | |||
| 132 | + | if entry.Result == nil || !entry.Result.Available || !entry.Result.Delivered { | |||
| 133 | + | t.Fatalf("result = %#v, want durable delivered fact without transient trace", entry.Result) | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | ||||
| 123 | func TestSubagentStatusSchemaHasOptionalAgentID(t *testing.T) { | 137 | func TestSubagentStatusSchemaHasOptionalAgentID(t *testing.T) { | ||
| 124 | var schema struct { | 138 | var schema struct { | ||
| 125 | Type string `json:"type"` | 139 | Type string `json:"type"` | ||
M packages/core/agent.go +9 -0
| @@ -963,6 +963,10 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext | |||
| 963 | 963 | if err != nil { | |
| 964 | 964 | return provider.StopError, provider.Message{}, &requestOpenError{err: err} | |
| 965 | 965 | } | |
| 966 | + | // A provider may already have queued events when cancellation wins. Keep a | |
| 967 | + | // consumer attached after this turn returns so bounded producer channels | |
| 968 | + | // cannot strand the provider goroutine and its response body. | |
| 969 | + | defer func() { go drainProviderStream(stream) }() | |
| 966 | 970 | ||
| 967 | 971 | sink(EvAssistantStart{}) | |
| 968 | 972 | ||
| @@ -1097,6 +1101,11 @@ streamDone: | |||
| 1097 | 1101 | return stop, finalMsg, finalErr | |
| 1098 | 1102 | } | |
| 1099 | 1103 | ||
| 1104 | + | func drainProviderStream(stream <-chan provider.Event) { | |
| 1105 | + | for range stream { | |
| 1106 | + | } | |
| 1107 | + | } | |
| 1108 | + | ||
| 1100 | 1109 | // executeTools runs every tool call in the assistant message and returns | |
| 1101 | 1110 | // a single tool-role message carrying all results. | |
| 1102 | 1111 | func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink func(AgentEvent)) (provider.Message, bool) { | |
| @@ -963,6 +963,10 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext | |||||
| 963 | if err != nil { | 963 | if err != nil { | ||
| 964 | return provider.StopError, provider.Message{}, &requestOpenError{err: err} | 964 | return provider.StopError, provider.Message{}, &requestOpenError{err: err} | ||
| 965 | } | 965 | } | ||
| 966 | + | // A provider may already have queued events when cancellation wins. Keep a | |||
| 967 | + | // consumer attached after this turn returns so bounded producer channels | |||
| 968 | + | // cannot strand the provider goroutine and its response body. | |||
| 969 | + | defer func() { go drainProviderStream(stream) }() | |||
| 966 | 970 | ||||
| 967 | sink(EvAssistantStart{}) | 971 | sink(EvAssistantStart{}) | ||
| 968 | 972 | ||||
| @@ -1097,6 +1101,11 @@ streamDone: | |||||
| 1097 | return stop, finalMsg, finalErr | 1101 | return stop, finalMsg, finalErr | ||
| 1098 | } | 1102 | } | ||
| 1099 | 1103 | ||||
| 1104 | + | func drainProviderStream(stream <-chan provider.Event) { | |||
| 1105 | + | for range stream { | |||
| 1106 | + | } | |||
| 1107 | + | } | |||
| 1108 | + | ||||
| 1100 | // executeTools runs every tool call in the assistant message and returns | 1109 | // executeTools runs every tool call in the assistant message and returns | ||
| 1101 | // a single tool-role message carrying all results. | 1110 | // a single tool-role message carrying all results. | ||
| 1102 | func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink func(AgentEvent)) (provider.Message, bool) { | 1111 | func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink func(AgentEvent)) (provider.Message, bool) { | ||
M packages/core/agent_retry_test.go +42 -2
| @@ -61,6 +61,18 @@ func (silentStreamClient) Stream(ctx context.Context, _ provider.Request) (<-cha | |||
| 61 | 61 | return out, nil | |
| 62 | 62 | } | |
| 63 | 63 | ||
| 64 | + | type activeStreamClient struct{} | |
| 65 | + | ||
| 66 | + | func (activeStreamClient) Name() string { return "active-stream" } | |
| 67 | + | ||
| 68 | + | func (activeStreamClient) Stream(context.Context, provider.Request) (<-chan provider.Event, error) { | |
| 69 | + | out := make(chan provider.Event, 2) | |
| 70 | + | out <- provider.EventStart{} | |
| 71 | + | out <- provider.EventDone{Stop: provider.StopEnd} | |
| 72 | + | close(out) | |
| 73 | + | return out, nil | |
| 74 | + | } | |
| 75 | + | ||
| 64 | 76 | type requestOpenFailureClient struct{ calls int32 } | |
| 65 | 77 | ||
| 66 | 78 | func (c *requestOpenFailureClient) Name() string { return "request-open-failure" } | |
| @@ -71,7 +83,8 @@ func (c *requestOpenFailureClient) Stream(context.Context, provider.Request) (<- | |||
| 71 | 83 | } | |
| 72 | 84 | ||
| 73 | 85 | type readableAfterCancellationClient struct { | |
| 74 | - | cancel context.CancelFunc | |
| 86 | + | cancel context.CancelFunc | |
| 87 | + | drained chan struct{} | |
| 75 | 88 | } | |
| 76 | 89 | ||
| 77 | 90 | func (c readableAfterCancellationClient) Name() string { return "readable-after-cancellation" } | |
| @@ -80,6 +93,11 @@ func (c readableAfterCancellationClient) Stream(_ context.Context, _ provider.Re | |||
| 80 | 93 | out := make(chan provider.Event, 1) | |
| 81 | 94 | out <- provider.EventStart{} | |
| 82 | 95 | c.cancel() | |
| 96 | + | go func() { | |
| 97 | + | out <- provider.EventTextDelta{Delta: "discard after cancellation"} | |
| 98 | + | close(out) | |
| 99 | + | close(c.drained) | |
| 100 | + | }() | |
| 83 | 101 | return out, nil | |
| 84 | 102 | } | |
| 85 | 103 | ||
| @@ -98,11 +116,17 @@ func TestNewAgentUsesCodexStreamGuardDefaults(t *testing.T) { | |||
| 98 | 116 | ||
| 99 | 117 | func TestAgentStopsReadingStreamAfterCancellation(t *testing.T) { | |
| 100 | 118 | ctx, cancel := context.WithCancel(context.Background()) | |
| 101 | - | a := NewAgent(readableAfterCancellationClient{cancel: cancel}, "fake-model", "system", Registry{}) | |
| 119 | + | drained := make(chan struct{}) | |
| 120 | + | a := NewAgent(readableAfterCancellationClient{cancel: cancel, drained: drained}, "fake-model", "system", Registry{}) | |
| 102 | 121 | a.MaxRetries = 0 | |
| 103 | 122 | if err := a.Prompt(ctx, "hello", nil, nil); !errors.Is(err, context.Canceled) { | |
| 104 | 123 | t.Fatalf("Prompt error = %v, want context.Canceled", err) | |
| 105 | 124 | } | |
| 125 | + | select { | |
| 126 | + | case <-drained: | |
| 127 | + | case <-time.After(time.Second): | |
| 128 | + | t.Fatal("provider producer remained blocked after turn cancellation") | |
| 129 | + | } | |
| 106 | 130 | } | |
| 107 | 131 | ||
| 108 | 132 | func TestAgentStopsSilentStreamAtIdleDeadline(t *testing.T) { | |
| @@ -119,6 +143,22 @@ func TestAgentStopsSilentStreamAtIdleDeadline(t *testing.T) { | |||
| 119 | 143 | } | |
| 120 | 144 | } | |
| 121 | 145 | ||
| 146 | + | func TestAgentResetsIdleDeadlineForEveryStreamEvent(t *testing.T) { | |
| 147 | + | a := NewAgent(activeStreamClient{}, "fake-model", "system", Registry{}) | |
| 148 | + | a.MaxRetries = 0 | |
| 149 | + | a.StreamIdleTimeout = time.Minute | |
| 150 | + | var resets atomic.Int32 | |
| 151 | + | a.streamIdleTimer = func(time.Duration) (<-chan time.Time, func(), func()) { | |
| 152 | + | return make(chan time.Time), func() {}, func() { resets.Add(1) } | |
| 153 | + | } | |
| 154 | + | if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil { | |
| 155 | + | t.Fatal(err) | |
| 156 | + | } | |
| 157 | + | if got := resets.Load(); got != 2 { | |
| 158 | + | t.Fatalf("idle timer resets = %d, want one for each stream event", got) | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 122 | 162 | func TestAgentRetriesSilentStreamWithoutEndingTurn(t *testing.T) { | |
| 123 | 163 | client := &retryFakeClient{firstErr: ErrStreamIdleTimeout} | |
| 124 | 164 | a := NewAgent(client, "fake-model", "system", Registry{}) | |
| @@ -61,6 +61,18 @@ func (silentStreamClient) Stream(ctx context.Context, _ provider.Request) (<-cha | |||||
| 61 | return out, nil | 61 | return out, nil | ||
| 62 | } | 62 | } | ||
| 63 | 63 | ||||
| 64 | + | type activeStreamClient struct{} | |||
| 65 | + | ||||
| 66 | + | func (activeStreamClient) Name() string { return "active-stream" } | |||
| 67 | + | ||||
| 68 | + | func (activeStreamClient) Stream(context.Context, provider.Request) (<-chan provider.Event, error) { | |||
| 69 | + | out := make(chan provider.Event, 2) | |||
| 70 | + | out <- provider.EventStart{} | |||
| 71 | + | out <- provider.EventDone{Stop: provider.StopEnd} | |||
| 72 | + | close(out) | |||
| 73 | + | return out, nil | |||
| 74 | + | } | |||
| 75 | + | ||||
| 64 | type requestOpenFailureClient struct{ calls int32 } | 76 | type requestOpenFailureClient struct{ calls int32 } | ||
| 65 | 77 | ||||
| 66 | func (c *requestOpenFailureClient) Name() string { return "request-open-failure" } | 78 | func (c *requestOpenFailureClient) Name() string { return "request-open-failure" } | ||
| @@ -71,7 +83,8 @@ func (c *requestOpenFailureClient) Stream(context.Context, provider.Request) (<- | |||||
| 71 | } | 83 | } | ||
| 72 | 84 | ||||
| 73 | type readableAfterCancellationClient struct { | 85 | type readableAfterCancellationClient struct { | ||
| 74 | - | cancel context.CancelFunc | 86 | + | cancel context.CancelFunc |
| 87 | + | drained chan struct{} | |||
| 75 | } | 88 | } | ||
| 76 | 89 | ||||
| 77 | func (c readableAfterCancellationClient) Name() string { return "readable-after-cancellation" } | 90 | func (c readableAfterCancellationClient) Name() string { return "readable-after-cancellation" } | ||
| @@ -80,6 +93,11 @@ func (c readableAfterCancellationClient) Stream(_ context.Context, _ provider.Re | |||||
| 80 | out := make(chan provider.Event, 1) | 93 | out := make(chan provider.Event, 1) | ||
| 81 | out <- provider.EventStart{} | 94 | out <- provider.EventStart{} | ||
| 82 | c.cancel() | 95 | c.cancel() | ||
| 96 | + | go func() { | |||
| 97 | + | out <- provider.EventTextDelta{Delta: "discard after cancellation"} | |||
| 98 | + | close(out) | |||
| 99 | + | close(c.drained) | |||
| 100 | + | }() | |||
| 83 | return out, nil | 101 | return out, nil | ||
| 84 | } | 102 | } | ||
| 85 | 103 | ||||
| @@ -98,11 +116,17 @@ func TestNewAgentUsesCodexStreamGuardDefaults(t *testing.T) { | |||||
| 98 | 116 | ||||
| 99 | func TestAgentStopsReadingStreamAfterCancellation(t *testing.T) { | 117 | func TestAgentStopsReadingStreamAfterCancellation(t *testing.T) { | ||
| 100 | ctx, cancel := context.WithCancel(context.Background()) | 118 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 101 | - | a := NewAgent(readableAfterCancellationClient{cancel: cancel}, "fake-model", "system", Registry{}) | 119 | + | drained := make(chan struct{}) |
| 120 | + | a := NewAgent(readableAfterCancellationClient{cancel: cancel, drained: drained}, "fake-model", "system", Registry{}) | |||
| 102 | a.MaxRetries = 0 | 121 | a.MaxRetries = 0 | ||
| 103 | if err := a.Prompt(ctx, "hello", nil, nil); !errors.Is(err, context.Canceled) { | 122 | if err := a.Prompt(ctx, "hello", nil, nil); !errors.Is(err, context.Canceled) { | ||
| 104 | t.Fatalf("Prompt error = %v, want context.Canceled", err) | 123 | t.Fatalf("Prompt error = %v, want context.Canceled", err) | ||
| 105 | } | 124 | } | ||
| 125 | + | select { | |||
| 126 | + | case <-drained: | |||
| 127 | + | case <-time.After(time.Second): | |||
| 128 | + | t.Fatal("provider producer remained blocked after turn cancellation") | |||
| 129 | + | } | |||
| 106 | } | 130 | } | ||
| 107 | 131 | ||||
| 108 | func TestAgentStopsSilentStreamAtIdleDeadline(t *testing.T) { | 132 | func TestAgentStopsSilentStreamAtIdleDeadline(t *testing.T) { | ||
| @@ -119,6 +143,22 @@ func TestAgentStopsSilentStreamAtIdleDeadline(t *testing.T) { | |||||
| 119 | } | 143 | } | ||
| 120 | } | 144 | } | ||
| 121 | 145 | ||||
| 146 | + | func TestAgentResetsIdleDeadlineForEveryStreamEvent(t *testing.T) { | |||
| 147 | + | a := NewAgent(activeStreamClient{}, "fake-model", "system", Registry{}) | |||
| 148 | + | a.MaxRetries = 0 | |||
| 149 | + | a.StreamIdleTimeout = time.Minute | |||
| 150 | + | var resets atomic.Int32 | |||
| 151 | + | a.streamIdleTimer = func(time.Duration) (<-chan time.Time, func(), func()) { | |||
| 152 | + | return make(chan time.Time), func() {}, func() { resets.Add(1) } | |||
| 153 | + | } | |||
| 154 | + | if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil { | |||
| 155 | + | t.Fatal(err) | |||
| 156 | + | } | |||
| 157 | + | if got := resets.Load(); got != 2 { | |||
| 158 | + | t.Fatalf("idle timer resets = %d, want one for each stream event", got) | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | ||||
| 122 | func TestAgentRetriesSilentStreamWithoutEndingTurn(t *testing.T) { | 162 | func TestAgentRetriesSilentStreamWithoutEndingTurn(t *testing.T) { | ||
| 123 | client := &retryFakeClient{firstErr: ErrStreamIdleTimeout} | 163 | client := &retryFakeClient{firstErr: ErrStreamIdleTimeout} | ||
| 124 | a := NewAgent(client, "fake-model", "system", Registry{}) | 164 | a := NewAgent(client, "fake-model", "system", Registry{}) | ||
M packages/provider/amazon_bedrock.go +14 -18
| @@ -585,28 +585,24 @@ func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event, | |||
| 585 | 585 | } | |
| 586 | 586 | modelID := resolveBedrockInferenceProfileID(req.Model, c.region) | |
| 587 | 587 | url := c.baseURL + "/model/" + modelID + "/converse-stream" | |
| 588 | - | httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) | |
| 589 | - | if err != nil { | |
| 590 | - | return nil, err | |
| 588 | + | if c.bearerToken == "" && c.sigv4 == nil { | |
| 589 | + | return nil, fmt.Errorf("bedrock: no auth configured") | |
| 591 | 590 | } | |
| 592 | - | httpReq.Header.Set("content-type", "application/json") | |
| 593 | - | httpReq.Header.Set("accept", "application/vnd.amazon.eventstream") | |
| 594 | - | if c.bearerToken != "" { | |
| 595 | - | httpReq.Header.Set("authorization", "Bearer "+c.bearerToken) | |
| 596 | - | } else if c.sigv4 != nil { | |
| 597 | - | if err := signSigV4(httpReq, body, "bedrock", c.region, c.sigv4, time.Now().UTC()); err != nil { | |
| 591 | + | newRequest := func() (*http.Request, error) { | |
| 592 | + | httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) | |
| 593 | + | if err != nil { | |
| 594 | + | return nil, err | |
| 595 | + | } | |
| 596 | + | httpReq.Header.Set("content-type", "application/json") | |
| 597 | + | httpReq.Header.Set("accept", "application/vnd.amazon.eventstream") | |
| 598 | + | if c.bearerToken != "" { | |
| 599 | + | httpReq.Header.Set("authorization", "Bearer "+c.bearerToken) | |
| 600 | + | } else if err := signSigV4(httpReq, body, "bedrock", c.region, c.sigv4, time.Now().UTC()); err != nil { | |
| 598 | 601 | return nil, fmt.Errorf("bedrock: sigv4 sign: %w", err) | |
| 599 | 602 | } | |
| 600 | - | } else { | |
| 601 | - | return nil, fmt.Errorf("bedrock: no auth configured") | |
| 602 | - | } | |
| 603 | - | if err := ctx.Err(); err != nil { | |
| 604 | - | return nil, err | |
| 605 | - | } | |
| 606 | - | if req.Lifecycle != nil { | |
| 607 | - | req.Lifecycle.RequestAttempt(1, 1) | |
| 603 | + | return httpReq, nil | |
| 608 | 604 | } | |
| 609 | - | resp, err := c.http.Do(httpReq) | |
| 605 | + | resp, err := doStreamWithRetry(ctx, c.http, newRequest, req.Lifecycle) | |
| 610 | 606 | if err != nil { | |
| 611 | 607 | return nil, fmt.Errorf("bedrock: %w", err) | |
| 612 | 608 | } | |
| @@ -585,28 +585,24 @@ func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event, | |||||
| 585 | } | 585 | } | ||
| 586 | modelID := resolveBedrockInferenceProfileID(req.Model, c.region) | 586 | modelID := resolveBedrockInferenceProfileID(req.Model, c.region) | ||
| 587 | url := c.baseURL + "/model/" + modelID + "/converse-stream" | 587 | url := c.baseURL + "/model/" + modelID + "/converse-stream" | ||
| 588 | - | httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) | 588 | + | if c.bearerToken == "" && c.sigv4 == nil { |
| 589 | - | if err != nil { | 589 | + | return nil, fmt.Errorf("bedrock: no auth configured") |
| 590 | - | return nil, err | |||
| 591 | } | 590 | } | ||
| 592 | - | httpReq.Header.Set("content-type", "application/json") | 591 | + | newRequest := func() (*http.Request, error) { |
| 593 | - | httpReq.Header.Set("accept", "application/vnd.amazon.eventstream") | 592 | + | httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) |
| 594 | - | if c.bearerToken != "" { | 593 | + | if err != nil { |
| 595 | - | httpReq.Header.Set("authorization", "Bearer "+c.bearerToken) | 594 | + | return nil, err |
| 596 | - | } else if c.sigv4 != nil { | 595 | + | } |
| 597 | - | if err := signSigV4(httpReq, body, "bedrock", c.region, c.sigv4, time.Now().UTC()); err != nil { | 596 | + | httpReq.Header.Set("content-type", "application/json") |
| 597 | + | httpReq.Header.Set("accept", "application/vnd.amazon.eventstream") | |||
| 598 | + | if c.bearerToken != "" { | |||
| 599 | + | httpReq.Header.Set("authorization", "Bearer "+c.bearerToken) | |||
| 600 | + | } else if err := signSigV4(httpReq, body, "bedrock", c.region, c.sigv4, time.Now().UTC()); err != nil { | |||
| 598 | return nil, fmt.Errorf("bedrock: sigv4 sign: %w", err) | 601 | return nil, fmt.Errorf("bedrock: sigv4 sign: %w", err) | ||
| 599 | } | 602 | } | ||
| 600 | - | } else { | 603 | + | return httpReq, nil |
| 601 | - | return nil, fmt.Errorf("bedrock: no auth configured") | |||
| 602 | - | } | |||
| 603 | - | if err := ctx.Err(); err != nil { | |||
| 604 | - | return nil, err | |||
| 605 | - | } | |||
| 606 | - | if req.Lifecycle != nil { | |||
| 607 | - | req.Lifecycle.RequestAttempt(1, 1) | |||
| 608 | } | 604 | } | ||
| 609 | - | resp, err := c.http.Do(httpReq) | 605 | + | resp, err := doStreamWithRetry(ctx, c.http, newRequest, req.Lifecycle) |
| 610 | if err != nil { | 606 | if err != nil { | ||
| 611 | return nil, fmt.Errorf("bedrock: %w", err) | 607 | return nil, fmt.Errorf("bedrock: %w", err) | ||
| 612 | } | 608 | } | ||
M packages/provider/amazon_bedrock_test.go +31 -0
| @@ -2,13 +2,44 @@ package provider | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | + | "context" | |
| 5 | 6 | "encoding/json" | |
| 6 | 7 | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 7 | 9 | "strings" | |
| 10 | + | "sync/atomic" | |
| 8 | 11 | "testing" | |
| 9 | 12 | "time" | |
| 10 | 13 | ) | |
| 11 | 14 | ||
| 15 | + | func TestBedrockRetriesTransientRequestOpenFailures(t *testing.T) { | |
| 16 | + | var attempts atomic.Int32 | |
| 17 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 18 | + | if attempts.Add(1) < 5 { | |
| 19 | + | http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable) | |
| 20 | + | return | |
| 21 | + | } | |
| 22 | + | w.WriteHeader(http.StatusOK) | |
| 23 | + | })) | |
| 24 | + | t.Cleanup(server.Close) | |
| 25 | + | ||
| 26 | + | client := &bedrockClient{ | |
| 27 | + | bearerToken: "synthetic-token", | |
| 28 | + | region: "us-east-1", | |
| 29 | + | baseURL: server.URL, | |
| 30 | + | http: server.Client(), | |
| 31 | + | } | |
| 32 | + | events, err := client.Stream(context.Background(), Request{Model: "anthropic.claude-sonnet-4-5"}) | |
| 33 | + | if err != nil { | |
| 34 | + | t.Fatal(err) | |
| 35 | + | } | |
| 36 | + | for range events { | |
| 37 | + | } | |
| 38 | + | if got := attempts.Load(); got != 5 { | |
| 39 | + | t.Fatalf("request attempts = %d, want initial request plus four retries", got) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 12 | 43 | // TestSigV4Known checks our signer against AWS's published test vector | |
| 13 | 44 | // from the SigV4 documentation. The expected Authorization header below | |
| 14 | 45 | // is taken from "get-vanilla" in the suite (adapted: we use POST with | |
| @@ -2,13 +2,44 @@ package provider | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | + | "context" | |||
| 5 | "encoding/json" | 6 | "encoding/json" | ||
| 6 | "net/http" | 7 | "net/http" | ||
| 8 | + | "net/http/httptest" | |||
| 7 | "strings" | 9 | "strings" | ||
| 10 | + | "sync/atomic" | |||
| 8 | "testing" | 11 | "testing" | ||
| 9 | "time" | 12 | "time" | ||
| 10 | ) | 13 | ) | ||
| 11 | 14 | ||||
| 15 | + | func TestBedrockRetriesTransientRequestOpenFailures(t *testing.T) { | |||
| 16 | + | var attempts atomic.Int32 | |||
| 17 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 18 | + | if attempts.Add(1) < 5 { | |||
| 19 | + | http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable) | |||
| 20 | + | return | |||
| 21 | + | } | |||
| 22 | + | w.WriteHeader(http.StatusOK) | |||
| 23 | + | })) | |||
| 24 | + | t.Cleanup(server.Close) | |||
| 25 | + | ||||
| 26 | + | client := &bedrockClient{ | |||
| 27 | + | bearerToken: "synthetic-token", | |||
| 28 | + | region: "us-east-1", | |||
| 29 | + | baseURL: server.URL, | |||
| 30 | + | http: server.Client(), | |||
| 31 | + | } | |||
| 32 | + | events, err := client.Stream(context.Background(), Request{Model: "anthropic.claude-sonnet-4-5"}) | |||
| 33 | + | if err != nil { | |||
| 34 | + | t.Fatal(err) | |||
| 35 | + | } | |||
| 36 | + | for range events { | |||
| 37 | + | } | |||
| 38 | + | if got := attempts.Load(); got != 5 { | |||
| 39 | + | t.Fatalf("request attempts = %d, want initial request plus four retries", got) | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 12 | // TestSigV4Known checks our signer against AWS's published test vector | 43 | // TestSigV4Known checks our signer against AWS's published test vector | ||
| 13 | // from the SigV4 documentation. The expected Authorization header below | 44 | // from the SigV4 documentation. The expected Authorization header below | ||
| 14 | // is taken from "get-vanilla" in the suite (adapted: we use POST with | 45 | // is taken from "get-vanilla" in the suite (adapted: we use POST with | ||
A packages/tui/string_spinner.go +32 -0
| @@ -0,0 +1,32 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import "time" | |
| 4 | + | ||
| 5 | + | // StringSpinner selects one text frame for each configured interval. It owns | |
| 6 | + | // no ticker or goroutine; callers render it from their existing UI clock. | |
| 7 | + | type StringSpinner struct { | |
| 8 | + | frames []string | |
| 9 | + | interval time.Duration | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | // NewStringSpinner creates a reusable text animation. Empty frames render an | |
| 13 | + | // empty string, and a non-positive interval keeps the first frame stable. | |
| 14 | + | func NewStringSpinner(frames []string, interval time.Duration) StringSpinner { | |
| 15 | + | return StringSpinner{ | |
| 16 | + | frames: append([]string(nil), frames...), | |
| 17 | + | interval: interval, | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // FrameAt returns the frame for now relative to startedAt. Times before the | |
| 22 | + | // start clamp to the first frame. | |
| 23 | + | func (s StringSpinner) FrameAt(startedAt, now time.Time) string { | |
| 24 | + | if len(s.frames) == 0 { | |
| 25 | + | return "" | |
| 26 | + | } | |
| 27 | + | if s.interval <= 0 || startedAt.IsZero() || now.IsZero() || now.Before(startedAt) { | |
| 28 | + | return s.frames[0] | |
| 29 | + | } | |
| 30 | + | index := int(now.Sub(startedAt)/s.interval) % len(s.frames) | |
| 31 | + | return s.frames[index] | |
| 32 | + | } | |
| @@ -0,0 +1,32 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import "time" | |||
| 4 | + | ||||
| 5 | + | // StringSpinner selects one text frame for each configured interval. It owns | |||
| 6 | + | // no ticker or goroutine; callers render it from their existing UI clock. | |||
| 7 | + | type StringSpinner struct { | |||
| 8 | + | frames []string | |||
| 9 | + | interval time.Duration | |||
| 10 | + | } | |||
| 11 | + | ||||
| 12 | + | // NewStringSpinner creates a reusable text animation. Empty frames render an | |||
| 13 | + | // empty string, and a non-positive interval keeps the first frame stable. | |||
| 14 | + | func NewStringSpinner(frames []string, interval time.Duration) StringSpinner { | |||
| 15 | + | return StringSpinner{ | |||
| 16 | + | frames: append([]string(nil), frames...), | |||
| 17 | + | interval: interval, | |||
| 18 | + | } | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // FrameAt returns the frame for now relative to startedAt. Times before the | |||
| 22 | + | // start clamp to the first frame. | |||
| 23 | + | func (s StringSpinner) FrameAt(startedAt, now time.Time) string { | |||
| 24 | + | if len(s.frames) == 0 { | |||
| 25 | + | return "" | |||
| 26 | + | } | |||
| 27 | + | if s.interval <= 0 || startedAt.IsZero() || now.IsZero() || now.Before(startedAt) { | |||
| 28 | + | return s.frames[0] | |||
| 29 | + | } | |||
| 30 | + | index := int(now.Sub(startedAt)/s.interval) % len(s.frames) | |||
| 31 | + | return s.frames[index] | |||
| 32 | + | } | |||
A packages/tui/string_spinner_test.go +28 -0
| @@ -0,0 +1,28 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestStringSpinnerAdvancesAtConfiguredInterval(t *testing.T) { | |
| 9 | + | spinner := NewStringSpinner([]string{"Zzzz", "zZzz", "zzZz", "zzzZ"}, time.Second) | |
| 10 | + | started := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) | |
| 11 | + | ||
| 12 | + | tests := []struct { | |
| 13 | + | elapsed time.Duration | |
| 14 | + | want string | |
| 15 | + | }{ | |
| 16 | + | {elapsed: 0, want: "Zzzz"}, | |
| 17 | + | {elapsed: 999 * time.Millisecond, want: "Zzzz"}, | |
| 18 | + | {elapsed: time.Second, want: "zZzz"}, | |
| 19 | + | {elapsed: 2 * time.Second, want: "zzZz"}, | |
| 20 | + | {elapsed: 3 * time.Second, want: "zzzZ"}, | |
| 21 | + | {elapsed: 4 * time.Second, want: "Zzzz"}, | |
| 22 | + | } | |
| 23 | + | for _, test := range tests { | |
| 24 | + | if got := spinner.FrameAt(started, started.Add(test.elapsed)); got != test.want { | |
| 25 | + | t.Fatalf("FrameAt(%s) = %q, want %q", test.elapsed, got, test.want) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | } | |
| @@ -0,0 +1,28 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestStringSpinnerAdvancesAtConfiguredInterval(t *testing.T) { | |||
| 9 | + | spinner := NewStringSpinner([]string{"Zzzz", "zZzz", "zzZz", "zzzZ"}, time.Second) | |||
| 10 | + | started := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) | |||
| 11 | + | ||||
| 12 | + | tests := []struct { | |||
| 13 | + | elapsed time.Duration | |||
| 14 | + | want string | |||
| 15 | + | }{ | |||
| 16 | + | {elapsed: 0, want: "Zzzz"}, | |||
| 17 | + | {elapsed: 999 * time.Millisecond, want: "Zzzz"}, | |||
| 18 | + | {elapsed: time.Second, want: "zZzz"}, | |||
| 19 | + | {elapsed: 2 * time.Second, want: "zzZz"}, | |||
| 20 | + | {elapsed: 3 * time.Second, want: "zzzZ"}, | |||
| 21 | + | {elapsed: 4 * time.Second, want: "Zzzz"}, | |||
| 22 | + | } | |||
| 23 | + | for _, test := range tests { | |||
| 24 | + | if got := spinner.FrameAt(started, started.Add(test.elapsed)); got != test.want { | |||
| 25 | + | t.Fatalf("FrameAt(%s) = %q, want %q", test.elapsed, got, test.want) | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | } | |||