← 返回文章

SOURCE NOTE · MULTI-AGENT

Lesson 8: 多 Agent 子系统

多 Agent 不是一个模块,是三层各管一段:定义层回答「有哪些 agent」,调度层回答「这次派谁」,执行层回答「怎么跑起来」。

学完本课,你能回答:一个子 agent 是由谁、按什么顺序造出来的?它的系统提示词、模型、工具集分别来自哪里?subprocess 和 in_process 两种执行方式差在哪?结果怎么回到主 agent?——以及:我想加一种自己的 agent 类型 / 自己的执行后端,该在哪一行下刀?

一句话概括

多 agent 不是一个模块,是三层各管一段:coordinator/ 只回答「有哪些 agent 类型、它们长什么样」(定义层),tools/agent_tool.py 回答「这次派谁、带什么参数」(调度层),swarm/ 回答「用什么方式让它真的跑起来」(执行层)。三层之间只靠一个 dataclass——TeammateSpawnConfig——传话。

全景:一次 spawn 的数据流

主 agent 决定派活之后,数据流依次穿过四层:调度层 tools/agent_tool.py 查定义、组装 TeammateSpawnConfig、拿执行器;定义层 coordinator/agent_definitions.py 按内置 → 用户 → 插件合并出 AgentDefinition执行层 swarm/ 挑后端(in_process / tmux / subprocess)真正把任务跑起来;回收层 tasks/manager.pytask_id 管进程,task_output 读输出、SUBAGENT_STOP hook 发通知。

下面的交互块可以逐层点开看职责与陷阱,并切换执行后端观察 TeammateSpawnConfig 的字段消耗:

INTERACTIVE TRACE · SPAWN FLOW

一次 spawn,四层接力

主 agent 一句「派个子 agent 去查」,要穿过调度、定义、执行、回收四层才真正跑起来。 逐层点开看每层的职责与陷阱;切换执行后端,看 TeammateSpawnConfig 哪些字段被真正消费。

01 / 04
LAYER 01 · 调度层这次派谁、带什么参数

tools/agent_tool.py

这次派谁、带什么参数

AgentTool.execute() 三步:① get_agent_definition(subagent_type) 查定义,查不到降级为裸 agent;② 组装 TeammateSpawnConfig——AgentDefinition 二十多个字段里只有 model / system_prompt / permissions 三个被取走;③ registry.get_executor("subprocess") 拿执行器并 spawn。

这一层的陷阱

mode 参数不选后端。第 66 行硬编码 subprocess,mode 只是塞进 task_type 当标签——传 in_process_teammate 并不会走 in-process,因为 asyncio 内部 ID 用 task_* 工具查不到。刻意的取舍,不是 bug。

TeammateSpawnConfig(name, team, prompt, cwd, model, command, system_prompt, permissions, task_type)
TEAMMATE spawn config 字段视角当前后端:subprocess · 亮起 = 被消费
  • model
  • system_prompt
  • system_prompt_mode
  • command
  • task_type
  • plan_mode_required
  • name
  • team
  • parent_session_id
  • color
  • prompt

第一层:定义层 —— agent 是什么

源码:src/openharness/coordinator/agent_definitions.py(975 行,其中约 400 行是内置提示词原文)。

核心是一个 Pydantic 模型 AgentDefinition(agent_definitions.py:60)。字段可以分成五组:

三级合并,后写者赢

get_all_agent_definitions()(agent_definitions.py:905)按 内置 → 用户 → 插件 的顺序往同一个 dict 里塞,key 是 name。所以:

  1. 内置 _BUILTIN_AGENTS(:510)—— general-purpose、Explore、Plan、verification、statusline-setup、claude-code-guide、worker
  2. 用户 ~/.openharness/agents/*.md —— 同名覆盖内置
  3. 插件 —— 同名覆盖用户

这跟 Lesson 6 见过的 Skills 发现规则是同一个套路,但注意差别:Skills 有 bundled / user / project / plugin 四个来源、含项目级 .openharness/skills/;agent 定义只有三个来源,没有项目级目录。想给某个仓库配专属 agent,目前只能走插件。

一个用户 agent 长这样

放到 ~/.openharness/agents/db-reviewer.md,重启即生效——零行 Python:

~/.openharness/agents/db-reviewer.mdyaml
---
name: db-reviewer
description: 审查数据库迁移脚本。当改动涉及 migrations/ 或 schema 时使用。
tools: Read, Grep, Glob, Bash
model: sonnet
effort: high
max_turns: 20
---

你是数据库迁移审查专家。只读地检查迁移脚本,重点关注:
可逆性、对存量数据的影响、锁表时长、索引缺失。
不要修改任何文件,把发现按严重度排序汇报。

解析规则见 _parse_agent_frontmatter()(:633):YAML frontmatter 用 yaml.safe_load,解析失败会静默降级成朴素的 key: value 逐行切分——所以嵌套结构(hooks、mcpServers)写错缩进不会报错,只会悄悄丢掉。--- 之后的正文整体成为 system_prompt

第二层:调度层 —— 这次派谁

源码:src/openharness/tools/agent_tool.py,全文只有 141 行,值得整篇读完。它做四件事:

  1. 校验 mode ∈ {local_agent, remote_agent, in_process_teammate}
  2. subagent_type 查定义,查不到就是 None,不报错(降级为无系统提示词的裸 agent)
  3. 组装 TeammateSpawnConfig,调 executor.spawn(config)
  4. 注册 SUBAGENT_STOP hook 的完成监听器(回到 Lesson 5 的生命周期事件)

第 3 步是全课最该盯住的十一行(agent_tool.py:68-79):

tools/agent_tool.py:68-79python
config = TeammateSpawnConfig(
  name=agent_name,
  team=team,
  prompt=arguments.prompt,
  cwd=str(context.cwd),
  parent_session_id="main",
  model=arguments.model or (agent_def.model if agent_def else None),
  command=arguments.command,
  system_prompt=agent_def.system_prompt if agent_def else None,
  permissions=agent_def.permissions if agent_def else [],
  task_type=arguments.mode,
)

请数一下:AgentDefinition 有二十多个字段,这里只有三个来自它(model / system_prompt / permissions)。toolsdisallowed_toolspermission_modemax_turnseffortskillshooksisolation 全部止步于此。

第三层:执行层 —— 怎么跑起来

源码:src/openharness/swarm/(约 4900 行,本课只取两个后端 + 注册表)。

后端注册表与检测优先级

swarm/registry.py:128detect_backend(),三级优先:

  1. in_process —— 仅当之前 spawn 失败过、mark_in_process_fallback() 被调用过
  2. tmux —— 在 tmux 会话里且后端已注册(当前未实现,见 :379 的 _register_defaults,tmux 注册被留白了)
  3. subprocess —— 永远可用的兜底

后端是 Protocol(swarm/types.py:358 的 TeammateExecutor),只要求几个成员:typeis_available()spawn()send_message()shutdown()这就是你加自定义后端的接缝。

两个后端的实质差别

subprocess

  • 真开一个新进程:python -m openharness --task-worker <flags>
  • 配置靠命令行参数传递(build_inherited_cli_flags
  • 加上继承的环境变量(build_inherited_env_vars
  • 通信:stdin 一行 JSON
  • 崩溃隔离好;启动慢;跨平台稳
  • 子进程被强制设 CLAUDE_CODE_COORDINATOR_MODE=0,避免递归进入协调者模式

in_process

  • 同进程内一个 asyncio.Task,自己跑一遍 agent loop
  • 配置直接读 dataclass,不经过 CLI
  • 通信:文件邮箱 ~/.openharness/teams/<team>/agents/<id>/inbox/
  • TeammateAbortController 支持协作式取消
  • 启动快、能共享内存对象;一崩全崩
  • 只在 supports_swarm_mailbox 的平台注册

注意两者吃的配置字段并不相同:subprocess 用 model / system_prompt / system_prompt_mode / plan_mode_required / command / task_type;in_process 用 name / team / parent_session_id / color / plan_mode_required / prompt——它连 system_prompt 都不读。同一个 dataclass,两个后端各取所需,没取的那些就是死字段。

subprocess 是怎么把配置变成命令行的

swarm/spawn_utils.py:109build_inherited_cli_flags(),逐条映射:

子 agent 的系统提示词不是主 agent 装配好递过去的,是子进程自己重跑一遍 build_runtime_system_prompt(),只不过多吃了一个 --system-prompt 覆盖。它自己发现自己的 CLAUDE.md、自己列自己的技能。这解释了为什么 omit_claude_md 需要是 AgentDefinition 上的一个字段。

第四层:结果怎么回来

SubprocessBackend.spawn() 不自己管进程,它调 BackgroundTaskManager.create_agent_task()(tasks/manager.py:49),拿回一个 task_id。之后主 agent 有三条路知道结果:

  1. 主动轮询task_output / task_list 工具 → read_task_output(task_id, max_bytes=12000),注意有截断上限
  2. 被动通知register_completion_listener(),agent_tool.py:125 用它触发 SUBAGENT_STOP hook
  3. 结构化回传:coordinator_mode.py 的 format_task_notification() / parse_task_notification(),把结果包成 <task-notification> XML 信封(含 status / summary / result / usage),XML 特殊字符走 escape()