Vectrum
Sign In

subagent: run child agents alongside the session

A subagent is a whole agent — its own conversation, context window, model and
goroutine — and the parent sees only the report it ends with. That is the point:
work that would have filled the parent's window with file listings fills the
child's instead and comes back a paragraph long.

The supervisor owns them. Four run at once and the rest queue; pause, resume
and kill act at the boundaries the loop offers; a child's approvals go through
the session's policy tagged with its id. Presets say what kind of job it is and
nothing about what to spend on it — the agent doing the spawning picks the
model and the effort, because those are judgements about the task, and a pinned
id is a guess about someone else's endpoint.

A report reaches the parent exactly once. Both paths — agent_wait returning it
as a tool result, and the front end putting it into the conversation — claim it
first, and only one claim wins. Deciding that from a copy taken earlier is how
the same report was handed over twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jjonicommitted 1 parent 68a1174commit3869bc2

Showing 6 changed files with 2205 additions and 0 deletions.

+483−0internal/subagent/control_test.goaddedView File
@@ -0,0 +1,483 @@
1+ package subagent
2+
3+ import (
4+ "context"
5+ "strings"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "harness/internal/agent"
11+ "harness/internal/approval"
12+ "harness/internal/metrics"
13+ "harness/internal/tools"
14+ )
15+
16+ // Parallel agents are parallel spend, so only so many run at once and the rest
17+ // wait their turn.
18+ func TestTheConcurrencyCapQueuesTheRest(t *testing.T) {
19+ f := newFakeAPI(t)
20+ f.hold()
21+ s := newSupervisor(t, f, func(o *Options) { o.MaxRunning = 1 })
22+
23+ first, _ := s.Spawn(Spec{Preset: "explore", Task: "one"})
24+ second, _ := s.Spawn(Spec{Preset: "explore", Task: "two"})
25+ third, _ := s.Spawn(Spec{Preset: "explore", Task: "three"})
26+
27+ waitFor(t, "the first agent to start", func() bool { return statusOf(t, s, first) == Running })
28+ time.Sleep(settle)
29+ if got := statusOf(t, s, second); got != Queued {
30+ t.Errorf("second agent is %s, want queued behind the cap", got)
31+ }
32+ if got := statusOf(t, s, third); got != Queued {
33+ t.Errorf("third agent is %s, want queued behind the cap", got)
34+ }
35+ if n := f.count(); n != 1 {
36+ t.Errorf("%d agents were talking to the API at once, want 1", n)
37+ }
38+
39+ f.release()
40+ waitFor(t, "every agent to finish", func() bool {
41+ return statusOf(t, s, first) == Done && statusOf(t, s, second) == Done && statusOf(t, s, third) == Done
42+ })
43+ }
44+
45+ func TestKillStopsARunningAgent(t *testing.T) {
46+ f := newFakeAPI(t)
47+ f.hold()
48+ s := newSupervisor(t, f)
49+
50+ id, _ := s.Spawn(Spec{Preset: "general", Task: "go"})
51+ waitFor(t, "the agent to start", func() bool { return statusOf(t, s, id) == Running })
52+
53+ if err := s.Kill(id); err != nil {
54+ t.Fatalf("Kill: %v", err)
55+ }
56+ waitFor(t, "the agent to stop", func() bool { return statusOf(t, s, id) == Killed })
57+ f.release()
58+
59+ info, _ := s.Info(id)
60+ if info.Err != "" {
61+ t.Errorf("a killed agent recorded an error (%q); being stopped is not a failure", info.Err)
62+ }
63+ if !strings.Contains(info.Notification(), "stopped by the user") {
64+ t.Errorf("the parent would be told: %q", info.Notification())
65+ }
66+ }
67+
68+ // Killing something that has not started must not leave it queued, waiting to
69+ // start after it was stopped.
70+ func TestKillingAQueuedAgentNeverRunsIt(t *testing.T) {
71+ f := newFakeAPI(t)
72+ f.hold()
73+ s := newSupervisor(t, f, func(o *Options) { o.MaxRunning = 1 })
74+
75+ first, _ := s.Spawn(Spec{Preset: "explore", Task: "one"})
76+ queued, _ := s.Spawn(Spec{Preset: "explore", Task: "two"})
77+ waitFor(t, "the first agent to start", func() bool { return statusOf(t, s, first) == Running })
78+
79+ if err := s.Kill(queued); err != nil {
80+ t.Fatalf("Kill: %v", err)
81+ }
82+ if got := statusOf(t, s, queued); got != Killed {
83+ t.Fatalf("queued agent is %s after being killed", got)
84+ }
85+
86+ f.release()
87+ waitFor(t, "the first agent to finish", func() bool { return statusOf(t, s, first) == Done })
88+ time.Sleep(settle)
89+ if n := f.count(); n != 1 {
90+ t.Errorf("the API saw %d requests, want 1 — the killed agent still ran", n)
91+ }
92+ }
93+
94+ // Pausing holds the agent at the next turn boundary and spends nothing until
95+ // it is resumed.
96+ func TestPauseHoldsAnAgentAndResumeContinuesIt(t *testing.T) {
97+ f := newFakeAPI(t)
98+ f.twoTurns = true
99+ var sup *Supervisor
100+ // Pausing from inside the first turn's tool call is the only way to be
101+ // sure the request for the second turn has not gone out yet.
102+ s := newSupervisor(t, f, func(o *Options) {
103+ o.Observe = func(ev Event) {
104+ if ev.Agent.Kind == agent.EventToolResult {
105+ sup.Pause(ev.ID)
106+ }
107+ }
108+ })
109+ sup = s
110+
111+ id, _ := s.Spawn(Spec{Preset: "general", Task: "go"})
112+ waitFor(t, "the agent to be held", func() bool { return statusOf(t, s, id) == Paused })
113+ time.Sleep(settle)
114+ if n := f.count(); n != 1 {
115+ t.Fatalf("a paused agent made %d requests, want 1", n)
116+ }
117+
118+ if err := s.Resume(id); err != nil {
119+ t.Fatalf("Resume: %v", err)
120+ }
121+ waitFor(t, "the agent to finish", func() bool { return statusOf(t, s, id) == Done })
122+ if n := f.count(); n != 2 {
123+ t.Errorf("made %d requests overall, want 2", n)
124+ }
125+ }
126+
127+ // Pausing something that never started would be a lie in the sidebar: there is
128+ // nothing to hold. Say so, and point at the thing that does work.
129+ func TestPausingAQueuedAgentSaysItHasNotStarted(t *testing.T) {
130+ f := newFakeAPI(t)
131+ f.hold()
132+ s := newSupervisor(t, f, func(o *Options) { o.MaxRunning = 1 })
133+
134+ first, _ := s.Spawn(Spec{Preset: "explore", Task: "one"})
135+ queued, _ := s.Spawn(Spec{Preset: "explore", Task: "two"})
136+ waitFor(t, "the first agent to start", func() bool { return statusOf(t, s, first) == Running })
137+
138+ err := s.Pause(queued)
139+ if err == nil {
140+ t.Fatal("pausing a queued agent succeeded")
141+ }
142+ if !strings.Contains(err.Error(), "not started") {
143+ t.Errorf("error = %q, want it to say the agent has not started", err)
144+ }
145+ if got := statusOf(t, s, queued); got != Queued {
146+ t.Errorf("a failed pause changed the status to %s", got)
147+ }
148+ f.release()
149+ }
150+
151+ func TestPauseAndResumeOnAFinishedAgentAreRefused(t *testing.T) {
152+ f := newFakeAPI(t)
153+ s := newSupervisor(t, f)
154+ id, _ := s.Spawn(Spec{Preset: "explore", Task: "go"})
155+ waitFor(t, "the agent to finish", func() bool { return statusOf(t, s, id) == Done })
156+
157+ for _, err := range []error{s.Pause(id), s.Resume(id), s.Kill(id)} {
158+ if err == nil {
159+ t.Error("controlling a finished agent succeeded")
160+ } else if !strings.Contains(err.Error(), "already finished") {
161+ t.Errorf("error = %q, want it to say the agent has finished", err)
162+ }
163+ }
164+ }
165+
166+ func TestWaitReturnsHowTheAgentEnded(t *testing.T) {
167+ f := newFakeAPI(t)
168+ s := newSupervisor(t, f)
169+ id, _ := s.Spawn(Spec{Preset: "explore", Task: "find it"})
170+
171+ info, err := s.Wait(context.Background(), id)
172+ if err != nil {
173+ t.Fatalf("Wait: %v", err)
174+ }
175+ if info.Status != Done || info.Report != "handled: find it" {
176+ t.Errorf("Wait returned %+v", info)
177+ }
178+ }
179+
180+ // Interrupting the turn that is waiting must not kill the agent it waited for:
181+ // the wait is the parent's, the work is the child's.
182+ func TestWaitIsInterruptibleWithoutKillingTheAgent(t *testing.T) {
183+ f := newFakeAPI(t)
184+ f.hold()
185+ s := newSupervisor(t, f)
186+ id, _ := s.Spawn(Spec{Preset: "general", Task: "go"})
187+
188+ ctx, cancel := context.WithCancel(context.Background())
189+ cancel()
190+ if _, err := s.Wait(ctx, id); err == nil {
191+ t.Fatal("a cancelled Wait returned no error")
192+ }
193+ if got := statusOf(t, s, id); !got.Live() {
194+ t.Errorf("the agent is %s after its waiter gave up", got)
195+ }
196+
197+ f.release()
198+ waitFor(t, "the agent to finish on its own", func() bool { return statusOf(t, s, id) == Done })
199+ }
200+
201+ func TestWaitingOnAnUnknownAgentSaysSo(t *testing.T) {
202+ s := newSupervisor(t, newFakeAPI(t))
203+ if _, err := s.Wait(context.Background(), "a9"); err == nil {
204+ t.Fatal("waiting on an agent that does not exist succeeded")
205+ }
206+ }
207+
208+ // Reports are handed to the parent exactly once: twice would repeat the work
209+ // in its context, never would lose it.
210+ func TestReportsAreHandedOverOnce(t *testing.T) {
211+ f := newFakeAPI(t)
212+ s := newSupervisor(t, f)
213+ id, _ := s.Spawn(Spec{Preset: "explore", Task: "go"})
214+ waitFor(t, "the agent to finish", func() bool { return statusOf(t, s, id) == Done })
215+
216+ first := s.TakeReports()
217+ if len(first) != 1 || first[0].ID != id {
218+ t.Fatalf("TakeReports = %+v, want the finished agent", first)
219+ }
220+ if again := s.TakeReports(); len(again) != 0 {
221+ t.Errorf("TakeReports handed the same report over twice: %+v", again)
222+ }
223+ }
224+
225+ // The front end redraws on a callback rather than by polling, so a status
226+ // change that fires nothing would leave a finished agent showing as running.
227+ func TestChangedFiresOnStatusChanges(t *testing.T) {
228+ f := newFakeAPI(t)
229+ var changes int
230+ var mu = make(chan struct{}, 1)
231+ mu <- struct{}{}
232+ s := newSupervisor(t, f, func(o *Options) {
233+ o.Changed = func() {
234+ <-mu
235+ changes++
236+ mu <- struct{}{}
237+ }
238+ })
239+
240+ id, _ := s.Spawn(Spec{Preset: "explore", Task: "go"})
241+ waitFor(t, "the agent to finish", func() bool { return statusOf(t, s, id) == Done })
242+ waitFor(t, "the change callback", func() bool {
243+ <-mu
244+ defer func() { mu <- struct{}{} }()
245+ return changes >= 2
246+ })
247+ }
248+
249+ // Leaving the harness must not leave agents editing a tree nobody is watching.
250+ func TestShutdownStopsEverything(t *testing.T) {
251+ f := newFakeAPI(t)
252+ f.hold()
253+ s := newSupervisor(t, f, func(o *Options) { o.MaxRunning = 1 })
254+
255+ running, _ := s.Spawn(Spec{Preset: "general", Task: "one"})
256+ queued, _ := s.Spawn(Spec{Preset: "general", Task: "two"})
257+ waitFor(t, "the first agent to start", func() bool { return statusOf(t, s, running) == Running })
258+
259+ done := make(chan struct{})
260+ go func() { s.Shutdown(); close(done) }()
261+ f.release()
262+
263+ select {
264+ case <-done:
265+ case <-time.After(3 * time.Second):
266+ t.Fatal("Shutdown never returned")
267+ }
268+ for _, id := range []string{running, queued} {
269+ if got := statusOf(t, s, id); got.Live() {
270+ t.Errorf("agent %s is %s after shutdown", id, got)
271+ }
272+ }
273+ }
274+
275+ // A subagent's tool calls go through the same policy the session's own do, and
276+ // the request has to say which agent is asking — you cannot decide on a shell
277+ // command without knowing who wants to run it. While it waits, the sidebar has
278+ // to show that it is stopped on you rather than working.
279+ func TestASubagentsApprovalIsTaggedAndShowsAsWaiting(t *testing.T) {
280+ f := newFakeAPI(t)
281+ f.callTool = "noop"
282+
283+ asked := make(chan approval.Request, 4)
284+ release := make(chan approval.Decision)
285+ policy := approval.New(func(ctx context.Context, req approval.Request) (approval.Decision, error) {
286+ asked <- req
287+ select {
288+ case d := <-release:
289+ return d, nil
290+ case <-ctx.Done():
291+ return approval.Deny, ctx.Err()
292+ }
293+ })
294+ s := newSupervisor(t, f, func(o *Options) { o.Policy = policy })
295+
296+ id, _ := s.Spawn(Spec{Preset: "general", Task: "go"})
297+
298+ var req approval.Request
299+ select {
300+ case req = <-asked:
301+ case <-time.After(3 * time.Second):
302+ t.Fatal("the subagent's tool call never reached the approval policy")
303+ }
304+ if req.Agent != id {
305+ t.Errorf("the prompt says agent %q, want %q", req.Agent, id)
306+ }
307+ waitFor(t, "the agent to show as waiting", func() bool { return statusOf(t, s, id) == Waiting })
308+
309+ release <- approval.Once
310+ waitFor(t, "the agent to finish", func() bool { return statusOf(t, s, id) == Done })
311+ }
312+
313+ // newBlockedSupervisor holds every child inside its tool-set construction, so
314+ // a test can act on an agent in the window between being given a slot and
315+ // having anything to cancel. release lets them through.
316+ func newBlockedSupervisor(t *testing.T, f *fakeAPI) (*Supervisor, func()) {
317+ t.Helper()
318+ gate := make(chan struct{})
319+ root := t.TempDir()
320+ s := newSupervisor(t, f, func(o *Options) {
321+ o.Tools = func(state *agent.State, log *metrics.Logger) *tools.Registry {
322+ <-gate
323+ return tools.NewRegistry(log).Register(tools.NewRead(root, state))
324+ }
325+ })
326+ var once sync.Once
327+ return s, func() { once.Do(func() { close(gate) }) }
328+ }
329+
330+ // Killing an agent that has a slot but has not reached its first request must
331+ // stop it. Losing the kill there means watching an agent you stopped carry on
332+ // editing files.
333+ func TestKillingAnAgentThatIsStillStartingStopsIt(t *testing.T) {
334+ f := newFakeAPI(t)
335+ s, release := newBlockedSupervisor(t, f)
336+ defer release()
337+
338+ // Spawn blocks in the gate: a child is built on the goroutine that asked
339+ // for it, and the gate is inside that construction.
340+ go s.Spawn(Spec{Preset: "general", Task: "go"})
341+ waitFor(t, "the agent to take a slot", func() bool {
342+ rows := s.Snapshot()
343+ return len(rows) == 1 && rows[0].Status == Running
344+ })
345+ id := s.Snapshot()[0].ID
346+
347+ if err := s.Kill(id); err != nil {
348+ t.Fatalf("Kill: %v", err)
349+ }
350+ release()
351+ waitFor(t, "the agent to stop", func() bool { return statusOf(t, s, id) == Killed })
352+
353+ time.Sleep(settle)
354+ if n := f.count(); n != 0 {
355+ t.Errorf("a killed agent still made %d requests", n)
356+ }
357+ }
358+
359+ // Shutting down in that same window must not find a half-built child it
360+ // accounts for twice — once as never-started and once when it ends.
361+ func TestShutdownDuringStartupDoesNotPanic(t *testing.T) {
362+ f := newFakeAPI(t)
363+ s, release := newBlockedSupervisor(t, f)
364+
365+ go s.Spawn(Spec{Preset: "general", Task: "go"})
366+ waitFor(t, "the agent to take a slot", func() bool {
367+ rows := s.Snapshot()
368+ return len(rows) == 1 && rows[0].Status == Running
369+ })
370+ id := s.Snapshot()[0].ID
371+
372+ done := make(chan struct{})
373+ go func() { s.Shutdown(); close(done) }()
374+ time.Sleep(settle)
375+ release()
376+
377+ select {
378+ case <-done:
379+ case <-time.After(3 * time.Second):
380+ t.Fatal("Shutdown never returned")
381+ }
382+ if got := statusOf(t, s, id); got.Live() {
383+ t.Errorf("agent is %s after shutdown", got)
384+ }
385+
386+ // The half-built child is let go last. If shutting down accounted for it
387+ // once and its own ending accounts for it again, this is where the process
388+ // dies — so the test waits for that ending rather than leaving it to land
389+ // after the run, as a crash somebody sees once a month.
390+ waitFor(t, "the released agent to end", func() bool { return statusOf(t, s, id) == Killed })
391+ time.Sleep(settle)
392+ if n := f.count(); n != 0 {
393+ t.Errorf("an agent shut down before it started still made %d requests", n)
394+ }
395+ }
396+
397+ // The sequence that used to send every report twice: the agent finishes, the
398+ // front end drains it into its pending queue, and only then does the parent's
399+ // agent_wait land. Whoever hands the report to the model has to claim it, or
400+ // the other one hands it over again — which is what the whole feature exists
401+ // to avoid.
402+ func TestAReportDrainedBeforeTheWaitIsNotSentTwice(t *testing.T) {
403+ f := newFakeAPI(t)
404+ s := newSupervisor(t, f)
405+ id, _ := s.Spawn(Spec{Preset: "explore", Task: "find it"})
406+ waitFor(t, "the agent to finish", func() bool { return statusOf(t, s, id) == Done })
407+
408+ // The front end drains first — it holds the report, it has not given it
409+ // to the model yet.
410+ if reports := s.TakeReports(); len(reports) != 1 {
411+ t.Fatalf("TakeReports = %+v", reports)
412+ }
413+
414+ // Now the parent asks for it, and gets it as the tool's result.
415+ info, err := s.Wait(context.Background(), id)
416+ if err != nil {
417+ t.Fatalf("Wait: %v", err)
418+ }
419+ if !s.ClaimReport(info.ID) {
420+ t.Fatal("agent_wait could not claim a report nobody had delivered")
421+ }
422+
423+ // The front end, coming to inject the copy it drained, must be refused.
424+ if s.ClaimReport(id) {
425+ t.Error("the front end would put the same report into the conversation a second time")
426+ }
427+ }
428+
429+ // Claiming is the handover, so exactly one caller can win it however many ask.
430+ func TestOnlyOneClaimOnAReportEverWins(t *testing.T) {
431+ f := newFakeAPI(t)
432+ s := newSupervisor(t, f)
433+ id, _ := s.Spawn(Spec{Preset: "explore", Task: "find it"})
434+ waitFor(t, "the agent to finish", func() bool { return statusOf(t, s, id) == Done })
435+
436+ var won int
437+ for i := 0; i < 4; i++ {
438+ if s.ClaimReport(id) {
439+ won++
440+ }
441+ }
442+ if won != 1 {
443+ t.Errorf("%d callers were told to deliver the same report", won)
444+ }
445+ if s.ClaimReport("a99") {
446+ t.Error("an agent that does not exist had a report to claim")
447+ }
448+ }
449+
450+ // An agent still working has nothing to hand over yet.
451+ func TestARunningAgentsReportCannotBeClaimed(t *testing.T) {
452+ f := newFakeAPI(t)
453+ f.hold()
454+ s := newSupervisor(t, f)
455+ id, _ := s.Spawn(Spec{Preset: "general", Task: "go"})
456+ waitFor(t, "the agent to start", func() bool { return statusOf(t, s, id) == Running })
457+
458+ if s.ClaimReport(id) {
459+ t.Error("claimed a report from an agent that has not finished")
460+ }
461+ f.release()
462+ }
463+
464+ // A wait the user interrupted delivered nothing, so the report must still be
465+ // there for the front end to hand over.
466+ func TestAnInterruptedWaitLeavesTheReportToBeDelivered(t *testing.T) {
467+ f := newFakeAPI(t)
468+ f.hold()
469+ s := newSupervisor(t, f)
470+ id, _ := s.Spawn(Spec{Preset: "explore", Task: "find it"})
471+
472+ ctx, cancel := context.WithCancel(context.Background())
473+ cancel()
474+ if _, err := s.Wait(ctx, id); err == nil {
475+ t.Fatal("a cancelled Wait returned no error")
476+ }
477+
478+ f.release()
479+ waitFor(t, "the agent to finish", func() bool { return statusOf(t, s, id) == Done })
480+ if !s.ClaimReport(id) {
481+ t.Error("the report was claimed by a wait that was interrupted before it delivered anything")
482+ }
483+ }
+102−0internal/subagent/presets.goaddedView File
@@ -0,0 +1,102 @@
1+ package subagent
2+
3+ import (
4+ "fmt"
5+ "strings"
6+ )
7+
8+ // Preset is one kind of subagent: a brief and a turn budget. They are compiled
9+ // in rather than configurable, because a preset the model can invent is a
10+ // blast radius the model can invent.
11+ //
12+ // A preset deliberately pins no model and no effort level. Which model suits a
13+ // job is a judgement about that job — a search across forty files and a design
14+ // question are not the same errand — and the agent doing the spawning is the
15+ // one holding the task. Pinning them here also meant pinning ids that a
16+ // gateway may not serve at all.
17+ type Preset struct {
18+ Name string
19+ // Description is one line, shown in the spawn tool's description and in
20+ // the sidebar. It is what the model chooses between.
21+ Description string
22+ // MaxTurns overrides the session's cap when set: what differs between
23+ // these jobs is how long they are worth running, not what they run on.
24+ MaxTurns int
25+ // System is the brief. It replaces the session's system prompt outright:
26+ // a subagent is not a smaller copy of the main agent, it is a different
27+ // job with a different definition of done.
28+ System string
29+ }
30+
31+ // sharedRules are what every subagent has to know about being one. They report
32+ // back through a final message and nothing else: nobody is reading their
33+ // transcript turn by turn.
34+ const sharedRules = `
35+
36+ You are a subagent: another agent delegated this task to you and is waiting on
37+ your answer. You cannot ask it questions and you cannot spawn agents of your
38+ own. Work with what you were given.
39+
40+ Your final message is the entire report — it is all the agent that spawned you
41+ will see. Lead with the answer, then the specifics it needs: file paths with
42+ line numbers, what you changed, what you could not settle and why. Do not
43+ describe your process, and do not pad it.`
44+
45+ var presets = []Preset{
46+ {
47+ Name: "explore",
48+ Description: "search the repository and report what is where — for questions of fact, where a small fast model earns its keep",
49+ MaxTurns: 20,
50+ System: `You are a code exploration agent. You find things in a repository and report
51+ precisely where they are.
52+
53+ Search before you read: glob and grep cost less than reading files that turn
54+ out to be the wrong ones. Read only the regions you need. Answer with paths and
55+ line numbers, not with paraphrase — the agent reading your report will go to
56+ those lines.` + sharedRules,
57+ },
58+ {
59+ Name: "plan",
60+ Description: "work out how a change should be made and report the approach, without making it — worth a strong model",
61+ MaxTurns: 30,
62+ System: `You are a software design agent. You work out how a change should be made in
63+ this repository and report the plan.
64+
65+ Read the code that the change would touch before proposing anything: a plan
66+ that does not match the code as it is costs more than no plan. Follow the
67+ conventions already there rather than the ones you would have chosen. Report
68+ the approach, the files it touches, the order of the steps, and the risks worth
69+ knowing about.` + sharedRules,
70+ },
71+ {
72+ Name: "general",
73+ Description: "carry out a self-contained piece of work end to end, including edits and tests",
74+ MaxTurns: 40,
75+ System: `You are a coding agent working on a self-contained task delegated to you.
76+
77+ Never guess at file paths — find them with glob or grep. When you change
78+ something, verify it by running the project's own tests rather than asserting
79+ that it works. Stay inside the task you were given: work you were not asked for
80+ is work nobody will review.` + sharedRules,
81+ },
82+ }
83+
84+ // Presets are the kinds of subagent that can be spawned, in the order they are
85+ // offered.
86+ func Presets() []Preset { return append([]Preset(nil), presets...) }
87+
88+ // Lookup finds a preset by name. The error lists the real ones, because the
89+ // caller is usually a model that guessed.
90+ func Lookup(name string) (Preset, error) {
91+ for _, p := range presets {
92+ if p.Name == name {
93+ return p, nil
94+ }
95+ }
96+ var names []string
97+ for _, p := range presets {
98+ names = append(names, p.Name)
99+ }
100+ return Preset{}, fmt.Errorf("no such agent preset %q. The ones that exist are: %s",
101+ name, strings.Join(names, ", "))
102+ }
+721−0internal/subagent/supervisor.goaddedView File
@@ -0,0 +1,721 @@
1+ // Package subagent runs child agents alongside the session's own.
2+ //
3+ // A subagent is a whole agent: its own conversation, its own context window,
4+ // its own model, its own goroutine. The agent that spawned one never sees its
5+ // turns — only the report it ends with — which is the point. Work that would
6+ // have filled the parent's window is spent somewhere else and comes back a
7+ // paragraph long.
8+ //
9+ // Like the agent package, this one knows nothing about a terminal. It reports
10+ // through callbacks and a snapshot, so the TUI and the eval runner can both
11+ // drive it.
12+ package subagent
13+
14+ import (
15+ "context"
16+ "encoding/json"
17+ "errors"
18+ "fmt"
19+ "strings"
20+ "sync"
21+ "time"
22+
23+ "harness/internal/agent"
24+ "harness/internal/approval"
25+ "harness/internal/llm"
26+ "harness/internal/metrics"
27+ "harness/internal/tools"
28+ )
29+
30+ // Status is where one subagent is in its life.
31+ type Status string
32+
33+ const (
34+ // Queued is spawned but waiting for a slot: only so many agents run at
35+ // once, because parallel agents are parallel spend.
36+ Queued Status = "queued"
37+ // Waiting is blocked on an approval prompt — stopped by you, not by
38+ // itself.
39+ Waiting Status = "waiting"
40+ Running Status = "running"
41+ Paused Status = "paused"
42+ Done Status = "done"
43+ Failed Status = "failed"
44+ Killed Status = "killed"
45+ )
46+
47+ // Live reports whether the agent is still going to do something. A live agent
48+ // holds one of the concurrency slots; a finished one does not.
49+ func (s Status) Live() bool {
50+ switch s {
51+ case Queued, Running, Waiting, Paused:
52+ return true
53+ }
54+ return false
55+ }
56+
57+ // DefaultMaxRunning caps how many subagents run at once. Four is enough to
58+ // parallelise a search and few enough that a runaway spawn loop is visible
59+ // before it is expensive.
60+ const DefaultMaxRunning = 4
61+
62+ // Info is one subagent as the outside world sees it: everything the sidebar
63+ // draws and everything the parent is told.
64+ type Info struct {
65+ ID string
66+ Preset string
67+ Model string
68+ // Effort is the level this agent runs at, empty when it runs at the
69+ // session's — or when the model it is on takes no effort level at all.
70+ Effort string
71+ Task string
72+ Status Status
73+ Turns int
74+ Usage llm.Usage
75+ // Started is when it got a slot, not when it was spawned; a queued agent
76+ // has not started.
77+ Started time.Time
78+ Ended time.Time
79+ // Report is the agent's final message: the whole of what it hands back.
80+ Report string
81+ // Err is why it failed, empty otherwise.
82+ Err string
83+ }
84+
85+ // Cost prices the agent's usage at its own model, which is the only honest way
86+ // to bill a haiku agent spawned from an opus session.
87+ func (i Info) Cost() (float64, bool) { return i.Usage.Cost(i.Model) }
88+
89+ // Elapsed is how long it has been running, or how long it ran.
90+ func (i Info) Elapsed() time.Duration {
91+ if i.Started.IsZero() {
92+ return 0
93+ }
94+ if i.Ended.IsZero() {
95+ return time.Since(i.Started)
96+ }
97+ return i.Ended.Sub(i.Started)
98+ }
99+
100+ // Notification is what the parent agent is told when this agent finishes. It
101+ // goes into the conversation as a user message, so it names the agent and
102+ // repeats the task: by the time it lands, several turns may have passed and
103+ // the parent has to be able to place it.
104+ func (i Info) Notification() string {
105+ var b strings.Builder
106+ switch i.Status {
107+ case Done:
108+ fmt.Fprintf(&b, "[subagent %s (%s) finished after %s]\n", i.ID, i.Preset, plural(i.Turns, "turn"))
109+ fmt.Fprintf(&b, "Its task was: %s\n\nIts report:\n%s", i.Task, i.Report)
110+ case Failed:
111+ fmt.Fprintf(&b, "[subagent %s (%s) failed]\nIts task was: %s\nIt stopped because: %s\n\n", i.ID, i.Preset, i.Task, i.Err)
112+ b.WriteString("Nothing it may have done was rolled back. Decide whether to do this work yourself, spawn another agent, or leave it.")
113+ if i.Report != "" {
114+ fmt.Fprintf(&b, "\n\nWhat it had said before it stopped:\n%s", i.Report)
115+ }
116+ case Killed:
117+ fmt.Fprintf(&b, "[subagent %s (%s) was stopped by the user]\nIts task was: %s\n\n", i.ID, i.Preset, i.Task)
118+ b.WriteString("Do not spawn it again unless you are asked to: the user stopped it on purpose.")
119+ default:
120+ fmt.Fprintf(&b, "[subagent %s (%s) is %s]", i.ID, i.Preset, i.Status)
121+ }
122+ return b.String()
123+ }
124+
125+ // AlreadyDelivered is what agent_wait answers when the report has already
126+ // reached the parent another way. Repeating the text would charge it twice for
127+ // something already in its context, which is the cost this whole feature
128+ // exists to avoid.
129+ func (i Info) AlreadyDelivered() string {
130+ return fmt.Sprintf("[subagent %s (%s) %s after %s — its report has already been delivered to you "+
131+ "as a message in this conversation. Read it there rather than waiting again.]",
132+ i.ID, i.Preset, endedWord(i.Status), plural(i.Turns, "turn"))
133+ }
134+
135+ // endedWord is how an agent's ending is described in a sentence.
136+ func endedWord(s Status) string {
137+ switch s {
138+ case Failed:
139+ return "failed"
140+ case Killed:
141+ return "was stopped"
142+ }
143+ return "finished"
144+ }
145+
146+ func plural(n int, word string) string {
147+ if n == 1 {
148+ return fmt.Sprintf("%d %s", n, word)
149+ }
150+ return fmt.Sprintf("%d %ss", n, word)
151+ }
152+
153+ // Event is one child's agent event, tagged with the child it came from. The
154+ // front end keeps a transcript per agent and needs to know which.
155+ type Event struct {
156+ ID string
157+ Preset string
158+ Model string
159+ Agent agent.Event
160+ }
161+
162+ // Options wires a Supervisor.
163+ type Options struct {
164+ // Client is the session's. Children are cloned off it so they
165+ // authenticate identically and differ only in the model.
166+ Client *llm.Client
167+ Log *metrics.Logger
168+ // Policy gates children's tool calls. Nil means nothing is gated, which
169+ // is only right where the blast radius is already contained.
170+ Policy *approval.Policy
171+ // Base carries the session's agent settings — streaming, caching, the
172+ // context window, the identity prefix, the discovered skills. The preset
173+ // supplies the system prompt, the effort and the turn cap over the top.
174+ Base agent.Options
175+ // Tools builds the tool set for one child. It is supplied by the caller
176+ // because the tools need a root, and the supervisor has no business
177+ // knowing one. Note what it does not include: the spawn tools, so a
178+ // subagent cannot spawn subagents.
179+ Tools func(*agent.State, *metrics.Logger) *tools.Registry
180+ // MaxRunning caps concurrency. Zero means DefaultMaxRunning.
181+ MaxRunning int
182+ // Observe receives every child's events, tagged. It may be nil.
183+ Observe func(Event)
184+ // Changed is called when a status changes or a report is queued, so a
185+ // front end knows to redraw without polling. It may be nil.
186+ Changed func()
187+ }
188+
189+ // child is one subagent and the handles needed to control it.
190+ type child struct {
191+ info Info
192+ ag *agent.Agent
193+ cancel context.CancelFunc
194+ // killed records that the cancellation was deliberate, so the ordinary
195+ // context error is reported as a kill rather than as a failure.
196+ killed bool
197+ // pauseWanted holds a pause asked for before the agent existed.
198+ pauseWanted bool
199+ // delivered records that this agent's report has been handed to the
200+ // parent — as an agent_wait result or as a message the front end put into
201+ // the conversation. Whoever hands it over claims it first, so the other
202+ // one knows not to.
203+ delivered bool
204+ done chan struct{}
205+ }
206+
207+ // Supervisor owns every subagent of one session.
208+ type Supervisor struct {
209+ ctx context.Context
210+ opts Options
211+
212+ mu sync.Mutex
213+ byID map[string]*child
214+ order []*child
215+ n int
216+ // reports are finished agents the parent has not been told about yet.
217+ reports []Info
218+
219+ wg sync.WaitGroup
220+ }
221+
222+ // New builds a supervisor. ctx is the parent of every child's context: it
223+ // outlives any one turn, because a subagent must survive the interruption of
224+ // the turn that spawned it.
225+ func New(ctx context.Context, opts Options) *Supervisor {
226+ if opts.MaxRunning <= 0 {
227+ opts.MaxRunning = DefaultMaxRunning
228+ }
229+ return &Supervisor{ctx: ctx, opts: opts, byID: map[string]*child{}}
230+ }
231+
232+ // Subscribe sets the callbacks a front end wants. It exists because the TUI
233+ // cannot be built before the supervisor it watches: the program has to exist
234+ // to be sent messages, and the supervisor has to exist to be handed to it.
235+ func (s *Supervisor) Subscribe(observe func(Event), changed func()) {
236+ s.mu.Lock()
237+ defer s.mu.Unlock()
238+ s.opts.Observe, s.opts.Changed = observe, changed
239+ }
240+
241+ // observer and notifier read the callbacks under the lock, so Subscribe can be
242+ // called while children are already running.
243+ func (s *Supervisor) observer() func(Event) {
244+ s.mu.Lock()
245+ defer s.mu.Unlock()
246+ return s.opts.Observe
247+ }
248+
249+ func (s *Supervisor) changed() {
250+ s.mu.Lock()
251+ fn := s.opts.Changed
252+ s.mu.Unlock()
253+ if fn != nil {
254+ fn()
255+ }
256+ }
257+
258+ // Spec is what one subagent is asked to be: which preset, what to do, and what
259+ // to spend on it. Model and Effort are the caller's choice — the presets pin
260+ // neither — and both may be empty, which inherits the session's.
261+ type Spec struct {
262+ Preset string
263+ Task string
264+ Model string
265+ Effort string
266+ }
267+
268+ // Spawn starts a subagent, or queues it when the concurrency cap is full. It
269+ // returns the id immediately: the work happens on its own goroutine.
270+ func (s *Supervisor) Spawn(spec Spec) (string, error) {
271+ p, err := Lookup(spec.Preset)
272+ if err != nil {
273+ return "", err
274+ }
275+ if strings.TrimSpace(spec.Task) == "" {
276+ return "", errors.New("a subagent needs a task: say what it should do and what to report back")
277+ }
278+ task := spec.Task
279+
280+ model := spec.Model
281+ if model == "" {
282+ // Nobody chose: the session's own model is the one answer that is
283+ // certainly served by this endpoint.
284+ model = s.opts.Client.Model()
285+ }
286+
287+ effort := spec.Effort
288+ if effort != "" && !llm.ValidEffort(effort) {
289+ return "", fmt.Errorf("no such effort level %q. The ones that exist are: %s",
290+ effort, strings.Join(llm.EffortLevels, ", "))
291+ }
292+ // A level this model does not take is not a reason to refuse the spawn —
293+ // the job and the model may both be right — but it is not in force, and
294+ // recording it as though it were would be a lie in the sidebar and in the
295+ // answer the parent gets.
296+ if !llm.SupportsEffort(model, effort) {
297+ effort = ""
298+ }
299+
300+ s.mu.Lock()
301+ s.n++
302+ id := fmt.Sprintf("a%d", s.n)
303+ c := &child{
304+ info: Info{ID: id, Preset: p.Name, Model: model, Effort: effort, Task: task, Status: Queued},
305+ done: make(chan struct{}),
306+ }
307+ s.byID[id] = c
308+ s.order = append(s.order, c)
309+ s.mu.Unlock()
310+
311+ s.opts.Log.Log(metrics.Event{Type: "subagent_spawn", Extra: map[string]any{
312+ "agent": id, "preset": p.Name, "model": model, "effort": effort,
313+ }})
314+ s.changed()
315+ s.pump()
316+ return id, nil
317+ }
318+
319+ // pump starts queued children while there are slots. It is called whenever the
320+ // set of running agents can have changed.
321+ func (s *Supervisor) pump() {
322+ for {
323+ s.mu.Lock()
324+ running := 0
325+ for _, c := range s.order {
326+ if c.info.Status.Live() && c.info.Status != Queued {
327+ running++
328+ }
329+ }
330+ var next *child
331+ if running < s.opts.MaxRunning {
332+ for _, c := range s.order {
333+ if c.info.Status == Queued {
334+ next = c
335+ break
336+ }
337+ }
338+ }
339+ if next == nil {
340+ s.mu.Unlock()
341+ return
342+ }
343+ next.info.Status = Running
344+ next.info.Started = time.Now()
345+ // The context, its cancel, and the wait-group count are all taken here,
346+ // in the same hold of the lock that marks the child running. Setting
347+ // them up inside start instead would leave a window where the child
348+ // is running with nothing to cancel and nothing to wait for: a kill
349+ // landing in it would be lost, and a shutdown would account for the
350+ // child twice.
351+ ctx, cancel := context.WithCancel(s.ctx)
352+ next.cancel = cancel
353+ s.wg.Add(1)
354+ s.mu.Unlock()
355+
356+ s.start(next, ctx, cancel)
357+ s.changed()
358+ }
359+ }
360+
361+ // start builds the child's agent and runs it. The context is already live and
362+ // already cancellable by the time it gets here.
363+ func (s *Supervisor) start(c *child, ctx context.Context, cancel context.CancelFunc) {
364+ p, _ := Lookup(c.info.Preset)
365+ id, task := c.info.ID, c.info.Task
366+
367+ log := s.opts.Log.For(id)
368+ state := agent.NewState()
369+ reg := s.opts.Tools(state, log)
370+
371+ o := s.opts.Base
372+ o.System = p.System
373+ if c.info.Effort != "" {
374+ o.Effort = c.info.Effort
375+ }
376+ if p.MaxTurns > 0 {
377+ o.MaxTurns = p.MaxTurns
378+ }
379+ ag := agent.New(s.opts.Client.Clone(c.info.Model), reg, state, log, o)
380+ ag.Observe = func(ev agent.Event) { s.onEvent(id, ev) }
381+ if s.opts.Policy != nil {
382+ ag.Approve = approver{sup: s, id: id}
383+ }
384+
385+ s.mu.Lock()
386+ c.ag = ag
387+ pause := c.pauseWanted
388+ s.mu.Unlock()
389+ if pause {
390+ ag.Pause()
391+ }
392+
393+ go func() {
394+ defer s.wg.Done()
395+ defer cancel()
396+ // A kill that landed while this child was being built has already
397+ // cancelled ctx, so Run returns at once and finish records it as
398+ // killed — without the agent having done anything.
399+ report, err := ag.Run(ctx, task)
400+ s.finish(c, report, err)
401+ }()
402+ }
403+
404+ // onEvent keeps the snapshot current and forwards the event.
405+ func (s *Supervisor) onEvent(id string, ev agent.Event) {
406+ var status bool
407+
408+ s.mu.Lock()
409+ c := s.byID[id]
410+ if c != nil {
411+ switch ev.Kind {
412+ case agent.EventTurnStart:
413+ c.info.Turns = ev.Turn
414+ case agent.EventUsage:
415+ c.info.Usage = addUsage(c.info.Usage, ev.Usage)
416+ case agent.EventPaused:
417+ c.info.Status, status = Paused, true
418+ case agent.EventResumed:
419+ c.info.Status, status = Running, true
420+ }
421+ }
422+ var preset, model string
423+ if c != nil {
424+ preset, model = c.info.Preset, c.info.Model
425+ }
426+ s.mu.Unlock()
427+
428+ if fn := s.observer(); fn != nil {
429+ fn(Event{ID: id, Preset: preset, Model: model, Agent: ev})
430+ }
431+ if status {
432+ s.changed()
433+ }
434+ }
435+
436+ // finish records how a child ended and queues its report for the parent.
437+ func (s *Supervisor) finish(c *child, report string, err error) {
438+ s.mu.Lock()
439+ c.info.Ended = time.Now()
440+ c.info.Report = strings.TrimSpace(report)
441+ switch {
442+ case c.killed:
443+ c.info.Status = Killed
444+ case err != nil:
445+ c.info.Status = Failed
446+ c.info.Err = err.Error()
447+ default:
448+ c.info.Status = Done
449+ }
450+ info := c.info
451+ s.reports = append(s.reports, info)
452+ close(c.done)
453+ s.mu.Unlock()
454+
455+ cost, _ := info.Cost()
456+ s.opts.Log.Log(metrics.Event{Type: "subagent_finish", Turn: info.Turns, Extra: map[string]any{
457+ "agent": info.ID, "preset": info.Preset, "status": string(info.Status),
458+ "seconds": info.Elapsed().Seconds(), "cost_usd": cost,
459+ }})
460+ s.changed()
461+ s.pump()
462+ }
463+
464+ // Snapshot is every subagent this session has spawned, in spawn order.
465+ func (s *Supervisor) Snapshot() []Info {
466+ s.mu.Lock()
467+ defer s.mu.Unlock()
468+ out := make([]Info, 0, len(s.order))
469+ for _, c := range s.order {
470+ out = append(out, c.info)
471+ }
472+ return out
473+ }
474+
475+ // Info is one subagent by id.
476+ func (s *Supervisor) Info(id string) (Info, bool) {
477+ s.mu.Lock()
478+ defer s.mu.Unlock()
479+ c, ok := s.byID[id]
480+ if !ok {
481+ return Info{}, false
482+ }
483+ return c.info, true
484+ }
485+
486+ // Live is how many subagents are still going, for the status line.
487+ func (s *Supervisor) Live() int {
488+ s.mu.Lock()
489+ defer s.mu.Unlock()
490+ var n int
491+ for _, c := range s.order {
492+ if c.info.Status.Live() {
493+ n++
494+ }
495+ }
496+ return n
497+ }
498+
499+ // TakeReports hands over the finished agents the parent has not been told
500+ // about, and forgets them. The caller is expected to deliver every one.
501+ func (s *Supervisor) TakeReports() []Info {
502+ s.mu.Lock()
503+ defer s.mu.Unlock()
504+ out := s.reports
505+ s.reports = nil
506+ return out
507+ }
508+
509+ func (s *Supervisor) lookupLive(id, verb string) (*child, error) {
510+ c, ok := s.byID[id]
511+ if !ok {
512+ return nil, fmt.Errorf("no subagent %q. Check the id you were given when you spawned it", id)
513+ }
514+ if !c.info.Status.Live() {
515+ return nil, fmt.Errorf("subagent %s has already finished (%s), so it cannot be %s", id, c.info.Status, verb)
516+ }
517+ return c, nil
518+ }
519+
520+ // Pause holds a subagent at its next turn boundary.
521+ func (s *Supervisor) Pause(id string) error {
522+ s.mu.Lock()
523+ c, err := s.lookupLive(id, "paused")
524+ if err != nil {
525+ s.mu.Unlock()
526+ return err
527+ }
528+ if c.info.Status == Paused {
529+ s.mu.Unlock()
530+ return nil
531+ }
532+ if c.info.Status == Queued {
533+ // There is nothing to hold, and saying "paused" about an agent that
534+ // was never running would be a lie in the sidebar.
535+ s.mu.Unlock()
536+ return fmt.Errorf("subagent %s has not started yet — it is queued behind the ones running. Stop it if you do not want it to run", id)
537+ }
538+ // Marked before the turn in flight has finished, so the sidebar says what
539+ // was asked for rather than lagging a whole turn behind it. pauseWanted
540+ // covers the moment between getting a slot and having an agent to hold.
541+ c.pauseWanted = true
542+ c.info.Status = Paused
543+ ag := c.ag
544+ s.mu.Unlock()
545+
546+ if ag != nil {
547+ ag.Pause()
548+ }
549+ s.changed()
550+ return nil
551+ }
552+
553+ // Resume lets a paused subagent carry on.
554+ func (s *Supervisor) Resume(id string) error {
555+ s.mu.Lock()
556+ c, err := s.lookupLive(id, "resumed")
557+ if err != nil {
558+ s.mu.Unlock()
559+ return err
560+ }
561+ c.pauseWanted = false
562+ if c.info.Status == Paused {
563+ c.info.Status = Running
564+ }
565+ ag := c.ag
566+ s.mu.Unlock()
567+
568+ if ag != nil {
569+ ag.Resume()
570+ }
571+ s.changed()
572+ return nil
573+ }
574+
575+ // Kill stops a subagent now. Whatever it has already done to the working tree
576+ // stays done — this ends the agent, it does not undo it.
577+ func (s *Supervisor) Kill(id string) error {
578+ s.mu.Lock()
579+ c, err := s.lookupLive(id, "stopped")
580+ if err != nil {
581+ s.mu.Unlock()
582+ return err
583+ }
584+ c.killed = true
585+ cancel := c.cancel
586+ neverStarted := c.info.Status == Queued
587+ if neverStarted {
588+ // Nothing is running to notice the cancellation, so this is where it
589+ // ends: recorded, reported, and out of the queue.
590+ c.info.Status = Killed
591+ c.info.Ended = time.Now()
592+ s.reports = append(s.reports, c.info)
593+ close(c.done)
594+ }
595+ s.mu.Unlock()
596+
597+ if cancel != nil {
598+ cancel()
599+ }
600+ s.opts.Log.Log(metrics.Event{Type: "subagent_killed", Extra: map[string]any{"agent": id}})
601+ s.changed()
602+ if neverStarted {
603+ s.pump()
604+ }
605+ return nil
606+ }
607+
608+ // Wait blocks until a subagent finishes and returns how it ended. The context
609+ // is the caller's — interrupting the turn that is waiting does not kill the
610+ // agent it was waiting for.
611+ func (s *Supervisor) Wait(ctx context.Context, id string) (Info, error) {
612+ s.mu.Lock()
613+ c, ok := s.byID[id]
614+ s.mu.Unlock()
615+ if !ok {
616+ return Info{}, fmt.Errorf("no subagent %q. Check the id you were given when you spawned it", id)
617+ }
618+
619+ select {
620+ case <-c.done:
621+ case <-ctx.Done():
622+ return Info{}, ctx.Err()
623+ }
624+
625+ s.mu.Lock()
626+ defer s.mu.Unlock()
627+ return c.info, nil
628+ }
629+
630+ // ClaimReport asks for the right to give one finished agent's report to the
631+ // parent, and reports whether the caller got it. Exactly one caller ever does.
632+ //
633+ // Two of them compete, and which one arrives first is a race nobody can
634+ // arrange: agent_wait, which returns the report as its tool result, and the
635+ // front end, which puts it into the conversation as a message. Claiming is the
636+ // handover itself rather than a flag either side reads from a copy — a copy
637+ // taken before the other side acted is exactly how the same report used to be
638+ // handed over twice.
639+ func (s *Supervisor) ClaimReport(id string) bool {
640+ s.mu.Lock()
641+ defer s.mu.Unlock()
642+ c, ok := s.byID[id]
643+ // An agent still working has nothing to hand over yet.
644+ if !ok || c.info.Status.Live() || c.delivered {
645+ return false
646+ }
647+ c.delivered = true
648+ return true
649+ }
650+
651+ // Shutdown kills every subagent and waits for them to unwind. It is what
652+ // leaving the harness does: an agent outliving the session it belongs to would
653+ // keep editing a tree nobody is watching.
654+ func (s *Supervisor) Shutdown() {
655+ s.mu.Lock()
656+ var cancels []context.CancelFunc
657+ for _, c := range s.order {
658+ if !c.info.Status.Live() {
659+ continue
660+ }
661+ c.killed = true
662+ if c.cancel != nil {
663+ cancels = append(cancels, c.cancel)
664+ continue
665+ }
666+ // Queued and never started: nothing will report it, so record it here.
667+ c.info.Status = Killed
668+ c.info.Ended = time.Now()
669+ close(c.done)
670+ }
671+ s.mu.Unlock()
672+
673+ for _, cancel := range cancels {
674+ cancel()
675+ }
676+ s.wg.Wait()
677+ }
678+
679+ // approver routes a child's approval requests through the session's policy,
680+ // tagged with the agent asking, and marks the agent as waiting while a human
681+ // decides. Several agents can be waiting at once; the UI queues them.
682+ type approver struct {
683+ sup *Supervisor
684+ id string
685+ }
686+
687+ func (a approver) Approve(ctx context.Context, tool string, input json.RawMessage) error {
688+ a.sup.setWaiting(a.id, true)
689+ defer a.sup.setWaiting(a.id, false)
690+ return a.sup.opts.Policy.ApproveAs(ctx, a.id, tool, input)
691+ }
692+
693+ func (s *Supervisor) setWaiting(id string, waiting bool) {
694+ s.mu.Lock()
695+ c := s.byID[id]
696+ if c == nil {
697+ s.mu.Unlock()
698+ return
699+ }
700+ switch {
701+ case waiting && c.info.Status == Running:
702+ c.info.Status = Waiting
703+ case !waiting && c.info.Status == Waiting:
704+ c.info.Status = Running
705+ default:
706+ // Paused, killed, or already finished: an approval coming or going
707+ // does not get to overwrite that.
708+ s.mu.Unlock()
709+ return
710+ }
711+ s.mu.Unlock()
712+ s.changed()
713+ }
714+
715+ func addUsage(a, b llm.Usage) llm.Usage {
716+ a.InputTokens += b.InputTokens
717+ a.OutputTokens += b.OutputTokens
718+ a.CacheReadInputTokens += b.CacheReadInputTokens
719+ a.CacheCreationInputTokens += b.CacheCreationInputTokens
720+ return a
721+ }
+511−0internal/subagent/supervisor_test.goaddedView File
@@ -0,0 +1,511 @@
1+ package subagent
2+
3+ import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "io"
8+ "net/http"
9+ "net/http/httptest"
10+ "sort"
11+ "strings"
12+ "sync"
13+ "testing"
14+ "time"
15+
16+ "harness/internal/agent"
17+ "harness/internal/llm"
18+ "harness/internal/metrics"
19+ "harness/internal/tools"
20+ )
21+
22+ // settle is how long a test waits before concluding nothing more is coming.
23+ const settle = 150 * time.Millisecond
24+
25+ // fakeAPI answers every child's request. What it answers is derived from the
26+ // task the child was given, so a test can tell two agents' traffic apart.
27+ type fakeAPI struct {
28+ mu sync.Mutex
29+ requests int
30+ models []string
31+ // bodies is every request body, for asking what a child actually sent.
32+ bodies [][]byte
33+ // gate, when non-nil, holds every request until it is closed: the way a
34+ // test keeps an agent running while it inspects the supervisor.
35+ gate chan struct{}
36+ // status, when non-zero, is returned instead of a response.
37+ status int
38+ // twoTurns makes the first answer a tool call, so the agent has a second
39+ // turn — which is where a pause lands.
40+ twoTurns bool
41+ // callTool makes the first answer a call to a named tool, for asking what
42+ // happens when a child reaches for one it does not have.
43+ callTool string
44+ srv *httptest.Server
45+ }
46+
47+ func newFakeAPI(t *testing.T) *fakeAPI {
48+ t.Helper()
49+ f := &fakeAPI{}
50+ f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
51+ body, _ := io.ReadAll(r.Body)
52+ var req llm.Request
53+ json.Unmarshal(body, &req)
54+
55+ f.mu.Lock()
56+ f.requests++
57+ f.models = append(f.models, req.Model)
58+ f.bodies = append(f.bodies, body)
59+ gate, status := f.gate, f.status
60+ f.mu.Unlock()
61+
62+ if gate != nil {
63+ <-gate
64+ }
65+ if status != 0 {
66+ w.WriteHeader(status)
67+ io.WriteString(w, `{"type":"error","error":{"type":"overloaded_error","message":"nope"}}`)
68+ return
69+ }
70+ f.mu.Lock()
71+ two := f.twoTurns
72+ f.mu.Unlock()
73+ f.mu.Lock()
74+ named := f.callTool
75+ f.mu.Unlock()
76+ if len(req.Messages) == 1 {
77+ switch {
78+ case named != "":
79+ io.WriteString(w, toolUse("tu_1", named))
80+ return
81+ case two:
82+ io.WriteString(w, toolUse("tu_1", "noop"))
83+ return
84+ }
85+ }
86+ io.WriteString(w, endTurn("handled: "+task(req)))
87+ }))
88+ t.Cleanup(f.srv.Close)
89+ return f
90+ }
91+
92+ func (f *fakeAPI) hold() { f.mu.Lock(); f.gate = make(chan struct{}); f.mu.Unlock() }
93+ func (f *fakeAPI) release() {
94+ f.mu.Lock()
95+ gate := f.gate
96+ f.gate = nil
97+ f.mu.Unlock()
98+ if gate != nil {
99+ close(gate)
100+ }
101+ }
102+ func (f *fakeAPI) fail(status int) { f.mu.Lock(); f.status = status; f.mu.Unlock() }
103+ func (f *fakeAPI) count() int { f.mu.Lock(); defer f.mu.Unlock(); return f.requests }
104+
105+ // requestsSeen decodes every request the fake was sent.
106+ func (f *fakeAPI) requestsSeen(t *testing.T) []llm.Request {
107+ t.Helper()
108+ f.mu.Lock()
109+ defer f.mu.Unlock()
110+ out := make([]llm.Request, 0, len(f.bodies))
111+ for _, b := range f.bodies {
112+ var req llm.Request
113+ if err := json.Unmarshal(b, &req); err != nil {
114+ t.Fatalf("request body does not decode: %v", err)
115+ }
116+ out = append(out, req)
117+ }
118+ return out
119+ }
120+
121+ // effortSent is the effort level the one request carried, or "" for none.
122+ func (f *fakeAPI) effortSent(t *testing.T) string {
123+ t.Helper()
124+ reqs := f.requestsSeen(t)
125+ if len(reqs) != 1 {
126+ t.Fatalf("want exactly one request, got %d", len(reqs))
127+ }
128+ if reqs[0].OutputConfig == nil {
129+ return ""
130+ }
131+ return reqs[0].OutputConfig.Effort
132+ }
133+
134+ func (f *fakeAPI) modelsSeen() []string {
135+ f.mu.Lock()
136+ defer f.mu.Unlock()
137+ return append([]string(nil), f.models...)
138+ }
139+
140+ // task pulls the first user message out of a request: the brief the child was
141+ // spawned on.
142+ func task(req llm.Request) string {
143+ for _, m := range req.Messages {
144+ if m.Role == llm.RoleUser && len(m.Content) > 0 {
145+ return m.Content[0].Text
146+ }
147+ }
148+ return ""
149+ }
150+
151+ func toolUse(id, name string) string {
152+ return `{"id":"msg","type":"message","role":"assistant","model":"m","content":[` +
153+ `{"type":"tool_use","id":"` + id + `","name":"` + name + `","input":{}}],` +
154+ `"stop_reason":"tool_use","usage":{"input_tokens":100,"output_tokens":20}}`
155+ }
156+
157+ func endTurn(text string) string {
158+ b, _ := json.Marshal(text)
159+ return `{"id":"msg","type":"message","role":"assistant","model":"m","content":[{"type":"text","text":` +
160+ string(b) + `}],"stop_reason":"end_turn","usage":{"input_tokens":100,"output_tokens":20}}`
161+ }
162+
163+ func newSupervisor(t *testing.T, f *fakeAPI, opts ...func(*Options)) *Supervisor {
164+ t.Helper()
165+ log := metrics.NewWriter(&bytes.Buffer{}, "test")
166+ root := t.TempDir()
167+ o := Options{
168+ Client: llm.New(llm.WithBaseURL(f.srv.URL), llm.WithAPIKey("k"), llm.WithModel("claude-opus-5")),
169+ Log: log,
170+ Tools: func(state *agent.State, log *metrics.Logger) *tools.Registry {
171+ return tools.NewRegistry(log).Register(
172+ tools.NewRead(root, state),
173+ tools.New("noop", "does nothing", func(context.Context, struct{}) (string, error) {
174+ return "ok", nil
175+ }),
176+ )
177+ },
178+ }
179+ for _, fn := range opts {
180+ fn(&o)
181+ }
182+ s := New(context.Background(), o)
183+ t.Cleanup(s.Shutdown)
184+ return s
185+ }
186+
187+ // waitFor polls until cond holds, so tests do not race the goroutines they are
188+ // watching. It fails rather than hanging.
189+ func waitFor(t *testing.T, what string, cond func() bool) {
190+ t.Helper()
191+ deadline := time.Now().Add(3 * time.Second)
192+ for time.Now().Before(deadline) {
193+ if cond() {
194+ return
195+ }
196+ time.Sleep(5 * time.Millisecond)
197+ }
198+ t.Fatalf("timed out waiting for %s", what)
199+ }
200+
201+ func statusOf(t *testing.T, s *Supervisor, id string) Status {
202+ t.Helper()
203+ info, ok := s.Info(id)
204+ if !ok {
205+ t.Fatalf("no agent %q", id)
206+ }
207+ return info.Status
208+ }
209+
210+ func TestSpawnRunsTheAgentAndKeepsItsReport(t *testing.T) {
211+ f := newFakeAPI(t)
212+ s := newSupervisor(t, f)
213+
214+ id, err := s.Spawn(Spec{Preset: "explore", Task: "find the approval policy"})
215+ if err != nil {
216+ t.Fatalf("Spawn: %v", err)
217+ }
218+ waitFor(t, "the agent to finish", func() bool { return statusOf(t, s, id) == Done })
219+
220+ info, _ := s.Info(id)
221+ if info.Report != "handled: find the approval policy" {
222+ t.Errorf("report = %q, want the agent's final text", info.Report)
223+ }
224+ if info.Preset != "explore" || info.Task != "find the approval policy" {
225+ t.Errorf("info = %+v, want the preset and task it was spawned with", info)
226+ }
227+ if info.Turns != 1 {
228+ t.Errorf("turns = %d, want 1", info.Turns)
229+ }
230+ if info.Usage.OutputTokens != 20 {
231+ t.Errorf("usage = %+v, want the child's token counts", info.Usage)
232+ }
233+ if info.Ended.IsZero() || info.Ended.Before(info.Started) {
234+ t.Errorf("started %v, ended %v", info.Started, info.Ended)
235+ }
236+ }
237+
238+ // Ids are what the sidebar shows and what the model says back to you, so they
239+ // are short and in spawn order rather than random.
240+ func TestIDsAreShortAndInSpawnOrder(t *testing.T) {
241+ f := newFakeAPI(t)
242+ s := newSupervisor(t, f)
243+
244+ first, _ := s.Spawn(Spec{Preset: "explore", Task: "one"})
245+ second, _ := s.Spawn(Spec{Preset: "plan", Task: "two"})
246+ if first != "a1" || second != "a2" {
247+ t.Errorf("ids = %q, %q; want a1, a2", first, second)
248+ }
249+
250+ got := []string{}
251+ for _, info := range s.Snapshot() {
252+ got = append(got, info.ID)
253+ }
254+ if strings.Join(got, " ") != "a1 a2" {
255+ t.Errorf("snapshot order = %v, want spawn order", got)
256+ }
257+ }
258+
259+ // A preset the model made up must come back as something it can act on, not as
260+ // a silent no-op or a crash.
261+ func TestUnknownPresetNamesTheOnesThatExist(t *testing.T) {
262+ f := newFakeAPI(t)
263+ s := newSupervisor(t, f)
264+
265+ _, err := s.Spawn(Spec{Preset: "researcher", Task: "go"})
266+ if err == nil {
267+ t.Fatal("spawning an unknown preset succeeded")
268+ }
269+ for _, p := range Presets() {
270+ if !strings.Contains(err.Error(), p.Name) {
271+ t.Errorf("error %q does not offer the %q preset", err, p.Name)
272+ }
273+ }
274+ if len(s.Snapshot()) != 0 {
275+ t.Error("a rejected spawn still created an agent")
276+ }
277+ }
278+
279+ // No preset pins a model: the agent doing the spawning picks one for the job,
280+ // and a spawn that names none runs on the session's own model rather than on
281+ // something the harness chose for it.
282+ func TestTheSpawningAgentChoosesTheModel(t *testing.T) {
283+ f := newFakeAPI(t)
284+ s := newSupervisor(t, f)
285+
286+ unnamed, _ := s.Spawn(Spec{Preset: "explore", Task: "one"})
287+ chosen, _ := s.Spawn(Spec{Preset: "explore", Task: "two", Model: "claude-haiku-4-5"})
288+ waitFor(t, "both agents to finish", func() bool {
289+ return statusOf(t, s, unnamed) == Done && statusOf(t, s, chosen) == Done
290+ })
291+
292+ first, _ := s.Info(unnamed)
293+ second, _ := s.Info(chosen)
294+ if first.Model != "claude-opus-5" {
295+ t.Errorf("a spawn naming no model ran on %q, want the session's", first.Model)
296+ }
297+ if second.Model != "claude-haiku-4-5" {
298+ t.Errorf("the chosen model was %q", second.Model)
299+ }
300+ // Both agents run at once, so which request arrives first is not
301+ // something to assert on — only that each asked for its own model.
302+ models := f.modelsSeen()
303+ sort.Strings(models)
304+ want := []string{"claude-haiku-4-5", "claude-opus-5"}
305+ if strings.Join(models, " ") != strings.Join(want, " ") {
306+ t.Errorf("requests asked for %v, want %v", models, want)
307+ }
308+ }
309+
310+ // An agent whose model call fails is finished, not stuck: the status says so
311+ // and the reason is kept for the parent to be told.
312+ func TestAFailedAgentIsRecordedAsFailed(t *testing.T) {
313+ f := newFakeAPI(t)
314+ f.fail(http.StatusBadRequest)
315+ s := newSupervisor(t, f)
316+
317+ id, _ := s.Spawn(Spec{Preset: "explore", Task: "go"})
318+ waitFor(t, "the agent to fail", func() bool { return statusOf(t, s, id) == Failed })
319+
320+ info, _ := s.Info(id)
321+ if info.Err == "" {
322+ t.Error("a failed agent kept no reason")
323+ }
324+ if info.Ended.IsZero() {
325+ t.Error("a failed agent never ended")
326+ }
327+ }
328+
329+ // Every event a child produces reaches the front end tagged with the agent it
330+ // came from, because they all land in one UI.
331+ func TestEventsAreTaggedWithTheAgent(t *testing.T) {
332+ f := newFakeAPI(t)
333+ var mu sync.Mutex
334+ var seen []Event
335+ s := newSupervisor(t, f, func(o *Options) {
336+ o.Observe = func(ev Event) {
337+ mu.Lock()
338+ defer mu.Unlock()
339+ seen = append(seen, ev)
340+ }
341+ })
342+
343+ id, _ := s.Spawn(Spec{Preset: "explore", Task: "go"})
344+ waitFor(t, "the agent to finish", func() bool { return statusOf(t, s, id) == Done })
345+
346+ mu.Lock()
347+ defer mu.Unlock()
348+ if len(seen) == 0 {
349+ t.Fatal("no events were observed")
350+ }
351+ for _, ev := range seen {
352+ if ev.ID != id {
353+ t.Fatalf("event %+v is not tagged with %q", ev, id)
354+ }
355+ if ev.Preset != "explore" {
356+ t.Fatalf("event %+v does not name the preset", ev)
357+ }
358+ }
359+ }
360+
361+ // How hard a subagent thinks is the other half of what it costs, so the agent
362+ // spawning it chooses that too.
363+ func TestTheChosenEffortReachesTheChild(t *testing.T) {
364+ f := newFakeAPI(t)
365+ s := newSupervisor(t, f, func(o *Options) { o.Base.Effort = "high" })
366+
367+ id, err := s.Spawn(Spec{Preset: "explore", Task: "go", Model: "claude-opus-5", Effort: "low"})
368+ if err != nil {
369+ t.Fatalf("Spawn: %v", err)
370+ }
371+ waitFor(t, "the agent to finish", func() bool { return statusOf(t, s, id) == Done })
372+
373+ if got := f.effortSent(t); got != "low" {
374+ t.Errorf("the child asked for effort %q, want the level it was spawned with", got)
375+ }
376+ if info, _ := s.Info(id); info.Effort != "low" {
377+ t.Errorf("Info.Effort = %q", info.Effort)
378+ }
379+ }
380+
381+ // Choosing nothing inherits the session's level, the way it always did.
382+ func TestASpawnWithNoEffortInheritsTheSessions(t *testing.T) {
383+ f := newFakeAPI(t)
384+ s := newSupervisor(t, f, func(o *Options) { o.Base.Effort = "xhigh" })
385+
386+ id, _ := s.Spawn(Spec{Preset: "explore", Task: "go", Model: "claude-opus-5"})
387+ waitFor(t, "the agent to finish", func() bool { return statusOf(t, s, id) == Done })
388+
389+ if got := f.effortSent(t); got != "xhigh" {
390+ t.Errorf("the child asked for effort %q, want the session's", got)
391+ }
392+ }
393+
394+ // A level that is not a level is a typo, and the model needs to be told which
395+ // words exist rather than watching an agent run at something else.
396+ func TestAnUnknownEffortLevelIsRefused(t *testing.T) {
397+ s := newSupervisor(t, newFakeAPI(t))
398+
399+ _, err := s.Spawn(Spec{Preset: "explore", Task: "go", Effort: "lowest"})
400+ if err == nil {
401+ t.Fatal("an unknown effort level was accepted")
402+ }
403+ for _, level := range llm.EffortLevels {
404+ if !strings.Contains(err.Error(), level) {
405+ t.Errorf("error %q does not offer %q", err, level)
406+ }
407+ }
408+ if len(s.Snapshot()) != 0 {
409+ t.Error("a rejected spawn still created an agent")
410+ }
411+ }
412+
413+ // Haiku takes no effort level at all. The spawn is still a good spawn — the
414+ // model picked a cheap model for a cheap job — so it runs, and the answer says
415+ // plainly that the level was not applied rather than implying it was.
416+ func TestEffortOnAModelThatCannotTakeItIsRunAndReported(t *testing.T) {
417+ f := newFakeAPI(t)
418+ s := newSupervisor(t, f)
419+
420+ id, err := s.Spawn(Spec{Preset: "explore", Task: "go", Model: "claude-haiku-4-5", Effort: "low"})
421+ if err != nil {
422+ t.Fatalf("Spawn: %v", err)
423+ }
424+ waitFor(t, "the agent to finish", func() bool { return statusOf(t, s, id) == Done })
425+
426+ if got := f.effortSent(t); got != "" {
427+ t.Errorf("the child sent effort %q to a model that rejects it", got)
428+ }
429+ info, _ := s.Info(id)
430+ if info.Effort != "" {
431+ t.Errorf("Info.Effort = %q, want empty — the level is not in force", info.Effort)
432+ }
433+ }
434+
435+ // A borrowed Claude Code credential is only accepted alongside its identity
436+ // block: a request without it comes back 429, which reads like a quota problem
437+ // and is not one. Subagents authenticate with the same credential, so they
438+ // carry the same block — and they get their own system prompt, which is
439+ // exactly where it could have been dropped.
440+ func TestASubagentCarriesTheIdentityBlockOfItsSession(t *testing.T) {
441+ f := newFakeAPI(t)
442+ s := newSupervisor(t, f, func(o *Options) {
443+ o.Base.SystemPrefix = agent.ClaudeCodeIdentity
444+ })
445+
446+ id, _ := s.Spawn(Spec{Preset: "explore", Task: "go"})
447+ waitFor(t, "the agent to finish", func() bool { return statusOf(t, s, id) == Done })
448+
449+ reqs := f.requestsSeen(t)
450+ if len(reqs) != 1 {
451+ t.Fatalf("saw %d requests", len(reqs))
452+ }
453+ blocks := reqs[0].System
454+ if len(blocks) < 2 {
455+ t.Fatalf("the child sent %d system blocks, want the identity block and its own prompt", len(blocks))
456+ }
457+ if blocks[0].Text != agent.ClaudeCodeIdentity {
458+ t.Errorf("the first system block is %q, want the identity string", blocks[0].Text)
459+ }
460+ if !strings.Contains(blocks[1].Text, "You are a subagent") {
461+ t.Errorf("the child's own brief is missing:\n%s", blocks[1].Text)
462+ }
463+ }
464+
465+ // Children authenticate exactly as the session does, including a credential
466+ // that is resolved per request and may need refreshing — one source, shared,
467+ // rather than a copy per agent that refreshes on its own.
468+ func TestChildrenAuthenticateThroughTheSessionsCredential(t *testing.T) {
469+ var mu sync.Mutex
470+ var calls int
471+ var bearers []string
472+
473+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
474+ mu.Lock()
475+ bearers = append(bearers, r.Header.Get("authorization"))
476+ mu.Unlock()
477+ io.WriteString(w, endTurn("ok"))
478+ }))
479+ t.Cleanup(srv.Close)
480+
481+ client := llm.New(llm.WithBaseURL(srv.URL), llm.WithTokenSource(func(context.Context) (string, error) {
482+ mu.Lock()
483+ defer mu.Unlock()
484+ calls++
485+ return "borrowed-token", nil
486+ }))
487+ log := metrics.NewWriter(&bytes.Buffer{}, "test")
488+ root := t.TempDir()
489+ s := New(context.Background(), Options{
490+ Client: client,
491+ Log: log,
492+ Tools: func(state *agent.State, log *metrics.Logger) *tools.Registry {
493+ return tools.NewRegistry(log).Register(tools.NewRead(root, state))
494+ },
495+ })
496+ t.Cleanup(s.Shutdown)
497+
498+ id, _ := s.Spawn(Spec{Preset: "explore", Task: "go"})
499+ waitFor(t, "the agent to finish", func() bool { return statusOf(t, s, id) == Done })
500+
501+ mu.Lock()
502+ defer mu.Unlock()
503+ if calls == 0 {
504+ t.Fatal("the child never asked the session's token source for a credential")
505+ }
506+ for _, b := range bearers {
507+ if b != "Bearer borrowed-token" {
508+ t.Errorf("the child sent authorization %q", b)
509+ }
510+ }
511+ }
+107−0internal/subagent/tools.goaddedView File
@@ -0,0 +1,107 @@
1+ package subagent
2+
3+ import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ "harness/internal/tools"
9+ )
10+
11+ // spawnArgs is what the model sends to start an agent.
12+ type spawnArgs struct {
13+ Preset string `json:"preset" jsonschema:"description=Which kind of agent to start. See the list in this tool's description."`
14+ Task string `json:"task" jsonschema:"description=The complete brief for the agent: what to do and what to report back. It cannot ask you questions and it cannot see this conversation, so everything it needs has to be in here — the paths you already know, what you have tried, what the answer should look like."`
15+ Effort string `json:"effort,omitempty" jsonschema:"description=How hard it should think: low, medium, high, xhigh or max. Low is right for mechanical work — searching, listing, reading something back; reach higher when the answer has to be right. Leave it out to use this session's level. Some models take no effort level at all, and then this is ignored and the answer says so."`
16+ Model string `json:"model,omitempty" jsonschema:"description=Which model to run it on. Pick one that suits the job — a small fast model for mechanical search, a strong one for design or code that has to be right. Leave it out to use this session's model, which is the safe answer when you do not know what this endpoint serves."`
17+ }
18+
19+ type waitArgs struct {
20+ ID string `json:"id" jsonschema:"description=The agent id returned by spawn_agent, such as a1."`
21+ }
22+
23+ // Tools are the tools the parent agent gets. Subagents are built from the
24+ // caller's own registry factory and never see these, which is what stops an
25+ // agent from spawning agents that spawn agents.
26+ func (s *Supervisor) Tools() []tools.Tool {
27+ return []tools.Tool{
28+ tools.New("spawn_agent", spawnDescription(), func(_ context.Context, in spawnArgs) (string, error) {
29+ // The context is deliberately ignored: it belongs to the turn that
30+ // is spawning, and the agent has to outlive that turn. Children
31+ // descend from the supervisor's own context instead.
32+ id, err := s.Spawn(Spec{Preset: in.Preset, Task: in.Task, Model: in.Model, Effort: in.Effort})
33+ if err != nil {
34+ return "", err
35+ }
36+ info, _ := s.Info(id)
37+
38+ var note string
39+ switch {
40+ case info.Effort != "":
41+ note = " at " + info.Effort + " effort"
42+ case in.Effort != "":
43+ // Asked for, and dropped: that model takes no effort level,
44+ // and letting the parent believe otherwise would be worse
45+ // than refusing the spawn.
46+ note = fmt.Sprintf(" — %s takes no effort level, so %q was not applied", info.Model, in.Effort)
47+ }
48+ return fmt.Sprintf("started %s (%s) on %s%s, status %s. It runs in the background: carry on, "+
49+ "and its report will arrive as a message when it finishes. Call agent_wait(%q) only if "+
50+ "you cannot do anything useful until then.", id, info.Preset, info.Model, note, info.Status, id), nil
51+ }),
52+ tools.New("agent_wait", "Block until a subagent finishes and return its report. Use this only when "+
53+ "you genuinely cannot proceed without the answer — a finished agent's report reaches you on its "+
54+ "own, and waiting spends this turn doing nothing.",
55+ func(ctx context.Context, in waitArgs) (string, error) {
56+ info, err := s.Wait(ctx, in.ID)
57+ if err != nil {
58+ return "", err
59+ }
60+ // Whoever hands the report over claims it. Losing the claim
61+ // means the front end has already put it into the
62+ // conversation, so all this has to do is point at it.
63+ if !s.ClaimReport(info.ID) {
64+ return info.AlreadyDelivered(), nil
65+ }
66+ return info.Notification(), nil
67+ }),
68+ }
69+ }
70+
71+ // spawnDescription is built from the presets so the two cannot drift apart.
72+ func spawnDescription() string {
73+ var b strings.Builder
74+ b.WriteString(`Start a subagent to do a piece of work on its own, in the background.
75+
76+ A subagent has its own context window and its own conversation: it sees the
77+ task you give it and nothing else, and you see its final report and nothing
78+ else. That is what makes it worth using — a search that would fill your context
79+ with file listings fills its instead, and comes back a paragraph long. Spawn
80+ one for work that is self-contained and describable in writing; do the rest
81+ yourself, because a task you have to explain at length is quicker to do.
82+
83+ Several can run at once, and you can keep working while they do. Their reports
84+ arrive as messages when they finish. Subagents cannot spawn subagents, and each
85+ spawn is shown to the user to approve before it starts.
86+
87+ You choose the model and the effort. The preset says what kind of job it is;
88+ what to spend on it is your call, and those two are what decide what a subagent
89+ costs.
90+
91+ Use the newest model listed in your system prompt unless the user asked for a
92+ different one, or that model has already failed for you this session. Omit the
93+ model and it runs on this session's, which is the right answer when nothing was
94+ listed; an id this endpoint does not serve fails that agent with the API's own
95+ error before its first turn.
96+
97+ Effort is the other half: low for mechanical work, higher when the answer has
98+ to be right rather than quick. Omit it and the agent runs at this session's
99+ level. Not every model has one — Haiku 4.5 takes none at all — and where it
100+ does not, the level is dropped and the answer tells you so.
101+
102+ The presets:`)
103+ for _, p := range presets {
104+ fmt.Fprintf(&b, "\n %s — %s", p.Name, p.Description)
105+ }
106+ return b.String()
107+ }
+281−0internal/subagent/tools_test.goaddedView File
@@ -0,0 +1,281 @@
1+ package subagent
2+
3+ import (
4+ "context"
5+ "encoding/json"
6+ "strings"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "harness/internal/agent"
12+ "harness/internal/tools"
13+ )
14+
15+ func toolNamed(t *testing.T, s *Supervisor, name string) tools.Tool {
16+ t.Helper()
17+ for _, tool := range s.Tools() {
18+ if tool.Name() == name {
19+ return tool
20+ }
21+ }
22+ t.Fatalf("no %s tool", name)
23+ return nil
24+ }
25+
26+ func TestTheParentGetsSpawnAndWaitAndNothingElse(t *testing.T) {
27+ s := newSupervisor(t, newFakeAPI(t))
28+ var names []string
29+ for _, tool := range s.Tools() {
30+ names = append(names, tool.Name())
31+ }
32+ if strings.Join(names, " ") != "spawn_agent agent_wait" {
33+ t.Errorf("tools = %v", names)
34+ }
35+ }
36+
37+ // The description is what the model chooses a preset from, so every preset has
38+ // to be in it — and so does the rule that subagents cannot spawn subagents,
39+ // which it would otherwise discover by trying.
40+ func TestTheSpawnDescriptionExplainsThePresetsAndTheRules(t *testing.T) {
41+ s := newSupervisor(t, newFakeAPI(t))
42+ desc := toolNamed(t, s, "spawn_agent").Description()
43+
44+ for _, p := range Presets() {
45+ if !strings.Contains(desc, p.Name) {
46+ t.Errorf("description never mentions the %q preset", p.Name)
47+ }
48+ }
49+ for _, phrase := range []string{"background", "approve", "cannot spawn"} {
50+ if !strings.Contains(strings.ToLower(desc), phrase) {
51+ t.Errorf("description never says %q:\n%s", phrase, desc)
52+ }
53+ }
54+ }
55+
56+ func TestSpawnToolReturnsTheIDToReferToItBy(t *testing.T) {
57+ f := newFakeAPI(t)
58+ f.hold()
59+ s := newSupervisor(t, f)
60+
61+ out, err := toolNamed(t, s, "spawn_agent").Execute(context.Background(),
62+ json.RawMessage(`{"preset":"explore","task":"find the policy"}`))
63+ if err != nil {
64+ t.Fatalf("spawn_agent: %v", err)
65+ }
66+ if !strings.Contains(out, "a1") {
67+ t.Errorf("spawn_agent returned %q, which does not name the agent", out)
68+ }
69+ f.release()
70+ }
71+
72+ func TestSpawnToolReportsAnUnknownPresetToTheModel(t *testing.T) {
73+ s := newSupervisor(t, newFakeAPI(t))
74+ _, err := toolNamed(t, s, "spawn_agent").Execute(context.Background(),
75+ json.RawMessage(`{"preset":"researcher","task":"go"}`))
76+ if err == nil {
77+ t.Fatal("an unknown preset was accepted")
78+ }
79+ if !strings.Contains(err.Error(), "explore") {
80+ t.Errorf("error %q does not offer a preset that exists", err)
81+ }
82+ }
83+
84+ // The spawning tool call belongs to one turn; the agent it starts does not.
85+ // Interrupting that turn — or simply the turn ending — must not take the
86+ // subagent with it.
87+ func TestASpawnedAgentOutlivesTheTurnThatSpawnedIt(t *testing.T) {
88+ f := newFakeAPI(t)
89+ f.hold()
90+ s := newSupervisor(t, f)
91+
92+ ctx, cancel := context.WithCancel(context.Background())
93+ if _, err := toolNamed(t, s, "spawn_agent").Execute(ctx,
94+ json.RawMessage(`{"preset":"general","task":"keep going"}`)); err != nil {
95+ t.Fatalf("spawn_agent: %v", err)
96+ }
97+ cancel()
98+
99+ time.Sleep(settle)
100+ if got := statusOf(t, s, "a1"); !got.Live() {
101+ t.Fatalf("the agent is %s after the spawning turn ended", got)
102+ }
103+ f.release()
104+ waitFor(t, "the agent to finish anyway", func() bool { return statusOf(t, s, "a1") == Done })
105+ }
106+
107+ func TestAgentWaitHandsBackTheReport(t *testing.T) {
108+ s := newSupervisor(t, newFakeAPI(t))
109+ id, _ := s.Spawn(Spec{Preset: "explore", Task: "find it"})
110+
111+ out, err := toolNamed(t, s, "agent_wait").Execute(context.Background(),
112+ json.RawMessage(`{"id":"`+id+`"}`))
113+ if err != nil {
114+ t.Fatalf("agent_wait: %v", err)
115+ }
116+ if !strings.Contains(out, "handled: find it") {
117+ t.Errorf("agent_wait returned %q, want the agent's report", out)
118+ }
119+ }
120+
121+ func TestAgentWaitOnAnUnknownIDIsAnError(t *testing.T) {
122+ s := newSupervisor(t, newFakeAPI(t))
123+ _, err := toolNamed(t, s, "agent_wait").Execute(context.Background(), json.RawMessage(`{"id":"a9"}`))
124+ if err == nil {
125+ t.Fatal("waiting on an unknown agent succeeded")
126+ }
127+ }
128+
129+ // Nesting is the way a spawn loop becomes a fork bomb with a credit card. A
130+ // subagent's tool set simply does not contain the spawn tools, and calling one
131+ // comes back as an ordinary unknown-tool error it can read.
132+ func TestASubagentCannotSpawnSubagents(t *testing.T) {
133+ f := newFakeAPI(t)
134+ f.callTool = "spawn_agent"
135+ var mu sync.Mutex
136+ var errors []string
137+ s := newSupervisor(t, f, func(o *Options) {
138+ o.Observe = func(ev Event) {
139+ if ev.Agent.Kind == agent.EventToolResult && ev.Agent.IsError {
140+ mu.Lock()
141+ errors = append(errors, ev.Agent.Result)
142+ mu.Unlock()
143+ }
144+ }
145+ })
146+
147+ id, _ := s.Spawn(Spec{Preset: "general", Task: "go"})
148+ waitFor(t, "the agent to finish", func() bool { return statusOf(t, s, id) == Done })
149+
150+ mu.Lock()
151+ defer mu.Unlock()
152+ if len(errors) != 1 || !strings.Contains(errors[0], "unknown tool") {
153+ t.Fatalf("a subagent's spawn attempt produced %v", errors)
154+ }
155+ if n := len(s.Snapshot()); n != 1 {
156+ t.Errorf("%d agents exist; the subagent managed to spawn one", n)
157+ }
158+ }
159+
160+ // The model is the main thing that decides what a subagent costs, and nothing
161+ // else picks it any more — so the description has to say how to choose. It
162+ // names no ids of its own: what this endpoint serves is in the system prompt,
163+ // and a list in two places is a list that goes out of step.
164+ func TestTheSpawnDescriptionExplainsHowToChooseAModel(t *testing.T) {
165+ s := newSupervisor(t, newFakeAPI(t))
166+ desc := toolNamed(t, s, "spawn_agent").Description()
167+
168+ for _, phrase := range []string{"you choose the model", "newest model", "system prompt"} {
169+ if !strings.Contains(strings.ToLower(desc), phrase) {
170+ t.Errorf("the description never says %q:\n%s", phrase, desc)
171+ }
172+ }
173+ if strings.Contains(desc, "claude-") {
174+ t.Errorf("the description hardcodes a model id, which will drift from what the endpoint serves:\n%s", desc)
175+ }
176+ // And it must not promise a model per preset, which is what it used to do.
177+ for _, p := range Presets() {
178+ line := p.Name + " — " + p.Description
179+ if !strings.Contains(desc, line) {
180+ t.Errorf("preset line missing or changed shape: %q", line)
181+ }
182+ }
183+ }
184+
185+ // Waiting on an agent whose report the front end has already put into the
186+ // conversation returns a pointer to it, not the text again: the parent is
187+ // paying for context, and it already has this.
188+ func TestWaitingOnAnAlreadyDeliveredReportDoesNotRepeatIt(t *testing.T) {
189+ f := newFakeAPI(t)
190+ s := newSupervisor(t, f)
191+ id, _ := s.Spawn(Spec{Preset: "explore", Task: "find it"})
192+ waitFor(t, "the agent to finish", func() bool { return statusOf(t, s, id) == Done })
193+
194+ // The front end delivered it.
195+ if !s.ClaimReport(id) {
196+ t.Fatal("could not claim a fresh report")
197+ }
198+
199+ out, err := toolNamed(t, s, "agent_wait").Execute(context.Background(), json.RawMessage(`{"id":"`+id+`"}`))
200+ if err != nil {
201+ t.Fatalf("agent_wait: %v", err)
202+ }
203+ if strings.Contains(out, "handled: find it") {
204+ t.Errorf("agent_wait repeated a report the conversation already has:\n%s", out)
205+ }
206+ for _, want := range []string{id, "already"} {
207+ if !strings.Contains(out, want) {
208+ t.Errorf("agent_wait's answer does not say %q:\n%s", want, out)
209+ }
210+ }
211+ }
212+
213+ // And when nobody has delivered it, agent_wait is the delivery.
214+ func TestWaitingFirstHandsOverTheWholeReport(t *testing.T) {
215+ f := newFakeAPI(t)
216+ s := newSupervisor(t, f)
217+ id, _ := s.Spawn(Spec{Preset: "explore", Task: "find it"})
218+
219+ out, err := toolNamed(t, s, "agent_wait").Execute(context.Background(), json.RawMessage(`{"id":"`+id+`"}`))
220+ if err != nil {
221+ t.Fatalf("agent_wait: %v", err)
222+ }
223+ if !strings.Contains(out, "handled: find it") {
224+ t.Errorf("agent_wait did not hand over the report:\n%s", out)
225+ }
226+ if s.ClaimReport(id) {
227+ t.Error("the front end would send it again after agent_wait delivered it")
228+ }
229+ }
230+
231+ // The effort argument is the answer to "spawn these on low", which the tool
232+ // could not express before.
233+ func TestSpawnToolTakesAnEffortLevel(t *testing.T) {
234+ f := newFakeAPI(t)
235+ f.hold()
236+ s := newSupervisor(t, f)
237+
238+ out, err := toolNamed(t, s, "spawn_agent").Execute(context.Background(),
239+ json.RawMessage(`{"preset":"explore","task":"find it","model":"claude-opus-5","effort":"low"}`))
240+ if err != nil {
241+ t.Fatalf("spawn_agent: %v", err)
242+ }
243+ if !strings.Contains(out, "low effort") {
244+ t.Errorf("the answer does not say what it is running at:\n%s", out)
245+ }
246+ if info, _ := s.Info("a1"); info.Effort != "low" {
247+ t.Errorf("Info.Effort = %q", info.Effort)
248+ }
249+ f.release()
250+ }
251+
252+ // Asking for an effort level on a model that has none must not read as though
253+ // it worked.
254+ func TestSpawnToolSaysWhenTheEffortCouldNotBeApplied(t *testing.T) {
255+ f := newFakeAPI(t)
256+ f.hold()
257+ s := newSupervisor(t, f)
258+
259+ out, err := toolNamed(t, s, "spawn_agent").Execute(context.Background(),
260+ json.RawMessage(`{"preset":"explore","task":"find it","model":"claude-haiku-4-5","effort":"low"}`))
261+ if err != nil {
262+ t.Fatalf("spawn_agent: %v", err)
263+ }
264+ for _, want := range []string{"takes no effort level", "not applied"} {
265+ if !strings.Contains(out, want) {
266+ t.Errorf("the answer does not say the level was dropped (%q):\n%s", want, out)
267+ }
268+ }
269+ f.release()
270+ }
271+
272+ // And the description has to say the knob exists, or nothing will reach for it.
273+ func TestTheSpawnDescriptionMentionsEffort(t *testing.T) {
274+ s := newSupervisor(t, newFakeAPI(t))
275+ desc := strings.ToLower(toolNamed(t, s, "spawn_agent").Description())
276+ for _, want := range []string{"effort", "low"} {
277+ if !strings.Contains(desc, want) {
278+ t.Errorf("the description never mentions %q", want)
279+ }
280+ }
281+ }