Coding Agent Harness 拆解 · 多 Agent:subagent、team 与后台任务(十一)
本文是《Coding Agent Harness 拆解》系列的一篇。整个系列逐个拆开一个真实在用的终端 AI 编程 agent(codebot),以及它底下的执行内核 agentcore(两个独立的 Go module:codebot 负责编排,agentcore 负责执行)。本篇回答一个大家点名最多的问题:一个 coding agent 明明只有"一个主循环",它是怎么变出"派一个子 agent 去查资料""让一个任务在后台跑""拉一支能互相发消息的队伍"这些花样的?答案是三层:内核原语 → harness 接线 → 模型可见工具,本篇把这三层从下到上串一遍。
一个主 agent 为什么不够
主 agent 的上下文是稀缺资源。让它亲自去读三十个文件找一处调用、或者跑一条要十分钟的构建、又或者同时推进四条互不相干的支线,每一样都会把主对话塞满、把注意力打散。所以需要"分身":
- 一次性委托:开一个上下文隔离的子 agent,扔给它一个任务,拿回一段结果,用完即弃。
- 后台任务:委托出去后不等它,主 agent 立刻返回继续干别的,子 agent 跑完再回来通知。
- 长期队友(team):常驻的"人",有信箱、turn 之间保持空闲、能被反复喊话协作,不像一次性子 agent 那样用完即弃。
codebot 把这三样都建在同一套内核原语上,再由 harness 接线成产品能力,最后以工具形式暴露给模型。
三层结构总览
| 层 | 位置 | 提供什么 |
| --- | --- | --- |
| 内核原语 | agentcore/{subagent,task,team} | subagent.Tool(委托执行核)、task.Runtime(后台任务注册表)、team(信箱 + 队友循环) |
| harness 接线 | internal/agent/teammate_*.go、team_inbox.go、internal/team/protocol.go | TeammateSpawner、TeammateWaker、LeaderInboxPump、wire 格式与优先级策略 |
| 模型可见工具 | internal/tools/*.go | subagent(委托)、task_output/task_stop(后台任务)、send_message、team_create |
内核给机制,harness 定策略,工具是模型的把手。下面从原语讲起。
原语一:subagent.Tool,一个工具五种模式
subagent.Tool(agentcore/subagent/subagent.go)是委托的执行核。它对模型只暴露一个名叫 subagent 的工具,靠参数区分五种互斥模式:
// agentcore/subagent/subagent.go — one tool, five mutually exclusive modes
if p.TeamName != "" {
return t.executeTeamSpawn(ctx, p, modelOverride) // long-lived teammate
}
if p.Background {
return t.executeBackground(ctx, p.Agent, p.Task, p.Description, modelOverride)
}
switch {
case hasChain:
return t.executeChain(ctx, p.Chain, modelOverride) // sequential, {previous}
case hasParallel:
return t.executeParallel(ctx, p.Tasks, modelOverride) // bounded concurrency
default:
return t.executeSingle(ctx, p.Agent, p.Task, modelOverride)
}
- single(
agent + task):跑一个隔离子 agent,返回最后一段助手文本。
- parallel(
tasks 数组):并发跑多个,maxParallelTasks=8、maxConcurrency=4 限流,sync.WaitGroup 收口。
- chain(
chain 数组):顺序跑,后一步用 {previous} 占位符引用前一步输出。
- background(
agent + task + background=true):立刻返回,跑完再通知,需要 task.Runtime。
- team spawn(
agent + task + team_name):不跑一次性循环,转而委托给 harness 注入的 TeamSpawner 拉起一个常驻队友。
模式一多,产品侧还需要能"编程式"调用而不走 JSON,所以 Tool 还有个 Run(ctx, agent, task) (RunResult, error) 的类型化入口,跳过 JSON 编解码、直接返回结构化结果与 error 链,dream(私有子 agent)之类的宿主代码走的就是它。
::: tip 单条委托的每种模式都共用一个 runAgent
不管 single 还是 background,最终都落到 runAgent:它用 agentcore.AgentLoop 起一个全新的、上下文隔离的循环,带 panic 恢复(子 agent 崩溃绝不拖垮父级),把 usage(token、turn、tool 计数)累计回 RunResult。隔离的是上下文,不是代码。
:::
原语二:task.Runtime,统一的后台任务注册表
后台跑的东西(shell 命令、子 agent、队友)需要一个统一的地方登记、查询、取消。这就是 task.Runtime(agentcore/task/task.go):一张 map[string]*Entry,Type 区分 TypeShell / TypeSubAgent / TypeTeammate。
background 模式的关键动作:
// agentcore/subagent/subagent.go — background spawn, abridged
childDepth := task.DepthFromContext(callerCtx) + 1
if childDepth > task.MaxAgentDepth { // depth guard: refuse runaway recursion
return json.Marshal(map[string]any{"error": "...exceeds max depth"})
}
taskID := rt.NextID("bg")
bgCtx, cancel := context.WithCancel(context.Background()) // outlive the caller's turn
entry := &task.Entry{ID: taskID, Type: task.TypeSubAgent, Status: task.Running, Depth: childDepth}
entry.SetCancel(cancel)
rt.Register(entry)
go func() { /* runAgent → rt.Update terminal status → t.notify(taskID) */ }()
三个要点:
- 脱离调用者 ctx。后台任务要活过主 agent 当前这一 turn,所以用
context.Background() 派生;进程级关停由 Runtime.StopAll()(Runtime.Close 里调)兜底,不会留僵尸 goroutine。
- 深度守卫。
task.MaxAgentDepth = 5,DepthFromContext/WithDepth 把当前深度顺着 ctx 传下去,超限直接拒绝,防止某天队友拿到 spawn 通道后无限递归烧 token。
- 完成即通知。跑完
rt.Update 写终态,再 notify 把 NotificationFromEntry(entry).ToAgentMessage()(一段 <background-task-completed> XML)经 notifyFn(接到 Agent.FollowUp)作为 follow-up 喂回主 agent。
模型这边通过 task_output(可 block=true 等完成)和 task_stop(rt.Stop(id))两个工具读/停后台任务(internal/tools/task.go)。运行中还能中途插话:send_message 对着 bg-3 这样的 task ID 调 rt.AppendPending,子 agent 循环每个 steering tick 用 rt.DrainPending 取走当作新的 user turn。
原语三:team,长期队友与信箱
一次性子 agent 是"发射后不管、返回一个结果";队友(Teammate)是常驻的对等体:turn 之间空闲挂起、能收 leader 或其他队友的消息、只有显式 shutdown 握手才退出(agentcore/team/team.go)。
核心是三块:
- Mailbox(
mailbox.go):单消费者多生产者的内存信箱。Send 追加消息并往带缓冲的 wake channel 发一个信号;Wait 阻塞到有消息,靠 channel 唤醒,没有轮询;Drain 一次性取空。
- Registry(
registry.go):会话级 team 状态,管 name → taskID 与每个 agent 一个 Mailbox。leader 用保留名 TeamLeadName("team-lead")自动注册。
- runner + spawn(
runner.go / spawn.go):Spawn 登记 Entry、分配信箱、起一个常驻 Run goroutine。
Run 的循环形状(简化,符号皆真实):
// agentcore/team/runner.go — the long-lived teammate loop, sketched
for {
produced, err := cfg.Execute(identityCtx, turnInput) // run ONE agent turn
history = append(history, produced...) // stitch into running history
lastText := lastAssistantText(produced)
// mark idle + forward this turn's last words to the leader's mailbox
if envelope := hooks.EncodeIdle(name, lastText); envelope != "" {
leaderBox.Send(Message{From: name, Text: envelope})
}
// block on our mailbox until the next message arrives (or ctx cancels)
if err := mailbox.Wait(identityCtx); err != nil { return nil }
localQueue = mailbox.Drain()
currentPrompt = hooks.FormatPrompt(localQueue[hooks.PickPriority(localQueue)])
}
注意 cfg.Execute 是个 TurnExecutor 接口,内核不知道这一 turn 具体怎么跑;wire 格式(消息长什么样)、优先级(谁先处理)、空闲通知(要不要回话)全都是 ProtocolHooks 里的钩子,由应用层注入。内核只保证"turn 循环 + 信箱"这套机制,任何 nil 钩子退化成最朴素的透传。这就是"内核给机制、harness 定策略"最干净的一处体现。
一次性委托 vs 长期队友
flowchart TB
subgraph oneshot["一次性委托 (subagent single/parallel/chain/background)"]
L1["主 agent"] -->|"subagent(agent, task)"| R1["runAgent: 全新隔离 AgentLoop"]
R1 -->|"最后一段文本 + usage"| L1
R1 -.->|"background: 脱离 ctx"| BG["task.Runtime Entry\n跑完 notifyFn 回灌"]
end
subgraph team["长期队友 (team)"]
L2["主 agent = leader (team-lead)"] -->|"subagent(team_name)"| SP["TeamSpawner → team.Spawn"]
SP --> TM["队友 Run goroutine\n常驻 · 有信箱 · 有身份"]
L2 <-->|"send_message ↔ Mailbox"| TM
TM -->|"EncodeIdle → leader 信箱"| PUMP["LeaderInboxPump → Agent.Inject"]
PUMP --> L2
end
左边是"借一只手用完就还",右边是"招一个同事长期共事"。两者的区别在生命周期和通信方式:一次性委托是同步/一次回灌,队友是常驻 + 信箱双向。
harness 接线:把内核原语焊成产品能力
内核只给 Spawn/Run/Mailbox。要让"模型调一次 subagent{team_name} 就多一个能协作的同事",harness 得补上三处接线(internal/bootstrap/assemble_runtime.go 里装配):
| 接线件 | 位置 | 职责 |
| --- | --- | --- |
| TeammateSpawner | internal/agent/teammate_spawn.go | 把 TeamSpawnRequest 变成一次 team.Spawn:合并强制注入的协作工具、按需开 git worktree 隔离、组装 SystemBlocks、构造 TurnExecutor、写 roster/transcript |
| TeammateWaker | internal/agent/teammate_waker.go | 惰性复活:send_message 打到一个已退出的队友名时,从持久化的 roster + transcript 重新拉起它,消息本身即开场白 |
| LeaderInboxPump | internal/agent/team_inbox.go | leader 没有队友那样的 runner 循环,pump 常驻 Wait leader 信箱、过滤掉 idle_notification 的 JSON 壳、把有效消息 Agent.Inject 进主 agent |
TeammateSpawner 构造 executor 时用的是 buildTeammateExecutor:同样包 agentcore.AgentLoop,但把 ContextManager 和 SystemBlocks 只在 spawn 时组装一次(队友可能跑几百 turn,每 turn 重建 summary 状态不可接受),并给每个队友一条独立的 prompt-cache 血缘。wire 格式来自 internal/team/protocol.go:<teammate-message> XML 信封、idle_notification/shutdown_request JSON、以及 PickPriority 的优先级(shutdown > leader > peer,同级 FIFO)。
一次 spawn → run → 汇报 的时序
sequenceDiagram
participant Model as Leader 模型
participant SA as subagent.Tool
participant SP as TeammateSpawner
participant Team as team.Spawn / Registry
participant Run as 队友 Run goroutine
participant Pump as LeaderInboxPump
Model->>SA: subagent(agent, task, team_name)
SA->>SP: executeTeamSpawn → TeamSpawnRequest
SP->>Team: team.Spawn(SpawnConfig{Execute,...})
Team->>Run: 起 goroutine + 注册信箱/身份
Team-->>Model: {task_id, agent_id, status:"running"}
Run->>Run: Execute 一个 turn(隔离 AgentLoop)
Run->>Pump: EncodeIdle → leader 信箱
Pump->>Model: Agent.Inject(下一轮可见)
Model->>Run: send_message → Mailbox(下一个 turn 边界取走)
leader 拿到的是立刻返回的 task_id/agent_id,队友在后台自己跑;两边之后只靠信箱异步通信。
模型看到的把手
模型并不直接碰 Registry 或 Runtime,它只有几个工具:
| 工具 | 文件 | 作用 |
| --- | --- | --- |
| subagent | agentcore/subagent | 委托:single/parallel/chain/background/team-spawn 五合一 |
| task_output / task_stop | internal/tools/task.go | 读取/等待、终止后台任务 |
| send_message | internal/tools/send_message.go | 给队友(按名)或后台子 agent(按 task ID)投递消息 |
| team_create | internal/tools/team_create.go | 给队伍改一个有意义的名字(默认队伍在会话启动时已预建) |
send_message 是统一投递面:先查 team registry(队友/team-lead → 走 Mailbox),查不到就试 waker.Wake 惰性复活,再退回 task runtime(子 agent → 走 AppendPending)。一个工具,三条路由。
安全关键点:无审批门,隔离靠裁剪工具集
这是本篇最该记住的一条。主 agent 在 buildAgent 里装了审批门 agentcore.WithToolGate(approvalEngine.AsToolGate()),危险操作要过闸。但子 agent 与队友的循环里没有这道门:subagent.runAgent 和 buildTeammateExecutor 构造的 LoopConfig 都不带 ToolGate。也就是说,一旦委托出去,子 agent 的工具调用不再经过人工审批。
那怎么保证安全?答案是在构建期就裁剪掉危险工具,不做运行期拦截。裁剪逻辑在 internal/agent/tool_filter.go:
// internal/agent/tool_filter.go — the subagent tool is ALWAYS dropped from a
// sub-agent's pool (recursion guard), regardless of any allow/deny list.
if name == subagentToolName {
continue
}
FilterToolsForAgent 的几道闸,从下到上收窄可用工具集:
subagent 自身永远被剔除:递归 spawn 守卫,配合 MaxAgentDepth 把深度结构性地压在 1。
allAgentDisallowed:ask_user / enter_plan_mode / task_create / team_create / send_message 等,一切"抢占用户对话通道"或"改写协调面"的工具,任何子 agent 都禁用。
customAgentDisallowed:给用户自定义 agent(.codebot/agents/*.md)预留的更严一档。
IsAsync 时只放行 asyncAgentAllowed:后台 agent 无法向用户提问,于是收成 read/write/edit/bash/glob/grep/ls/web 的白名单。
BuildToolPool 给每种子 agent 独立的 read/write/edit 实例(独立 FileReadState),免得子 agent 的读状态污染父级的"先读后写"检查。
队友再额外强制注入回一小撮协作工具(send_message + task_create/update/get/list),task_stop/team_* 仍留给 leader。所以"隔离"这件事的实现是:能力边界在装配期就焊死在工具集里,运行期不再有审批这一层活口。危险操作要么根本不在子 agent 的工具箱里,要么(如需写文件的隔离队友)用 git worktree 沙箱把写入关进独立 checkout。
三问回顾
- 三层各解决什么? 内核原语(subagent/task/team)给"隔离循环 + 后台注册表 + 信箱"这套稳定机制;harness 接线(spawner/waker/pump)把机制焊成"能协作的同事";模型工具(subagent/send_message/team_create)是这一切对模型的唯一把手。三层各司其职,换 wire 格式不动内核,换模型不动接线。
- 一次性子 agent 和队友到底差在哪? 生命周期和通信:子 agent 是"跑完回灌一个结果",队友是"常驻 + 双向信箱 + 只在 shutdown 退出"。但它们共用同一个隔离
AgentLoop 与同一张 task.Runtime。
- 委托出去为什么不危险? 因为危险性在构建期就被工具集裁剪解决了,不靠运行期审批。子 agent 循环无
ToolGate,但它的工具箱里没有 subagent、没有会改写协调面的工具、后台模式还收成只读白名单,闸门前移,隔离即裁剪。
延伸阅读
- 想看 subagent/task/team 在内核究竟处于哪一层、和主循环怎么接:本系列《架构总览》(01-架构总览.md)
- 想弄清"主 agent 有审批门、子 agent 没有"这套边界怎么划:《权限与审批门》(06-权限与审批门.md)
- 想看一个"私有子 agent"的完整用法(不通知、对模型不可见):《Dream》(15-Dream.md)
- 想看这些接线件最后怎么在
assemble_runtime 里焊到一起:《装配全景》(10-装配全景.md)