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>
Showing 6 changed files with 2205 additions and 0 deletions.
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |