Subagents
Overview
Add the task(description, subagent_type) tool: a middleware that spawns a child agent loop with its own isolated message history, its own middleware stack, and the same filesystem backend. Subagent specs live in the parent agent’s config, with a general-purpose default auto-inserted. Results cross back as the last non-empty assistant text. Recursion is refused at call time with a message rather than by hiding the tool, and a depth cap applies. Subagent runs execute on the subagent scheduler lane so they cannot starve top-level runs.
Requirements
Functional
- One tool,
task(description, subagent_type), whose description embeds the available subagent list so discovery happens through the schema, not the system prompt. - Subagent specs come from
agents.config->'subagents':name,description,system_prompt,tools(nil inherits parent),model(empty inherits),interrupt_on(phase 15; nil inherits). - A
general-purposesubagent is auto-inserted unless the agent defines one with that name. - The child loop starts with an empty message history containing only the
descriptionas the first user message; the parent’s transcript is never inherited. - The result returned to the parent is the last non-empty assistant text block; if none exists, an explicit error result naming the problem.
- Child file writes are visible to the parent because both share the
{tenant, agent}backend namespace. - Depth cap
HANGO_SUBAGENT_MAX_DEPTH(default 2): at the cap,taskreturns a refusal string explaining the limit; the tool stays visible. - Child execution is charged to the same run: it runs synchronously inside the parent's tool call, and the `subagent` lane is acquired **non-blockingly**. At depth > 0 the gate is `TryAcquire`: if no slot is free, `task` returns a non-error tool result saying the delegation capacity is busy and to retry later or do the work directly. A depth-0 caller may wait, bounded by the run deadline. A blocking acquire at depth > 0 would self-deadlock — the parent holds a slot while waiting for one — so re-entrant blocking is forbidden by construction, not by convention.
- Tool calls within one batch execute **sequentially** in v1, including multiple `task` calls. Parallel tool execution is not a v1 feature, so there is no "parallel task calls bounded by the lane" behaviour to test; the lane bounds concurrency across runs, not within a batch.
- Child events are emitted to the parent run’s event stream with a
depthandsubagentlabel so an SSE client sees subagent progress. - Todos, memory state and skills metadata are not propagated back to the parent state.
Non-functional
- Child loops reuse the same
agent.Agent, chain builder and store; no duplicated loop implementation. The child is invoked through the phase-5Agent.Run(ctx, sessionID, input, sink, RunOptions{Depth: d+1, ParentSink: sink})— the signature does not change in this phase. - `internal/agent` still imports nothing from `internal/run`. The lane is injected as `agent.Gate func(ctx context.Context) (release func(), error)` (a `TryAcquire` closure) built by `internal/app` from the scheduler, and the child's events go through the `agent.EventSink` the parent already holds.
- A cancelled parent run cancels its children promptly (shared context).
Architecture
// internal/agent/subagent.go
type Subagent struct {
Name string `json:"name"`
Description string `json:"description"`
SystemPrompt string `json:"system_prompt"`
Tools []string `json:"tools,omitempty"` // nil = inherit parent
Model string `json:"model,omitempty"` // "" = inherit
InterruptOn map[string]InterruptConfig `json:"interrupt_on,omitempty"`
}
// Red team: F15 — InterruptConfig is defined here, in the phase that first references it.
// Phase 15 extends it with the When clause and consumes it in HITLMiddleware.
type InterruptConfig struct {
AllowedDecisions []string `json:"allowed_decisions,omitempty"` // default: all four
}
// internal/agent/mwsubagent.go
// Red team: F5 — Gate is a plain func injected by the composition root, so internal/agent
// has no import of internal/run. Red team: F4 — it never blocks at depth > 0.
type Gate func(ctx context.Context) (release func(), error) // returns ErrNoCapacity when full
type SubagentMiddleware struct {
Specs []Subagent
MaxDepth int
Gate Gate
Build ChildBuilder // constructs a child Agent + chain from a spec
} // ToolProvider
const refusalNoCapacity = "delegation capacity is busy right now; retry this task later or complete it directly"
type ChildBuilder func(ctx context.Context, parent *Agent, spec Subagent, depth int) (*Agent, error)
const refusalAtDepth = "subagent depth limit reached; complete this work directly instead of delegating"-- migrations/00007_subagent_calls.sql
create table subagent_calls (
id uuid primary key,
tenant_id uuid not null references tenants(id) on delete cascade,
run_id uuid not null references runs(id) on delete cascade,
parent_tool_call_id text not null,
subagent_name text not null,
depth int not null,
description text not null,
state text not null check (state in ('running','succeeded','failed','cancelled')),
result text not null default '',
iterations int not null default 0,
usage jsonb,
trace_id text not null default '',
started_at timestamptz not null default now(),
finished_at timestamptz
);
create index subagent_calls_tenant_run_idx on subagent_calls (tenant_id, run_id, started_at);Instrumentation
- Nested
invoke_agent {subagent name}span per child, parented by theexecute_tool taskspan, carryinggen_ai.operation.name=invoke_agent,gen_ai.agent.name=<subagent>,hango.subagent.depth,hango.subagent.parent_tool_call_id, andlangfuse.observation.type=agentso Langfuse renders a nested agent observation inside the parent trace. - The child’s
chatandexecute_toolspans hang off the childinvoke_agentspan, so the Langfuse trace tree mirrors the delegation tree exactly. - Span events on the parent:
subagent.spawned,subagent.refused_depth,subagent.empty_result. - Metrics:
gen_ai.invoke_agent.durationand...inference_callsgain ahango.subagent.depthattribute;hango.subagent.callscounter (attributessubagent_name,state);hango.subagent.lane_waithistogram. - Log fields: child logs carry
hango.subagent.nameandhango.subagent.depthin addition to the parent run fields, so Loki filtered byhango.run.idshows parent and child interleaved but distinguishable.
Related Code Files
Create
internal/agent/subagent.go,internal/agent/mwsubagent.go,internal/agent/mwsubagent_test.go.internal/agent/childbuilder.go— builds the child stack (Filesystem → offload → Summarization slot → RepairToolCalls → RepeatCall → allowlist) without Subagent unless depth allows.internal/run/lane.go— the lane semaphore plusTryAcquire, exposed tointernal/appas anagent.Gateclosure;internal/agentnever imports it.internal/store/pg/subagent_call.go,internal/store/pg/subagent_call_test.go.migrations/00007_subagent_calls.sql.docs/learning/09-subagents.md.
Modify
internal/agent/mwstack.go— fill the Subagent slot (after Filesystem, before Summarization); pass the depth so the child stack omits Subagent at the cap.internal/agent/loop.go— populateRunOptions.Depthand wrap the sink with thedepth/subagentdecorator; the signature is unchanged from phase 5.internal/run/scheduler.go— register thesubagentlane and exposeAcquire/TryAcquire;internal/appadapts them into anagent.Gate.internal/server/handlers_runs.go—GET /v1/runs/{id}includes the subagent call summary.internal/config/config.go—HANGO_SUBAGENT_MAX_DEPTH,HANGO_LANE_SUBAGENT_CONCURRENCY.internal/store/pg/tenancy_test.go— addsubagent_calls.docs/system-architecture.md,docs/learning/README.md.
Implementation Steps
- Write
internal/agent/subagent.gowith the spec type andParseSubagents(cfg json.RawMessage) ([]Subagent, error), validating names (unique, non-empty, no whitespace) and injectinggeneral-purposewhen absent. - Write
internal/run/lane.go: a buffered-channel semaphore withAcquire(ctx)(blocking, used only at depth 0) andTryAcquire()(non-blocking, used at depth > 0, returningErrNoCapacity), recordinghango.subagent.lane_wait.internal/appwraps the right one per depth into theagent.Gateclosure. - Write
internal/agent/childbuilder.go: given the parentAgent, a spec and a depth, resolve model and tool allowlist (inherit when unset), build the child stack viaDefaultStackwithDepth: depth+1and the Subagent slot omitted at the cap, and return a childAgentsharing the store, provider, backend and emitter. - Write
internal/agent/mwsubagent.go: aToolProviderexposingtask. Its description enumerates available subagents with their descriptions plus the policy text (when to delegate, that the child sees only the description, that the result is the child’s final message).Executevalidatessubagent_type, checks the depth (returningrefusalAtDepthas a non-error result so the model can recover), acquires a lane slot throughGate(returningrefusalNoCapacityas a non-error result when the gate reports no capacity, never blocking at depth > 0) , persists asubagent_callsrow, runs the child loop with the description as the only user message, extracts the last non-empty assistant text, persists the terminal state, and returns it. - Child message persistence: child messages are written to a child session (
sessions.key = parent session key + ":sub:" + subagent_call_id) so the transcript is inspectable without polluting the parent session. The parent’s history gains only the tool result. - Wire event propagation: wrap the parent’s
EventSinkso child events carrydepthandsubagent_name, and emitsubagent.started/subagent.finishedrun events. - Add the nested span and metrics per the Instrumentation section.
- Insert into
DefaultStackand update the pinned order test. - Tests:
general-purposeauto-insertion; child sees only the description (assert the child’s first message list); parent receives the last non-empty text; empty-result path returns a named error; depth cap returns the refusal string and the tool remains listed; a full lane at depth > 0 returnsrefusalNoCapacityimmediately and the parent run completes (the self-deadlock regression test) ; child file write is visible to the parent viaread_file, and a concurrent parent/child write to the same path produces a version conflict result rather than a lost update ; parent cancellation cancels the child (assert both terminal states);subagent_callscross-tenant and intra-tenant cases. - Update docs and write the learning doc.
Learning Doc Outline
docs/learning/09-subagents.md must contain:
- Concept: delegation as context isolation, not parallelism — the point is that the child’s transcript never enters the parent’s window.
- Design choices: one
tasktool with the roster in its description, specs as agent config data, shared backend and separate history, last-non-empty-text result extraction, refusal-at-call-time for recursion, depth cap, a dedicated scheduler lane, child sessions persisted for inspection. - Trade-offs: synchronous in-tool execution vs enqueuing a separate run (chose synchronous: the parent’s tool call needs the result, and the lane still bounds concurrency); lossy text result vs structured result; sharing the filesystem vs isolating it.
- goclaw comparison: subagent spawn lives in tools plus a
subagent_taskstable, withinternal/orchestrationproviding a genericBatchQueue[T]andChildResult, plus teams/delegation/cron features hango skips. - deepagents comparison:
SubAgentMiddlewareadds onetasktool with{available_agents}embedded in the description; each declarativeSubAgentcompiles its own stack (Filesystem → Summarization → PatchToolCalls); result is the last non-empty AI text;_EXCLUDED_STATE_KEYSblocksmessages/todos/structured_response/skills_metadata/privatefrom propagating;_FORK_RECURSION_REFUSALrefuses at call time;recursion_limitis effectively unbounded; experimentalmode="fork"inherits the parent transcript. - Why hango diverges: a real depth cap, a bounded lane, persisted child sessions and
subagent_callsrows for auditability, no fork mode. - The known lossiness pitfall: a child that does all its work in tool calls and ends with an empty message, and the walk-back workaround.
- How to debug this layer: the Langfuse trace shows the nested agent observation tree, so “which subagent burned the tokens” is one click;
hango.subagent.callsby name and state shows failure concentration;subagent.empty_resultspan events identify the lossy-result pitfall in production; Loki filtered byhango.run.idplushango.subagent.depthseparates parent from child narration. - Why a re-entrant blocking semaphore is a deadlock and a non-blocking gate with a "retry later" tool result is not, and why the gate is an injected function rather than an imported type.
- Exercises: define a second subagent in agent config and delegate to it; nest to the depth cap and read the refusal; fill the lane and read the capacity refusal; make a child end with an empty message and observe the error path.
- Time budget: 10h implementation + 2h learning doc.
Success Criteria
-
go build ./... && go vet ./... && go test ./...pass. -
general-purposeexists without configuration; a user-defined subagent with that name overrides it (test). - The child’s initial message list contains exactly the description (test).
- A child file write is readable by the parent through
read_file(integration test). - At the depth cap,
taskreturns the refusal string as a non-error result and the tool is still advertised (test). - With the
subagentlane full, ataskcall at depth > 0 returns the capacity refusal immediately (no blocking) and the parent run still reachessucceeded; a depth-2 delegation chain never deadlocks. - Parent cancellation drives the child to
cancelledwithin the lane’s cancellation path (test). -
subagent_callsrows record name, depth, state, iterations, usage and trace id; the table is in the cross-tenant suite. - In Langfuse, a delegating run renders as a trace with a nested
agentobservation containing the child’s generations and tool calls; in Tempo the same nesting is visible under theexecute_tool taskspan. -
hango.subagent.callsandhango.subagent.lane_waitseries exist in Prometheus.
Risk Assessment
| Risk | Likelihood x Impact | Signal | Pre-decided response |
|---|---|---|---|
| Runaway delegation exhausts tokens or workers | Medium x High | Deep or wide task fan-out, lane saturation | Depth cap with a refusal string, subagent lane concurrency limit, and the parent’s run deadline bounding total work |
| Empty subagent result confuses the parent | High x Medium | subagent.empty_result events | Walk back to the last non-empty assistant text; if still empty, return a named error result telling the model to retry with a more explicit instruction |
| Child inherits the parent transcript by accident | Medium x High | Token usage on the child matches the parent | The child builder constructs a fresh history; a test asserts the exact first message list |
| Shared filesystem lets a child clobber parent files | Medium x Medium | Unexpected file content, lost updates | Same namespace is intentional (it is how results are passed); the phase-8 version column turns a concurrent overwrite into a retryable conflict result. The within-batch mutation guard does not cover this case and is not cited as a mitigation |
| Re-entrant lane acquisition self-deadlocks | Medium x Critical | A depth-2 delegation hangs until the run deadline | TryAcquire at depth > 0 with a capacity-refusal tool result; the full-lane test is a success criterion |
| Nested spans explode trace size | Low x Low | Very large traces in Tempo | Depth cap bounds nesting; sampling ratio is configurable |
| deepagents’ unbounded recursion pattern | Certain x Medium | Temptation to skip the cap | Locked: HANGO_SUBAGENT_MAX_DEPTH default 2, refusal at call time |
Rollback
goose down one step drops subagent_calls. Removing the Subagent slot from DefaultStack removes the task tool; child sessions already written remain readable through the session API.