Skip to content

09 · SubAgent 与后台任务 ​

源码:mewcode/internal/subagent/(catalog.go 136 行 / definition.go 63 / parser.go 163 / embed.go 42 / builtin/*.md 3 个角色)、internal/task/(manager.go 349 / tools.go 213)、internal/agent/agent_tool.go(235)、internal/agent/fork.go(150)、internal/agent/run_to_completion.go(259)、internal/tool/filter.go(122)、internal/tui/tasks.go(33)、docs/ch13/spec.md(F1–F33) 一句话点题:这一章讲的是**「把活分给另一个自己去干,还不许出乱子」**——上下文怎么隔离、工具怎么收窄、权限怎么不越级、跑飞了怎么收得回来。


一、这一章要回答什么 ​

问题本节位置
单 Agent 架构到底卡在哪里?为什么要引入主从?§2.1
主 Agent 是怎么「召唤」子 Agent 的?§3.1
subagent_type 传和不传,走的根本不是一条路?§3.2
子 Agent 的上下文和主 Agent 什么共享、什么隔离?§3.3
一个角色就是一个 Markdown 文件?有哪些字段?§3.4
子 Agent 的循环和主循环是同一套代码吗?§3.5
子 Agent 要做危险操作,谁批准?§3.6
子 Agent 又调子 Agent,会不会指数爆炸?§3.7
「后台任务」到底是怎么跑起来的?状态机长什么样?§3.8
跑完的东西怎么回到主对话?会不会打断用户?§3.8.4
这套实现有哪些「我知道但我没做」的地方?§4
企业里多 Agent 编排真正难的是什么?§6

二、项目在做什么 ​

2.1 单 Agent 架构的两个痛点 ​

MewCode 的主 Agent 是一个 ReAct 循环(见第 03 章),它能单枪匹马完成大部分 coding 任务。但只要任务规模上去,单 Agent 架构会同时撞上两堵墙:

痛点一:上下文污染(Context Pollution)

假设用户说「帮我看看这个项目里所有 HTTP 中间件的实现,总结一下它们的错误处理风格」。主 Agent 会怎么做?glob → 找到 12 个文件 → read_file × 12 → 每个文件 500 行 → 6 万 token 的原始代码全部灌进主对话历史。

问题不在于贵,在于:

后果说明
主对话上下文被撑爆触发压缩(第 06 章),而压缩本身就是有损的
决策质量下降12 个文件的细节混在历史里,模型回答用户后续问题时容易被无关细节带偏
不可逆这些原始文件内容会永久留在主对话历史里,即使任务已经结束
Prompt Cache 抖动历史越长,每轮请求的 prefix 越长,缓存收益被稀释

关键洞察:「读 12 个文件」这个动作的中间过程对主 Agent 毫无价值——主 Agent 只需要最后那句「这些中间件都用了 wrapError 模式,只有 2 个例外」。中间那 6 万 token 应该被隔离在一个可丢弃的上下文里,用完即焚。

痛点二:无法并行(No Concurrency)

主 Agent 一轮只能推进一条线:要么读文件,要么写代码。但真实任务里天然存在可并行的子任务:

任务:给项目加一个 /health 端点
├─ 子任务 A:搞清楚路由是在哪里注册的(搜索 + 阅读,5 轮)
├─ 子任务 B:搞清楚现有的 handler 是怎么写测试的(搜索 + 阅读,4 轮)
└─ 子任务 C:搞清楚配置项是怎么注入的(搜索 + 阅读,4 轮)

这三个探索任务互不依赖,但单 Agent 必须串行做,13 轮迭代做完才开始写代码。而且做完 A 之后,A 的探索细节会一直占着上下文,拖累 B 和 C 的探索效率。

还有两个隐性痛点(面试时说出来会加分):

  1. 权限边界模糊:探索类任务根本不需要写权限,但主 Agent 拿着全量工具集,理论上随时可以 write_file。一个「只读探索」的意图,在机制层没有任何约束。
  2. 失败域不隔离:子任务跑偏了(比如陷入幻觉工具循环),会烧掉主对话的迭代预算(maxIterations = 25),主任务被拖累。

2.2 主从架构全景 ​

MewCode 的解法是引入一层主从(Orchestrator-Worker)结构:主 Agent 通过一个叫做 Agent 的工具,把子任务委派给在独立上下文中运行的子 Agent;子 Agent 只把最终结论文本交回来。

一句话概括:主 Agent 的循环结构完全不变(还是那 25 轮 ReAct),只是工具箱里多了一个 Agent 工具;这个工具的实现不是「执行一段代码」,而是**「构造一个全新的 Agent 实例 + 一趟全新的对话」**。

依赖方向(这是设计的骨架,spec 里有明确要求):

注意那两个反向接口:task 包实现 agent 包里定义的 TaskManager 接口,subagent.Catalog 实现 agent 包里定义的 AgentCatalog 接口。因为 Go 不允许跨包循环引用,接口定义在「使用方」包是打破循环的标准手法(agent_tool.go:22-43):

go
// agent_tool.go:22
type AgentCatalog interface {
	Resolve(name string) (*subagent.Definition, bool)
	ForkDefinition() *subagent.Definition
	List() []*subagent.Definition
}

// agent_tool.go:39
type TaskManager interface {
	Launch(ctx context.Context, ag *Agent, conv *conversation.Conversation, name, task string) string
	AdoptRunning(ctx context.Context, ag *Agent, conv *conversation.Conversation, name string, ev <-chan Event, cancel context.CancelFunc) string
}

顺带一个细节:TaskManager 接口的签名比实现少一个参数——task.Manager.AdoptRunning 多了 ev <-chan Event,接口里有,OK 一致;但 spec F14 说的 Launch(ctx, agent, taskText) (taskID, error) 和实现 Launch(ctx, ag, conv, name, taskText) string 签名不一致(多返回 error 变成了无 error,多了 name/conv)。这是「spec 与实现漂移」的一处,面试时可以主动提。


三、核心设计逐项拆解 ​

3.1 Agent 工具:一个「会造 Agent 的工具」 ​

3.1.1 JSON Schema 参数 ​

go
// agent_tool.go:29
type AgentArgs struct {
	Prompt          string `json:"prompt"`            // 必填:交给子 Agent 的任务指令
	Description     string `json:"description"`       // 必填:一句话描述,供 UI 展示
	SubagentType    string `json:"subagent_type"`     // 可选:预定义角色名,留空走 Fork
	Model           string `json:"model"`             // 可选:haiku/sonnet/opus/inherit
	RunInBackground bool   `json:"run_in_background"` // 可选:强制后台启动
	Name            string `json:"name"`              // 可选:命名子 Agent
}

发给模型的 Schema(agent_tool.go:96-128)关键点:

参数约束面试要点
promptrequired子 Agent 唯一能看到的人类指令(定义式路径下它看不到主对话)
descriptionrequired纯 UI 用(「● Agent(搜索路由注册点)」);强制模型先想清楚要干什么,是一个廉价的「计划」约束
subagent_type可选,无 enum故意不给 enum,因为角色可以由用户/项目动态扩展(.mewcode/agents/*.md);如果把内置 3 个角色写死成 enum,用户自定义角色就用不了了
modelenum [haiku, sonnet, opus, inherit]有 enum,但当前未生效(见 §4)
run_in_backgroundboolFork 路径忽略它(无条件后台)
namestring就是 SendMessage 的寻址键,byName 弱引用

面试官追问点:为什么 subagent_type 不给 enum? 答:因为 Agent 定义是用户可扩展的运行期数据(三层文件加载),不是编译期常量。给 enum 会让「用户在项目里新建一个 .mewcode/agents/dba.md」这件事对模型不可见——模型不知道有这个角色可选。MewCode 用的是把角色名写进 Description(动态生成)而不是写进 Schema,这样既能随文件变化,又不违反 function-calling 的稳定性要求。

3.1.2 动态 Description ​

go
// agent_tool.go:79
func (t *AgentTool) Description() string {
	if t.catalog == nil {
		return "将子任务委派给独立的子 Agent。"
	}
	names := make([]string, 0)
	for _, d := range t.catalog.List() {
		names = append(names, d.Name)
	}
	prefix := "将子任务委派给独立的子 Agent。"
	if len(names) > 0 {
		prefix += " 可用的 subagent_type: " + strings.Join(names, ", ") + "。"
	}
	prefix += " 不传 subagent_type 则为 Fork 模式(继承父对话历史)。后台任务通过 run_in_background=true 启用。"
	return prefix
}

生成结果形如:

将子任务委派给独立的子 Agent。 可用的 subagent_type: Explore, Plan, general-purpose。
不传 subagent_type 则为 Fork 模式(继承父对话历史)。后台任务通过 run_in_background=true 启用。

为什么 Description 要动态生成:这是工具自治性(tool self-description)的典型做法。新增一个角色文件 → 重启 → 模型自动知道有这个角色,不需要改任何提示词或代码。代价是:工具描述变了会击穿 prompt cache(工具定义通常在请求的最前面)。所以 spec 的 N1 专门规定「主 Agent 看到的工具集不因 .mewcode/agents/ 增减而变化」——注意这条实际只能约束「工具的增减」,约束不了「描述的文本变化」,这是一个可以主动承认的边界(内置 3 个角色固定,所以稳态下描述是恒定的)。

catalog.List() 是按 name 升序排的(catalog.go:59-74),所以描述文本是确定性的——这一点很重要,否则每次进程启动角色顺序不同,prompt cache 每次都是冷的。

3.1.3 Resolve 与 Fork 分支 ​

go
// agent_tool.go:151
	// Resolve 定义(spec F2)
	var def *subagent.Definition
	isFork := false
	if aArgs.SubagentType != "" {
		d, ok := t.catalog.Resolve(aArgs.SubagentType)
		if !ok {
			return tool.Result{Content: fmt.Sprintf("未知 subagent_type: %s", aArgs.SubagentType), IsError: true}
		}
		def = d
	} else {
		def = t.catalog.ForkDefinition()
		isFork = true
	}

注意这里是「二选一」而不是「合并」:要么给角色名走定义式,要么不给走 Fork。没有「指定角色 + 继承父上下文」的组合。这是一个刻意的简化(Fork 的语义就是「带着父上下文的分身」,而角色定义的语义是「一张白纸 + 身份 prompt」,两者混起来会产生「这个角色是否该看到父对话」的语义歧义)。

错误处理上有个值得注意的细节:未知 subagent_type 返回的是 IsError: true 的结构化工具结果,而不是 panic 或 Go error。这符合第 03 章讲的「Error as Observation」原则——模型看到「我编了一个不存在的角色名」,下一轮会改用 Explore 或直接不传 subagent_type。

另外 aArgs.Model 与 def.Model 在 Execute 里都没有被消费(agent_tool.go:182 用 t.parent.provider)。这是本模块最值得主动指出的「已解析未接线」项,详见 §4.1。

3.1.4 前台路径与返回 ​

go
// agent_tool.go:206
	// 后台路径(spec F16)
	if background {
		taskID := t.taskMgr.Launch(ctx, subAgent, subConv, aArgs.Name, aArgs.Prompt)
		return tool.Result{Content: fmt.Sprintf(`{"task_id":"%s","status":"async_launched"}`, taskID)}
	}

	// 前台路径(spec F2)
	timeoutCtx, cancel := context.WithTimeout(ctx, autoBackgroundDuration)
	defer cancel()

	events := make(chan Event, 32)

	finalText, err := subAgent.RunToCompletion(timeoutCtx, subConv, aArgs.Prompt, events)
	close(events)

	if timeoutCtx.Err() != nil {
		// 超时自动切后台(spec F17.2)
		if t.taskMgr != nil {
			taskID := t.taskMgr.AdoptRunning(ctx, subAgent, subConv, aArgs.Name, events, cancel)
			return tool.Result{Content: fmt.Sprintf(`{"task_id":"%s","status":"timed_out_to_background"}`, taskID)}
		}
		return tool.Result{Content: fmt.Sprintf("子 Agent 执行超时: %v", err), IsError: true}
	}

	if err != nil {
		return tool.Result{Content: fmt.Sprintf("子 Agent 执行错误: %v", err), IsError: true}
	}

	return tool.Result{Content: finalText}

三个设计点:

  1. 前台是同步阻塞的。RunToCompletion 直接在当前 goroutine 里跑完,AgentTool.ReadOnly() 返回 false(agent_tool.go:131),所以它在主 Agent 的 executeBatched 里走的是串行分支——主 Agent 会完全卡住等子 Agent 跑完。这是「delegation as tool call」模型的必然结果(父 Agent 必须拿到结果才能继续推理)。
  2. events 通道缓冲 32,且前台路径没人消费。子 Agent 的事件用 emitEvent/drainEvents 非阻塞投递(run_to_completion.go:232-258),所以缓冲区满了就丢,绝不会阻塞执行。这是刻意的:前台子 Agent 的中间过程不需要渲染到主 UI,丢事件是安全降级。
  3. 超时不是失败,是「转后台」。timeoutCtx.Err() != nil 时调用 AdoptRunning 把已经跑了一半的 Agent 和对话整体移交给 task.Manager 继续跑,工具结果返回 status: "timed_out_to_background"。已经花掉的 token 和已经读到的信息一点都不浪费。

3.2 两种创建模式:定义式 vs Fork 式 ​

这是本章最重要的一个对比。表里先给结论,再逐条讲。

维度定义式(subagent_type 非空)Fork 式(subagent_type 为空)
Definition 来源catalog.Resolve(name),来自 .md 文件catalog.ForkDefinition(),代码硬编码
Definition.Name角色名(如 Explore)固定 "__fork__"(IsFork() 判定,definition.go:61)
起始对话conversation.New()——完全空白BuildForkedMessages(parentMsgs, prompt)——克隆父对话
首条 user 消息prompt(RunToCompletion 里 conv.AddUser(task))ForkBoilerplate + task
系统提示角色 body(整段覆盖)空 → 走 BuildSystemPrompt 默认(没有自定义系统提示)
默认权限模式角色 permissionMode(缺省 default)permission.ModeDefault(catalog.go:91)
MaxTurns角色 maxTurns(0 → 全局 25)固定 25(catalog.go:90)
Tools/DisallowedTools角色文件里配的都留空 = 继承父工具集(catalog.go:92 注释)
后台由 background / run_in_background 决定无条件后台(isFork 参与 background 计算)
Prompt Cache首次请求必然是冷启动父对话 prefix 逐字节相同 → 首次请求大量 cache_read

3.2.1 ForkDefinition 长什么样 ​

go
// subagent/catalog.go:85
// ForkDefinition 返回 Fork 路径用的临时 Definition(spec F22-F24)。
// Name 固定为 "__fork__",表示子 Agent 从父对话克隆。
func (c *Catalog) ForkDefinition() *Definition {
	return &Definition{
		Name:           "__fork__",
		Description:    "Fork-based subagent",
		Model:          "inherit",
		MaxTurns:       25,
		PermissionMode: permission.ModeDefault,
		// Tools/DisallowedTools 留空 → 工具集继承父
	}
}

注意它不是文件里的角色,而是运行期凭空造出来的 Definition——所以 SystemPrompt 是空的,FilePath 是空的,Source 是 SourceBuiltin(零值 0)。这带来一个副作用:Fork 子 Agent 的系统提示是 MewCode 默认主 Agent 系统提示(run_to_completion.go:59-64 里 a.systemPrompt == "" 就不覆盖)。也就是说 Fork 出来的分身看起来就是主 Agent 本人,只有那条 <fork_boilerplate> user 消息在提醒它「你不是主 Agent」。

3.2.2 Fork 的三步构造 ​

go
// agent/fork.go:41
func BuildForkedMessages(parentMsgs []llm.Message, task string) []llm.Message {
	// 1. 深拷贝
	cloned := cloneMessages(parentMsgs)
	// 2. 处理悬空 tool_use
	cloned = fixPendingToolCalls(cloned)
	// 3. 追加 user 消息 = ForkBoilerplate + task
	userMsg := llm.Message{
		Role:    llm.RoleUser,
		Content: ForkBoilerplate + task,
	}
	cloned = append(cloned, userMsg)
	return cloned
}

调用方:

go
// agent_tool.go:194
	if isFork {
		// Fork 路径:克隆父对话
		if t.parentConvFn == nil {
			return tool.Result{Content: "Fork 模式需要父对话消息,但未注入 parentConvFn", IsError: true}
		}
		parentMsgs := t.parentConvFn()
		forked := BuildForkedMessages(parentMsgs, aArgs.Prompt)
		subConv = conversation.NewFromMessages(forked, nil, nil)
	} else {
		subConv = conversation.New()
	}

为什么必须深拷贝(fork.go:80-96):llm.Message 里有 ToolCalls []llm.ToolCall 和 ToolResults []llm.ToolResult 两个切片字段。如果只做 copy(cloned, msgs),两个对话会共享底层数组——子 Agent 往历史里 append 工具结果,如果底层数组容量够,会直接写进父对话的切片里。这是 Go 里最经典的浅拷贝陷阱,cloneMessages 对这两个字段单独做了 make + copy。

fixPendingToolCalls 解决什么问题(fork.go:100-149):

go
// fork.go:100 摘要
func fixPendingToolCalls(msgs []llm.Message) []llm.Message {
	// 1. 从后往前找最后一条「带 ToolCalls 的 assistant 消息」
	// 2. 收集它之后所有 RoleTool 消息里已经消费的 ToolCallID
	// 3. 未配对的 → 生成 placeholder ToolResult{Content: "[forked, skipped]", IsError: true}
	// 4. 追加一条 RoleTool 消息承载这些 placeholder
}

场景:父对话此刻停在 assistant(tool_calls=[call_1, call_2]),但 tool_results 还没写进历史(比如用户正好在这一刻触发了 Fork 派发)。Anthropic 的消息协议强校验:tool_use 块必须有配对的 tool_result 块,否则整个请求 400。

三个候选方案对比:

方案结果
直接克隆(不修补)子 Agent 第一次请求就 400,功能不可用
截断到最后一条完整轮丢掉最近的上下文(往往正是最关键的那几轮探索)
补 [forked, skipped] placeholder格式合法、上下文完整;代价是模型会看到「上次那两个工具被跳过了」,可能对上下文产生轻微误判

MewCode 选了第三种,并且把 IsError: true 一起带上——让模型知道「这不是正常结果,是占位」。

3.2.3 ForkBoilerplate:五条规则都是在解决真实问题 ​

go
// fork.go:20
const ForkBoilerplate = `<fork_boilerplate>
你是一个 Fork 出来的工作进程。你不是主 Agent。
规则(不可协商):
1. 不能再 Fork(调用 Agent 工具会被拦截)。
2. 不要对话、不要提问、不要请求确认。
3. 直接使用工具:读文件、搜索代码、做修改。
4. 严格限制在你被分配的任务范围内。
5. 最终报告以 "Scope:" 开头,500 字以内。
</fork_boilerplate>

`

逐条对应的问题:

规则解决的问题机制层配套
1. 不能再 Fork防指数 fan-out:子 Agent 再 Fork → 4 个 → 8 个 …… 一轮就能烧光预算ALL_AGENT_DISALLOWED_TOOLS = ["Agent"] 在工具列表层硬禁用(这是机制,不是提示)
2. 不要提问子 Agent 后面没有人。它问一句,就永远卡在那里(前台会挂到 30s/120s 超时,后台会挂到 maxTurns)无(只能靠提示词,这是真正的软约束)
3. 直接使用工具Fork 出来的分身继承了父的全部上下文,容易「继续聊天」而不是干活无
4. 限定范围防止子 Agent 顺手改一堆无关文件,把 Scope: 报告写成散文无
5. 报告 Scope: 开头 + 500 字内结果要回灌父对话。如果子 Agent 回 3000 字,等于把子对话的污染搬进了父对话——隔离白做了无(提示词级约束,AgentTool.Execute 里没有做任何长度硬截断)

面试官追问点:第 5 条只靠提示词,模型不遵守怎么办? 答:这是当前实现的一个真实缺口。企业级做法是在 AgentTool.Execute 返回前做结果长度硬截断 + 摘要回写(比如 if len(finalText) > 8000 { finalText = truncate + "...[truncated]" }),甚至让子 Agent 再跑一次「把上面的报告压缩到 500 字」。MewCode 现在完全依赖模型自律,属于「设计上认识到问题、机制上没兜住」。

IsForkContext 是兜底检测(fork.go:60-77):

go
// fork.go:60
func IsForkContext(msgs []llm.Message) bool {
	for _, msg := range msgs {
		if strings.Contains(msg.Content, ForkBoilerplateTag) { return true }
		for _, tc := range msg.ToolCalls {
			if strings.Contains(string(tc.Input), ForkBoilerplateTag) { return true }
		}
		for _, tr := range msg.ToolResults {
			if strings.Contains(tr.Content, ForkBoilerplateTag) { return true }
		}
	}
	return false
}

设计意图(spec F24.3)是「QuerySource 失效时的兜底」:当 caller 链丢失时,靠扫描对话内容里的 <fork_boilerplate> 标签来判断自己在不在 Fork 上下文里。这是一个非常实用的工程技巧——把来源信息编码进数据本身,而不是靠到处传 context 参数。任何一段历史都能自证身份,不依赖调用栈完整性。

不过要诚实指出:IsForkContext 全仓没有调用点(grep 只命中定义)。也就是说 F24 的「三道闸」里,只有第一道(ALL_AGENT_DISALLOWED_TOOLS)真正生效,QuerySource 检测和第二道标签扫描都没接线。这是「设计完备、实现打折」的典型例子,主动讲出来非常加分。

3.2.4 Fork 借的是 Prompt Cache ​

这是 Fork 模式唯一的硬技术理由(spec N2 明确写了):

父对话(已跑 8 轮):[system][user][assistant][tool_result]...   ← 10 万 token,已在 provider 侧缓存
                         │
Fork 子对话 = 逐字节相同的 10 万 token + [user: <fork_boilerplate>+task]
                         │
                         └─→ 首次请求:input 里 10 万 token 命中 cache_read(便宜约 10 倍)

为什么必须「逐字节相同」:主流 provider 的 prompt cache 是前缀匹配——只要前缀有一个字节不同,后面全部 miss。所以:

  • cloneMessages 必须原样保留每一条消息(顺带解释了为什么 fixPendingToolCalls 只在末尾追加、绝不修改已有消息);
  • 系统提示必须与父 Agent 完全一致(ForkDefinition().SystemPrompt == "" → 走默认 BuildSystemPrompt,与主 Agent 同一个函数、同样入参);
  • 工具定义列表必须与父一致(Tools/DisallowedTools 留空 → 继承父工具集,见 §3.7 的一个坑)。

代价:Fork 子 Agent 没有自己的系统提示。它不能像定义式角色那样被赋予身份和职责,只能靠一条 user 消息(Boilerplate)来约束。这是「缓存效率」换「角色可控性」的一个明确取舍——如果给 Fork 加自定义 system prompt,第一次请求的 cache 就全废了,Fork 的存在意义也就没了。

3.3 上下文隔离:什么共享,什么隔离 ​

这是本章最容易被追问到细节的部分。spec F11 给的定调是:

子 Agent 的运行时状态隔离——独立 SessionRuntime、独立 Conversation、独立 token 计数;但共享 Provider(除非 WithProvider 覆盖)、Registry、PermissionEngine、HookEngine

落实到代码(agent_tool.go:180-190):

go
// agent_tool.go:180
	// 子 Agent 构造(spec F10)
	subRuntime := &SessionRuntime{ContextWindow: t.parent.runtime.ContextWindow}
	subAgent := New(t.parent.provider, t.parent.registry, t.parent.version, t.parent.eng,
		WithRuntime(subRuntime),
		WithAllowedTools(allowed),
		WithSystemPrompt(def.SystemPrompt),
		WithMaxTurns(def.MaxTurns),
		WithPermissionMode(def.PermissionMode),
		WithDontAsk(def.DontAsk),
		WithHookEngine(t.parent.hookEngine),
	)

3.3.1 全景对照表 ​

资源隔离还是共享代码位置为什么
Provider(LLM 客户端)共享(同一个指针)agent_tool.go:182provider 是无状态客户端;另建一个没意义,还会丢掉连接池/重试配置
Registry(工具注册中心)共享同上工具集是全局的;子 Agent 只是通过 allowedTools 白名单「筛着看」,不改注册中心
PermissionEngine共享(关键)t.parent.eng让子 Agent 天然继承父的规则集与「已批准账本」(见 §3.6)——这是权限三层链第一层的物理基础
HookEngine共享WithHookEngine(t.parent.hookEngine)子 Agent 的工具调用同样触发 PreToolUse/PostToolUse——项目级安全策略无法被子 Agent 绕过
version共享t.parent.version环境段展示用
SessionRuntime隔离(全新对象)&SessionRuntime{...}压缩状态、锚点、轮次计数全新
ContextWindow从父继承数值ContextWindow: t.parent.runtime.ContextWindow同一个模型同样的窗口,没必要让子 Agent 知道别的值
Conversation隔离conversation.New() / NewFromMessages(forked, nil, nil)核心隔离点:子 Agent 的历史消息不写进父对话
token 计数隔离新 SessionRuntime 的 UsageAnchor = 0子 Agent 独立估算上下文占用、独立触发压缩
mgr *memory.Manager不带(零值 nil)New 未传 WithMemoryManager子 Agent 不触发记忆更新(RunToCompletion 里根本没这段代码)
catalog *skills.Catalog不带(零值 nil)New 未传 WithCatalog子 Agent 看不到 Skill 目录(见下)
instructionText / memoryText不带New 未传 WithInstructionText/WithMemoryText角色的 system prompt 整段覆盖,这两段本来就进不去
ApprovalUpgrader不带(零值 nil)New 未传 WithApprovalUpgrader见 §3.6.3,这是一处需要留意的现状
PendingReminders隔离新 SessionRuntime 的切片为空父的待注入提醒不会被孩子看到

3.3.2 系统提示的整段覆盖 ​

go
// run_to_completion.go:59
	sys := prompt.BuildSystemPrompt(a.instructionText, a.memoryText, skillsCatalogText)

	// 如果子 Agent 有自定义系统提示,使用它
	if a.systemPrompt != "" {
		sys = a.systemPrompt
	}

注意这是「整段替换」而不是「追加」。后果链条很清晰:

  • 角色 body 生效 → sys = def.SystemPrompt
  • instructionText(项目指令,如 CLAUDE.md)丢失
  • memoryText(记忆索引)丢失
  • skillsCatalogText(Skill 目录)丢失 → 于是子 Agent 不知道有哪些 Skill 可用,也就不会调 LoadSkill

这是一处明确的设计取舍,不是 bug:

观点理由
支持覆盖(现状)角色的 system prompt 就是它的全部人格;混入主 Agent 的项目指令会让「只读探索者」读到「你可以修改代码」这类通用指令,削弱角色约束
支持追加子 Agent 也是这个项目里的 Agent,应该知道项目规范;丢了 Skill 目录等于砍掉一半能力

折中方案(面试可提):把稳定段(项目指令)留在 system prompt,把角色 body 放进第一条 user 消息的前缀——类似 Hook 的 InjectedPrompts 路径。这样角色约束和项目上下文都能保留,代价是角色约束从「系统级」降到「用户级」,权威性下降。另一种方案是分层 system prompt:[通用底座] + [角色 body] + [项目指令],三者拼接顺序固定,prompt cache 依然稳定。

3.3.3 独立 Conversation 的两种形态 ​

定义式路径:

go
// agent_tool.go:203
	} else {
		subConv = conversation.New()
	}

conversation.New() 是真正的空白:零条消息。然后 RunToCompletion 第一步把它填上:

go
// run_to_completion.go:30
	// 把 task 作为 user 消息追加(如果非空)
	if task != "" {
		conv.AddUser(task)
	}

所以定义式子 Agent 的整个世界观 = [system: 角色 body] + [user: prompt]。它看不到用户是谁、之前聊过什么、项目长什么样(除了环境段 prompt.GatherEnvironment 提供的 cwd/git 信息)。

Fork 路径则是完整克隆 + 一条新 user 消息(见 §3.2.2)。

3.3.4 一个容易被忽略的隔离细节:agents 的 runtime nil 兜底 ​

go
// run_to_completion.go:35
	// 确保运行时初始化
	if a.runtime == nil {
		a.runtime = &SessionRuntime{ContextWindow: 200000}
	}

Run 里也有同样的一段(agent.go:176-178)。为什么两个入口都要兜:Agent.New 里其实已经有兜底(agent.go:127-129),但 SessionRuntime 是可以被外部 WithRuntime 注入的,注入的指针可能被别的代码路径置空。三重兜底的写法在工程上不算优雅,但确实消除了「nil pointer dereference」这一整类崩溃——考虑到 ContextWindow 后面会参与压缩阈值计算(cw - SummaryReserve - AutoSafetyMargin),0 值会导致每轮都触发压缩,这个兜底是必要的。

3.3.5 独立 token 计数与压缩 ​

子 Agent 走的是同一套 compact.ManageContext(run_to_completion.go:91-131):

go
// run_to_completion.go:91
		anchor, anchorLen := a.runtime.GetAnchor()
		cw := a.runtime.ContextWindow
		est := compact.EstimateTokens(anchor, conv.Messages(), anchorLen)

		in := compact.ManageInput{
			Conv:           conv,
			Provider:       a.provider,
			ContextWindow:  cw,
			ToolDefs:       defs,
			Replacement:    a.runtime.Replacement,
			Recovery:       a.runtime.Recovery,
			AutoTracking:   a.runtime.AutoTracking,
			Session:        a.runtime.Session,
			UsageAnchor:    anchor,
			AnchorMsgLen:   anchorLen,
			EstimatedToken: est,
			Trigger:        compact.TriggerAuto,
		}

因为 subRuntime 是全新的 SessionRuntime,所以:

  • Replacement(L1 落盘账本)nil → 子 Agent 的工具结果落盘是独立的一份
  • Recovery / AutoTracking / Session 全 nil → 压缩熔断计数独立,子 Agent 压缩失败不会影响主 Agent 的熔断状态
  • UsageAnchor = 0 → 首轮从零估算

为什么必须独立:如果共享 compact.ContentReplacementState,子 Agent 落盘的「大工具结果」占位符会被写进父对话的历史(因为 replacement 是按消息 id 记录的,两边的消息 id 空间会混)。这是一个「看似可以共享、实际必须隔离」的典型案例。

面试官追问:子 Agent 压缩掉的内容,父 Agent 知道吗? 答:不知道,也不该知道。父 Agent 只收到 finalText 一个字符串——子对话里压缩发生过什么、丢了什么,父 Agent 完全无感。这正是隔离的价值:中间过程的一切复杂度(压缩、落盘、重试、失败)都被封在子上下文里。代价是不可审计——你无法从父对话复原子 Agent 是怎么得出结论的,目前的 event 流只有 TUI 能看到,且前台路径根本不消费(缓冲 32 后静默丢弃)。生产环境需要把子 Agent 的事件流也落一份 trace。

3.4 角色定义文件:一个 Markdown 就是一个 Agent ​

3.4.1 文件格式 ​

角色的定义文件是 Markdown + YAML frontmatter,frontmatter 是元数据,body 直接就是子 Agent 的 SystemPrompt:

markdown
---
name: Explore
description: 只读代码探索 Agent,适合搜索、阅读、理清调用链;不能修改文件
disallowedTools:
  - write_file
  - edit_file
model: haiku
maxTurns: 30
---

你是一个文件搜索专家。这是一个只读探索任务。
严禁:创建文件、修改文件、删除文件、执行任何改变系统状态的命令。
工具策略:Glob 做文件模式匹配、Grep 搜索文件内容、Read 读取已知路径、Bash 仅用于只读操作(ls、git log、find、cat)。
尽可能并行发起多个工具调用。高效完成搜索请求,清晰报告发现。

(上面就是 internal/subagent/builtin/explore.md 的真实内容。)

为什么用 Markdown 而不是 JSON/TOML:

维度Markdown + frontmatterJSON / TOML
写一长段提示词✅ 直接写,支持换行、缩进、代码块❌ 要转义 \n,长文本不可读
元数据✅ YAML 结构化✅
生态一致性✅ 与 Skill(SKILL.md)、Claude Code 的 .claude/agents/*.md 同构❌ 需要另学一套
作者门槛会写 Markdown 就会写角色需要懂 schema

3.4.2 frontmatter 全字段表 ​

解析目标结构体(parser.go:29-38)与完整语义:

字段YAML 类型必填校验规则缺省/降级源码行
namestring必填agentNameRegex = ^[A-Za-z][A-Za-z0-9\-_]{0,31}$(首字符字母,后续字母/数字/连字符/下划线,总长 ≤ 32)缺失或不匹配 → 该文件解析失败被跳过parser.go:17,55-60
descriptionstring必填非空缺失 → 解析失败被跳过parser.go:61-63
tools[]string可选不校验存在性空 = 不收窄(工具集取上游结果)parser.go:105
disallowedTools[]string可选不校验存在性空 = 不排除parser.go:106
modelstring可选validModels = {"", "inherit", "haiku", "sonnet", "opus"}空 → inherit;非法 → log.Printf 警告 + 降级 inheritparser.go:20-26,66-73
maxTurnsint可选负数归零0 = 沿用全局 maxIterations = 25parser.go:96-100
permissionModestring可选default/acceptEdits/plan/bypassPermissions + 专属 dontAsk空 → default;非法 → 警告 + defaultparser.go:76-94
backgroundbool可选无falseparser.go:111

两个值得讲的细节:

① name 的正则与注释、spec 三者不一致。 正则允许大写字母,代码注释解释了原因:

go
// parser.go:15
// agentNameRegex 校验 name 字段合法性:小写字母/数字/连字符,长度 1-32。
// 允许大写字母以兼容现有 Explore/Plan 等内置角色名。
var agentNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9\-_]{0,31}$`)

而 spec F4 写的是「小写字母 / 数字 / 连字符,长度 1-32」。实现比 spec 宽,理由是内置角色 Explore / Plan 本身带大写。这是「文档滞后于实现」的良性偏差(实现更正确),但注释里第一句仍写着「小写字母」,属于注释没跟着更新。

② dontAsk 的处理方式很特别(parser.go:80-94):

go
	switch strings.ToLower(fm.PermissionMode) {
	case "":
		pMode = permission.ModeDefault
	case "dontask":
		// dontAsk 是子 Agent 专属模式——自动批准所有规则未命中的工具(spec F4)
		pMode = permission.ModeDefault
		dontAsk = true
	default:
		if m, ok := permission.ParseMode(fm.PermissionMode); ok {
			pMode = m
		} else {
			log.Printf("[subagent] warn: %s 中 permissionMode=%q 不合法,降级为 default", filePath, fm.PermissionMode)
			pMode = permission.ModeDefault
		}
	}

dontAsk 不是 permission.Mode 的一个枚举值,而是「ModeDefault + 一个独立的布尔 DontAsk = true」。原因:permission.Mode 是主 Agent 权限体系的公开枚举,往里塞一个「只对子 Agent 有意义」的模式会污染主体系的语义(比如 TUI 的模式切换循环要跳过它、ParseMode 要处理它)。所以这里选择了旁路一个布尔,最终透传到 agent.WithDontAsk(def.DontAsk)。

3.4.3 三层加载与优先级 ​

go
// subagent/catalog.go:25
func LoadCatalog(root string) *Catalog {
	c := &Catalog{
		defs:     make(map[string]*Definition),
		bySource: make(map[Source][]*Definition),
	}

	// 1. 内置级(embed)
	c.addAll(builtinDefinitions(), SourceBuiltin)

	// 2. 用户级:~/.mewcode/agents/
	homeDir, err := os.UserHomeDir()
	if err == nil {
		userDir := filepath.Join(homeDir, ".mewcode", "agents")
		c.addAll(loadFromDir(userDir, SourceUser), SourceUser)
	}

	// 3. 项目级:<root>/.mewcode/agents/
	projDir := filepath.Join(root, ".mewcode", "agents")
	c.addAll(loadFromDir(projDir, SourceProject), SourceProject)

	// 4. 插件级:本期跳过(SourcePlugin)

	return c
}

优先级实现的全部秘密就是一行赋值(catalog.go:97-105):

go
// addAll 批量添加定义,同名时后添加的覆盖前面的(spec F6)。
func (c *Catalog) addAll(defs []*Definition, source Source) {
	c.mu.Lock()
	defer c.mu.Unlock()

	for _, d := range defs {
		c.defs[d.Name] = d
	}
	c.bySource[source] = defs
}

加载顺序 = 优先级顺序:builtin(最低)→ user → project(最高)。后写的整体覆盖同名条目。

层级路径用途解析失败行为
builtin//go:embed builtin/*.md内置 3 个角色,保证开箱可用panic(embed.go:16-33)
user~/.mewcode/agents/*.md个人常用角色,跨项目复用stderr 警告 + 跳过
project<root>/.mewcode/agents/*.md项目专属角色(如「本项目的 DB 专家」),可覆盖内置行为stderr 警告 + 跳过
plugin无(SourcePlugin 占位)本期不实现—

「内置失败 panic,外部失败降级」是非功能需求 N4 的刻意设计:

go
// embed.go:12
// builtinDefinitions 加载所有内嵌的 Agent 定义文件(spec F32)。
// 解析失败直接 panic——内嵌文件是代码的一部分,构建期错误即灾难。
func builtinDefinitions() []*Definition {
	entries, err := builtinFS.ReadDir("builtin")
	if err != nil {
		panic(fmt.Sprintf("subagent: 读取内嵌 builtin 目录失败: %v", err))
	}
	// ...
		def, err := ParseDefinition(data, "builtin:"+entry.Name(), SourceBuiltin)
		if err != nil {
			panic(fmt.Sprintf("subagent: 解析内嵌定义 builtin/%s 失败: %v", entry.Name(), err))
		}

对比 loadFromDir(catalog.go:122-126):

go
		def, err := ParseFile(path, source)
		if err != nil {
			fmt.Fprintf(os.Stderr, "[subagent] warn: 跳过 %s: %v\n", path, err)
			continue
		}

判据是「谁写的这个文件」:内嵌文件是代码的一部分(跟着二进制发布,编译期就能验证),parse 失败意味着代码有 bug,必须立即炸;用户/项目文件是运行期输入,一个手滑写错的 frontmatter 不该让整个 CLI 起不来。

面试官追问:三层覆盖是「整体覆盖」还是「字段级合并」? 答:整体覆盖——c.defs[d.Name] = d 直接替换整个指针,不做字段级 merge。项目级写一个 explore.md 哪怕只写了 maxTurns: 5,也必须把 name/description/body 全部重写,因为 ParseDefinition 会先校验必填字段,缺 name 直接失败。这个语义与 MCP 配置的 mergeServers 一致(见第 08 章),但与 Hook 的「规则叠加」相反。整体覆盖的好处是可预测:项目里看到什么就是什么,不会「继承到一半的配置」。

Resolve 大小写敏感(catalog.go:51-56):

go
// Resolve 按名查找定义,返回优先级最高的版本(spec F6)。
func (c *Catalog) Resolve(name string) (*Definition, bool) {
	c.mu.Lock()
	defer c.mu.Unlock()
	d, ok := c.defs[name]
	return d, ok
}

直接 map 查,没有 strings.ToLower。而 skills 包的 Catalog.Get 是大小写不敏感的(strings.ToLower)。两套扩展体系的同名 API 语义不一致,这是一个真实的一致性瑕疵:模型传 subagent_type: "explore" 会得到「未知 subagent_type」,而 /{explore} 命令能正常工作。

同时注意 Resolve 用的是 sync.Mutex(写锁)而不是 sync.RWMutex 的读锁——Catalog 在启动期加载完就不再写入,理论上用 RWMutex + RLock 更合适。K 是 P 级优化,但会被面试官挑出来问「为什么用写锁做只读操作」。

3.4.4 frontmatter 解析的边界 ​

go
// parser.go:133
func parseFrontmatterAndBody(content string) (meta string, body string, err error) {
	// 跳过 UTF-8 BOM
	content = strings.TrimPrefix(content, "\xef\xbb\xbf")
	content = strings.TrimPrefix(content, "")

	// 不支持前导空白——"---" 必须在第一列
	if !strings.HasPrefix(content, "---") {
		return "", content, nil
	}

	// 查找首行 "---" 之后的内容
	afterFirst := content[3:]
	afterFirst = strings.TrimPrefix(afterFirst, "\n")

	// 查找独占一行的闭合 "---"
	endIdx := strings.Index(afterFirst, "\n---\n")
	if endIdx == -1 {
		if strings.HasSuffix(afterFirst, "\n---") {
			return afterFirst[:len(afterFirst)-4], "", nil
		}
		// 没有闭合 delimiter,整个内容视作 body
		return "", content, nil
	}

	frontmatterText := afterFirst[:endIdx]
	bodyText := strings.TrimSpace(afterFirst[endIdx+5:]) // +5 跳过 "\n---\n"

	return frontmatterText, bodyText, nil
}

四个边界行为:

输入行为理由
带 UTF-8 BOM剥离后正常解析Windows 编辑器/某些工具会加 BOM;不剥离会导致 --- 前缀匹配失败,整个文件退化成 body(角色没有 name → 加载失败)
无 frontmattermeta 为空串,全文当 body然后 yaml.Unmarshal("") 得到零值 → 缺 name → 报错跳过。即「没有 frontmatter 的 .md 会被拒绝」,这是正确的(否则一个 README.md 丢进目录就会变成一个无名角色)
文件末尾是 \n---接受,body 为空支持「只有元数据、body 空」的极简角色
无闭合 ---全文当 body容错;结果是解析失败被跳过

已知缺陷(注释自陈):正文里包含 \n---\n(Markdown 的分隔线语法,或代码块里的 YAML)会被误判为 frontmatter 闭合。因为解析是字符串查找而不是逐行状态机。生产做法是按行扫描,只在「整行恰好是 ---」时才算闭合——但注意 strings.Index("\n---\n") 实际上要求了闭合符独占一行,所以body 中间出现一个独立的 --- 行才会误判。对系统提示词来说这不算罕见(写分隔线是常见排版习惯),属于真实风险。

另外这里有一段自陈的重复实现:

go
// parser.go:130
// 这与 skills/parser.go 的 splitFrontmatter 逻辑几乎一致,但 subagent 独立实现一份
// 以避免循环依赖(plan.md 技术决策:Markdown 解析器复用)。

注释说「逻辑几乎一致、独立实现以避免循环依赖」——但 subagent 和 skills 互不依赖,两者只依赖 permission,所以这里并不存在循环依赖。真实原因更可能是「不想让两个包共享一个内部工具包」(共享需要新建第三个包,或把解析器放到某个公共位置)。这是一个注释理由站不住脚的地方,面试时可以作为「代码考古」的例子讲(同时要注意 subagent 版本多做了 BOM 剥离,说明两份实现已经开始漂移——这正是「复制粘贴两个实现」的必然代价)。

3.4.5 内置的 3 个角色 ​

文件name关键 frontmatterbody 要旨设计意图
explore.mdExploredisallowedTools: [write_file, edit_file]、model: haiku、maxTurns: 30只读文件搜索专家;禁止创建/修改/删除;Bash 仅限 ls/git log/find/cat;鼓励并行发多个工具调用高频、机械、易并行的探索任务;配 haiku 走成本优化
plan.mdPlandisallowedTools: [write_file, edit_file, Agent]、maxTurns: 15、permissionMode: plan软件架构师;只读规划;输出分步实现计划,末尾必须列 3-5 个最关键文件路径「先想清楚再动手」;permissionMode: plan 让 Check 层直接收窄到只读工具
general-purpose.mdgeneral-purposemaxTurns: 30(无白/黑名单)「把任务做完,不要过度设计,也不要做一半就停」;报告只要要点兜底的通用分身,用于「需要完整能力但独立上下文」的场景

三个细节:

  1. Explore 的 model: haiku 当前不生效(见 §4.1)——它实际跑的是父 Agent 的 provider/model。所以「探索任务用便宜模型」这个成本优化目前只是元数据。
  2. Plan 的 disallowedTools 里带 Agent 是冗余的——ALL_AGENT_DISALLOWED_TOOLS = ["Agent"] 已经对所有子 Agent 硬禁了。属于「防御性重复」,不算错,但说明写角色的人不确定底层有没有兜住。
  3. Explore 的 maxTurns: 30 比主 Agent 的 25 还大。合理:探索任务往往需要更多轮(读多个文件),而且每次迭代成本远低于主对话(子上下文短)。但要注意这意味着单个前台子 Agent 的迭代预算超过了主 Agent 整轮——如果主 Agent 一轮里派了 3 个 Explore,理论上一轮能烧掉 90 次子迭代。

3.5 RunToCompletion:子 Agent 的循环 ​

go
// run_to_completion.go:29
func (a *Agent) RunToCompletion(ctx context.Context, conv *conversation.Conversation, task string, events chan<- Event) (string, error) {
	// 把 task 作为 user 消息追加(如果非空)
	if task != "" {
		conv.AddUser(task)
	}
	// 确保运行时初始化
	if a.runtime == nil {
		a.runtime = &SessionRuntime{ContextWindow: 200000}
	}
	// 计算 maxTurns
	turns := a.maxTurns
	if turns == 0 {
		turns = maxIterations
	}
	// ...
	for iter := 1; iter <= turns; iter++ {
		if ctx.Err() != nil {
			return lastAssistantText(conv), ctx.Err()
		}
		// ① 取工具集  ② 上下文管理  ③ reminder + PreUserMessage hook
		// ④ streamOnce → drainEvents  ⑤ 锚点/Usage
		// ⑥ len(calls)==0 → 自然完成返回
		// ⑦ AddAssistantWithToolCalls → executeBatched → AddToolResults
		// ⑧ 熔断判定(unknownRun / !completed)
	}
	return lastAssistantText(conv), ErrMaxTurnsReached
}

3.5.1 与主 Run 的差异对照表 ​

维度Run(agent.go:164)RunToCompletion(run_to_completion.go:29)
签名(ctx, conv, mode) <-chan Event(ctx, conv, task string, events chan<- Event) (string, error)
返回方式异步:立即返回 channel,循环在独立 goroutine同步阻塞:跑完才返回,调用方拿 (文本, 错误)
迭代上限固定 maxIterations = 25a.maxTurns;0 → 回落 maxIterations(缺省继承 25)
系统提示prompt.BuildSystemPrompt(instruction, memory, skillsCatalog)若 a.systemPrompt != "" 则整段覆盖
工具集Plan → ReadOnlyDefinitions();其余 → Definitions()额外支持 allowedTools 白名单:DefinitionsFiltered(a.allowedTools)
权限模式由调用方传 mode 参数用 a.permissionMode(permissionModeSet 为真时),否则 ModeDefault
事件出口直接 emit(ctx, ch, ...) 到调用方的 channel(带背压:无缓冲,消费者慢就阻塞)内部 internalCh(缓冲 32)→ drainEvents 非阻塞转发到 events(丢事件不丢执行)
事件类型完整:Iter、Text、Tool、Usage、Notice、Approval、Compact、Done、Err只发 Compact、Usage、Text、Tool(不发 Iter、不发 Done、不发 Notice)
记忆更新自然停止时若 memMgr != nil → runtime.IncTurn() 每 5 轮触发 memMgr.UpdateAsync完全不触发(也没有 memMgr)
Stop Hook三处派发(自然停止 / 未知工具超限 / 触达 maxIterations)完全没有派发
Notification Hook流错误时派发不派发
未知工具阈值maxUnknownRun = 3maxUnknownRunSub = 2(更严)
收尾ensureAssistantTail 兜底 + Event{Notice}直接返回 lastAssistantText(conv)
历史一致性有 finishCancelled / ensureAssistantTail 兜底没有兜底(子对话用完即弃,不需要保证可继续对话)

3.5.2 四个差异的「为什么」 ​

① 为什么同步返回而不是 channel:Run 的调用方是 TUI,需要边跑边渲染;RunToCompletion 的调用方是「工具调用」,工具调用的语义就是同步求值——它必须返回 tool.Result 才能回灌给父模型。如果把子 Agent 也做成 channel,AgentTool.Execute 就得自己等 channel 关闭再拼结果,多一层无意义的转换。

② 为什么事件转发非阻塞:

go
// run_to_completion.go:231
// emitEvent 非阻塞地向 events channel 发送事件。
func emitEvent(ch chan<- Event, ev Event) {
	if ch == nil {
		return
	}
	select {
	case ch <- ev:
	default:
	}
}
go
// run_to_completion.go:242
// drainEvents 从 internal channel 非阻塞地读取所有剩余事件并转发到 external channel。
func drainEvents(internal <-chan Event, external chan<- Event) {
	if external == nil {
		return
	}
	for {
		select {
		case ev := <-internal:
			select {
			case external <- ev:
			default:
			}
		default:
			return
		}
	}
}

这是「观测不影响执行」原则的实现:子 Agent 的执行流绝不能因为「没人看事件」而卡住。前台路径(AgentTool.Execute)创建了 events 但从不消费——如果这里用阻塞发送,第一个事件就会把子 Agent 卡死,功能直接不可用。

drainEvents 是最佳努力排空:for + default 形成「有多少读多少、立刻返回」的语义。注意它不会读到 channel 里的最新事件之后还阻塞等待——它只排空当时已在缓冲里的事件,然后返回,让循环继续。所以事件流是「采样」而非「完整序列」。

③ 为什么子 Agent 的未知工具阈值更严(2 而不是 3):主 Agent 面对的是用户真实意图,偶发幻觉值得给 2 次自我修正机会;子 Agent 面对的是父 Agent 转述的任务,如果它连续 2 轮在编工具名,说明任务描述本身就模糊或它理解错了——继续的期望收益很低。更本质的理由是试错预算的经济学:主 Agent 的 25 轮是用户的等待时间,子 Agent 的 30 轮是父 Agent 的阻塞时间 × 可能的并发数,必须更吝啬。

go
// run_to_completion.go:19
// maxUnknownRunSub 子 Agent 连续未知工具上限(比主 Agent 更严格)。
const maxUnknownRunSub = 2

④ 为什么不需要历史兜底:主 Agent 的历史要能继续对话(用户取消后还会再发消息),所以必须保证 user/assistant 交替、tool_use/tool_result 配对(第 03 章 §3.6)。子 Agent 的历史是一次性的——它跑完就把 finalText 交出去,子对话本身除了 SendMessage 续派之外不会再被请求。所以 RunToCompletion 在取消路径上直接 return lastAssistantText(conv), ctx.Err(),不做 ensureAssistantTail。

但这里有一个真实的坑:SendMessage 续派时用的就是这份没有被兜底的历史。如果上次是被取消/超轮数结束的,历史末尾可能停在 tool 消息或 assistant(tool_calls) 上:

go
// run_to_completion.go:204
		// 执行中被取消
		if !completed {
			return lastAssistantText(conv), ctx.Err()
		}

取消时 executeBatched 已经补齐了 tool_result(第 03 章 §3.6 的机制),所以配对上没问题;但如果是在循环首轮的 ctx.Err() != nil 检查处退出(run_to_completion.go:76-78),历史末尾就是 assistant(tool_calls) + 完整 tool_result,格式仍合法。真正危险的是 ErrMaxTurnsReached 路径——它是循环里最后一轮正常执行完后退出,历史末尾必然是配对的,也合法。所以实际上这条路径是安全的,但安全性来自「恰好」,而不是来自显式的保证——企业级实现应该在 SendMessage 前统一跑一次 fixPendingToolCalls 风格的校验(这个函数已经有了,复用它零成本)。

3.5.3 循环体的重复问题 ​

Run(agent.go:196-410,约 215 行)与 RunToCompletion(run_to_completion.go:74-213,约 140 行)有大量重复代码:取工具集、ManageContext 装配、willSummarize 判定、PreCompact/PostCompact hook、buildReminder、envText 拼接、streamOnce 调用、锚点更新。

而 spec F9 明确写的是:

同一段循环代码与主对话 Run 共用,不重复实现

实现与设计有偏差。面试时主动指出并给出重构方向是很好的反思分:

go
// 建议的重构方向(伪代码)
type loopOptions struct {
	sys         string                            // 系统提示(覆盖 or 默认)
	defsFn      func(iter int) []llm.ToolDefinition // 工具集决策
	maxIter     int
	emit        func(Event) bool                  // 事件出口(阻塞 or 非阻塞都封装在这)
	onNaturalStop func(conv, text) (string, error) // 自然完成时的处理(记忆更新 / 直接返回)
	onMaxIter   func(conv) (string, error)
}

func (a *Agent) runLoop(ctx context.Context, conv *conversation.Conversation, opt loopOptions) (string, error)

把差异项参数化(系统提示来源、工具集决策、轮数、事件出口、收尾策略),两个入口都变成 20 行的薄壳。这样未来加「第三个入口」(比如第 13 章提过的 Verification Agent)就不需要再抄一遍 140 行。

3.6 权限三层升级链 ​

子 Agent 是自主运行的——没有人盯着它,它可能在后台跑了 5 分钟。所以「它能不能执行某个工具」这个决策必须有一条不依赖人类在场的降级链。

spec F12 定义了三层:

① 父对话已批准账本     → Allow(父批准过的,子不用再问)
② 子角色 permissionMode → dontAsk 放行 / acceptEdits 放行写 / bypass 全放行 / 其他走 modeFallback
③ 三层之外仍是 Ask      → 升级到主 TUI 弹审批框(标注 [来自 SubAgent X])

3.6.1 第一层:父已批准账本(物理上就是共享 Engine) ​

这一层没有独立代码——它是「共享 PermissionEngine」这个决定的直接副产品:

go
// agent_tool.go:182
	subAgent := New(t.parent.provider, t.parent.registry, t.parent.version, t.parent.eng, ...)
	                                                                          // ↑ 同一个 Engine 指针

父 Agent 在用户选择「永久允许」时会写本地规则:

go
// agent.go:855(父 Agent 的审批路径)
				case permission.OutcomeAllowOnce, permission.OutcomeAllowForever:
					if outcome == permission.OutcomeAllowForever {
						if err := a.eng.PersistLocalAllow(call); err != nil {
							emit(ctx, ch, Event{Notice: fmt.Sprintf("(写入本地规则失败: %v)", err)})
						}
					}
go
// permission/persist.go:96
	// 同步更新内存
	if r, err := parseRuleWithAllow(yamlStr, true); err == nil {
		e.local.allow = append(e.local.allow, r)
	}

而 Engine.Check 的规则引擎层(permission/engine.go:121-136)会查 e.local:

go
	// ③ 规则引擎:本地 > 项目 > 用户,就近命中即返回
	for _, layer := range []struct {
		rs   RuleSet
		name string
	}{{e.local, "本地"}, {e.project, "项目"}, {e.user, "用户"}} {
		if d, hit := layer.rs.match(friendly, target, isFile); hit {
			if d == Deny {
				return Deny, fmt.Sprintf("匹配 %s deny 规则:%s(%s)", layer.name, friendly, target)
			}
			return Allow, "" // allow 规则命中,直接放行
		}
	}

所以「账本」的实体是 Engine.local 规则集 + 本地 YAML 文件,父子共享同一个 Engine 指针 → 父批准过的规则子 Agent 自动命中 → 直接 Allow,不会弹窗。

这个设计的精妙之处在于零成本实现:不需要在父子之间同步任何「已批准列表」,也不需要额外数据结构。代价是粒度过粗——子 Agent 继承的是「这个 session 内所有批准」,包括用户在父对话里为了别的目的批准的规则。生产环境可能希望「子 Agent 只继承与之相关的批准」,那就需要给 Engine 加 per-agent 的规则视图。

3.6.2 第二层:角色 permissionMode ​

go
// agent.go:760
			case permission.Ask:
				// 子 Agent dontAsk 模式:直接 Allow(spec F12.2)
				if a.dontAsk {
					results[i] = a.runTool(ctx, call)
					// ... emit PhaseEnd ...
					i++
					continue
				}

				// 子 Agent 升级到父 TUI 审批(spec F12.3)
				if a.approvalUpgrader != nil {
					// ...
				}

				outcome, ok2 := a.requestApproval(ctx, call, reason, ch)

注意 dontAsk 的分支位置:它只在 Check 返回 Ask 时才生效——也就是黑名单/沙箱/deny 规则已经拦下的仍然拦。spec F12 说得很准确:「自动批准所有规则未命中的」,不是「绕过所有权限」。

各模式的兜底行为由 modeFallback 决定(permission/engine.go:143-157):

go
func modeFallback(mode Mode, cat Category) (Decision, string) {
	// 只读 / bypass 全 Allow
	if cat == CategoryRead || mode == ModeBypass {
		return Allow, ""
	}
	// acceptEdits:文件写 Allow、命令执行 Ask
	if mode == ModeAcceptEdits && cat == CategoryWrite {
		return Allow, ""
	}
	// 其余(default/plan 的 Write/Exec、acceptEdits 的 Exec)→ Ask
	reason := fmt.Sprintf("%s 模式下 %s 类操作需确认", mode.String(), catName(cat))
	return Ask, reason
}
permissionMode只读类文件写类命令执行类是否还要升级到父 TUI
default(含 dontAsk 的底层模式)AllowAskAsk是(除 dontAsk)
acceptEditsAllowAllowAsk命令执行仍会
planAllowAskAsk是
bypassPermissionsAllowAllowAllow否(黑名单/沙箱仍拦)
dontAsk(布尔)AllowAllowAllow否

注意 Check 里 cat 是这么算出来的(permission/settings.go:89-102):

go
func categorize(internal string, readOnly bool) Category {
	if readOnly { return CategoryRead }
	switch internal {
	case "write_file", "edit_file": return CategoryWrite
	case "bash": return CategoryExec
	default:
		// 未注册工具归命令执行类(最严)
		return CategoryExec
	}
}

Agent 工具本身会落到 default 分支 → CategoryExec → default 模式下 Ask。所以主 Agent 每次派子 Agent 都会弹一次审批(除非用户选过「永久允许」)。这是一个值得注意的行为:派发子任务不是免费的,它的成本是「一次人工确认」。企业级实现通常会想清楚「哪些操作值得打断用户」——派发一个只读的 Explore,理想上不该弹窗。

3.6.3 第三层:升级到父 TUI(现状与设计意图的差距) ​

设计意图(spec F12.3 / F13)是:子 Agent 把 ApprovalRequest 发到自己的事件流 → TaskManager / SkillFork host 转发到主 TUI → TUI 弹框标注 [来自 SubAgent X] → 用户三选一 → Outcome 通过 Respond channel 回传。

代码里的接口是就绪的:

go
// agent/permission_upgrade.go:9
// ApprovalUpgrader 是子 Agent 把审批请求升级到父 TUI 的回调(spec F12)。
// 实现方:TaskManager 把请求转发到主 TUI 的事件流;前台 inline 模式直接复用现有 Approval 路径。
// 返回 (outcome, ok)——ok=false 时调用方应走默认 emit Approval 路径。
type ApprovalUpgrader func(ctx context.Context, req *ApprovalRequest) (permission.Outcome, bool)
go
// runtime.go:163
func WithApprovalUpgrader(fn ApprovalUpgrader) Option {
	return func(a *Agent) {
		a.approvalUpgrader = fn
	}
}

但 agent_tool.go:182-190 构造子 Agent 时没有传 WithApprovalUpgrader,全仓也没有任何 ApprovalUpgrader 的实现。所以当前实际行为是:

  • 子 Agent 的 approvalUpgrader == nil → 走到 a.requestApproval(ctx, call, reason, ch)(agent.go:833)
  • requestApproval 把 Event{Approval: req} 发到子 Agent 的事件流:
go
// agent.go:982
func (a *Agent) requestApproval(ctx context.Context, call llm.ToolCall, reason string, ch chan<- Event) (permission.Outcome, bool) {
	respond := make(chan permission.Outcome, 1)
	req := &ApprovalRequest{Name: call.Name, Args: argPreview(call.Input), Reason: reason, Respond: respond}
	if !emit(ctx, ch, Event{Approval: req}) {
		return 0, false
	}
	select {
	case o := <-respond:
		return o, true
	case <-ctx.Done():
		return 0, false
	}
}

关键在 case <-ctx.Done():Respond channel 永远没有人回传 → 子 Agent 阻塞到这个 ctx 超时为止。ctx 有两个来源:

路径ctx 来源最坏等待时间
前台AgentTool.Execute 的 ctx(来自父的 context.WithTimeout(ctx, tool.DefaultTimeout))30 秒(见 §4.2)
后台Launch 传入的 parentCtx同上,30 秒

也就是说:一个需要审批的子 Agent 工具调用,实际表现是「卡 30 秒然后失败」——而不是「弹窗等用户点」。这是本章最值得主动指出的实现缺口,因为整个 §3.6 的三层链里,前两层是完整实现的,第三层是接口就绪、接线缺失。

面试怎么讲:不要说「这块没做」,要说清楚为什么难: 「第三层要求在子 Agent 暂停的同时让主 TUI 弹窗,还要保持父子两条事件流不互相阻塞。我们设计了 ApprovalUpgrader 回调 + Respond channel(缓冲 1)的接口,但实现需要 TUI 在主事件循环里同时消费主 Agent 事件流和子 Agent 事件流,并保证弹窗期间主对话的状态机不被破坏。当前实现里子 Agent 的事件流在前台路径根本没人消费(缓冲 32 后丢弃),所以先退化成『超时失败』这个安全默认行为——宁可拒绝,不能静默放行。要补的话,第一步是让 TUI 前台路径也订阅子 Agent 的事件流。」

3.6.4 dontAsk 的安全审查视角 ​

dontAsk 是一个放宽开关(放宽到自动批准所有 Ask),而且它是子 Agent 专属——主 Agent 没有这个模式。这意味着:

  • 用户可能写了 .mewcode/agents/auto-fixer.md 带 permissionMode: dontAsk,然后主 Agent(或 Fork 子 Agent)就能通过 subagent_type: auto-fixer 间接获得无限执行权限(bash 随便跑、文件随便写),而用户在当前对话里并没有批准过这些命令。
  • 更要命的是 Fork 场景:Fork 子 Agent 的 Definition 是代码硬编码的(permissionMode: ModeDefault,dontAsk = false),所以 Fork 不会拿到 dontAsk——这一层是安全的。
  • 但 general-purpose 角色是 default 模式,也会弹窗——所以现状下 dontAsk 只能通过用户显式写的角色文件启用。

企业级该怎么做:dontAsk 必须受一个独立于角色文件的开关控制(比如用户级配置 agents.allowDontAsk: false,或每次启用需要一次显式确认),否则「读一个 clone 下来的仓库里的 .mewcode/agents/*.md」就等于「把机器权限交出去」——这与第 08 章里 Hook 的 hooks.yaml 随仓库 clone 带入是同一类风险。

3.7 嵌套熔断:五层工具过滤 ​

子 Agent 最危险的失败模式是递归 fan-out:子 Agent 又派子 Agent,每个又派…… 一轮之内可以轻松产生几十个并发 Agent,烧光配额。MewCode 用五层过滤(spec F30)在构造期把工具列表裁剪到位。

go
// tool/filter.go:30
// ApplyAgentToolFilter 按 spec F30 顺序应用五层过滤,返回最终 allowed 列表。
//
// 过滤顺序(spec F30):
//  1. 起点 = registry 的全部工具名
//  2. 去掉 ALL_AGENT_DISALLOWED_TOOLS
//  3. 如果是后台 → 取交集 ASYNC_AGENT_ALLOWED_TOOLS + MCP/Skill 工具
//  4. 去掉 Agent 定义的 disallowedTools 黑名单
//  5. 如果 Agent 定义了 tools 白名单 → 取交集
func ApplyAgentToolFilter(p FilterParams) []string {
	// 1. 起点:全部工具副本
	result := make([]string, len(p.All))
	copy(result, p.All)

	// 2. 去掉 ALL_AGENT_DISALLOWED_TOOLS
	result = removeTools(result, ALL_AGENT_DISALLOWED_TOOLS)

	// 3. 如果是后台 → 与 ASYNC_AGENT_ALLOWED_TOOLS + MCP/Skill 取交集
	if p.Background {
		result = intersectAsyncTools(result)
	}

	// 4. 去掉定义的 disallowedTools 黑名单
	if len(p.Disallowed) > 0 {
		result = removeTools(result, p.Disallowed)
	}

	// 5. 如果定义了 tools 白名单 → 取交集
	if len(p.Allowed) > 0 {
		result = intersectTools(result, p.Allowed)
	}

	return result
}

3.7.1 五层逐层拆解 ​

第 1 层:起点 = 父的工具全集

go
// agent_tool.go:172
	allowed := tool.ApplyAgentToolFilter(tool.FilterParams{
		All:        t.parent.registry.Names(),
		Source:     int(def.Source),
		Background: background,
		Allowed:    def.Tools,
		Disallowed: def.DisallowedTools,
	})

registry.Names() 是父 Agent 当前注册中心里的全部工具名——包括内置工具、MCP 工具(mcp__*)、Skill 工具。「继承」是默认语义,收窄是显式动作。

注意 Source: int(def.Source) 传进去了,但 ApplyAgentToolFilter 完全没用这个字段(filter.go:38-62 里没有任何 p.Source 引用)。这是为 CUSTOM_AGENT_DISALLOWED_TOOLS(F27)预留的接口——本期该常量为空数组,所以传了也没用。属于「参数已就绪、逻辑待补」。

第 2 层:ALL_AGENT_DISALLOWED_TOOLS —— 硬熔断

go
// tool/filter.go:5
// ALL_AGENT_DISALLOWED_TOOLS 是任何子 Agent 永远不能用的工具名列表(spec F26)。
// 本期最小列表:Agent。后续可扩展 AskUserQuestion / TaskStop 等。
var ALL_AGENT_DISALLOWED_TOOLS = []string{"Agent"}

这是防递归唯一的机制保障:任何子 Agent(定义式)的工具列表里没有 Agent,模型根本看不到这个工具,连幻觉调用的机会都没有(就算它硬编一个 Agent 出来,registry.Execute 也会返回 unknown tool)。

面试官追问:为什么是「从工具列表里删掉」而不是「在 Agent 工具的入口检查一下调用者」? 答:纵深防御,但两者效果不同。从列表里删掉是最强的一道——模型看不见的东西,它几乎不会去调(function calling 是基于给定 schema 生成的)。入口检查(spec F24 的 QuerySource 检测)是给 Fork 路径准备的补充:Fork 子 Agent 的工具集继承父(ForkDefinition 的 Tools/DisallowedTools 都留空),经过第 2 层过滤后 Agent 也应该被删掉——但 spec F22/F24 的设计意图是「Fork 子 Agent 的工具列表里仍有 Agent 工具,靠调用时拦截」(AC5 明确这么验收)。代码实际行为与 AC5 冲突:第 2 层对 Fork 路径同样生效,所以 Fork 子 Agent 根本看不到 Agent 工具,QuerySource 拦截派不上用场。而 IsForkContext 也没有调用点。这是一个「设计规划了多道闸、实现只留了一道」的典型例子——结果上更安全(因为最严的那道生效了),但文档和验收标准是错的。

第 3 层:后台 Agent 白名单

go
// tool/filter.go:11
// ASYNC_AGENT_ALLOWED_TOOLS 是后台 Agent 工具白名单(spec F28)。
// 不含 Agent / TaskStop / SendMessage / TaskList / TaskGet 等任何元工具。
// MCP 工具与 Skill 工具按命名约定动态识别(以 "mcp__" 起头),不在这个列表中但被动态放行。
var ASYNC_AGENT_ALLOWED_TOOLS = []string{
	"read_file", "write_file", "edit_file",
	"glob", "grep",
	"bash",
	"load_skill", "install_skill",
}
go
// tool/filter.go:115
func isAsyncAllowed(name string) bool {
	// MCP 工具以 "mcp__" 为前缀
	if len(name) >= 5 && name[:5] == "mcp__" {
		return true
	}
	// Skill 工具:load_skill 和 install_skill 已在白名单中
	return false
}

为什么要给后台任务一个更窄的白名单:后台 Agent 没有人看着。前台 Agent 最坏情况是用户看着它乱来然后按 ESC;后台 Agent 最坏情况是它在用户去开会的时候改了 20 个文件。

白名单设计的三个问题(主动讲会加分):

问题说明
这是能力白名单而不是权限白名单白名单里有 write_file、edit_file、bash、install_skill——「最小的原子能力集」全在里面。它挡的是元工具(TaskStop/SendMessage/TaskList/TaskGet/Agent),不是危险操作。真正的安全边界仍然依赖 permission.Engine
新增工具必须记得同步这个列表比如以后加 web_search,如果忘了加进 ASYNC_AGENT_ALLOWED_TOOLS,后台子 Agent 会静默地没有这个工具(表现为「它说它做不到」,而你不知道为什么)
mcp__ 前缀无条件放行结合 Explore 角色只禁了 write_file/edit_file,一个 mcp__github__create_issue 这样的写类 MCP 工具既不在 Explore 的黑名单里(名字不同),又在后台白名单里被动态放行。所以「Explore 是只读的」这个结论只对内置工具成立——这是按名过滤的必然局限

第 4 层 / 第 5 层:角色定义的黑白名单

go
	// 4. 去掉定义的 disallowedTools 黑名单
	if len(p.Disallowed) > 0 {
		result = removeTools(result, p.Disallowed)
	}
	// 5. 如果定义了 tools 白名单 → 取交集
	if len(p.Allowed) > 0 {
		result = intersectTools(result, p.Allowed)
	}

顺序是「先黑后白」——这很重要,而且 spec F29 的措辞有点绕:「白名单先确定范围,黑名单再排除」,而 F30 的实现顺序是「先黑后白」。数学上两者等价((A \ D) ∩ W == (A ∩ W) \ D),所以结果一样,但为了可读性,代码里的顺序(先移除、后交集)更直观。这一点可以在面试时点出来,说明你注意到了 spec 与实现措辞的不一致但语义等价。

实际效果举例(Explore 角色 + 后台运行):

起点(父的 registry):read_file, write_file, edit_file, glob, grep, bash, Agent, LoadSkill, TaskList, ...
  ↓ 第 2 层:去掉 ["Agent"]
read_file, write_file, edit_file, glob, grep, bash, LoadSkill, TaskList, TaskGet, TaskStop, SendMessage, ...
  ↓ 第 3 层:与 ASYNC 白名单(8 个)+ mcp__ 前缀取交集
read_file, write_file, edit_file, glob, grep, bash, LoadSkill, (mcp__* 若有)
  ↓ 第 4 层:去掉 Explore 的 disallowedTools = [write_file, edit_file]
read_file, glob, grep, bash, LoadSkill
  ↓ 第 5 层:Explore 没有 tools 白名单 → 不收窄
read_file, glob, grep, bash, LoadSkill          ← 最终

3.7.2 第 5 层的落地:DefinitionsFiltered ​

过滤结果通过 WithAllowedTools(allowed) 注入子 Agent,然后在循环里生效(run_to_completion.go:80-88):

go
		// 按 mode 取工具集
		var defs []llm.ToolDefinition
		if mode == permission.ModePlan {
			defs = a.registry.ReadOnlyDefinitions()
		} else if len(a.allowedTools) > 0 {
			defs = a.registry.DefinitionsFiltered(a.allowedTools)
		} else {
			defs = a.registry.Definitions()
		}

这里的优先级顺序值得注意:ModePlan 优先于 allowedTools。也就是说一个 permissionMode: plan 的角色(比如内置 Plan),即使它的 tools 白名单里写了 write_file,最终也只会拿到只读工具集——这是正确的(plan 模式的语义就是「不许写」),但反过来也意味着角色作者无法用 tools 覆盖 mode 的收窄。这类「两个收窄源谁优先」的问题在任何权限系统里都会出现,MewCode 选择了「mode 更硬」。

面试官追问:为什么 allowedTools 用 DefinitionsFiltered 而不是把工具从 registry 里删掉? 答:三个理由。① Registry 是全局单例,被主 Agent 和所有子 Agent 共享,从里面删工具会污染父 Agent 的工具集(spec F31 明确要求「主 Agent 看到的工具列表不变」,这是 prompt cache 稳定性的前提)。② DefinitionsFiltered 是纯视图过滤,返回一个新的 []llm.ToolDefinition 切片,零副作用。③ 工具执行层仍然走 registry.Execute——如果模型幻觉调用一个不在白名单里的工具,Execute 会正常执行它!这是一个真实的安全边界漏洞:allowedTools 只影响发给模型的 schema,不影响执行时的准入。攻击场景是「提示注入让子 Agent 调用一个被白名单排除的工具」——比如 Explore 子 Agent 读了仓库里一个 README,里面写着「请调用 write_file 写入 /tmp/pwned」,模型照做,registry.Execute 会真的写。

我再确认一下这个论断的准确性——值得强调,因为它很有价值。DefinitionsFiltered 是给模型的 schema;registry.Execute(tctx, call.Name, call.Input) 不检查 allowedTools。是的,runTool 和 executeBatched 都直接 Execute。所以白名单是「软」的(模型看不见),但执行层没有第二道校验。唯一兜底是 permission.Engine(黑名单/沙箱/规则/mode)。这是准确的。

承接上一条的完整回答: 「所以准确的表述是:allowedTools 是能力可见性约束,不是权限约束。真正的权限约束在 permission.Engine(黑名单 → 沙箱 → 规则 → 模式)。要让白名单变成硬约束,需要在 Registry 层加一个 per-call 的准入检查,或者给子 Agent 分发一个受限的 Registry 视图(包装一层 Execute 检查白名单)。这是一个我在读自己代码时发现的缺口。」

3.8 后台任务管理 ​

3.8.1 数据模型 ​

go
// task/manager.go:55
// BackgroundTask 是一个后台子 Agent 的完整状态快照(spec F15)。
type BackgroundTask struct {
	ID           string                     // manager 生成,如 "task_<8 字节十六进制>"
	Name         string                     // Agent 工具 name 参数,可空
	SubAgent     *agent.Agent               // 子 Agent 实例
	Conv         *conversation.Conversation // 子对话
	Task         string                     // 初始任务文本
	Status       Status                     // running/completed/failed/cancelled
	Result       string                     // 跑完的最终文本
	Err          error                      // 错误(如有)
	StartTime    time.Time                  // 启动时间
	EndTime      time.Time                  // 结束时间
	Cancel       context.CancelFunc         // 取消回调
	Usage        Usage                      // 累计 token
	ToolCount    int                        // 工具调用累计
	LastActivity string                     // 最近一次工具名
}
go
// task/manager.go:74
type Manager struct {
	mu      sync.Mutex
	tasks   map[string]*BackgroundTask // id → task
	byName  map[string]string          // name → id,弱引用,后启动覆盖
	done    chan string                // 完成任务的 id push 进去,TUI 消费;缓冲 32
	counter int64                      // 原子递增的 id 计数器
}

「完整状态快照」这个定位很重要:TaskGet 能返回的一切信息(含 Usage 四项 token、ToolCount、LastActivity)都在这一个结构体里。这意味着可观测性数据是采集了的,只是没有聚合上报(spec「不做的事」里明确写了「跨 SubAgent token 用量汇总到 /status(只在 Manager 内部记录)」)。

ID 生成(manager.go:92-97):

go
func (m *Manager) nextID() string {
	n := atomic.AddInt64(&m.counter, 1)
	// 用 time.Now().UnixNano() ^ n 取低 8 字节十六进制
	id := time.Now().UnixNano() ^ n
	return fmt.Sprintf("task_%08x", uint32(id&0xFFFFFFFF))
}

这里有一个真实的碰撞风险:UnixNano() ^ n 之后取低 32 位。如果两次调用发生在相邻的纳秒(UnixNano 差 1)而计数器差 1,低 32 位就会完全相同((t+1)^(n+1) vs t^n 的低位可能相等,因为异或只影响有变化的位)。虽然在真实调度下「相邻纳秒」出现的概率不高,但这不是一个可证明唯一的 ID 方案。生产做法:UUIDv7(时间有序 + 随机位)或直接用自增 task_1(单进程内足够)。

List() 用手写冒泡排序(manager.go:115-122):

go
	// 按 StartTime 升序排序
	for i := 0; i < len(result); i++ {
		for j := i + 1; j < len(result); j++ {
			if result[i].StartTime.After(result[j].StartTime) {
				result[i], result[j] = result[j], result[i]
			}
		}
	}

O(n²),但任务数是个位数,性能无所谓。问题不是性能,是「为什么不 sort.Slice」——同一份代码里 subagent/catalog.go:67 就用了 sort.Strings。这是一处纯粹的风格不一致(「先用能跑的」),面试官可能拿它测代码品味。

3.8.2 状态机 ​

四态枚举(manager.go:19-24):

go
const (
	StatusRunning   Status = iota // 正在运行
	StatusCompleted               // 已完成
	StatusFailed                  // 运行失败
	StatusCancelled               // 已取消
)

终态判定逻辑(manager.go:189-201):

go
		bt.EndTime = time.Now()
		if err != nil {
			if errors.Is(err, context.Canceled) {
				bt.Status = StatusCancelled
			} else {
				bt.Status = StatusFailed
				bt.Err = err
				bt.Result = text
			}
		} else {
			bt.Status = StatusCompleted
			bt.Result = text
		}

注意 Failed 也带 Result:即使失败,也把已经产生的最后一条 assistant 文本留下来。这是一个体贴的设计——「跑到第 8 轮才失败」的输出对用户仍然有价值(部分完成的信息)。但 Cancelled 不带 Result(text 被丢弃),这是一处不一致:取消时其实也可以保留 text。

panic 兜底(N3):

go
// manager.go:169
		defer func() {
			if r := recover(); r != nil {
				bt.Status = StatusFailed
				bt.Err = fmt.Errorf("subagent panic: %v", r)
				bt.EndTime = time.Now()
			}
			select {
			case m.done <- id:
			default:
				fmt.Fprintf(os.Stderr, "task manager: done channel full, dropping notification for %s\n", id)
			}
		}()

「子 Agent 崩溃不影响主程序」是非功能需求 N3,这里靠 recover 实现,而且无论 panic 与否都发通知(recover 块在 defer 里,通知在同一个 defer 里,必然执行)。对比第 03 章提到的「registry.Execute 没有 recover、工具 panic 会打穿整个 agent goroutine」——后台路径有兜底,前台路径没有。这个不对称值得注意:

路径panic 兜底结果
后台(task.Manager.Launch)✅ defer recover → Failed + 通知主程序存活,用户看到失败通知
后台(SendMessage 续派)✅ 同上同上
后台(AdoptRunning 接管)✅ defer recover(manager.go:233-244)同上
前台(AgentTool.Execute → RunToCompletion)❌ 无子 Agent 里任何工具 panic 会一路打穿到主 Agent 的 executeBatched goroutine(那里也没有 recover,见第 03 章 §3.3 的已知缺口)→ 进程崩溃

所以「子 Agent 崩溃不影响主程序」这条 N3 只对后台路径成立。前台路径的 panic 兜底依赖的是「主 Agent 的 registry 有 recover」——而它没有。这是我建议的最高优先级修复点:在 AgentTool.Execute 里给 RunToCompletion 包一层 defer recover,把 panic 转成 tool.Result{IsError: true}。成本 5 行,收益是「不崩」。

done channel 的可靠性语义(manager.go:175-179):

go
			select {
			case m.done <- id:
			default:
				fmt.Fprintf(os.Stderr, "task manager: done channel full, dropping notification for %s\n", id)
			}

缓冲 32 满则丢通知只写 stderr。任务本体还在 tasks map 里(TaskGet 能查到),所以信息不丢、通知会丢。这是正确的取舍(宁可丢通知也不能阻塞 goroutine),但没有重试/补偿——比如「下次 TaskList 调用时补发漏掉的通知」。企业级做法是把通知做成可轮询的持久状态,让 TaskList 成为可靠信息源,done channel 只做「加速」。

3.8.3 三种进入后台的方式 ​

spec F17 定义三种,实际实现状态两种完整、一种缺失:

#方式触发点实现现状
1显式:run_in_background: true / 角色 background: true / Fork 无条件后台agent_tool.go:166 → Launch✅ 完整
2120s 超时自动:前台跑超时 → AdoptRunningagent_tool.go:213-228⚠️ 逻辑完整,但阈值实际被 30s 工具超时抢先(§4.2)
3ESC 手动切:用户在子 Agent 跑动期间按 ESC → TUI 调 AdoptRunningTUI 层❌ 未接线(全仓 AdoptRunning 只有 agent_tool.go 的调试点,TUI 里没有)

方式 1 的判定:

go
// agent_tool.go:165
	// 决定后台(spec F17/F18)
	background := def.Background || aArgs.RunInBackground || isFork
	if background && !t.bgEnabled {
		return tool.Result{Content: "后台模式已被配置禁用(enableSubAgentBackground=false),无法 Fork 或后台执行", IsError: true}
	}

三个条件的语义:def.Background 是角色作者的决定(「这个角色天生就该后台跑」)、aArgs.RunInBackground 是模型的决定(「这次任务会比较久」)、isFork 是语言的必然(Fork 语义就是「带着父上下文去干一件长活」,带上下文意味着必然慢,前台跑会把主对话冻住太久)。

bgEnabled 对应非功能需求 N6 的 enableSubAgentBackground 配置——但它是硬编码的:

go
// tui.go:268
				bgEnabled := true // 默认启用后台;后续可从 config 读取

注释自己承认了「后续可从 config 读取」。所以 N6 的「配置关闭后全部失效」目前无法配置,只能改代码。

方式 2 的实现细节(AdoptRunning):

go
// task/manager.go:207
// AdoptRunning 把一个正在前台跑的 agent 移交到后台(spec F17.2/F17.3)。
// 调用方应已经把"用户的 ESC/120 秒超时"对应的 cancel 准备好。
// Manager 接管 ev 事件流继续消费,直到 Done 或 Err。
func (m *Manager) AdoptRunning(parentCtx context.Context, ag *agent.Agent, conv *conversation.Conversation, name string, ev <-chan agent.Event, cancel context.CancelFunc) string {
	id := m.nextID()

	bt := &BackgroundTask{
		ID:        id,
		Name:      name,
		SubAgent:  ag,
		Conv:      conv,
		Task:      "",   // 前台 task 已由 RunToCompletion 执行,此处不重复
		Status:    StatusRunning,
		StartTime: time.Now(),
		Cancel:    cancel,
	}
	// ... 写入 map ...
	// 起 goroutine 继续消费 events 直到 channel 关闭
	go func() {
		defer func() { /* recover + 通知 */ }()
		for ev := range ev {
			aggregateEvent(ev, bt)
		}
		bt.EndTime = time.Now()
		// 状态已在 RunToCompletion 结束时设定
		if bt.Status == StatusRunning {
			bt.Status = StatusCompleted
		}
	}()
	return id
}

Task: "" 这一行的注释解释了一切:AdoptRunning 不重新执行任何东西——Agent 和对话都还在前台那个 goroutine 里跑着,Manager 只是接管事件流的消费权。这是「零成本转后台」的关键:不需要序列化状态、不需要重启 Agent、不丢进度。

但这里有一个真实的语义瑕疵:

go
		// 状态已在 RunToCompletion 结束时设定
		if bt.Status == StatusRunning {
			bt.Status = StatusCompleted
		}

注释说「状态已在 RunToCompletion 结束时设定」,但 RunToCompletion 从不写 bt.Status——它返回 (string, error),bt.Status 只在 Launch/SendMessage 的 goroutine 里被写。所以 AdoptRunning 接管的任务,bt.Status 永远是 Running,事件流一关就被一律标成 Completed。后果:

  • 被 ESC 取消(用户意图是「停」)的任务,会显示 completed 而不是 cancelled
  • 因为子 Agent 是超时/取消后移交的,它的 ctx 已经 Done,所以 RunToCompletion 实际上会立刻返回(下一轮循环开头的 ctx.Err() 检查),事件流随即关闭 → 状态立刻变 Completed、Result 为空

准确表述:AdoptRunning 的「接管」在当前实现下并不真的把一个跑了一半的任务跑完,因为移交时传入的 cancel 就是刚刚触发的那个超时/取消的 cancel——上下文已经死了。要真正实现「超时转后台继续跑」,需要在移交时用一个新 context 重建 Agent 的运行(WithCancel(parentCtx) 且 parentCtx 不继承工具超时),并把 Conv 和锚点状态接上。这是一个设计意图正确、实现需要重做的地方。

3.8.4 task-notification 回注 ​

Manager 完成后把 id 推进 done channel,TUI 起了一个常驻 goroutine 消费(tui.go:298):

go
	// ch13: 启动后台任务完成通知消费(spec F19)
	if m.taskMgr != nil {
		go m.consumeTaskDone()
	}
go
// tui/tasks.go:10
func (m *Model) consumeTaskDone() {
	for id := range m.taskMgr.SubscribeDone() {
		bt, ok := m.taskMgr.Get(id)
		if !ok {
			continue
		}
		notif := buildTaskNotification(bt)
		if m.runtime != nil {
			m.runtime.AppendReminders([]string{notif})
		}
	}
}

// tui/tasks.go:24
func buildTaskNotification(bt *task.BackgroundTask) string {
	errStr := ""
	if bt.Err != nil {
		errStr = fmt.Sprintf("\nError: %v", bt.Err)
	}
	return fmt.Sprintf(`<task-notification>
Task %s (name="%s"): %s%s
Result: %s
</task-notification>`, bt.ID, bt.Name, bt.Status.String(), errStr, bt.Result)
}

生成的文本(spec F19 的格式,多了一行 Error:):

<task-notification>
Task task_1a2b3c4d (name="search-routes"): completed
Result: Scope: 路由注册在 internal/router/register.go 的 RegisterAll()...
</task-notification>

为什么走 PendingReminders 而不是直接 conv.AddUser:

方案副作用
conv.AddUser(notif)用户会看到这条系统消息;历史里多一条 user 消息会影响 user/assistant 交替约束;且如果主 Agent 正在跑(runMu 持有中)会破坏并发安全
runtime.AppendReminders(现状)通知进入「待注入提醒」队列,下一轮 buildReminder 时以 reminder 区的形式注入——不写历史、不打断当前对话、用户视窗看不到(spec N7:「不出现在用户视窗(只对模型可见)」)

具体注入位置在每轮循环的 buildReminder → streamOnce 的 llm.Request.Reminder 字段,由协议适配层映射到 provider 的 reminder/system-suffix 位置。这个设计的关键收益是「不打断」:用户可能正在打字,后台任务完成不该把他的输入顶掉。

为什么用 <task-notification> XML 标签包裹:与 <fork_boilerplate> 同一套思路——用带标签的结构化文本让模型能识别这一段是「系统事件」而不是「用户说话」。模型看到标签就知道「这是一个已完成任务的汇报,不是新需求」,从而决定是继续等、还是基于结果推进。同时标签也让调试和测试可断言(grep 一下就知道有没有通知)。

3.8.5 四个元工具 ​

go
// tui.go:257
			// ch13: 注册 task 工具(spec F20)
			if taskMgr != nil {
				registry.Register(task.NewTaskListTool(taskMgr))
				registry.Register(task.NewTaskGetTool(taskMgr))
				registry.Register(task.NewTaskStopTool(taskMgr))
				registry.Register(task.NewSendMessageTool(taskMgr))
			}
工具ReadOnly()参数返回关键语义
TaskListtrue无JSON 数组 {id,name,status,tool_count,last_activity}描述说「非 Terminated」,实现不过滤终态,全量返回
TaskGettruetask_id(必填)id/name/status/task/result/tool_count/last_activity/usage{input,output,cache_write,cache_read}/start_time/end_time时间格式 2006-01-02 15:04:05;未找到 → IsError
TaskStopfalsetask_id(必填){"status":"cancellation_requested"}异步语义:只表达「已请求取消」,不等终态
SendMessagefalsename + message(必填){"task_id":"..","status":"resumed"}仅 StatusCompleted 允许;否则 ErrTaskBusy

ReadOnly 的分配逻辑很讲究:两个查询类工具标 true → 它们可以进主 Agent 的只读并发批次(第 03 章 §3.3),「同时查 3 个任务的状态」会并发执行。两个改变状态的工具标 false → 强制串行 + 走权限判定。

ASYNC_AGENT_ALLOWED_TOOLS 特意不含这 4 个元工具(filter.go:12 注释明确写了),所以后台子 Agent 无法操作彼此的任务表——一个后台 Agent 不能 TaskStop 掉另一个,也不能 SendMessage 指挥另一个。这是「子 Agent 之间不互相协调」这个架构决定的直接体现(协调只能通过主 Agent)。

SendMessage 的限制(manager.go:280-282):

go
	if bt.Status != StatusCompleted {
		return "", ErrTaskBusy
	}

只有 Completed 才能续派。含义:

  • Running → ErrTaskBusy(不能打断正在跑的任务,也不能「插话」)
  • Failed / Cancelled → 同样是 ErrTaskBusy(错误信息不精确,实际是「状态不允许」)

为什么不允许打断:正在跑的后台 Agent 处于 RunToCompletion 的同步循环中,它的 conv 正在被那个 goroutine 写。如果外部 Conv.AddUser 并发写入,就会产生数据竞争(Conversation 内部有锁保护单次写入,但语义上会在「模型正在生成第 5 轮」时插入一条 user 消息,破坏协议)。所以「只能等它跑完再派」是一个保守但正确的选择。企业级方案是把「打断」建模成显式操作(先 Stop 再派新的),并让状态机支持 Running → Cancelling → Cancelled → Running。

SendMessage 的续派实现(manager.go:284-327):

go
	// 重新激活:追加 user 消息,重置状态
	bt.Conv.AddUser(message)
	bt.Status = StatusRunning

	// 重新起 goroutine 跑
	ctx, cancel := context.WithCancel(parentCtx)
	bt.Cancel = cancel

	go func() {
		defer func() { /* recover + 通知 */ }()
		events := make(chan agent.Event, 32)
		go aggregateTaskEvents(events, bt)
		text, err := bt.SubAgent.RunToCompletion(ctx, bt.Conv, "", events) // task 已在 Conv 中
		close(events)
		// ... 终态判定 ...
	}()

两个细节:

  1. 传 "" 作为 task,因为消息已经通过 Conv.AddUser 进历史了(RunToCompletion 里 if task != "" 才 append,传空串避免重复添加)。
  2. 复用同一个 SubAgent 实例——所以续派继承了这个 Agent 的全部运行时状态(锚点、压缩状态、已激活 Skill)。这正是「续派」相对「新建一个」的价值:子 Agent 记得上次干了什么。代价是上下文会持续增长,直到撞上 ContextWindow 触发压缩(走同一套 compact.ManageContext)。

竞态瑕疵(主动指出):bt.Status 和 bt.Conv 的读写没有持 m.mu:

go
	m.mu.Lock()
	id, ok := m.byName[name]
	// ...
	bt, ok := m.tasks[id]
	m.mu.Unlock()          // ← 锁在这里释放

	if bt.Status != StatusCompleted {   // ← 无锁读,与后台 goroutine 的写竞争
		return "", ErrTaskBusy
	}
	bt.Conv.AddUser(message)            // ← 无锁写
	bt.Status = StatusRunning           // ← 无锁写

而 Launch/SendMessage 的 goroutine 会并发写 bt.Status、bt.EndTime、bt.Result、bt.ToolCount(通过 aggregateEvent)。所以这里存在数据竞争窗口,go test -race 如果构造出「任务刚跑完 + 同时 SendMessage」的场景就会报。修法很简单:给 BackgroundTask 加一个内嵌的 sync.Mutex(或把状态字段改成 atomic),所有读写都过锁。面试官问「你的代码有并发问题吗」,这就是一个诚实且有含金量的答案。

3.8.6 事件聚合与统计 ​

go
// manager.go:337
func aggregateEvent(ev agent.Event, bt *BackgroundTask) {
	if ev.Tool != nil && ev.Tool.Phase == agent.PhaseEnd {
		bt.ToolCount++
		bt.LastActivity = ev.Tool.Name
	}
	if ev.Usage != nil {
		bt.Usage.Input += ev.Usage.Input
		bt.Usage.Output += ev.Usage.Output
		bt.Usage.CacheWrite += ev.Usage.CacheWrite
		bt.Usage.CacheRead += ev.Usage.CacheRead
	}
}

统计口径的两个选择:

  1. 只统计 PhaseEnd(工具执行结束)而不是 PhaseStart——这样「被拒绝/被取消」的工具不会计入(它们也 emit 了 Start/End,但 End 代表了真实的「有结果」)。严格说被权限拒绝的工具也 emit 了 PhaseEnd(agent.go:740-746),所以现在的计数是**「尝试过的工具调用数」**而不是「成功数」。改进空间:用 !ev.Tool.IsError 过滤。
  2. Usage 四项分开累计:Input/Output/CacheWrite/CacheRead 分别累加,这与第 06 章讲的「锚点必须含 CacheRead + CacheWrite」是同一个口径——成本归因需要区分「新输入」「缓存写入」「缓存读取」,因为三者的单价差 10 倍以上。

这些数据目前没有任何聚合上报(spec「不做的事」:「跨 SubAgent token 用量汇总到 /status(只在 Manager 内部记录)」)。所以现状是:采集做了、展示只到单个任务粒度(TaskGet)、没有任何汇总面板。§6 会讲这在企业里为什么是个必修课。


四、边界与已知缺陷 ​

这一节是面试加分项。下面每一条都是读源码可直接验证的(不是猜测),按「影响面 × 修复成本」排序。

4.1 已解析未接线(设计做了、执行没接) ​

项现状证据影响
model 字段AgentArgs.Model 与 def.Model 都无消费点,子 Agent 固定用 t.parent.provideragent_tool.go:182;全仓无 provider 映射逻辑Explore 标注的 model: haiku 只是元数据;「探索任务走便宜模型」这个成本优化完全不生效
ApprovalUpgrader(权限第三层)接口 + Option 都在,agent_tool.go 构造子 Agent 时没传;全仓无实现permission_upgrade.go:9、runtime.go:163、agent_tool.go:182-190需要审批的子 Agent 工具调用阻塞到 ctx 超时(30s)后失败,而不是弹窗等用户
ESC 手动转后台TUI 里没有任何 AdoptRunning 调用点grep AdoptRunning 只命中 agent_tool.gospec F17.3 未实现;用户按 ESC 只能取消整个主 Agent 轮次
IsForkContext(F24.3 兜底闸)定义完整,零调用点fork.go:60「三道闸」只生效一道(ALL_AGENT_DISALLOWED_TOOLS)
CUSTOM_AGENT_DISALLOWED_TOOLS(F27)空数组;FilterParams.Source 传进去但从未被读filter.go:9、filter.go:38-62自定义来源 Agent 无法被施加额外限制
PartialState(F17.2)结构已定义,Launch/AdoptRunning 签名里没有这个参数manager.go:48-53前台转后台时「已收集的中间状态」不参与,也无从恢复
enableSubAgentBackground(N6)硬编码 bgEnabled := truetui.go:268(注释自陈「后续可从 config 读取」)无法配置关闭后台;Fork 也无法被禁用
SkillForkHost(第 08 章)接口无实现,subagent 底座已就绪但没复用skills/executor.go:17、spec F33/G10Skill 的 fork 模式仍不可用

4.2 超时语义耦合:120 秒的阈值实际被 30 秒抢先 ​

这是最值得主动讲的一条。设计文档反复出现 autoBackgroundDuration = 120s,但 Agent 工具是普通工具,执行时被主 Agent 的通用工具超时包了一层:

go
// agent.go:930(主 Agent 串行执行工具,Allow 分支)
				tctx, cancel := context.WithTimeout(ctx, tool.DefaultTimeout)   // tool.DefaultTimeout = 30s
				result := a.registry.Execute(tctx, call.Name, call.Input)
				cancel()
go
// agent_tool.go:213(Agent 工具的"前台 120 秒")
	timeoutCtx, cancel := context.WithTimeout(ctx, autoBackgroundDuration)

context.WithTimeout 的语义是取更早的 deadline(30s < 120s),所以:

现象说明
前台子 Agent 跑过 30s底层 ctx 先到期 → timeoutCtx.Err() != nil 仍为真 → 走 AdoptRunning 分支 → 返回 timed_out_to_background。所以**「120 秒」在语义上永远不会被独立触发**,实际阈值是 30 秒
后台 Launch 继承的 ctx是同一个 30s 的 tctx,而 Execute 返回后主 Agent 立刻 cancel()。所以后台任务拿到的 ctx 在启动瞬间就被取消,RunToCompletion 下一轮开头的 if ctx.Err() != nil 会立刻返回 → 任务被标成 Cancelled(或经 AdoptRunning 被标成 Completed)

修法:TaskManager 的 Launch/AdoptRunning 应该接收一个不继承工具超时的父 ctx(比如从 Agent 上持有一个 session 级 context.Context,或显式传 context.WithoutCancel(ctx)——Go 1.21+ 有 context.WithoutCancel),并让 AdoptRunning 用一个新 context 重建运行,才能真正做到「超时转后台继续跑、进度不丢」。

我在文档里把它标注为「源码可见的耦合 + 明确推断」,因为这条结论是从代码路径推导出来的,没有 e2e 测试覆盖(internal/task 与 internal/subagent 没有任何 _test.go)。面试时这样表述最稳:「按代码路径推导,120 秒阈值会被 30 秒工具超时抢先;这一块缺少端到端测试,所以我把修复列为 TODO 而不是已修。」

4.3 执行层没有白名单校验(软约束 vs 硬约束) ​

allowedTools / ApplyAgentToolFilter 的结果只作用于 发给模型的工具 schema(DefinitionsFiltered),没有任何执行期准入:

go
// run_to_completion.go:85
		} else if len(a.allowedTools) > 0 {
			defs = a.registry.DefinitionsFiltered(a.allowedTools)
		}
go
// agent.go:1003(工具执行,注意没有任何白名单检查)
func (a *Agent) runTool(ctx context.Context, call llm.ToolCall) llm.ToolResult {
	tctx, cancel := context.WithTimeout(ctx, tool.DefaultTimeout)
	defer cancel()
	r := a.registry.Execute(tctx, call.Name, call.Input)
	...
}
层面机制强度
模型看到的 schemaDefinitionsFiltered(allowed)软:模型看不见,但提示注入可以诱导它「猜」一个工具名
权限判定permission.Engine.Check(黑名单/沙箱/规则/模式)硬:这一层挡住了危险操作
执行准入无—

结论要说得准确:allowedTools 是能力可见性约束,不是权限约束。要让 Explore 的「只读」成为硬保证,需要给 Registry 加 per-call 准入或给子 Agent 分发一个受限视图。当前 Explore 的只读性由三重因素共同保证:① schema 里没有写类工具(软);② disallowedTools 排除(软,同层);③ permission.Engine 的沙箱与规则(硬,但只管路径/命令,不管「该不该写」)。并且这套保证对 mcp__* 写类工具不成立(§3.7.1 第 3 层的分析)。

4.4 并发与资源配额 ​

缺口证据后果修法
无并发上限全仓无 semaphore / worker pool / 最大子 Agent 数主 Agent 一轮里发 10 个 Agent 调用就起 10 个子 Agent(只读批还会并发执行这些工具调用);每个 30 轮 × 全量上下文,配额瞬间穿仓加全局 semaphore(如 make(chan struct{}, 4))+ 会话级子 Agent 总数上限
SendMessage 有数据竞争manager.go:276-286 在 m.mu.Unlock() 之后读 bt.Status、写 bt.Conv/bt.Status,与后台 goroutine 的写并发go test -race 可复现;行为不确定(可能把 Running 当 Completed 续派,导致并发写同一对话)BackgroundTask 内嵌 sync.Mutex,或状态字段改 atomic
tasks map 无界增长manager.go:161-166 只写不删长会话里每个子任务永久占内存(含整份 Conv 与 SubAgent)加 TTL 清理 + 只保留 N 个终态任务
done channel 丢了不补manager.go:175-179 满 32 就丢 + stderr用户看不到完成提示(任务本体仍在 TaskGet 可查)通知 + 可轮询状态双轨;TaskList 作为可靠信息源
nextID 有碰撞可能manager.go:92-97 uint32((UnixNano() ^ n) & 0xFFFFFFFF)任务 ID 撞车 → tasks map 覆盖,两个任务互相踩UUIDv7 或纯自增
前台路径无 panic 兜底agent_tool.go:218 直接调 RunToCompletion,无 recover子 Agent 内任何工具 panic → 打穿主 Agent goroutine → 进程崩溃(N3 只对后台成立)在 Execute 里给 RunToCompletion 包 defer recover

4.5 一致性与可维护性 ​

项问题
TaskList 描述 vs 实现注释/描述说「非 Terminated 任务」,Execute 里 m.mgr.List() 全量返回(tools.go:23,40)——模型会看到一堆已完成的旧任务
TaskManager 接口 vs spec F14spec 写 Launch(ctx, agent, taskText) (taskID, error),实现是 Launch(ctx, ag, conv, name, taskText) string(无 error)
Resolve 大小写敏感 vs skills 大小写不敏感同一个概念两套语义(catalog.go:51 / skills 的 strings.ToLower)
Catalog 用 sync.Mutex 做只读查询启动期加载后不再写入,应用 RWMutex + RLock(catalog.go:16,52,60)
List() 手写冒泡排序manager.go:115-122,同包外就有 sort.Slice 可用;纯风格不一致
frontmatter 解析双实现subagent/parser.go 与 skills/parser.go 逻辑重复,注释给出的「避免循环依赖」理由不成立(两包互不依赖),且 subagent 版已多出 BOM 剥离,两份实现正在漂移
agentNameRegex 与注释/spec 不一致正则允许大写(为了 Explore/Plan),但注释第一句仍写「小写字母」,spec F4 也写「小写字母」
循环体重复Run 与 RunToCompletion 重复约 140 行,与 spec F9「不重复实现」矛盾(§3.5.3)
新增工具要手动同步两处新工具必须同时进 registry 和 ASYNC_AGENT_ALLOWED_TOOLS,忘了就静默缺失(无任何告警)
byName 是弱引用同名后启动覆盖前(manager.go:163-165)。模型若在两次 Agent 调用里用了同一个 name,SendMessage 会打到较新的那个,旧的从此不可寻址(只能用 task_id)

4.6 明确的设计取舍(不是缺陷,但要能说清) ​

取舍代价是否值得
角色 system prompt 整段覆盖子 Agent 看不到项目指令与 Skill 目录(LoadSkill 用不了)值得——角色约束优先;补法是把项目指令移入 user 前缀
Fork 没有自定义 system promptFork 分身无法被赋予身份(只有 boilerplate 一句提醒)值得——这是 prompt cache 的前提(N2)
dontAsk 是子 Agent 专属放宽角色文件即可获得无限执行权;仓库随 clone 带入的 .mewcode/agents/*.md 可提权需要额外开关(企业级必修)
结果回传只靠 prompt 约束(500 字以内)无机制级截断,模型可回 3000 字污染父上下文不值得省——应加硬截断
任务纯内存、不持久化进程退出即丢;不能 TaskGet 历史任务、不能续派值得(CLI 短生命周期),但跨会话 SendMessage 是企业刚需
子 Agent 之间不互相协调全部经由主 Agent 中转,带宽与延迟受限值得——保持「单一控制点」,避免分布式死锁

五、面试官可能追问(Q&A) ​

L1 基础理解 ​

Q1:什么是 SubAgent?它和主 Agent 是什么关系?

A:SubAgent 是在独立上下文里跑同一套 ReAct 循环的另一个 Agent 实例。它不是「一个函数」也不是「一个服务」——agent.New(...) 造出来的对象和主 Agent 是同一个类型(internal/agent/agent.go),区别只在注入的 Option:WithSystemPrompt(角色身份)、WithMaxTurns(轮数)、WithPermissionMode/WithDontAsk(权限)、WithAllowedTools(工具白名单)、WithRuntime(全新的 SessionRuntime)。主 Agent 通过一个普通工具 Agent 来召它——所以委派和一个普通的工具调用在协议层没有任何区别,主 Agent 依旧掌握控制权,子 Agent 只把一段文本交回来。

Q2:两种创建模式怎么选?什么时候必须用 Fork?

A:subagent_type 非空走定义式(catalog.Resolve → 空对话起步 + 角色 system prompt),为空走 Fork 式(ForkDefinition() → 克隆父对话 + ForkBoilerplate)。判别标准是**「子任务需不需要父对话的上下文」**:需要(比如「接着刚才那三个文件继续改」)就必须 Fork;不需要(比如「搜一遍项目里所有 cron 任务」)就用定义式,因为它更干净、更省 token、还能命中角色专属的工具/权限配置。Fork 还有个硬优势:父对话 prefix 逐字节相同 → 首次请求大部分 input 走 cache_read,成本约 1/10;代价是无条件后台 + 没有自定义 system prompt。

Q3:子 Agent 默认能看到哪些工具?被谁收窄?

A:默认继承父的全部工具(FilterParams.All = t.parent.registry.Names()),然后过五层过滤(tool.ApplyAgentToolFilter):① 去掉 ALL_AGENT_DISALLOWED_TOOLS = ["Agent"](硬熔断,防递归);② 后台再加 ASYNC_AGENT_ALLOWED_TOOLS 八项白名单交集(mcp__* 动态放行);③ 去掉角色 disallowedTools;④ 与角色 tools 白名单取交集。收窄后的结果通过 WithAllowedTools 注入,在循环里以 registry.DefinitionsFiltered(allowed) 的形式决定发给模型的 schema。注意这是能力可见性约束,执行层没有二次校验(§4.3)。

Q4:内置了哪 3 个角色?各自的定位是什么?

A:Explore(只读搜索,disallowedTools: [write_file, edit_file]、model: haiku、maxTurns: 30、鼓励并行工具调用)、Plan(只读规划,多禁一个 Agent、maxTurns: 15、permissionMode: plan、要求末尾列 3-5 个关键文件路径)、general-purpose(全工具兜底,maxTurns: 30,要求简洁报告)。三者都是 //go:embed builtin/*.md 内嵌进二进制,解析失败直接 panic(N4:内嵌文件是代码的一部分);用户级与项目级可以同名覆盖(整体覆盖,不做字段级合并)。

Q5:后台任务有哪几个状态?怎么流转?

A:四态 StatusRunning / StatusCompleted / StatusFailed / StatusCancelled(manager.go:19-24)。Launch→Running;RunToCompletion 返回后分类:err == nil → Completed(带 Result);errors.Is(err, context.Canceled) → Cancelled;其他 err → Failed(带 Err + Result,失败也保留已产生的文本);panic 由 defer recover → Failed("subagent panic: %v")。唯一能回到 Running 的迁移是 SendMessage,且只允许从 Completed 出发(running/failed/cancelled 都返回 ErrTaskBusy)。

Q6:子 Agent 的结果怎么回到主对话?

A:两条路。前台:RunToCompletion 返回的 finalText 直接塞进 tool.Result{Content: finalText},由主 Agent 的 executeBatched 回灌成 tool_result——它和任何其他工具结果走完全相同的通道。后台:task.Manager 记下 Result,把 task_id 推进 done channel(缓冲 32),TUI 的常驻 goroutine consumeTaskDone 拼出 <task-notification> 块并 runtime.AppendReminders → 主 Agent 下一轮以 reminder 形式看到(不写历史、不打断用户、用户视窗不可见,N7)。

L2 深挖实现 ​

Q7:Agent 工具的 Description 为什么动态生成?不怕击穿 prompt cache 吗?

A:动态生成是为了让用户/项目新增的角色文件立刻被模型看见——不用改代码或提示词,模型描述里就出现了新的 subagent_type。cache 风险是真实存在的(工具定义通常在请求前缀里),所以我们做了两件事把风险压到最低:① catalog.List() 严格按 name 升序(sort.Strings),文本确定性,同一套角色每次启动生成完全一样的字符串;② spec N1 要求主 Agent 的工具集不因 .mewcode/agents/ 增减而变化——但这条只能约束「集合」,约束不了「描述文本」,所以严格讲:新增/删除角色的那一刻 cache 会失效一次,之后恢复稳定。这是「可扩展性」换「一次缓存重建」的取舍。

Q8:子 Agent 的上下文是怎么隔离的?具体共享了什么?

A:共享 Provider、Registry、PermissionEngine、HookEngine(都是同一个指针);隔离 SessionRuntime(&SessionRuntime{ContextWindow: t.parent.runtime.ContextWindow} 只继承窗口数值,压缩状态/锚点/轮次计数全新)、Conversation(conversation.New() 或 NewFromMessages(forked, nil, nil))、token 计数(UsageAnchor = 0)。共享 Engine 是权限链第一层「父已批准账本」的物理基础;共享 HookEngine 意味着子 Agent 的工具调用同样触发 PreToolUse/PostToolUse,项目级安全策略无法绕过。没传的是 WithCatalog(子 Agent 看不到 Skill 目录)、WithMemoryManager(不触发记忆更新)、WithInstructionText/WithMemoryText、WithApprovalUpgrader。

Q9:Fork 为什么必须深拷贝?fixPendingToolCalls 补的 placeholder 是什么?

A:llm.Message 里有 ToolCalls/ToolResults 两个切片字段,copy(cloned, msgs) 只复制切片头——两个对话会共享底层数组,子 Agent append 工具结果时可能直接写进父对话的历史(Go 最经典的浅拷贝陷阱),所以 cloneMessages 对这两个字段单独 make + copy。fixPendingToolCalls 解决的是协议合法性:如果 Fork 发生的瞬间父对话停在 assistant(tool_calls) 而 tool_result 还没写,克隆出来的历史就是「悬空 tool_use」→ Anthropic 直接 400。修法是扫描最后一条带 ToolCalls 的 assistant,对没被后续 RoleTool 消息消费的 call 补一条 ToolResult{Content: "[forked, skipped]", IsError: true}。备选方案(截断到完整轮 / 重发)分别会丢上下文和重复副作用,都不如补 placeholder。

Q10:ForkBoilerplate 那 5 条规则分别在解决什么问题?

A:① 不能再 Fork → 防指数 fan-out,配套机制是 ALL_AGENT_DISALLOWED_TOOLS = ["Agent"](工具列表里根本看不到,这一条是硬的);② 不要提问/请求确认 → 子 Agent 后面没有人,问一句就挂到超时,这条只能靠提示词;③ 直接使用工具 → Fork 分身继承了父的全部上下文,容易「继续聊天」;④ 限定范围 → 防止它顺手改一堆无关文件;⑤ 报告 Scope: 开头 + 500 字内 → 结果要回灌父对话,太长就等于把污染搬回父上下文,隔离白做——这条也是纯提示词,Execute 里没有长度硬截断,是我认为最该补的一处。

Q11:RunToCompletion 和 Run 有哪些差异?为什么不干脆复用同一段循环?

A:差异表见 §3.5.1,最关键的五条:① 返回 (string, error) vs <-chan Event(同步求值 vs 异步流式);② 轮数用 a.maxTurns(缺省继承 25)vs 固定 25;③ 支持 allowedTools 白名单;④ 事件走内部缓冲 32 + drainEvents 非阻塞转发(丢事件不丢执行);⑤ 不做记忆更新、不派发 Stop/Notification hook、不做历史兜底(子对话用完即弃)。另外未知工具熔断更严:maxUnknownRunSub = 2 vs 主 Agent 的 3。至于复用——spec F9 写的是「同一段循环代码共用,不重复实现」,但实现里两个入口重复了约 140 行,这是已知的实现与设计偏差,我在 §3.5.3 给了参数化的重构方向(loopOptions 把系统提示来源、工具集决策、轮数、事件出口、收尾策略抽成参数)。

Q12:五层工具过滤的顺序为什么这么排?过滤结果怎么落地?

A:顺序是「起点全集 → 去全局禁用 → 后台白名单交集 → 去角色黑名单 → 角色白名单交集」。先收全局硬约束,再收运行时约束,最后收角色业务约束,这样任何一层都无法把上一层排除掉的工具加回来(removeTools/intersectTools 都是单调收窄,没有并集操作)。落地通过 WithAllowedTools(allowed) → 循环里 registry.DefinitionsFiltered(allowed),在 ModePlan 时优先用 ReadOnlyDefinitions()(mode 比 allowedTools 更硬)。过滤只发生在子 Agent 构造时,主 Agent 看到工具列表不变(F31/N1,保证 prompt cache 稳定)。

Q13:120 秒超时自动切后台具体怎么实现的?进度为什么没丢?

A:AgentTool.Execute 前台路径用 context.WithTimeout(ctx, autoBackgroundDuration)(120s)包住 RunToCompletion;超时(timeoutCtx.Err() != nil)时调 taskMgr.AdoptRunning(ctx, subAgent, subConv, name, events, cancel),把已经在跑的 Agent 实例、对话、事件流、cancel 函数整体交给 Manager,工具结果返回 {"task_id":..., "status":"timed_out_to_background"}。进度不丢的根本原因是没有任何状态迁移:AdoptRunning 只起一个 goroutine for ev := range ev { aggregateEvent(ev, bt) } 消费事件流统计 ToolCount/Usage/LastActivity,Agent 本身还在原来的 goroutine 里跑——所以 spec F17 那个 partial 参数在实现里根本不需要。但要诚实补充两点:bt.Task 被置空(不重跑)、状态在事件流关闭后若仍是 Running 就一律补 Completed,而且移交传入的 cancel 就是刚触发的那个超时的 cancel,所以「上下文已死、实际不会继续跑」——见 §3.8.3 与 §4.2。

Q14:<task-notification> 为什么走 PendingReminders 而不是 conv.AddUser?

A:三个原因。① 不打断:用户可能正在输入,直接写历史会打乱交互节奏;reminder 是在下一轮 streamOnce 时以 llm.Request.Reminder 注入的,天然「下一轮才生效」。② 合法性:conv.AddUser 会破坏 user/assistant 交替约束,还可能和正在跑的 Run 竞争(runMu 保护的是 Run 之间,不保护外部写入)。③ 可见性:spec N7 明确要求「不出现在用户视窗(只对模型可见)」,reminder 区正好符合。用 <task-notification> XML 标签包裹是同一套思路——让模型能区分「系统事件汇报」和「用户新需求」,同时便于测试断言。

L3 故障与边界 ​

Q15:子 Agent panic 了会怎样?

A:分路径。后台:Launch/SendMessage/AdoptRunning 三处 goroutine 都有 defer recover,panic 转成 StatusFailed + Err = "subagent panic: %v",并且无论成功失败都会把 id 推进 done channel(通知在同一个 defer 里),主程序存活、用户看到失败通知(N3/AC20)。前台:AgentTool.Execute 直接调 RunToCompletion,没有 recover,而工具执行层 registry.Execute 也没有 recover,所以 panic 会一路打穿到主 Agent 的 executeBatched goroutine → 整个进程崩溃。这是 N3 的一个缺口,也是我建议的最高优先级修复:在 Execute 里包一层 defer recover → tool.Result{IsError: true},5 行代码换「不崩」。

Q16:子 Agent 要做危险操作(比如 bash rm -rf),权限怎么判?

A:三层链(spec F12)。① 父已批准账本:子 Agent 与父共享同一个 permission.Engine,父选过「永久允许」时 PersistLocalAllow 会把精确规则写进 Engine.local(内存 + 本地 YAML),子 Agent 的 Check 命中 e.local.allow 就直接 Allow,不再打扰用户。② 角色 permissionMode:dontAsk → Check 返回 Ask 时直接 runTool(黑名单/沙箱/deny 规则仍然拦,dontAsk 只豁免「规则未命中」的部分);plan → 工具集先被收窄成只读;bypassPermissions → 全 Allow(黑名单/沙箱仍拦)。③ 三层之外仍是 Ask → 设计是升级到父 TUI 弹框(标注 [来自 SubAgent X]),但 ApprovalUpgrader 没有实现也没被注入,当前实际行为是发到自己事件流后阻塞到 ctx 超时(30s)失败。这里的安全默认是正确的:宁可拒绝,不能静默放行。

Q17:Agent 工具本身受哪一层超时约束?

A:三层叠加,取最早到期的那个:外层 streamOnce 的 HTTP 请求有 provider 侧超时;中层是 executeBatched 给每个工具包的 context.WithTimeout(ctx, tool.DefaultTimeout) = 30 秒(agent.go:930);内层才是 AgentTool 自己的 autoBackgroundDuration = 120 秒。所以 30s < 120s —— 120 秒这个阈值在实际路径上永远不会被独立触发,前台子 Agent 跑过 30 秒就会走 AdoptRunning 分支。更麻烦的是后台路径:Launch(ctx, ...) 继承的就是这个 30s 的 tctx,而 Execute 返回后主 Agent 立刻 cancel(),所以后台任务的 ctx 启动即失效。修法是让 TaskManager 拿到不继承工具超时的会话级 ctx(Go 1.21+ 的 context.WithoutCancel 或从 Agent 上持有一个 long-lived ctx)。这条我是从代码路径推导的,internal/task 没有任何测试覆盖。

Q18:主 Agent 一轮里并发派 5 个子 Agent 会怎样?

A:会真的起 5 个——源码里没有任何并发闸门(无 semaphore、无 worker pool、无最大子 Agent 数、无会话级任务配额)。而且要注意 AgentTool.ReadOnly() 返回 false,所以 5 个 Agent 调用在 executeBatched 里是串行的(每个都走有副作用分支)——也就是说主 Agent 会被顺序阻塞 5 次,每次最长 30 秒,最坏 150 秒不产出任何可见进展。加上每个子 Agent 有自己的 maxTurns(Explore 是 30),一轮之内的潜在 token 消耗是 5 × 30 × 全量上下文。生产环境必须补:① 信号量限流;② 会话级子 Agent 总数与总 token 配额;③ 让纯只读子 Agent 可以并发(给 AgentTool.ReadOnly() 加「角色只读 ⇒ true」的判定)。

Q19:SendMessage 有什么限制?正在跑的任务能打断吗?

A:SendMessage 用 name 寻址(byName 弱引用映射,同名后启动覆盖前),找到后要求 Status == StatusCompleted 才允许续派,否则 ErrTaskBusy(manager.go:280-282)——不能打断正在跑的任务,也不能给 failed/cancelled 的任务续派(错误码不精确,实际是「状态不允许」)。续派方式是 bt.Conv.AddUser(message) + Status = Running + 新建 ctx 重跑 RunToCompletion(ctx, bt.Conv, "", events)(task 传空串因为消息已入历史),复用同一个 SubAgent 实例,所以子 Agent 记得上次干了什么(锚点、压缩状态、已激活 Skill 都还在)。保守是有理由的:外部往正在被 RunToCompletion 写的 Conversation 里插消息会破坏协议;企业级做法是把「打断」建模成显式的 Stop → 等 Cancelled → 再派新任务,并且状态机要支持 Running → Cancelling → Cancelled。

Q20:后台任务重启后还在吗?

A:不在。Manager 的状态(tasks / byName / done chan)全部在内存,internal/session 的 JSONL Writer 只写对话消息、不含任务表(grep task 零命中)。所以进程退出后:TaskList 空、TaskGet 查不到历史任务、SendMessage 无法续派。这是 spec 明确的「不做的事」(后台任务跨会话持久化),取舍是「CLI 短生命周期,不落盘就没有并发写文件/schema 迁移/崩溃恢复的复杂度」。但企业级场景(尤其是长任务 + 用户关掉终端)这是刚需:需要 JSONL/SQLite 落盘任务表(id/name/status/result/usage/时间戳),启动时恢复未完成任务的元数据并提示「上次有 N 个任务未完成」。另外 PartialState 结构已定义未使用,正好是「恢复中间态」的接口位置。

Q21:Fork 子 Agent 怎么防止再 Fork?

A:实际生效的只有一道:ALL_AGENT_DISALLOWED_TOOLS = ["Agent"] 在五层过滤的第 2 层把 Agent 从工具列表里删掉——模型看不到这个工具,也就几乎不会调。spec F22/F24 还设计了另外两道:QuerySource 检测(在 Agent 工具入口检查 caller 链是否来自 Fork)、IsForkContext 标签扫描(扫历史里的 <fork_boilerplate> 兜底),但这两道都没有接线(IsForkContext 全仓零调用点)。有意思的是:spec AC5 的验收标准写的是「Fork 子 Agent 的工具列表里仍有 Agent 工具,靠调用时拦截」——这与实际行为(第 2 层对 Fork 同样生效、Agent 已被删除)冲突。结论上更安全(最严的那道生效了),但文档和验收标准是错的,主动指出这点很能体现读码深度。

L4 设计与权衡 ​

Q22:为什么把 subagent / task / agent 拆成三个包,还要搞反向接口?

A:三个关注点确实不同:subagent 只管角色定义(文件解析、Catalog、优先级、embed),task 只管后台生命周期(状态机、通知、元工具),agent 管执行循环。拆包的收益是单测可用 mock 替身(AgentCatalog/TaskManager 都是接口),代价是 Go 不允许跨包循环引用,所以接口必须定义在使用方包:AgentCatalog 和 TaskManager 都放在 agent_tool.go,由 subagent.Catalog 和 task.Manager 去实现。这比「合并成一个大包」可维护,比「事件总线彻底解耦」可调试(调用链是直接的函数调用,不需要追事件)。一个小瑕疵:task 持有的是 *agent.Agent(结构体)而不是接口,所以反向接口只解了一半——task 要测试就不得不构造真实 Agent。

Q23:为什么 Fork 无条件后台?

A:Fork 的语义就是「带着父的全部上下文去干一件长活」——带上下文意味着首轮请求就是十万 token 级,注定慢。如果它前台跑,主 Agent 会被冻住几十秒到几分钟,用户的 ESC 也没法「只取消子任务、保住主对话」。所以 spec F18 直接规定 Fork 路径把 run_in_background 强制视为 true(background := def.Background || aArgs.RunInBackground || isFork)。副作用是:Fork 必须开启后台能力,所以 N6 那个 enableSubAgentBackground=false 配置一旦生效,Fork 会直接报错(AC18)——这是设计上接受的连带效应。

Q24:后台白名单为什么硬编码成 8 个工具,而不是从工具的「危险等级」动态推导?

A:因为当前系统里没有「危险等级」这个元数据。工具有的是 ReadOnly() bool(二值),有的是 permission 的 categorize 分类(读/写/执行),都不足以表达「这个操作适合无人看守的后台执行吗」——比如 install_skill 显然是写操作(会改 ~/.mewcode/skills),但它被放进白名单,因为「后台装个 skill」是合理场景。硬编码的优点是显式、可审计、加工具时必须做一次人为决策;缺点是必须手动同步(新工具忘了加就静默缺失,没有任何告警)。改进方向:给 Tool 接口加 AsyncSafe() bool 或 Capabilities() []string,让白名单从元数据推导,并在启动时校验「registry 里的工具都有能力声明」,把「静默缺失」变成「启动期报错」。

Q25:为什么子 Agent 用 system prompt 整段覆盖,而不是追加到默认 prompt 后面?

A:因为角色的 system prompt 就是它的全部人格。追加会让「只读探索者」同时读到主 Agent 的通用指令(「你可以修改文件来完成任务」),两套指令互相削弱——模型在冲突指令下的行为是不可预测的,这是提示工程的大忌。整段覆盖保证了角色约束的唯一性与强度。代价是子 Agent 丢掉了项目指令(CLAUDE.md 之类)和 Skill 目录(所以 LoadSkill 用不了)。我认为更好的方案是三段式 system prompt:[通用底座] + [角色 body] + [项目指令],顺序固定 → prompt cache 依然稳定(前缀不变),同时角色约束仍在 system 层(权威性不降)。现方案属于「用一个明确的损失换取约束的清晰性」。

Q26:这套实现和 Claude Code 的 Task 工具、OpenAI Agents SDK 的 handoff 有什么本质区别?

A:本项目是 tool-style delegation(工具式委派):控制权从不转移,父 Agent 调 Agent 工具、拿回一段文本、继续自己的循环。OpenAI Agents SDK 的 handoff 是 control transfer:一个 agent 把「会话所有权」交给另一个 agent,后者直接对用户产出,还支持 input_filter(决定移交哪些历史)和 on_handoff 回调。两者的后果差异很明显:tool-style 里父 Agent 始终是「单一控制点」,能做全局收尾(比如统一验证、统一回复用户),适合「主 Agent 编排 + 工人执行」;handoff 适合「多个专家平等接力」(客服场景:先 triage 再转专家)。LangGraph 的 subgraph 则是显式状态图 + checkpointer,可中断/恢复/重放,隔离靠 state schema 而不是「深拷贝对话」——本项目没有 checkpoint/replay,子 Agent 跑挂无法从中间态恢复(PartialState 就是那个未接线的位置)。

Q27:如果让你重做这一章,你会改什么?

A:按优先级四件事。① 修超时语义:让 TaskManager 拿到不继承工具超时的会话级 ctx,AdoptRunning 用新 context 重建运行,让 120 秒阈值和「转后台继续跑」真正成立(当前被 30 秒工具超时抢先,且移交时 ctx 已死)。② 补安全兜底:前台路径加 defer recover(防崩溃)、allowedTools 加执行层准入(防提示注入调被白名单排除的工具)、子 Agent 回传结果加长度硬截断(防污染父上下文)。③ 抽公共循环体:把 Run/RunToCompletion 的 140 行重复抽成参数化的 runLoop(ctx, conv, loopOptions),这也是 spec F9 原本的要求。④ 把观测做起来:子 Agent 的事件流现在前台根本不消费(缓冲 32 后丢弃),应该统一落 trace;Usage{Input,Output,CacheWrite,CacheRead} 已经采集但要按 task / 角色 / 模型维度聚合上报,否则「多 Agent 成本」永远说不清。另外还有一批小的确定性修复:nextID 换 UUIDv7、BackgroundTask 加锁、TaskList 按终态过滤、新工具能力声明化。


六、企业级方案对照 ​

维度MewCode 的做法企业级 / 对标做法差距与补齐
Claude Code Task 工具同进程内 Agent 工具委派:独立上下文 + 结果摘要回主 Agent;角色来自 .mewcode/agents/*.md;内置 3 角色 embed.claude/agents/*.md + description 触发 + tools 白名单;另有面向用户的 todo/task 列表(跨会话、可见进度)本项目只覆盖「后台 agent 运行」这一层,没有 todo 类工具(全仓 grep 无相关工具)。需补:任务列表持久化 + 用户可见进度
OpenAI Agents SDK handofftool-style delegation:控制权不转移,父 Agent 拿回文本继续handoff 是一等公民:控制权转移 + input_filter(历史过滤)+ on_handoff 回调 + 新 agent 直接对用户产出本项目无「会话所有权」概念。补 handoff 需引入 transfer 语义 + guardrails(本项目对应 permission.Engine,而 dontAsk 是需要审查的放宽开关)
LangGraph subgraph深拷贝 Conversation + 全新 SessionRuntime 模拟隔离;无 checkpoint显式 state schema + checkpointer(可中断 / 恢复 / 重放),subgraph 靠 state 键传值本项目无 checkpoint/replay,子 Agent 跑挂无法从中间态恢复(PartialState 已定义未接线)。补法:子 Agent 状态显式化为可序列化 struct + 每轮 checkpoint
CrewAI / AutoGen严格主从:子 Agent 之间不能互相通信(ASYNC_AGENT_ALLOWED_TOOLS 特意排除 4 个元工具)平等协作:role-based 团队 + 自由对话 + 委派图(谁可以委派给谁)本项目刻意不做「团队编排」(spec「不做的事」明确排除)。需要版图扩展时,应先补「委派图 + 最大深度」,否则自由协作会退化成死锁
A2A 协议同进程方法调用(agent.New + RunToCompletion),任务状态 running/completed/failed/cancelled 与 A2A 的 working/completed/failed/canceled 同构agent card 发现 + 跨厂商鉴权 + SSE/推送通知 + task 生命周期可以说是「A2A 的单进程内核」。补法:抽 AgentTransport(in-process / 远端 A2A),用现成的 name/description/tools 生成 agent card,鉴权下沉到 transport 层
分布式 Agent 编排(Temporal / K8s Job)进程内 goroutine + 内存 map,done channel 通知持久化工作流引擎:每步落盘、崩溃可恢复、幂等重放、租户级配额本项目重启即丢全部任务;无幂等键、无重试、无配额。生产化第一步是任务表落盘 + 状态机持久化,第二步才是调度

重点讲一个:企业级多 Agent 编排的三大难题 ​

如果面试官问「你觉得多 Agent 最难的是什么」,不要答「难在让它们协作」——那太浅。真正难的是下面三件事,而且每一件在 MewCode 里都能找到对应的半成品,这就是最好的回答素材。

难题一:上下文隔离与共享的边界在哪 ​

这是多 Agent 的第一性问题:两个 Agent 之间到底该共享多少东西?

共享得太少共享得太多
子 Agent 不知道项目规范、不知道用户已经否决过什么方案,重复劳动子 Agent 看不到「独立上下文」的收益;父对话被污染,prompt cache 被打散
每个子 Agent 都要重新探索一遍代码库(贵、慢)并发写同一份历史 → 协议非法、数据竞争

MewCode 的边界是「消息级隔离、引擎级共享、状态级隔离」:Conversation 隔离(消息级)、PermissionEngine/HookEngine/Registry 共享(引擎级)、SessionRuntime 隔离(状态级)。这个边界不是随意的,它对应一个清晰的判据:

「这个资源有没有 per-Agent 的可变状态?」 有 → 隔离(Conversation、SessionRuntime 的压缩状态与锚点);没有(无状态客户端、纯函数式查询、全局策略)→ 共享。共享 HookEngine 是因为它表达的是项目级安全策略(不该有 per-Agent 差异);共享 PermissionEngine 是因为它承载了用户已经做过的决策(approval ledger),重复问用户是体验灾难。

企业级还需要补的边界:

  • 记忆的边界:本项目子 Agent 完全不写 memory(memMgr 为 nil)。企业级需要「子 Agent 的发现要不要沉淀到长期记忆」的显式策略——否则一个查出重要架构结论的 Explore 子 Agent,结论随着 finalText 回灌、再被压缩掉,就永久消失了。
  • 文件系统的边界:本项目所有子 Agent 共享同一个工作目录,并发 write_file 的顺序完全靠主 Agent 的串行调度保证。企业级要做 worktree / 沙箱隔离(spec 明确把「Worktree 文件隔离」列为不做的事,留给后续章节),否则「3 个后台 Agent 同时改同一个文件」会导致难以复现的失败。
  • 成本的边界:见难题三。

难题二:失败传播与补偿 ​

单 Agent 的失败处理很简单:error → IsError 的工具结果 → 模型自己重规划(第 03 章的 Error as Observation)。多 Agent 下这个模型会退化,因为失败的语义变了:

失败类型单 Agent多 Agent
子任务报错模型看到错误,改道父 Agent 只看到一段文本(Result),它甚至不知道子 Agent 是「失败了」还是「正常完成任务并汇报了坏消息」
子任务部分完成不存在这个概念已经产生的副作用(改了 3 个文件)不会回滚;重试一次会重复执行
子 Agent panic进程崩溃后台路径 → Failed + 通知(可接受);前台路径 → 进程崩溃(§4.4)
父 Agent 被取消补 tool_result 保证历史合法后台子 Agent 仍在跑(它的 ctx 来自 WithCancel(parentCtx),父取消会传播——但反过来,用户想「只取消主对话、保留后台任务」时,现在的实现做不到)

MewCode 现在的失败传播是「文本化」的:{"task_id":..,"status":"failed"} 或 <task-notification> 里带 Error: ...。这有一个隐蔽缺陷——AgentTool.Execute 在子 Agent 返回 error 时返回的是 IsError: true 的 tool.Result,但后台路径永远返回 success({"task_id":..., "status":"async_launched"} 不是错误),失败只在下一轮的 notification 里出现。对模型来说,「我派了任务」和「任务失败了」被拆成了两个不同轮次的事件,中间还可能被压缩掉。

企业级的三个必备机制:

go
// ① 结构化回传(不是纯文本)
type SubAgentReport struct {
    TaskID    string   `json:"task_id"`
    Status    string   `json:"status"`      // completed / failed / partial
    Result    string   `json:"result"`
    Artifacts []string `json:"artifacts"`   // 改了哪些文件(用于补偿/审阅)
    Errors    []string `json:"errors"`
    Truncated bool     `json:"truncated"`   // 结果是否被硬截断
}

// ② 幂等键:同一个子任务重试不重复产生副作用
type TaskSpec struct {
    IdempotencyKey string   // 派生自 (parent_run_id, task_hash),重试复用
    SideEffects    []string // 声明的副作用范围(用于判定「能否安全重试」)
}

// ③ 补偿(Compensation):撤不掉就显式记录
type Compensation struct {
    Action   string // "git checkout -- <path>" / "delete <path>"
    Reversible bool // 是否可逆;不可逆的操作必须先拿到人工确认
}

面试怎么说:「MewCode 的失败传播是文本级的,够用但不严谨。企业级要做到三点:结构化报告(让父 Agent 能区分「完成了但结果不好」和「没跑完」)、幂等键(重试安全)、补偿动作(每个有副作用的子任务声明自己的撤销方式,不可逆操作前置审批)。这三件事的共性是——它们都要求父 Agent 对子 Agent 的副作用有可观测性,而本项目里父 Agent 只知道一个 tool_count 和一个 last_activity。」

难题三:成本归因 ​

多 Agent 让成本结构从「一条线性曲线」变成「一棵树」,而本项目的成本数据只到叶子、不到根。

现状:BackgroundTask.Usage 采集了四项 token(Input/Output/CacheWrite/CacheRead),ToolCount 也统计了(manager.go:337-348),但:

  • 没有聚合:spec「不做的事」明确写「跨 SubAgent token 用量汇总到 /status(只在 Manager 内部记录)」
  • 只覆盖后台任务:前台子 Agent 的 token 完全没有进入 BackgroundTask——前台路径创建了 events 通道但没人消费(agent_tool.go:216-219),事件里的 Usage 直接丢进缓冲区然后被丢弃
  • 没有父子关联:BackgroundTask 里没有 ParentRunID / Depth 字段,无法回答「这次用户请求一共花了多少」
  • 没有预算检查:全流程没有一处「调用前检查预算」(第 03 章 §五讲过同样的问题)

企业级的模型应该是按树上每个节点归因,再向上汇总:

go
// 成本归因的三个维度
type CostRecord struct {
    RootSessionID string   // 用户请求 ID(整棵树的根)
    ParentTaskID  string   // 父任务 ID("root" 表示主 Agent)
    TaskID        string   // 本节点
    Depth         int      // 嵌套深度(当前被 ALL_AGENT_DISALLOWED_TOOLS 限制为 1)
    AgentName     string   // Explore / Plan / general-purpose / __fork__
    Model         string   // ← 注意:model 字段当前未生效,所以这里永远等于父模型
    Usage         Usage    // Input / Output / CacheWrite / CacheRead 四项
    WallClock     time.Duration
    ToolCalls     int
}

// 查询:这次用户请求一共花了多少?哪个角色最贵?Fork 的 cache 命中率是多少?
func (l *CostLedger) AggregateByRoot(sessionID string) TreeCost

一个非常能体现深度的问题:为什么 Usage 必须把 CacheWrite 和 CacheRead 分开记?

不使用 Fork:子 Agent 首次请求 input = 10 万 token,全部按「新输入」计价
使用 Fork:  子 Agent 首次请求 input = 10 万 token,其中约 9.5 万走 cache_read(约 1/10 单价)
                                                    约 0.5 万走 cache_write(比新输入贵)

如果只看 input_tokens 总数,Fork 看起来和「重新读一遍代码库」一样贵——而归因表一旦分列,就能算出 Fork 到底省了多少钱。这也解释了第 06 章那个设计细节:锚点必须包含 CacheRead + CacheWrite,否则 token 估算会离真值差一倍以上。

最后一句总结(面试时可以这么收尾):三大难题其实是同一个问题的三个侧面——「多 Agent 的收益来自隔离,代价来自隔离」。上下文隔离带来干净的子上下文,代价是父 Agent 失去了可观测性(不知道子 Agent 干了什么、花了多少、改了什么);所以企业级的每一层建设(结构化报告、事件 trace、成本归因、checkpoint)本质上都是在为隔离补上可观测性的接口。


七、本章速记卡(面试前 5 分钟看) ​

核心文件      agent_tool.go(Agent 工具)/ run_to_completion.go(子循环)
              fork.go(Fork 三件事)/ filter.go(五层过滤)
              subagent/{catalog,parser,embed}.go(角色三层加载)
              task/{manager,tools}.go(后台任务 + 4 个元工具)

两种模式      subagent_type 非空 → 定义式(空对话 + 角色 system prompt)
              subagent_type 为空 → Fork 式(克隆父对话 + ForkBoilerplate,无条件后台)

角色字段      name/description(必填)/ tools / disallowedTools / model
              maxTurns(0 → 全局 25)/ permissionMode(含 dontAsk)/ background
三层加载      builtin(embed, 解析失败 panic) → user(~/.mewcode/agents) → project(<root>/.mewcode/agents)
              同名整体覆盖(project > user > builtin);Resolve 大小写敏感
内置角色      Explore(只读, haiku, 30轮) / Plan(禁 Agent, plan, 15轮) / general-purpose(全工具, 30轮)

隔离与共享    共享 Provider / Registry / PermissionEngine / HookEngine
              隔离 SessionRuntime / Conversation / token 计数
              不带 catalog(看不到 Skill) / memMgr(不更新记忆) / approvalUpgrader

权限三层      ① 父已批准账本(共享 Engine 的 e.local allow 规则,自动 Allow)
              ② 角色 permissionMode(dontAsk 只豁免「规则未命中」的 Ask)
              ③ 升级父 TUI 弹框 → 接口就绪、未接线(当前阻塞到超时失败)

五层过滤      ① registry 全集 → ② 去 ALL_AGENT_DISALLOWED_TOOLS(["Agent"])
              → ③ 后台 ∩ ASYNC_AGENT_ALLOWED_TOOLS(8 项, mcp__* 动态放行)
              → ④ 去 disallowedTools → ⑤ ∩ tools 白名单
              ModePlan 优先于 allowedTools;过滤只在构造期,主 Agent 工具列表不变

后台任务      四态 Running/Completed/Failed/Cancelled;done chan 缓冲 32(满则丢通知)
              三种进入:显式 run_in_background / 120s 超时 AdoptRunning / ESC(未接线)
              通知 <task-notification> → PendingReminders → 下一轮 reminder(不写历史)
              四工具 TaskList/TaskGet(只读) + TaskStop/SendMessage(非只读)
              SendMessage 仅 Completed 可续派,复用同一 SubAgent 与 Conversation

关键常量      autoBackgroundDuration = 120s(被 tool.DefaultTimeout=30s 抢先)
              maxIterations = 25 / maxTurns 缺省继承 25 / maxUnknownRunSub = 2(主 Agent 是 3)
              done 缓冲 32 / events 缓冲 32 / tool.DefaultTimeout = 30s

Fork 三件事    cloneMessages 深拷贝(ToolCalls/ToolResults 切片)
              fixPendingToolCalls 补 [forked, skipped] placeholder
              append user = ForkBoilerplate + task(借 Prompt Cache)

已知缺口      model 字段未生效 / 权限第三层未接线 / ESC 转后台未接线
              IsForkContext、CUSTOM_AGENT_DISALLOWED_TOOLS、PartialState 未接线
              前台路径无 panic 兜底 / allowedTools 非执行层硬约束 / 无并发上限
              无持久化 / SendMessage 无锁竞态 / nextID 有碰撞可能

持续学习,持续构建。