fix(agent): align subagent turn limit schema
- commit
- f3e679e6d007019fae16d6de4d89902834040c4e
- author
- brice · · 2026-08-05 08:54 UTC
- committer
- brice · · 2026-08-05 09:01 UTC
- parent
- 1513f55
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +119 -7
M README.md +1 -1
| @@ -395,7 +395,7 @@ Background subagents that run alongside your main session. Each one is a separat | |||
| 395 | 395 | ||
| 396 | 396 | **`/session export` does NOT bundle subagents.** A `.zotsession` 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. | |
| 397 | 397 | ||
| 398 | - | **Auto-subagents.** With `/settings` -> auto-subagents on, the main agent gets the canonical `subagent_spawn` tool and a system-prompt nudge to delegate independent work. The tool returns the child id and lifecycle state immediately; completion is a durable `turn.result` with a `subagent://<id>/result` reference. It accepts named profiles, timeout, `max_turns`, and `isolation:"shared"` or `isolation:"worktree"`. Global concurrency and total-spawn budgets apply equally to slash commands, tool calls, and batches. Off by default; toggle from `/settings`. | |
| 398 | + | **Auto-subagents.** With `/settings` -> auto-subagents on, the main agent gets the canonical `subagent_spawn` tool and a system-prompt nudge to delegate independent work. The tool returns the child id and lifecycle state immediately; completion is a durable `turn.result` with a `subagent://<id>/result` reference. It accepts named profiles, timeout, `max_turns`, and `isolation:"shared"` or `isolation:"worktree"`. Omit `max_turns` to use the supervisor policy; the default ceiling is 3 and `subagents.max_turns` changes it. Global concurrency and total-spawn budgets apply equally to slash commands, tool calls, and batches. Off by default; toggle from `/settings`. | |
| 399 | 399 | ||
| 400 | 400 | ### Named subagent profiles | |
| 401 | 401 | ||
| @@ -395,7 +395,7 @@ Background subagents that run alongside your main session. Each one is a separat | |||||
| 395 | 395 | ||||
| 396 | **`/session export` does NOT bundle subagents.** A `.zotsession` 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. | 396 | **`/session export` does NOT bundle subagents.** A `.zotsession` 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. | ||
| 397 | 397 | ||||
| 398 | - | **Auto-subagents.** With `/settings` -> auto-subagents on, the main agent gets the canonical `subagent_spawn` tool and a system-prompt nudge to delegate independent work. The tool returns the child id and lifecycle state immediately; completion is a durable `turn.result` with a `subagent://<id>/result` reference. It accepts named profiles, timeout, `max_turns`, and `isolation:"shared"` or `isolation:"worktree"`. Global concurrency and total-spawn budgets apply equally to slash commands, tool calls, and batches. Off by default; toggle from `/settings`. | 398 | + | **Auto-subagents.** With `/settings` -> auto-subagents on, the main agent gets the canonical `subagent_spawn` tool and a system-prompt nudge to delegate independent work. The tool returns the child id and lifecycle state immediately; completion is a durable `turn.result` with a `subagent://<id>/result` reference. It accepts named profiles, timeout, `max_turns`, and `isolation:"shared"` or `isolation:"worktree"`. Omit `max_turns` to use the supervisor policy; the default ceiling is 3 and `subagents.max_turns` changes it. Global concurrency and total-spawn budgets apply equally to slash commands, tool calls, and batches. Off by default; toggle from `/settings`. |
| 399 | 399 | ||||
| 400 | ### Named subagent profiles | 400 | ### Named subagent profiles | ||
| 401 | 401 | ||||
M docs/subagents.md +1 -1
| @@ -105,4 +105,4 @@ Use `/subagents resume-session <id>` to continue the existing session without re | |||
| 105 | 105 | ||
| 106 | 106 | ## Resource policy | |
| 107 | 107 | ||
| 108 | - | The persisted `subagents` config object supports `max_concurrent`, `max_concurrent_per_parent`, `max_total_spawned`, `queue_timeout`, `default_timeout`, `max_turns`, output caps, allowed tools/roots, heartbeat and idle timeouts. Limits apply to slash commands, `subagent_spawn`, and batch operations. A child cannot create descendants in v1. Per-agent timeouts are retained across reload/resume. Packaged `zot run` agents keep their declared capability ceiling by disabling subagent delegation; profiles are not a substitute for that permission boundary. | |
| 108 | + | The persisted `subagents` config object supports `max_concurrent`, `max_concurrent_per_parent`, `max_total_spawned`, `queue_timeout`, `default_timeout`, `max_turns`, output caps, allowed tools/roots, heartbeat and idle timeouts. A missing or non-positive configured `max_turns` uses the default ceiling of 3. The `max_turns` field in `subagent_spawn` is optional: omit it to use the policy ceiling, or provide a value from 1 through the configured maximum. Limits apply to slash commands, `subagent_spawn`, and batch operations. A child cannot create descendants in v1. Per-agent timeouts are retained across reload/resume. Packaged `zot run` agents keep their declared capability ceiling by disabling subagent delegation; profiles are not a substitute for that permission boundary. | |
| @@ -105,4 +105,4 @@ Use `/subagents resume-session <id>` to continue the existing session without re | |||||
| 105 | 105 | ||||
| 106 | ## Resource policy | 106 | ## Resource policy | ||
| 107 | 107 | ||||
| 108 | - | The persisted `subagents` config object supports `max_concurrent`, `max_concurrent_per_parent`, `max_total_spawned`, `queue_timeout`, `default_timeout`, `max_turns`, output caps, allowed tools/roots, heartbeat and idle timeouts. Limits apply to slash commands, `subagent_spawn`, and batch operations. A child cannot create descendants in v1. Per-agent timeouts are retained across reload/resume. Packaged `zot run` agents keep their declared capability ceiling by disabling subagent delegation; profiles are not a substitute for that permission boundary. | 108 | + | The persisted `subagents` config object supports `max_concurrent`, `max_concurrent_per_parent`, `max_total_spawned`, `queue_timeout`, `default_timeout`, `max_turns`, output caps, allowed tools/roots, heartbeat and idle timeouts. A missing or non-positive configured `max_turns` uses the default ceiling of 3. The `max_turns` field in `subagent_spawn` is optional: omit it to use the policy ceiling, or provide a value from 1 through the configured maximum. Limits apply to slash commands, `subagent_spawn`, and batch operations. A child cannot create descendants in v1. Per-agent timeouts are retained across reload/resume. Packaged `zot run` agents keep their declared capability ceiling by disabling subagent delegation; profiles are not a substitute for that permission boundary. |
M packages/agent/subagents/supervisor.go +8 -0
| @@ -201,6 +201,14 @@ func (f *Supervisor) RepoRoot() string { | |||
| 201 | 201 | return f.cfg.RepoRoot | |
| 202 | 202 | } | |
| 203 | 203 | ||
| 204 | + | // MaxTurns returns the maximum prompt-level turns allowed for subsequently | |
| 205 | + | // spawned agents. | |
| 206 | + | func (f *Supervisor) MaxTurns() int { | |
| 207 | + | f.mu.Lock() | |
| 208 | + | defer f.mu.Unlock() | |
| 209 | + | return f.cfg.Policy.MaxTurns | |
| 210 | + | } | |
| 211 | + | ||
| 204 | 212 | // SetFastMode updates the fast-mode setting for subsequently spawned | |
| 205 | 213 | // children. Existing agents keep the setting captured at their spawn | |
| 206 | 214 | // boundary so resumes remain deterministic. | |
| @@ -201,6 +201,14 @@ func (f *Supervisor) RepoRoot() string { | |||||
| 201 | return f.cfg.RepoRoot | 201 | return f.cfg.RepoRoot | ||
| 202 | } | 202 | } | ||
| 203 | 203 | ||||
| 204 | + | // MaxTurns returns the maximum prompt-level turns allowed for subsequently | |||
| 205 | + | // spawned agents. | |||
| 206 | + | func (f *Supervisor) MaxTurns() int { | |||
| 207 | + | f.mu.Lock() | |||
| 208 | + | defer f.mu.Unlock() | |||
| 209 | + | return f.cfg.Policy.MaxTurns | |||
| 210 | + | } | |||
| 211 | + | ||||
| 204 | // SetFastMode updates the fast-mode setting for subsequently spawned | 212 | // SetFastMode updates the fast-mode setting for subsequently spawned | ||
| 205 | // children. Existing agents keep the setting captured at their spawn | 213 | // children. Existing agents keep the setting captured at their spawn | ||
| 206 | // boundary so resumes remain deterministic. | 214 | // boundary so resumes remain deterministic. | ||
M packages/agent/tools/subagent_spawn.go +19 -5
| @@ -63,7 +63,7 @@ type subagentSpawnArgs struct { | |||
| 63 | 63 | MaxTurns *int `json:"max_turns,omitempty"` | |
| 64 | 64 | } | |
| 65 | 65 | ||
| 66 | - | const subagentSpawnSchema = `{ | |
| 66 | + | const subagentSpawnSchemaTemplate = `{ | |
| 67 | 67 | "type": "object", | |
| 68 | 68 | "properties": { | |
| 69 | 69 | "task": { | |
| @@ -104,7 +104,8 @@ const subagentSpawnSchema = `{ | |||
| 104 | 104 | "max_turns": { | |
| 105 | 105 | "type": "integer", | |
| 106 | 106 | "minimum": 1, | |
| 107 | - | "description": "Maximum prompt-level turns for this worker." | |
| 107 | + | "maximum": %d, | |
| 108 | + | "description": "Optional maximum prompt-level turns for this worker. Omit to use the supervisor default; the effective policy allows 1 through %d." | |
| 108 | 109 | } | |
| 109 | 110 | }, | |
| 110 | 111 | "required": ["task"] | |
| @@ -115,7 +116,15 @@ func (t *SubagentSpawnTool) Description() string { | |||
| 115 | 116 | return "Spawn a background sub-agent to work on a parallel sub-task. Optionally select a named markdown profile, model/provider/reasoning, timeout, turn limit, and shared/worktree isolation, and fast-mode preference. Returns the sub-agent id immediately; completion arrives through the result/event path." | |
| 116 | 117 | ||
| 117 | 118 | } | |
| 118 | - | func (t *SubagentSpawnTool) Schema() json.RawMessage { return json.RawMessage(subagentSpawnSchema) } | |
| 119 | + | func (t *SubagentSpawnTool) Schema() json.RawMessage { | |
| 120 | + | maxTurns := 3 | |
| 121 | + | if t.Supervisor != nil { | |
| 122 | + | if limit := t.Supervisor.MaxTurns(); limit > 0 { | |
| 123 | + | maxTurns = limit | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | return json.RawMessage(fmt.Sprintf(subagentSpawnSchemaTemplate, maxTurns, maxTurns)) | |
| 127 | + | } | |
| 119 | 128 | ||
| 120 | 129 | func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | |
| 121 | 130 | prefix := t.Name() | |
| @@ -149,8 +158,13 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr | |||
| 149 | 158 | } | |
| 150 | 159 | timeout = parsed | |
| 151 | 160 | } | |
| 152 | - | if a.MaxTurns != nil && *a.MaxTurns < 1 { | |
| 153 | - | return protocolToolError(prefix + ": max_turns must be positive") | |
| 161 | + | if a.MaxTurns != nil { | |
| 162 | + | if *a.MaxTurns < 1 { | |
| 163 | + | return protocolToolError(prefix + ": max_turns must be positive") | |
| 164 | + | } | |
| 165 | + | if limit := t.Supervisor.MaxTurns(); limit > 0 && *a.MaxTurns > limit { | |
| 166 | + | return protocolToolError(fmt.Sprintf("%s: max_turns must be 1 through %d; omit it to use the supervisor default", prefix, limit)) | |
| 167 | + | } | |
| 154 | 168 | } | |
| 155 | 169 | ||
| 156 | 170 | agentName := strings.TrimSpace(a.Agent) | |
| @@ -63,7 +63,7 @@ type subagentSpawnArgs struct { | |||||
| 63 | MaxTurns *int `json:"max_turns,omitempty"` | 63 | MaxTurns *int `json:"max_turns,omitempty"` | ||
| 64 | } | 64 | } | ||
| 65 | 65 | ||||
| 66 | - | const subagentSpawnSchema = `{ | 66 | + | const subagentSpawnSchemaTemplate = `{ |
| 67 | "type": "object", | 67 | "type": "object", | ||
| 68 | "properties": { | 68 | "properties": { | ||
| 69 | "task": { | 69 | "task": { | ||
| @@ -104,7 +104,8 @@ const subagentSpawnSchema = `{ | |||||
| 104 | "max_turns": { | 104 | "max_turns": { | ||
| 105 | "type": "integer", | 105 | "type": "integer", | ||
| 106 | "minimum": 1, | 106 | "minimum": 1, | ||
| 107 | - | "description": "Maximum prompt-level turns for this worker." | 107 | + | "maximum": %d, |
| 108 | + | "description": "Optional maximum prompt-level turns for this worker. Omit to use the supervisor default; the effective policy allows 1 through %d." | |||
| 108 | } | 109 | } | ||
| 109 | }, | 110 | }, | ||
| 110 | "required": ["task"] | 111 | "required": ["task"] | ||
| @@ -115,7 +116,15 @@ func (t *SubagentSpawnTool) Description() string { | |||||
| 115 | return "Spawn a background sub-agent to work on a parallel sub-task. Optionally select a named markdown profile, model/provider/reasoning, timeout, turn limit, and shared/worktree isolation, and fast-mode preference. Returns the sub-agent id immediately; completion arrives through the result/event path." | 116 | return "Spawn a background sub-agent to work on a parallel sub-task. Optionally select a named markdown profile, model/provider/reasoning, timeout, turn limit, and shared/worktree isolation, and fast-mode preference. Returns the sub-agent id immediately; completion arrives through the result/event path." | ||
| 116 | 117 | ||||
| 117 | } | 118 | } | ||
| 118 | - | func (t *SubagentSpawnTool) Schema() json.RawMessage { return json.RawMessage(subagentSpawnSchema) } | 119 | + | func (t *SubagentSpawnTool) Schema() json.RawMessage { |
| 120 | + | maxTurns := 3 | |||
| 121 | + | if t.Supervisor != nil { | |||
| 122 | + | if limit := t.Supervisor.MaxTurns(); limit > 0 { | |||
| 123 | + | maxTurns = limit | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | return json.RawMessage(fmt.Sprintf(subagentSpawnSchemaTemplate, maxTurns, maxTurns)) | |||
| 127 | + | } | |||
| 119 | 128 | ||||
| 120 | func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | 129 | func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | ||
| 121 | prefix := t.Name() | 130 | prefix := t.Name() | ||
| @@ -149,8 +158,13 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr | |||||
| 149 | } | 158 | } | ||
| 150 | timeout = parsed | 159 | timeout = parsed | ||
| 151 | } | 160 | } | ||
| 152 | - | if a.MaxTurns != nil && *a.MaxTurns < 1 { | 161 | + | if a.MaxTurns != nil { |
| 153 | - | return protocolToolError(prefix + ": max_turns must be positive") | 162 | + | if *a.MaxTurns < 1 { |
| 163 | + | return protocolToolError(prefix + ": max_turns must be positive") | |||
| 164 | + | } | |||
| 165 | + | if limit := t.Supervisor.MaxTurns(); limit > 0 && *a.MaxTurns > limit { | |||
| 166 | + | return protocolToolError(fmt.Sprintf("%s: max_turns must be 1 through %d; omit it to use the supervisor default", prefix, limit)) | |||
| 167 | + | } | |||
| 154 | } | 168 | } | ||
| 155 | 169 | ||||
| 156 | agentName := strings.TrimSpace(a.Agent) | 170 | agentName := strings.TrimSpace(a.Agent) | ||
M packages/agent/tools/subagent_spawn_test.go +90 -0
| @@ -30,6 +30,55 @@ func newTestSupervisor(t *testing.T) *subagents.Supervisor { | |||
| 30 | 30 | return manager | |
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | + | func TestSubagentSpawnSchemaAdvertisesEffectiveMaxTurns(t *testing.T) { | |
| 34 | + | for _, tc := range []struct { | |
| 35 | + | name string | |
| 36 | + | policy int | |
| 37 | + | want int | |
| 38 | + | }{ | |
| 39 | + | {name: "default", want: 3}, | |
| 40 | + | {name: "negative-default", policy: -1, want: 3}, | |
| 41 | + | {name: "configured", policy: 7, want: 7}, | |
| 42 | + | } { | |
| 43 | + | t.Run(tc.name, func(t *testing.T) { | |
| 44 | + | root := t.TempDir() | |
| 45 | + | manager := subagents.New(subagents.Config{ | |
| 46 | + | Root: filepath.Join(root, "subagents"), | |
| 47 | + | RepoRoot: root, | |
| 48 | + | Policy: subagents.SubagentPolicy{MaxTurns: tc.policy}, | |
| 49 | + | NewRunner: func(*subagents.Agent) subagents.Runner { | |
| 50 | + | return noopSupervisorRunner{} | |
| 51 | + | }, | |
| 52 | + | }) | |
| 53 | + | t.Cleanup(manager.StopAll) | |
| 54 | + | tool := &SubagentSpawnTool{Supervisor: manager} | |
| 55 | + | ||
| 56 | + | var schema struct { | |
| 57 | + | Properties struct { | |
| 58 | + | MaxTurns struct { | |
| 59 | + | Minimum int `json:"minimum"` | |
| 60 | + | Maximum int `json:"maximum"` | |
| 61 | + | Description string `json:"description"` | |
| 62 | + | } `json:"max_turns"` | |
| 63 | + | } `json:"properties"` | |
| 64 | + | } | |
| 65 | + | if err := json.Unmarshal(tool.Schema(), &schema); err != nil { | |
| 66 | + | t.Fatal(err) | |
| 67 | + | } | |
| 68 | + | maxTurns := schema.Properties.MaxTurns | |
| 69 | + | if maxTurns.Minimum != 1 { | |
| 70 | + | t.Fatalf("max_turns minimum = %d, want 1", maxTurns.Minimum) | |
| 71 | + | } | |
| 72 | + | if maxTurns.Maximum != tc.want { | |
| 73 | + | t.Fatalf("max_turns maximum = %d, want %d", maxTurns.Maximum, tc.want) | |
| 74 | + | } | |
| 75 | + | if !strings.Contains(strings.ToLower(maxTurns.Description), "omit") { | |
| 76 | + | t.Fatalf("max_turns description = %q, want omit-to-default guidance", maxTurns.Description) | |
| 77 | + | } | |
| 78 | + | }) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 33 | 82 | func TestSubagentSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) { | |
| 34 | 83 | tool := &SubagentSpawnTool{ | |
| 35 | 84 | Supervisor: newTestSupervisor(t), | |
| @@ -132,6 +181,47 @@ func TestSubagentSpawnRejectsExplicitZeroMaxTurns(t *testing.T) { | |||
| 132 | 181 | } | |
| 133 | 182 | } | |
| 134 | 183 | ||
| 184 | + | func TestSubagentSpawnRejectsMaxTurnsAbovePolicy(t *testing.T) { | |
| 185 | + | for _, tc := range []struct { | |
| 186 | + | name string | |
| 187 | + | policy int | |
| 188 | + | wantLimit string | |
| 189 | + | }{ | |
| 190 | + | {name: "default", wantLimit: "3"}, | |
| 191 | + | {name: "configured", policy: 7, wantLimit: "7"}, | |
| 192 | + | } { | |
| 193 | + | t.Run(tc.name, func(t *testing.T) { | |
| 194 | + | root := t.TempDir() | |
| 195 | + | manager := subagents.New(subagents.Config{ | |
| 196 | + | Root: filepath.Join(root, "subagents"), | |
| 197 | + | RepoRoot: root, | |
| 198 | + | Policy: subagents.SubagentPolicy{MaxTurns: tc.policy}, | |
| 199 | + | NewRunner: func(*subagents.Agent) subagents.Runner { | |
| 200 | + | return noopSupervisorRunner{} | |
| 201 | + | }, | |
| 202 | + | }) | |
| 203 | + | t.Cleanup(manager.StopAll) | |
| 204 | + | tool := &SubagentSpawnTool{ | |
| 205 | + | Supervisor: manager, | |
| 206 | + | Enabled: func() bool { return true }, | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs","max_turns":8}`), nil) | |
| 210 | + | if err != nil { | |
| 211 | + | t.Fatal(err) | |
| 212 | + | } | |
| 213 | + | text := textResult(res.Content) | |
| 214 | + | want := "max_turns must be 1 through " + tc.wantLimit | |
| 215 | + | if !res.IsError || !strings.Contains(text, want) || !strings.Contains(text, "omit") { | |
| 216 | + | t.Fatalf("result = %#v, want model-visible policy guidance", res) | |
| 217 | + | } | |
| 218 | + | if got := len(tool.Supervisor.List()); got != 0 { | |
| 219 | + | t.Fatalf("spawned agents = %d, want 0", got) | |
| 220 | + | } | |
| 221 | + | }) | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | ||
| 135 | 225 | func TestSubagentSpawnSelectsProfileAndReasoning(t *testing.T) { | |
| 136 | 226 | tool := &SubagentSpawnTool{ | |
| 137 | 227 | Supervisor: newTestSupervisor(t), | |
| @@ -30,6 +30,55 @@ func newTestSupervisor(t *testing.T) *subagents.Supervisor { | |||||
| 30 | return manager | 30 | return manager | ||
| 31 | } | 31 | } | ||
| 32 | 32 | ||||
| 33 | + | func TestSubagentSpawnSchemaAdvertisesEffectiveMaxTurns(t *testing.T) { | |||
| 34 | + | for _, tc := range []struct { | |||
| 35 | + | name string | |||
| 36 | + | policy int | |||
| 37 | + | want int | |||
| 38 | + | }{ | |||
| 39 | + | {name: "default", want: 3}, | |||
| 40 | + | {name: "negative-default", policy: -1, want: 3}, | |||
| 41 | + | {name: "configured", policy: 7, want: 7}, | |||
| 42 | + | } { | |||
| 43 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 44 | + | root := t.TempDir() | |||
| 45 | + | manager := subagents.New(subagents.Config{ | |||
| 46 | + | Root: filepath.Join(root, "subagents"), | |||
| 47 | + | RepoRoot: root, | |||
| 48 | + | Policy: subagents.SubagentPolicy{MaxTurns: tc.policy}, | |||
| 49 | + | NewRunner: func(*subagents.Agent) subagents.Runner { | |||
| 50 | + | return noopSupervisorRunner{} | |||
| 51 | + | }, | |||
| 52 | + | }) | |||
| 53 | + | t.Cleanup(manager.StopAll) | |||
| 54 | + | tool := &SubagentSpawnTool{Supervisor: manager} | |||
| 55 | + | ||||
| 56 | + | var schema struct { | |||
| 57 | + | Properties struct { | |||
| 58 | + | MaxTurns struct { | |||
| 59 | + | Minimum int `json:"minimum"` | |||
| 60 | + | Maximum int `json:"maximum"` | |||
| 61 | + | Description string `json:"description"` | |||
| 62 | + | } `json:"max_turns"` | |||
| 63 | + | } `json:"properties"` | |||
| 64 | + | } | |||
| 65 | + | if err := json.Unmarshal(tool.Schema(), &schema); err != nil { | |||
| 66 | + | t.Fatal(err) | |||
| 67 | + | } | |||
| 68 | + | maxTurns := schema.Properties.MaxTurns | |||
| 69 | + | if maxTurns.Minimum != 1 { | |||
| 70 | + | t.Fatalf("max_turns minimum = %d, want 1", maxTurns.Minimum) | |||
| 71 | + | } | |||
| 72 | + | if maxTurns.Maximum != tc.want { | |||
| 73 | + | t.Fatalf("max_turns maximum = %d, want %d", maxTurns.Maximum, tc.want) | |||
| 74 | + | } | |||
| 75 | + | if !strings.Contains(strings.ToLower(maxTurns.Description), "omit") { | |||
| 76 | + | t.Fatalf("max_turns description = %q, want omit-to-default guidance", maxTurns.Description) | |||
| 77 | + | } | |||
| 78 | + | }) | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 33 | func TestSubagentSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) { | 82 | func TestSubagentSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) { | ||
| 34 | tool := &SubagentSpawnTool{ | 83 | tool := &SubagentSpawnTool{ | ||
| 35 | Supervisor: newTestSupervisor(t), | 84 | Supervisor: newTestSupervisor(t), | ||
| @@ -132,6 +181,47 @@ func TestSubagentSpawnRejectsExplicitZeroMaxTurns(t *testing.T) { | |||||
| 132 | } | 181 | } | ||
| 133 | } | 182 | } | ||
| 134 | 183 | ||||
| 184 | + | func TestSubagentSpawnRejectsMaxTurnsAbovePolicy(t *testing.T) { | |||
| 185 | + | for _, tc := range []struct { | |||
| 186 | + | name string | |||
| 187 | + | policy int | |||
| 188 | + | wantLimit string | |||
| 189 | + | }{ | |||
| 190 | + | {name: "default", wantLimit: "3"}, | |||
| 191 | + | {name: "configured", policy: 7, wantLimit: "7"}, | |||
| 192 | + | } { | |||
| 193 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 194 | + | root := t.TempDir() | |||
| 195 | + | manager := subagents.New(subagents.Config{ | |||
| 196 | + | Root: filepath.Join(root, "subagents"), | |||
| 197 | + | RepoRoot: root, | |||
| 198 | + | Policy: subagents.SubagentPolicy{MaxTurns: tc.policy}, | |||
| 199 | + | NewRunner: func(*subagents.Agent) subagents.Runner { | |||
| 200 | + | return noopSupervisorRunner{} | |||
| 201 | + | }, | |||
| 202 | + | }) | |||
| 203 | + | t.Cleanup(manager.StopAll) | |||
| 204 | + | tool := &SubagentSpawnTool{ | |||
| 205 | + | Supervisor: manager, | |||
| 206 | + | Enabled: func() bool { return true }, | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs","max_turns":8}`), nil) | |||
| 210 | + | if err != nil { | |||
| 211 | + | t.Fatal(err) | |||
| 212 | + | } | |||
| 213 | + | text := textResult(res.Content) | |||
| 214 | + | want := "max_turns must be 1 through " + tc.wantLimit | |||
| 215 | + | if !res.IsError || !strings.Contains(text, want) || !strings.Contains(text, "omit") { | |||
| 216 | + | t.Fatalf("result = %#v, want model-visible policy guidance", res) | |||
| 217 | + | } | |||
| 218 | + | if got := len(tool.Supervisor.List()); got != 0 { | |||
| 219 | + | t.Fatalf("spawned agents = %d, want 0", got) | |||
| 220 | + | } | |||
| 221 | + | }) | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | ||||
| 135 | func TestSubagentSpawnSelectsProfileAndReasoning(t *testing.T) { | 225 | func TestSubagentSpawnSelectsProfileAndReasoning(t *testing.T) { | ||
| 136 | tool := &SubagentSpawnTool{ | 226 | tool := &SubagentSpawnTool{ | ||
| 137 | Supervisor: newTestSupervisor(t), | 227 | Supervisor: newTestSupervisor(t), | ||