detached 0958647

fix(subagents): address follow-up review findings

commit
0958647be9c2f50fb5c5cc8246d0edae4ed6ff76
author
brice · · 2026-08-05 04:08 UTC
committer
brice · · 2026-08-05 04:30 UTC
parent
01d4ebe
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +184 -31
M packages/agent/modes/interactive.go +7 -0
@@ -603,6 +603,10 @@ type Interactive struct {
603603 subagentWatchMu sync.Mutex
604604 subagentWatch []*subagentWatchEntry
605605
606+ // subagentsWaitWatcherDone is an optional test hook invoked when a
607+ // /subagents wait watcher exits.
608+ subagentsWaitWatcherDone func()
609+
606610 // pendingFork is true when the user ran /session fork: the next
607611 // jump-picker selection should branch off that message instead
608612 // of scrolling. Flag resets after the action fires or the dialog
@@ -7400,6 +7404,9 @@ func removeLastAutoSubagentsAddendum(system, addendum string) (string, bool) {
74007404 // The profile manifest and delegation guidance are managed together so
74017405 // toggling auto-subagents never leaves the model with names but no tool.
74027406 func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) {
7407+ // i.mu is sufficient here: this path updates the existing agent's system
7408+ // prompt and managed addenda in place; it does not replace the agent or
7409+ // its tool registry, so agentMu is not required.
74037410 i.mu.Lock()
74047411 defer i.mu.Unlock()
74057412 if i.agent == nil {
@@ -603,6 +603,10 @@ type Interactive struct {
603 subagentWatchMu sync.Mutex603 subagentWatchMu sync.Mutex
604 subagentWatch []*subagentWatchEntry604 subagentWatch []*subagentWatchEntry
605 605
606+ // subagentsWaitWatcherDone is an optional test hook invoked when a
607+ // /subagents wait watcher exits.
608+ subagentsWaitWatcherDone func()
609+
606 // pendingFork is true when the user ran /session fork: the next610 // pendingFork is true when the user ran /session fork: the next
607 // jump-picker selection should branch off that message instead611 // jump-picker selection should branch off that message instead
608 // of scrolling. Flag resets after the action fires or the dialog612 // of scrolling. Flag resets after the action fires or the dialog
@@ -7400,6 +7404,9 @@ func removeLastAutoSubagentsAddendum(system, addendum string) (string, bool) {
7400 // The profile manifest and delegation guidance are managed together so7404 // The profile manifest and delegation guidance are managed together so
7401 // toggling auto-subagents never leaves the model with names but no tool.7405 // toggling auto-subagents never leaves the model with names but no tool.
7402 func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) {7406 func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) {
7407+ // i.mu is sufficient here: this path updates the existing agent's system
7408+ // prompt and managed addenda in place; it does not replace the agent or
7409+ // its tool registry, so agentMu is not required.
7403 i.mu.Lock()7410 i.mu.Lock()
7404 defer i.mu.Unlock()7411 defer i.mu.Unlock()
7405 if i.agent == nil {7412 if i.agent == nil {
M packages/agent/modes/subagents_slash.go +6 -0
@@ -243,7 +243,13 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) {
243243 i.subagentsStatus("", "wait: no such agent "+rest)
244244 return
245245 }
246+ waitWatcherDone := i.subagentsWaitWatcherDone
246247 go func() {
248+ defer func() {
249+ if waitWatcherDone != nil {
250+ waitWatcherDone()
251+ }
252+ }()
247253 if err := a.WaitContext(ctx); err != nil {
248254 return
249255 }
@@ -243,7 +243,13 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) {
243 i.subagentsStatus("", "wait: no such agent "+rest)243 i.subagentsStatus("", "wait: no such agent "+rest)
244 return244 return
245 }245 }
246+ waitWatcherDone := i.subagentsWaitWatcherDone
246 go func() {247 go func() {
248+ defer func() {
249+ if waitWatcherDone != nil {
250+ waitWatcherDone()
251+ }
252+ }()
247 if err := a.WaitContext(ctx); err != nil {253 if err := a.WaitContext(ctx); err != nil {
248 return254 return
249 }255 }
M packages/agent/modes/subagents_slash_test.go +14 -3
@@ -81,8 +81,11 @@ func TestRunSupervisorSubcommandsDoNotPanic(t *testing.T) {
8181 iv.runSubagents(context.Background(), args)
8282 }()
8383 }
84- if !strings.Contains(iv.statusErr, "cancel") || !strings.Contains(iv.statusErr, "wait") {
85- t.Fatalf("unknown-command hint = %q; want cancel and wait", iv.statusErr)
84+ iv.mu.Lock()
85+ statusErr := iv.statusErr
86+ iv.mu.Unlock()
87+ if !strings.Contains(statusErr, "cancel") || !strings.Contains(statusErr, "wait") {
88+ t.Fatalf("unknown-command hint = %q; want cancel and wait", statusErr)
8689 }
8790 }
8891
@@ -242,6 +245,8 @@ func TestRunSupervisorWaitHonorsRunContext(t *testing.T) {
242245 t.Fatal(err)
243246 }
244247 ctx, cancel := context.WithCancel(context.Background())
248+ watcherDone := make(chan struct{})
249+ iv.subagentsWaitWatcherDone = func() { close(watcherDone) }
245250 iv.runSubagents(ctx, []string{"wait", a.ID})
246251 iv.mu.Lock()
247252 status := iv.statusOK
@@ -253,7 +258,13 @@ func TestRunSupervisorWaitHonorsRunContext(t *testing.T) {
253258
254259 // The wait watcher must stop observing the run context and must not
255260 // later overwrite the status with a false completion after cleanup.
256- time.Sleep(20 * time.Millisecond)
261+ deadline := time.NewTimer(time.Second)
262+ defer deadline.Stop()
263+ select {
264+ case <-watcherDone:
265+ case <-deadline.C:
266+ t.Fatal("timed out waiting for wait watcher to exit")
267+ }
257268 iv.mu.Lock()
258269 status = iv.statusOK
259270 iv.mu.Unlock()
@@ -81,8 +81,11 @@ func TestRunSupervisorSubcommandsDoNotPanic(t *testing.T) {
81 iv.runSubagents(context.Background(), args)81 iv.runSubagents(context.Background(), args)
82 }()82 }()
83 }83 }
84- if !strings.Contains(iv.statusErr, "cancel") || !strings.Contains(iv.statusErr, "wait") {84+ iv.mu.Lock()
85- t.Fatalf("unknown-command hint = %q; want cancel and wait", iv.statusErr)85+ statusErr := iv.statusErr
86+ iv.mu.Unlock()
87+ if !strings.Contains(statusErr, "cancel") || !strings.Contains(statusErr, "wait") {
88+ t.Fatalf("unknown-command hint = %q; want cancel and wait", statusErr)
86 }89 }
87 }90 }
88 91
@@ -242,6 +245,8 @@ func TestRunSupervisorWaitHonorsRunContext(t *testing.T) {
242 t.Fatal(err)245 t.Fatal(err)
243 }246 }
244 ctx, cancel := context.WithCancel(context.Background())247 ctx, cancel := context.WithCancel(context.Background())
248+ watcherDone := make(chan struct{})
249+ iv.subagentsWaitWatcherDone = func() { close(watcherDone) }
245 iv.runSubagents(ctx, []string{"wait", a.ID})250 iv.runSubagents(ctx, []string{"wait", a.ID})
246 iv.mu.Lock()251 iv.mu.Lock()
247 status := iv.statusOK252 status := iv.statusOK
@@ -253,7 +258,13 @@ func TestRunSupervisorWaitHonorsRunContext(t *testing.T) {
253 258
254 // The wait watcher must stop observing the run context and must not259 // The wait watcher must stop observing the run context and must not
255 // later overwrite the status with a false completion after cleanup.260 // later overwrite the status with a false completion after cleanup.
256- time.Sleep(20 * time.Millisecond)261+ deadline := time.NewTimer(time.Second)
262+ defer deadline.Stop()
263+ select {
264+ case <-watcherDone:
265+ case <-deadline.C:
266+ t.Fatal("timed out waiting for wait watcher to exit")
267+ }
257 iv.mu.Lock()268 iv.mu.Lock()
258 status = iv.statusOK269 status = iv.statusOK
259 iv.mu.Unlock()270 iv.mu.Unlock()
M packages/agent/subagents/event.go +12 -4
@@ -216,15 +216,23 @@ type eventRing struct {
216216 }
217217
218218 func newEventRing() eventRing {
219- return eventRing{events: make([]Event, maxEventLogEvents)}
219+ return eventRing{events: make([]Event, 0)}
220220 }
221221
222222 func (r *eventRing) append(ev Event) {
223- r.events[r.next] = ev
224- r.next = (r.next + 1) % len(r.events)
225- if r.count < len(r.events) {
223+ if r.count < maxEventLogEvents {
224+ r.events = append(r.events, ev)
226225 r.count++
226+ if r.count == maxEventLogEvents {
227+ r.next = 0
228+ } else {
229+ r.next = r.count
230+ }
231+ return
227232 }
233+
234+ r.events[r.next] = ev
235+ r.next = (r.next + 1) % len(r.events)
228236 }
229237
230238 func (r *eventRing) last() (Event, bool) {
@@ -216,15 +216,23 @@ type eventRing struct {
216 }216 }
217 217
218 func newEventRing() eventRing {218 func newEventRing() eventRing {
219- return eventRing{events: make([]Event, maxEventLogEvents)}219+ return eventRing{events: make([]Event, 0)}
220 }220 }
221 221
222 func (r *eventRing) append(ev Event) {222 func (r *eventRing) append(ev Event) {
223- r.events[r.next] = ev223+ if r.count < maxEventLogEvents {
224- r.next = (r.next + 1) % len(r.events)224+ r.events = append(r.events, ev)
225- if r.count < len(r.events) {
226 r.count++225 r.count++
226+ if r.count == maxEventLogEvents {
227+ r.next = 0
228+ } else {
229+ r.next = r.count
230+ }
231+ return
227 }232 }
233+
234+ r.events[r.next] = ev
235+ r.next = (r.next + 1) % len(r.events)
228 }236 }
229 237
230 func (r *eventRing) last() (Event, bool) {238 func (r *eventRing) last() (Event, bool) {
M packages/agent/subagents/persist_test.go +8 -1
@@ -171,7 +171,14 @@ func TestResumePreservesPerAgentTimeout(t *testing.T) {
171171 if err != nil {
172172 t.Fatal(err)
173173 }
174- observed := <-observedCh
174+ var observed time.Duration
175+ deadline := time.NewTimer(time.Second)
176+ defer deadline.Stop()
177+ select {
178+ case observed = <-observedCh:
179+ case <-deadline.C:
180+ t.Fatal("timed out waiting for resumed runner to observe timeout")
181+ }
175182 if resumed.Timeout != wantTimeout || observed != wantTimeout {
176183 t.Fatalf("resumed timeout = %s, runner observed %s; want %s", resumed.Timeout, observed, wantTimeout)
177184 }
@@ -171,7 +171,14 @@ func TestResumePreservesPerAgentTimeout(t *testing.T) {
171 if err != nil {171 if err != nil {
172 t.Fatal(err)172 t.Fatal(err)
173 }173 }
174- observed := <-observedCh174+ var observed time.Duration
175+ deadline := time.NewTimer(time.Second)
176+ defer deadline.Stop()
177+ select {
178+ case observed = <-observedCh:
179+ case <-deadline.C:
180+ t.Fatal("timed out waiting for resumed runner to observe timeout")
181+ }
175 if resumed.Timeout != wantTimeout || observed != wantTimeout {182 if resumed.Timeout != wantTimeout || observed != wantTimeout {
176 t.Fatalf("resumed timeout = %s, runner observed %s; want %s", resumed.Timeout, observed, wantTimeout)183 t.Fatalf("resumed timeout = %s, runner observed %s; want %s", resumed.Timeout, observed, wantTimeout)
177 }184 }
M packages/agent/subagents/process_unix_test.go +3 -3
@@ -71,14 +71,14 @@ func TestWorkerCancellationClosesOutputPipes(t *testing.T) {
7171 defer stdout.Close()
7272 defer stderr.Close()
7373
74- configureWorkerProcess(cmd)
74+ configureWorkerProcess(cmd, stdout, stderr)
7575 if err := cmd.Cancel(); err != nil {
7676 t.Fatal(err)
7777 }
7878 for name, pipe := range map[string]io.Reader{"stdout": stdout, "stderr": stderr} {
7979 data := make([]byte, 1)
80- if _, err := pipe.Read(data); err != io.EOF {
81- t.Errorf("%s read error = %v, want EOF", name, err)
80+ if _, err := pipe.Read(data); !errors.Is(err, os.ErrClosed) {
81+ t.Errorf("%s read error = %v, want os.ErrClosed", name, err)
8282 }
8383 }
8484 }
@@ -71,14 +71,14 @@ func TestWorkerCancellationClosesOutputPipes(t *testing.T) {
71 defer stdout.Close()71 defer stdout.Close()
72 defer stderr.Close()72 defer stderr.Close()
73 73
74- configureWorkerProcess(cmd)74+ configureWorkerProcess(cmd, stdout, stderr)
75 if err := cmd.Cancel(); err != nil {75 if err := cmd.Cancel(); err != nil {
76 t.Fatal(err)76 t.Fatal(err)
77 }77 }
78 for name, pipe := range map[string]io.Reader{"stdout": stdout, "stderr": stderr} {78 for name, pipe := range map[string]io.Reader{"stdout": stdout, "stderr": stderr} {
79 data := make([]byte, 1)79 data := make([]byte, 1)
80- if _, err := pipe.Read(data); err != io.EOF {80+ if _, err := pipe.Read(data); !errors.Is(err, os.ErrClosed) {
81- t.Errorf("%s read error = %v, want EOF", name, err)81+ t.Errorf("%s read error = %v, want os.ErrClosed", name, err)
82 }82 }
83 }83 }
84 }84 }
M packages/agent/subagents/process_windows.go +14 -2
@@ -10,6 +10,7 @@ import (
1010 "os/exec"
1111 "strconv"
1212 "strings"
13+ "sync"
1314 "syscall"
1415 "time"
1516 )
@@ -17,11 +18,22 @@ import (
1718 // configureWorkerProcess makes CommandContext cancellation terminate the
1819 // worker and its descendants on Windows. Windows does not provide a Unix-like
1920 // process-group kill operation, so killProcessGroup uses taskkill's tree mode.
20-func configureWorkerProcess(cmd *exec.Cmd, _ ...io.Closer) {
21+func configureWorkerProcess(cmd *exec.Cmd, pipes ...io.Closer) {
2122 setProcessGroup(cmd)
2223 cmd.WaitDelay = workerProcessWaitDelay
24+ var closePipes sync.Once
2325 cmd.Cancel = func() error {
24- return killProcessGroup(cmd)
26+ err := killProcessGroup(cmd)
27+ closePipes.Do(func() { closeWorkerPipes(pipes) })
28+ return err
29+ }
30+}
31+
32+func closeWorkerPipes(pipes []io.Closer) {
33+ for _, pipe := range pipes {
34+ if pipe != nil {
35+ _ = pipe.Close()
36+ }
2537 }
2638 }
2739
@@ -10,6 +10,7 @@ import (
10 "os/exec"10 "os/exec"
11 "strconv"11 "strconv"
12 "strings"12 "strings"
13+ "sync"
13 "syscall"14 "syscall"
14 "time"15 "time"
15 )16 )
@@ -17,11 +18,22 @@ import (
17 // configureWorkerProcess makes CommandContext cancellation terminate the18 // configureWorkerProcess makes CommandContext cancellation terminate the
18 // worker and its descendants on Windows. Windows does not provide a Unix-like19 // worker and its descendants on Windows. Windows does not provide a Unix-like
19 // process-group kill operation, so killProcessGroup uses taskkill's tree mode.20 // process-group kill operation, so killProcessGroup uses taskkill's tree mode.
20-func configureWorkerProcess(cmd *exec.Cmd, _ ...io.Closer) {21+func configureWorkerProcess(cmd *exec.Cmd, pipes ...io.Closer) {
21 setProcessGroup(cmd)22 setProcessGroup(cmd)
22 cmd.WaitDelay = workerProcessWaitDelay23 cmd.WaitDelay = workerProcessWaitDelay
24+ var closePipes sync.Once
23 cmd.Cancel = func() error {25 cmd.Cancel = func() error {
24- return killProcessGroup(cmd)26+ err := killProcessGroup(cmd)
27+ closePipes.Do(func() { closeWorkerPipes(pipes) })
28+ return err
29+ }
30+}
31+
32+func closeWorkerPipes(pipes []io.Closer) {
33+ for _, pipe := range pipes {
34+ if pipe != nil {
35+ _ = pipe.Close()
36+ }
25 }37 }
26 }38 }
27 39
M packages/agent/subagents/supervisor.go +1 -8
@@ -313,7 +313,7 @@ type SpawnRequest struct {
313313 // disables fast mode for this child; true still cannot enable fast mode
314314 // when Config.FastMode is false. Nil inherits Config.FastMode.
315315 FastMode *bool
316- Subagent string // optional named markdown profile
316+ Subagent string // optional named markdown profile
317317
318318 // ParentID is metadata used for per-parent scheduling limits. A
319319 // non-empty RequesterAgentID identifies an untrusted child request;
@@ -847,13 +847,6 @@ func (f *Supervisor) stop(ctx context.Context, id string) error {
847847 defer timer.Stop()
848848 select {
849849 case <-a.done:
850- case <-ctx.Done():
851- if a.cancel != nil {
852- a.cancel()
853- }
854- if a.inbox != nil {
855- _ = a.inbox.Close()
856- }
857850 case <-timer.C:
858851 if a.cancel != nil {
859852 a.cancel()
@@ -313,7 +313,7 @@ type SpawnRequest struct {
313 // disables fast mode for this child; true still cannot enable fast mode313 // disables fast mode for this child; true still cannot enable fast mode
314 // when Config.FastMode is false. Nil inherits Config.FastMode.314 // when Config.FastMode is false. Nil inherits Config.FastMode.
315 FastMode *bool315 FastMode *bool
316- Subagent string // optional named markdown profile316+ Subagent string // optional named markdown profile
317 317
318 // ParentID is metadata used for per-parent scheduling limits. A318 // ParentID is metadata used for per-parent scheduling limits. A
319 // non-empty RequesterAgentID identifies an untrusted child request;319 // non-empty RequesterAgentID identifies an untrusted child request;
@@ -847,13 +847,6 @@ func (f *Supervisor) stop(ctx context.Context, id string) error {
847 defer timer.Stop()847 defer timer.Stop()
848 select {848 select {
849 case <-a.done:849 case <-a.done:
850- case <-ctx.Done():
851- if a.cancel != nil {
852- a.cancel()
853- }
854- if a.inbox != nil {
855- _ = a.inbox.Close()
856- }
857 case <-timer.C:850 case <-timer.C:
858 if a.cancel != nil {851 if a.cancel != nil {
859 a.cancel()852 a.cancel()
M packages/agent/subagents/supervisor_test.go +64 -0
@@ -141,6 +141,70 @@ func TestStopCancelsRunningAgent(t *testing.T) {
141141 }
142142 }
143143
144+func TestStopContextCallerCancellationDoesNotEndGracePeriod(t *testing.T) {
145+ const grace = 250 * time.Millisecond
146+ started := make(chan struct{})
147+ canceled := make(chan struct{})
148+ f := New(Config{
149+ Root: t.TempDir(),
150+ RepoRoot: t.TempDir(),
151+ Policy: SubagentPolicy{
152+ CancelGracePeriod: grace,
153+ },
154+ NewRunner: func(a *Agent) Runner {
155+ return RunnerFunc(func(ctx context.Context, sink Sink) error {
156+ listener, err := Listen(a.InboxPath)
157+ if err != nil {
158+ return err
159+ }
160+ defer listener.Close()
161+ close(started)
162+ select {
163+ case <-listener.Lines():
164+ case <-ctx.Done():
165+ close(canceled)
166+ return ctx.Err()
167+ }
168+ <-ctx.Done()
169+ close(canceled)
170+ return ctx.Err()
171+ })
172+ },
173+ })
174+ a, err := f.Spawn(context.Background(), "graceful stop")
175+ if err != nil {
176+ t.Fatal(err)
177+ }
178+ select {
179+ case <-started:
180+ case <-time.After(time.Second):
181+ t.Fatal("runner did not start")
182+ }
183+
184+ ctx, cancel := context.WithCancel(context.Background())
185+ if err := f.StopContext(ctx, a.ID); err != nil {
186+ t.Fatal(err)
187+ }
188+ cancel()
189+
190+ early := time.NewTimer(grace / 2)
191+ defer early.Stop()
192+ select {
193+ case <-canceled:
194+ t.Fatal("agent canceled when StopContext caller context was canceled")
195+ case <-early.C:
196+ }
197+
198+ deadline := time.NewTimer(grace * 2)
199+ defer deadline.Stop()
200+ select {
201+ case <-canceled:
202+ case <-deadline.C:
203+ t.Fatal("agent was not canceled after the grace period")
204+ }
205+ a.Wait()
206+}
207+
144208 func TestStopContextCancelsDetachedWaitWithoutHoldingOperationLock(t *testing.T) {
145209 if runtime.GOOS == "windows" {
146210 t.Skip("subagent inbox transport uses Unix-domain sockets")
@@ -141,6 +141,70 @@ func TestStopCancelsRunningAgent(t *testing.T) {
141 }141 }
142 }142 }
143 143
144+func TestStopContextCallerCancellationDoesNotEndGracePeriod(t *testing.T) {
145+ const grace = 250 * time.Millisecond
146+ started := make(chan struct{})
147+ canceled := make(chan struct{})
148+ f := New(Config{
149+ Root: t.TempDir(),
150+ RepoRoot: t.TempDir(),
151+ Policy: SubagentPolicy{
152+ CancelGracePeriod: grace,
153+ },
154+ NewRunner: func(a *Agent) Runner {
155+ return RunnerFunc(func(ctx context.Context, sink Sink) error {
156+ listener, err := Listen(a.InboxPath)
157+ if err != nil {
158+ return err
159+ }
160+ defer listener.Close()
161+ close(started)
162+ select {
163+ case <-listener.Lines():
164+ case <-ctx.Done():
165+ close(canceled)
166+ return ctx.Err()
167+ }
168+ <-ctx.Done()
169+ close(canceled)
170+ return ctx.Err()
171+ })
172+ },
173+ })
174+ a, err := f.Spawn(context.Background(), "graceful stop")
175+ if err != nil {
176+ t.Fatal(err)
177+ }
178+ select {
179+ case <-started:
180+ case <-time.After(time.Second):
181+ t.Fatal("runner did not start")
182+ }
183+
184+ ctx, cancel := context.WithCancel(context.Background())
185+ if err := f.StopContext(ctx, a.ID); err != nil {
186+ t.Fatal(err)
187+ }
188+ cancel()
189+
190+ early := time.NewTimer(grace / 2)
191+ defer early.Stop()
192+ select {
193+ case <-canceled:
194+ t.Fatal("agent canceled when StopContext caller context was canceled")
195+ case <-early.C:
196+ }
197+
198+ deadline := time.NewTimer(grace * 2)
199+ defer deadline.Stop()
200+ select {
201+ case <-canceled:
202+ case <-deadline.C:
203+ t.Fatal("agent was not canceled after the grace period")
204+ }
205+ a.Wait()
206+}
207+
144 func TestStopContextCancelsDetachedWaitWithoutHoldingOperationLock(t *testing.T) {208 func TestStopContextCancelsDetachedWaitWithoutHoldingOperationLock(t *testing.T) {
145 if runtime.GOOS == "windows" {209 if runtime.GOOS == "windows" {
146 t.Skip("subagent inbox transport uses Unix-domain sockets")210 t.Skip("subagent inbox transport uses Unix-domain sockets")
M packages/agent/tools/subagent_spawn.go +1 -1
@@ -273,7 +273,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
273273 "reasoning": reasoning,
274274 "fast_mode": agent.FastMode,
275275 "isolation": string(workspaceMode),
276- "timeout": timeout.String(),
276+ "timeout": agent.Timeout.String(),
277277 "max_turns": agent.MaxTurns,
278278 "state": agent.Status(),
279279 "process_state": string(agent.ProcessState()),
@@ -273,7 +273,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
273 "reasoning": reasoning,273 "reasoning": reasoning,
274 "fast_mode": agent.FastMode,274 "fast_mode": agent.FastMode,
275 "isolation": string(workspaceMode),275 "isolation": string(workspaceMode),
276- "timeout": timeout.String(),276+ "timeout": agent.Timeout.String(),
277 "max_turns": agent.MaxTurns,277 "max_turns": agent.MaxTurns,
278 "state": agent.Status(),278 "state": agent.Status(),
279 "process_state": string(agent.ProcessState()),279 "process_state": string(agent.ProcessState()),
M packages/agent/tools/subagent_spawn_test.go +39 -0
@@ -6,6 +6,7 @@ import (
66 "path/filepath"
77 "strings"
88 "testing"
9+ "time"
910
1011 "github.com/patriceckhart/zot/packages/agent/subagents"
1112 "github.com/patriceckhart/zot/packages/provider"
@@ -75,6 +76,44 @@ func TestSubagentSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) {
7576 }
7677 }
7778
79+func TestSubagentSpawnDetailsUseEffectiveTimeoutAndTurns(t *testing.T) {
80+ root := t.TempDir()
81+ manager := subagents.New(subagents.Config{
82+ Root: filepath.Join(root, "subagents"),
83+ RepoRoot: root,
84+ Policy: subagents.SubagentPolicy{
85+ DefaultTimeout: 37 * time.Minute,
86+ MaxTurns: 7,
87+ },
88+ NewRunner: func(*subagents.Agent) subagents.Runner {
89+ return noopSupervisorRunner{}
90+ },
91+ })
92+ t.Cleanup(manager.StopAll)
93+ tool := &SubagentSpawnTool{
94+ Supervisor: manager,
95+ Enabled: func() bool { return true },
96+ }
97+
98+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs"}`), nil)
99+ if err != nil {
100+ t.Fatal(err)
101+ }
102+ if res.IsError {
103+ t.Fatalf("unexpected tool error: %s", textResult(res.Content))
104+ }
105+ details, ok := res.Details.(map[string]any)
106+ if !ok {
107+ t.Fatalf("details type = %T, want map[string]any", res.Details)
108+ }
109+ if got := details["timeout"]; got != (37 * time.Minute).String() {
110+ t.Fatalf("omitted timeout detail = %v, want %s", got, (37 * time.Minute).String())
111+ }
112+ if got := details["max_turns"]; got != 7 {
113+ t.Fatalf("max_turns detail = %v, want 7", got)
114+ }
115+}
116+
78117 func TestSubagentSpawnRejectsExplicitZeroMaxTurns(t *testing.T) {
79118 tool := &SubagentSpawnTool{
80119 Supervisor: newTestSupervisor(t),
@@ -6,6 +6,7 @@ import (
6 "path/filepath"6 "path/filepath"
7 "strings"7 "strings"
8 "testing"8 "testing"
9+ "time"
9 10
10 "github.com/patriceckhart/zot/packages/agent/subagents"11 "github.com/patriceckhart/zot/packages/agent/subagents"
11 "github.com/patriceckhart/zot/packages/provider"12 "github.com/patriceckhart/zot/packages/provider"
@@ -75,6 +76,44 @@ func TestSubagentSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) {
75 }76 }
76 }77 }
77 78
79+func TestSubagentSpawnDetailsUseEffectiveTimeoutAndTurns(t *testing.T) {
80+ root := t.TempDir()
81+ manager := subagents.New(subagents.Config{
82+ Root: filepath.Join(root, "subagents"),
83+ RepoRoot: root,
84+ Policy: subagents.SubagentPolicy{
85+ DefaultTimeout: 37 * time.Minute,
86+ MaxTurns: 7,
87+ },
88+ NewRunner: func(*subagents.Agent) subagents.Runner {
89+ return noopSupervisorRunner{}
90+ },
91+ })
92+ t.Cleanup(manager.StopAll)
93+ tool := &SubagentSpawnTool{
94+ Supervisor: manager,
95+ Enabled: func() bool { return true },
96+ }
97+
98+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs"}`), nil)
99+ if err != nil {
100+ t.Fatal(err)
101+ }
102+ if res.IsError {
103+ t.Fatalf("unexpected tool error: %s", textResult(res.Content))
104+ }
105+ details, ok := res.Details.(map[string]any)
106+ if !ok {
107+ t.Fatalf("details type = %T, want map[string]any", res.Details)
108+ }
109+ if got := details["timeout"]; got != (37 * time.Minute).String() {
110+ t.Fatalf("omitted timeout detail = %v, want %s", got, (37 * time.Minute).String())
111+ }
112+ if got := details["max_turns"]; got != 7 {
113+ t.Fatalf("max_turns detail = %v, want 7", got)
114+ }
115+}
116+
78 func TestSubagentSpawnRejectsExplicitZeroMaxTurns(t *testing.T) {117 func TestSubagentSpawnRejectsExplicitZeroMaxTurns(t *testing.T) {
79 tool := &SubagentSpawnTool{118 tool := &SubagentSpawnTool{
80 Supervisor: newTestSupervisor(t),119 Supervisor: newTestSupervisor(t),
M packages/core/agent.go +9 -6
@@ -16,9 +16,13 @@ import (
1616 // Agent is a stateful conversation bound to a provider client, a model,
1717 // and a set of tools.
1818 type Agent struct {
19- Client provider.Client
20- Model string
21- System string
19+ Client provider.Client
20+ Model string
21+ // System is mutex-protected; use SetSystemPrompt to write it and
22+ // PromptConfig to read it.
23+ System string
24+ // Tools is mutex-protected; use SetTools to write it and PromptConfig
25+ // or ToolsSnapshot to read it.
2226 Tools Registry
2327 MaxSteps int
2428 Reasoning string
@@ -760,7 +764,7 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
760764 if !ok {
761765 continue
762766 }
763- res := a.runOneTool(ctx, tc, sink)
767+ res := a.runOneTool(ctx, tc, tools, sink)
764768 if res.IsError {
765769 hadError = true
766770 }
@@ -788,8 +792,7 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
788792 }, hadError
789793 }
790794
791-func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink func(AgentEvent)) ToolResult {
792- tools := a.ToolsSnapshot()
795+func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, tools Registry, sink func(AgentEvent)) ToolResult {
793796 tool, err := tools.Get(tc.Name)
794797 if err != nil {
795798 return ToolResult{
@@ -16,9 +16,13 @@ import (
16 // Agent is a stateful conversation bound to a provider client, a model,16 // Agent is a stateful conversation bound to a provider client, a model,
17 // and a set of tools.17 // and a set of tools.
18 type Agent struct {18 type Agent struct {
19- Client provider.Client19+ Client provider.Client
20- Model string20+ Model string
21- System string21+ // System is mutex-protected; use SetSystemPrompt to write it and
22+ // PromptConfig to read it.
23+ System string
24+ // Tools is mutex-protected; use SetTools to write it and PromptConfig
25+ // or ToolsSnapshot to read it.
22 Tools Registry26 Tools Registry
23 MaxSteps int27 MaxSteps int
24 Reasoning string28 Reasoning string
@@ -760,7 +764,7 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
760 if !ok {764 if !ok {
761 continue765 continue
762 }766 }
763- res := a.runOneTool(ctx, tc, sink)767+ res := a.runOneTool(ctx, tc, tools, sink)
764 if res.IsError {768 if res.IsError {
765 hadError = true769 hadError = true
766 }770 }
@@ -788,8 +792,7 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
788 }, hadError792 }, hadError
789 }793 }
790 794
791-func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink func(AgentEvent)) ToolResult {795+func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, tools Registry, sink func(AgentEvent)) ToolResult {
792- tools := a.ToolsSnapshot()
793 tool, err := tools.Get(tc.Name)796 tool, err := tools.Get(tc.Name)
794 if err != nil {797 if err != nil {
795 return ToolResult{798 return ToolResult{
M packages/core/intercept_test.go +6 -3
@@ -39,11 +39,12 @@ func TestBeforeToolExecuteModifiesArgs(t *testing.T) {
3939 }
4040
4141 ctx := context.Background()
42+ tools := a.ToolsSnapshot()
4243 res := a.runOneTool(ctx, provider.ToolCallBlock{
4344 ID: "T1",
4445 Name: "echo",
4546 Arguments: json.RawMessage(`{"command":"ls"}`),
46- }, func(AgentEvent) {})
47+ }, tools, func(AgentEvent) {})
4748 if res.IsError {
4849 t.Fatalf("unexpected error result: %v", res.Content)
4950 }
@@ -65,12 +66,13 @@ func TestBeforeToolExecuteInvalidJSONIgnored(t *testing.T) {
6566 }
6667
6768 ctx := context.Background()
69+ tools := a.ToolsSnapshot()
6870 orig := json.RawMessage(`{"command":"ls"}`)
6971 a.runOneTool(ctx, provider.ToolCallBlock{
7072 ID: "T1",
7173 Name: "echo",
7274 Arguments: orig,
73- }, func(AgentEvent) {})
75+ }, tools, func(AgentEvent) {})
7476 if string(rec.lastArgs) != string(orig) {
7577 t.Errorf("tool saw %s, want original %s", string(rec.lastArgs), string(orig))
7678 }
@@ -88,11 +90,12 @@ func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) {
8890 }
8991
9092 ctx := context.Background()
93+ tools := a.ToolsSnapshot()
9194 res := a.runOneTool(ctx, provider.ToolCallBlock{
9295 ID: "T1",
9396 Name: "echo",
9497 Arguments: json.RawMessage(`{"command":"ls"}`),
95- }, func(AgentEvent) {})
98+ }, tools, func(AgentEvent) {})
9699 if !res.IsError {
97100 t.Fatal("want error result, got success")
98101 }
@@ -39,11 +39,12 @@ func TestBeforeToolExecuteModifiesArgs(t *testing.T) {
39 }39 }
40 40
41 ctx := context.Background()41 ctx := context.Background()
42+ tools := a.ToolsSnapshot()
42 res := a.runOneTool(ctx, provider.ToolCallBlock{43 res := a.runOneTool(ctx, provider.ToolCallBlock{
43 ID: "T1",44 ID: "T1",
44 Name: "echo",45 Name: "echo",
45 Arguments: json.RawMessage(`{"command":"ls"}`),46 Arguments: json.RawMessage(`{"command":"ls"}`),
46- }, func(AgentEvent) {})47+ }, tools, func(AgentEvent) {})
47 if res.IsError {48 if res.IsError {
48 t.Fatalf("unexpected error result: %v", res.Content)49 t.Fatalf("unexpected error result: %v", res.Content)
49 }50 }
@@ -65,12 +66,13 @@ func TestBeforeToolExecuteInvalidJSONIgnored(t *testing.T) {
65 }66 }
66 67
67 ctx := context.Background()68 ctx := context.Background()
69+ tools := a.ToolsSnapshot()
68 orig := json.RawMessage(`{"command":"ls"}`)70 orig := json.RawMessage(`{"command":"ls"}`)
69 a.runOneTool(ctx, provider.ToolCallBlock{71 a.runOneTool(ctx, provider.ToolCallBlock{
70 ID: "T1",72 ID: "T1",
71 Name: "echo",73 Name: "echo",
72 Arguments: orig,74 Arguments: orig,
73- }, func(AgentEvent) {})75+ }, tools, func(AgentEvent) {})
74 if string(rec.lastArgs) != string(orig) {76 if string(rec.lastArgs) != string(orig) {
75 t.Errorf("tool saw %s, want original %s", string(rec.lastArgs), string(orig))77 t.Errorf("tool saw %s, want original %s", string(rec.lastArgs), string(orig))
76 }78 }
@@ -88,11 +90,12 @@ func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) {
88 }90 }
89 91
90 ctx := context.Background()92 ctx := context.Background()
93+ tools := a.ToolsSnapshot()
91 res := a.runOneTool(ctx, provider.ToolCallBlock{94 res := a.runOneTool(ctx, provider.ToolCallBlock{
92 ID: "T1",95 ID: "T1",
93 Name: "echo",96 Name: "echo",
94 Arguments: json.RawMessage(`{"command":"ls"}`),97 Arguments: json.RawMessage(`{"command":"ls"}`),
95- }, func(AgentEvent) {})98+ }, tools, func(AgentEvent) {})
96 if !res.IsError {99 if !res.IsError {
97 t.Fatal("want error result, got success")100 t.Fatal("want error result, got success")
98 }101 }

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