Vectrum
Sign In

harness: wire subagents into the session

The tool factory is now shared, so a child reaches exactly as far as its parent
and no further — with the spawn tools left out of it, which is what stops an
agent from spawning agents that spawn agents. -no-agents turns the feature off,
and -p and -no-tui are not offered it at all: a report has nowhere to land in
the line-based REPL.

The models the agent may choose between come from the endpoint itself, asked
for once at startup, because the answer goes into the cached prompt prefix. An
endpoint that does not serve that route gets a notice and no listing rather
than a list made up from memory.

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

Showing 2 changed files with 236 additions and 15 deletions.

+165−2README.mdView File
@@ -54,7 +54,7 @@ would break your Claude Code login.
5454
5555 Useful flags: `-model`, `-effort low|medium|high|xhigh|max`, `-max-turns`,
5656 `-resume latest`, `-no-stream`, `-no-cache`, `-v`, `-yolo`, `-no-tui`, `-no-skills`,
57- `-no-claude-code-auth`.
57+ `-no-agents`, `-no-claude-code-auth`.
5858
5959 ## The TUI
6060
@@ -101,13 +101,14 @@ to six lines.
101101 | `$` | the same, through `python3` |
102102 | `ctrl+j` | newline without sending |
103103 | `shift+tab` | cycle the approval mode |
104+ | `ctrl+b` | open the agents sidebar — see below |
104105 | `ctrl+r` | expand elided tool output |
105106 | `pgup`/`pgdn` | scroll the transcript |
106107 | `esc` | interrupt the turn |
107108 | `ctrl+c` | interrupt the turn; again when idle to exit |
108109
109110 Commands: `/allowed`, `/clear`, `/effort`, `/exit`, `/help`, `/model`,
110- `/setup`, `/skills`, and one per discovered skill.
111+ `/mouse`, `/setup`, `/skills`, and one per discovered skill.
111112
112113 A slash at the start of an empty prompt opens the command menu, which filters
113114 as you type — a name that starts with what you typed before one that merely
@@ -232,6 +233,168 @@ The TUI is skipped automatically when stdin or stdout is not a terminal, and by
232233 consume the same `agent.Event` stream, so neither can show something the other
233234 cannot.
234235
236+ ## Subagents
237+
238+ The agent can start other agents. `spawn_agent(preset, task)` returns
239+ immediately with a short id — `a1`, `a2`, … — and that agent runs in the
240+ background on its own goroutine while the main agent carries on with whatever
241+ it was doing. `agent_wait(id)` blocks until that one finishes, for the times
242+ the parent genuinely cannot proceed without the answer.
243+
244+ A subagent is a whole agent, not a subroutine: its own conversation, its own
245+ context window, its own model, its own turn loop. The parent never sees its
246+ turns — only the report it ends with. That is the point. Work that would have
247+ filled the parent's window with file listings fills the child's instead, and
248+ comes back a paragraph long.
249+
250+ Three presets, compiled in rather than configurable, because a preset the model
251+ can invent is a blast radius the model can invent:
252+
253+ | Preset | For | Turn cap |
254+ | --- | --- | --- |
255+ | `explore` | finding things, and reporting paths and line numbers | 20 |
256+ | `plan` | working out how a change should be made, without making it | 30 |
257+ | `general` | a self-contained piece of work, edits and tests included | 40 |
258+
259+ All three get the full tool set — read, glob, grep, edit, write, bash, skill —
260+ and none of them gets `spawn_agent`, so subagents cannot spawn subagents.
261+
262+ **A preset pins no model and no effort level.** Both are arguments to
263+ `spawn_agent`, because what to spend is a judgement about the job and the agent
264+ holding the task is the one making it: `model` picks what runs it — a small
265+ fast model for mechanical search, a strong one for work that has to be right —
266+ and `effort` picks how hard it thinks, `low` through `max`. Omit either and it
267+ inherits the session's. A pinned id here would be a guess about someone else's
268+ endpoint as well as about the job: point the harness at a gateway that serves
269+ neither, and every spawn of that preset fails before its first turn.
270+
271+ The models it may choose between are in its own system prompt: the harness asks
272+ the endpoint what it serves at startup — `GET /v1/models`, the same route the
273+ picker uses — and lists them newest first, with the rule that the newest is the
274+ one to reach for unless you asked for another or it has already failed this
275+ session. An endpoint that does not answer that route simply gets no listing,
276+ and the agent is told that naming no model runs the subagent on whatever the
277+ session is using. The list is read once, because it sits in the cached prefix
278+ and a prompt that changes mid-session throws the cache away. Subagents do not
279+ get this paragraph — they have no such choice to make.
280+
281+ `output_config.effort` is a per-model capability, not a universal parameter.
282+ Haiku 4.5 rejects a request carrying it, and `xhigh` did not exist before Opus
283+ 4.7 — so a request for a level its model does not take goes out without it,
284+ rather than failing with a 400 that reads like a bug in the harness. Where that
285+ happens to a subagent the answer says so outright (`claude-haiku-4-5 takes no
286+ effort level, so "low" was not applied`) instead of letting the parent believe
287+ it got what it asked for, and the effort pill in the status line reads `not on
288+ Haiku` rather than claiming a setting that is not in force.
289+
290+ A model this build has never heard of — a gateway serving something else, a
291+ family released after it — is treated the same way: the level is left off. The
292+ two mistakes are not symmetric. Sending effort to a model that lacks it is a
293+ 400 that ends the run before any work happens; omitting it costs the level and
294+ says so in the pill. Gateway decoration is seen past first, so
295+ `anthropic.claude-opus-5`, `openrouter/anthropic/claude-opus-5` and
296+ `claude-opus-5@20260401` are all recognised as the model they name — for
297+ pricing and for the thinking parameter too.
298+
299+ Every spawn goes through the ordinary approval box, which shows the preset, the
300+ model and the task. It is not rememberable per task — the task text differs
301+ every time, so "always this exact one" would never match twice — but "always
302+ allow this tool" still applies, for anyone who has decided to trust spawning as
303+ such. A subagent's own tool calls go through the same policy, and the box says
304+ which agent is asking.
305+
306+ Four run at once; further spawns queue. Two agents editing one file is not a
307+ special case: `edit_file` already refuses a file whose mtime moved since that
308+ agent read it, so the second one is told to read it again rather than
309+ clobbering the first.
310+
311+ A report reaches the parent exactly once, whichever way it gets there. Both
312+ paths — `agent_wait` returning it as a tool result, and the front end putting
313+ it into the conversation as a message — claim it from the supervisor first, and
314+ only one claim wins. Without that, an agent that finished before its
315+ `agent_wait` call was even emitted was handed over twice: once as the result,
316+ once as a message a turn later, which is the context cost the whole feature
317+ exists to avoid. A wait that loses the claim answers with a line saying where
318+ to find the report instead of repeating it.
319+
320+ **The report comes back on its own.** When a subagent finishes, its report is
321+ appended to the main conversation as a user message and the main agent starts a
322+ turn on it — you do not have to type anything. It is held back only while a
323+ turn is already running, while an approval is on screen, while a `!` or `$`
324+ command is running, or while you have text in the prompt box: a turn starting
325+ under your fingers would blur the input and eat what you were typing. It goes
326+ up the moment that clears. A failed or killed agent is reported the same way,
327+ so the parent is never left waiting on a result that is not coming.
328+
329+ ### The agents sidebar
330+
331+ `ctrl+b` opens a 30-column pane on the right and gives it the keyboard; the
332+ transcript reflows into what is left rather than being covered by it.
333+
334+ Open and focused are separate things. The pane answers every key while it has
335+ focus — that is what lets `k` be a kill rather than a letter — so **click the
336+ prompt to type again**, and the pane stays where it is. Click a row to hand the
337+ keys back. `ctrl+b` always moves towards the pane: it opens a closed one,
338+ focuses an open one, and only closes a pane that already has the keys. Which
339+ one is live is visible in two places: the heading carries the accent and the
340+ prompt frame goes dim, or the other way round.
341+
342+ ```
343+ ⏺ Two of those are independent, so I will farm │ Agents 2 ⏵
344+ them out. │
345+ │ ❯ ✳ a1 plan $0.23
346+ ⏺ SpawnAgent(preset=explore, task=…) │ Opus · 2m04s · 6 turns
347+ ⎿ started a2 (explore) on claude-haiku-4-5, s…│ ● a2 explore
348+ │ Haiku · waiting · 1 turn
349+ │
350+ │ ↑↓ move · enter open
351+ ✢ Deliberating… (8s · turn 5 · esc to interrup…│ p pause · k kill · esc back
352+ ╭──────────────────────────────────────────────╮│
353+ │ › ask for a change, /help, ! for a shell ││
354+ ╰──────────────────────────────────────────────╯│
355+ joni ✳ Opus 5 ⏵ 2 agents │
356+ ⏵ approvals on (shift+tab to cycle) · ctrl+b a…│
357+ ```
358+
359+ Each agent is two lines: the status glyph, the id and the preset, with the cost
360+ at the right, and under it the model, how long it has been going and how many
361+ turns it has taken. The model is the family alone — thirty columns will not
362+ hold `claude-haiku-4-5` as well as the numbers — and the full id is in the
363+ header of the thread, one keystroke away.
364+
365+ | Key | Does |
366+ | --- | --- |
367+ | `↑`/`↓` | move |
368+ | `enter` | open that agent's thread in the main pane |
369+ | `esc` | back to your own thread; again to close the pane |
370+ | `p` | pause or resume |
371+ | `k` | stop it |
372+ | click the prompt | type again, leaving the pane open |
373+
374+ The statuses are queued, running, waiting — blocked on an approval of yours —
375+ paused, done, failed and killed. Pause is checked between turns: the model call
376+ and the tool calls in flight finish, and it holds before the next request, so
377+ the conversation is never left with a `tool_use` whose `tool_result` never
378+ came. Kill cancels immediately, and what the agent already did to the tree
379+ stays done — this ends an agent, it does not undo one.
380+
381+ Mouse reporting is on, so clicking the agents pill in the status line toggles
382+ the pane and clicking a row selects it. `/mouse` turns reporting off and on
383+ again, because while it is on the terminal's own text selection is not.
384+
385+ A child's usage is priced at the child's model and rolled into the session
386+ total, so a haiku agent spawned from an opus session is not billed at opus
387+ rates. Every metrics event a child writes carries its agent id, so
388+ `.harness/events.jsonl` can be split per agent afterwards.
389+
390+ Subagent transcripts last as long as the session does: `-resume` brings back
391+ your conversation, not theirs.
392+
393+ Subagents belong to the TUI: their reports come back as messages the front end
394+ puts into the conversation, and the line-based REPL has nowhere to show four
395+ agents at once. `-p` and `-no-tui` runs are not offered the tools at all, and
396+ `-no-agents` turns them off for a TUI run too.
397+
235398 ## Setup
236399
237400 The first run on a terminal opens a full-screen wizard instead of the chat: the
+71−13cmd/harness/main.goView File
@@ -21,6 +21,7 @@ import (
2121 "harness/internal/llm"
2222 "harness/internal/metrics"
2323 "harness/internal/skills"
24+ "harness/internal/subagent"
2425 "harness/internal/tools"
2526 "harness/internal/tui"
2627 "harness/internal/ui"
@@ -57,6 +58,7 @@ func run() error {
5758 yolo := flag.Bool("yolo", false, "skip every approval prompt. Only against a throwaway clone or inside a container")
5859 noTUI := flag.Bool("no-tui", false, "use the plain line-based REPL instead of the full-screen TUI")
5960 noSkills := flag.Bool("no-skills", false, "skip discovering skills in ~/"+skills.Dirname+" and ./"+skills.Dirname)
61+ noAgents := flag.Bool("no-agents", false, "do not offer the subagent tools, so the agent works alone")
6062 noClaudeCodeAuth := flag.Bool("no-claude-code-auth", false, "do not fall back to the OAuth login in ~/"+claudecode.Filename)
6163 flag.Parse()
6264
@@ -196,19 +198,28 @@ func run() error {
196198 skillDirs = append(skillDirs, s.Dir)
197199 }
198200
199- state := agent.NewState()
200- reg := tools.NewRegistry(log).Register(
201- tools.NewRead(root, state, skillDirs...),
202- tools.NewGlob(root, skillDirs...),
203- tools.NewGrep(root, skillDirs...),
204- tools.NewEdit(root, state),
205- tools.NewWrite(root, state),
206- tools.NewBash(root),
207- )
208- if t := tools.NewSkill(found); t != nil {
209- reg.Register(t)
201+ // One factory, used for the session's own agent and for every subagent:
202+ // a child that could reach further than its parent would be a way around
203+ // the parent's limits, and a child that could reach less would be a
204+ // surprise the model has no way to know about.
205+ newTools := func(state *agent.State, log *metrics.Logger) *tools.Registry {
206+ reg := tools.NewRegistry(log).Register(
207+ tools.NewRead(root, state, skillDirs...),
208+ tools.NewGlob(root, skillDirs...),
209+ tools.NewGrep(root, skillDirs...),
210+ tools.NewEdit(root, state),
211+ tools.NewWrite(root, state),
212+ tools.NewBash(root),
213+ )
214+ if t := tools.NewSkill(found); t != nil {
215+ reg.Register(t)
216+ }
217+ return reg
210218 }
211219
220+ state := agent.NewState()
221+ reg := newTools(state, log)
222+
212223 in := bufio.NewScanner(os.Stdin)
213224 in.Buffer(make([]byte, 0, 64*1024), 1024*1024)
214225
@@ -220,7 +231,7 @@ func run() error {
220231 // The TUI says this itself, in the banner.
221232 fmt.Fprintln(os.Stderr, "harness: -yolo — no approval prompts. Use a throwaway clone or a container.")
222233 }
223- a := agent.New(client, reg, state, log, agent.Options{
234+ base := agent.Options{
224235 MaxTurns: *maxTurns,
225236 Effort: effortSetting,
226237 ContextWindow: *contextWindow,
@@ -229,7 +240,43 @@ func run() error {
229240 Stream: !*noStream,
230241 Skills: found,
231242 SystemPrefix: systemPrefix,
232- })
243+ }
244+
245+ // Subagents are a TUI feature: their reports come back as messages the
246+ // front end puts into the conversation, and the line-based REPL has
247+ // nowhere to show four agents working at once. Offering the tools where
248+ // nothing can deliver the answers would be worse than not offering them.
249+ var sup *subagent.Supervisor
250+ if useTUI && !*noAgents {
251+ // What the endpoint serves, so the agent picks subagent models from a
252+ // real list rather than from memory. It is asked for once, here,
253+ // because the answer goes into the cached prompt prefix and must not
254+ // change mid-session. An endpoint that does not answer simply leaves
255+ // the listing out: no list beats a made-up one, and the agent is told
256+ // to inherit this session's model when it has nothing to choose from.
257+ listCtx, cancelList := context.WithTimeout(context.Background(), 10*time.Second)
258+ models, listErr := client.ListModels(listCtx)
259+ cancelList()
260+ if listErr != nil {
261+ notice("could not list the models this endpoint serves (%v) — subagents will run on %s unless the agent is told otherwise", listErr, client.Model())
262+ }
263+ sup = subagent.New(context.Background(), subagent.Options{
264+ Client: client,
265+ Log: log,
266+ Policy: policy,
267+ Base: base,
268+ Tools: newTools,
269+ })
270+ defer sup.Shutdown()
271+ reg.Register(sup.Tools()...)
272+ // Only the agent that can spawn is told what there is to choose from:
273+ // the supervisor above was handed the options before this line, so a
274+ // subagent's prompt does not carry a paragraph about a choice it will
275+ // never make.
276+ base.Models = models
277+ }
278+
279+ a := agent.New(client, reg, state, log, base)
233280 a.Observe = render.Handle
234281 a.Approve = policy
235282
@@ -255,6 +302,7 @@ func run() error {
255302 SessionDir: sessionDir,
256303 SessionID: session,
257304 ToolNames: toolNames(reg),
305+ Agents: agentsOrNil(sup),
258306 Verbose: *verbose,
259307 YOLO: acceptAll,
260308 Skills: found,
@@ -430,6 +478,16 @@ func interactive() bool {
430478 return true
431479 }
432480
481+ // agentsOrNil keeps a nil *Supervisor from arriving as a non-nil interface
482+ // holding a nil pointer, which is the way the sidebar would appear for a
483+ // session that has no supervisor at all.
484+ func agentsOrNil(sup *subagent.Supervisor) tui.Agents {
485+ if sup == nil {
486+ return nil
487+ }
488+ return sup
489+ }
490+
433491 func toolNames(reg *tools.Registry) []string {
434492 var names []string
435493 for _, t := range reg.List() {