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

三层分工,只靠一个 dataclass 传话

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

先看全程:一次 spawn 怎么走

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

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

图Fig. 一次 spawn,四段接力交互演示 · 一次 spawnInteractive · one spawn

第一段Leg 1调度层

tools/agent_tool.py这次派谁、带什么参数

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

这一层的陷阱The trap here
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)

TeammateSpawnConfig 的字段去向Where the TeammateSpawnConfig fields go当前后端 subprocessbackend: subprocess

被读取 · 6 个Read · 6

  • model
  • system_prompt
  • system_prompt_mode
  • command
  • task_type
  • plan_mode_required

没人读 · 5 个Never read · 5

  • name
  • team
  • parent_session_id
  • color
  • prompt
第 1 段,共 4 段Leg 1 of 4

定义层:有哪些 agent,各长什么样

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

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

  • 身份 — name / description:必填。description 是给主 agent 模型看的「什么时候该派它」
  • 提示词与工具 — / tools / disallowed_tools:tools=None 与 tools=["*"] 等价,都表示全开
  • 模型与预算 — model / effort / max_turns:model=None 表示继承父进程
  • 能力接入 — skills / mcp_servers / required_mcp_servers / hooks:把第五、六、七篇的子系统按 agent 粒度接进来
  • 生命周期 — background / initial_prompt / memory / / permission_mode:isolation="worktree" 给它独立 git 工作树

三级合并,后写者赢

(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. 插件 —— 同名覆盖用户

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

一个用户 agent 长这样

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

例Example ~/.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)写错缩进不会报错,只会悄悄丢掉。--- 之后的正文整体成为 。

魔改陷阱

get_agent_definition(name)(:948)每次调用都完整跑一遍 get_all_agent_definitions()——包括重新扫描磁盘目录和重新加载所有插件,没有任何缓存。单次 spawn 无所谓,但如果你的魔改要在循环里查定义(比如给模型动态列出可用 agent),记得自己在外面缓存一层。

调度层:这次派谁,带什么

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

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

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

例Example 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,
)

请数一下: 有二十多个字段,这里只有三个来自它(model / / permissions)。tools、disallowed_tools、permission_mode、max_turns、effort、skills、hooks、 全部止步于此。

mode 不选后端

第 66 行硬编码 registry.get_executor("subprocess"),mode 只是被塞进 task_type 当标签。所以传 mode="in_process_teammate" 并不会走 in-process 后端——代码注释说明了原因:in-process 任务返回的是 asyncio 内部 ID,task_* 系列工具查不到。这是刻意的取舍,不是 bug,但你魔改时必须知道。

执行层:怎么让它真的跑起来

源码:src/openharness/swarm/(约 4900 行,这篇只看两个后端 + 注册表)。

用哪个后端,按什么顺序判断

swarm/registry.py:128 的 ,三级优先:

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

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

两个后端,读的字段不一样

图Fig. 两个后端,读的是同一份配置对照 · 两种执行后端Side by side · two backends
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_mode / plan_mode_required / command / task_type;in_process 用 name / team / parent_session_id / color / plan_mode_required / prompt——它连 都不读。同一个 dataclass,两个后端各取所需,没取的那些就是死字段。

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

swarm/spawn_utils.py:109 的 ,逐条映射:

  • permission_mode="bypassPermissions" → --dangerously-skip-permissions:plan_mode_required=True 时整段跳过——plan 优先于 bypass
  • permission_mode="acceptEdits" → --permission-mode acceptEdits:同上受 plan 压制
  • model → --model X:值为 "inherit" 时不生成,改走环境变量
  • → --system-prompt 或 --append-system-prompt:由 system_prompt_mode == "append" 决定
  • plugin_dirs → 每个目录一条 --plugin-dir:保证子进程看得见 inline 插件
防注入的那道闸

所有值都过 shlex.quote()——因为这个列表最终可能被拼成 shell 字符串。你往里加新 flag 时必须保持这个习惯。

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

最后一段:结果怎么回到主 Agent

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

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

agent_tool.py:125-131 有个容易漏看的细节:注册监听器之后立刻又查一次任务状态,如果任务已经结束就手动补触发一次——这是在补竞态窗口(任务可能在注册完成前就跑完了)。你写任何「注册回调 + 异步完成」的代码都该抄这个模式。