detached f3e679e

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
395395
396396 **`/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.
397397
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`.
399399
400400 ### Named subagent profiles
401401
@@ -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 profiles400 ### 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
105105
106106 ## Resource policy
107107
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 policy106 ## 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 {
201201 return f.cfg.RepoRoot
202202 }
203203
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+
204212 // SetFastMode updates the fast-mode setting for subsequently spawned
205213 // children. Existing agents keep the setting captured at their spawn
206214 // boundary so resumes remain deterministic.
@@ -201,6 +201,14 @@ func (f *Supervisor) RepoRoot() string {
201 return f.cfg.RepoRoot201 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 spawned212 // SetFastMode updates the fast-mode setting for subsequently spawned
205 // children. Existing agents keep the setting captured at their spawn213 // 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 {
6363 MaxTurns *int `json:"max_turns,omitempty"`
6464 }
6565
66-const subagentSpawnSchema = `{
66+const subagentSpawnSchemaTemplate = `{
6767 "type": "object",
6868 "properties": {
6969 "task": {
@@ -104,7 +104,8 @@ const subagentSpawnSchema = `{
104104 "max_turns": {
105105 "type": "integer",
106106 "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."
108109 }
109110 },
110111 "required": ["task"]
@@ -115,7 +116,15 @@ func (t *SubagentSpawnTool) Description() string {
115116 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."
116117
117118 }
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+}
119128
120129 func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
121130 prefix := t.Name()
@@ -149,8 +158,13 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
149158 }
150159 timeout = parsed
151160 }
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+ }
154168 }
155169
156170 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 = parsed159 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 {
3030 return manager
3131 }
3232
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+
3382 func TestSubagentSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) {
3483 tool := &SubagentSpawnTool{
3584 Supervisor: newTestSupervisor(t),
@@ -132,6 +181,47 @@ func TestSubagentSpawnRejectsExplicitZeroMaxTurns(t *testing.T) {
132181 }
133182 }
134183
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+
135225 func TestSubagentSpawnSelectsProfileAndReasoning(t *testing.T) {
136226 tool := &SubagentSpawnTool{
137227 Supervisor: newTestSupervisor(t),
@@ -30,6 +30,55 @@ func newTestSupervisor(t *testing.T) *subagents.Supervisor {
30 return manager30 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),

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close