harness - 子代理定义与任务委派
子代理(Subagent)是 Harness 的"任务执行单元":主代理通过 task / multitask 工具把子任务委派给它们,子代理在隔离的会话中独立推理,只把最终结果回传。本文说明当前版本子代理"怎么定义、怎么被加载、怎么被委派",以及委派过程中父子事件的可见性语义。
1、子代理的来源与发现机制
子代理定义(.md 文件)来自三类位置:
- 内置挂载:引擎构造时自动注册的
@harness-agents(类型AGENTS,来源是 classpath 的META-INF/solon/ai/harness/agents/),包含bash、explore、general、git-summary、plan五个内置子代理。 - 目录挂载:
harnessHome下的agents/或全局共享目录,通过MountType.AGENTS的挂载接入(如@global-agents)。 - 运行时注册:
generate工具动态创建,或代码调用engine.getAgentManager().addAgent(...)。
子代理目录(AgentCatalog)只扫描类型为 AGENTS 且启用的挂载:
- 递归深度上限 3、只取文件;跳过隐藏目录下的文件、以
.开头的文件、以及非.md文件; - 同名子代理保留先被发现者(
putIfAbsent); - 每个 md 必须以 front matter 的
name作为身份(见下节)。
AgentManager.getAgents()(也就是主代理提示词里的"当前可用子代理"列表)会过滤掉 hidden: true 的定义,所以内置的 git-summary 不会出现在该列表中;getAgent(name) 在缓存与目录里都找不到时会抛 IllegalArgumentException。
2、子代理定义(YAML front matter 与 AgentDefinition)
以内置的 bash 代理为例:
---
name: "bash"
description: "Bash 命令执行专家,专门执行 git 操作、命令行任务和终端操作(没有文件写权限)"
tools: ["list", "read", "bash"]
---
你是一个命令行执行专家……
front matter 字段与当前语义:
| 字段 | 状态 | 说明 |
|---|---|---|
name | 必需 | 子代理唯一标识。缺失或为空时该 md 会被跳过(解析期抛 IOException)。名称只取自 front matter,不再用文件名兜底 |
description | 建议 | 展示在主代理提示词的 <available_agents> 中,影响主代理的选择偏好 |
tools | 可选 | 允许的工具名列表,支持具体工具名与 * / ** / pi 通配 |
disallowedTools | 可选 | 禁用工具;优先于 tools,全局禁用工具同样优先生效 |
model | 可选 | 指定模型名;未指定时用会话选中模型,再回退默认模型 |
permissionRules | 可选 | 代理级权限规则增量(toolName / behavior / pattern / priority) |
enabled | 可选,默认 true | 是否启用 |
hidden | 可选,默认 false | 是否隐藏;隐藏代理不出现在主代理的可用子代理列表里 |
primary | 可选,默认 false | 是否主代理定义;影响 todo 工具的实现方式(主代理用文件模式,子代理用内存规划模式) |
skills、mcpServers、hooks、memory、background、isolation、teamName | 已 @Deprecated | 仅为兼容旧定义保留的字段,当前不参与运行(hooks 明确标注"暂不解析")。新定义不应再使用 |
解析与序列化入口:
AgentDefinition.fromParsedMarkdown(Markdown):当前推荐入口,接收已解析的 Markdown 文档(Markdown 的来源解析由来源描述负责)。AgentDefinition.fromMarkdown(String)/fromMarkdown(List<String>):已@Deprecated,内部转调新入口。AgentDefinition.toMarkdown():把 metadata 写回 YAML front matter,再接上 systemPrompt 正文;permissionRules因序列化限制单独手工追加。
Markdown 的 front matter 会直接绑定到 AgentDefinition.Metadata;permissionRules 需要运行时转换(pattern 为空时等价于工具级规则),因此由 fromParsedMarkdown 单独读取并转换为 PermissionRule 列表。
另外,AgentDefinition 提供三个常用常量:AGENT_MAIN("main")、AGENT_GENERAL("general")、ATTR_PERMISSION_CONTEXT("__permissionContext")。
3、代码方式定义子代理
运行时可以用 builder 构造定义并交给 AgentManager 管理:
AgentSession session = engine.getSession("default");
// 动态定义智能体
AgentDefinition definition = AgentDefinition.builder()
.name("reviewer")
.description("代码审查专家(只读)")
.systemPrompt("你是一个代码审查专家……")
.toolsAdd("read", "grep", "glob") // 也接受 Collection<String>
.disallowedToolsAdd("bash") // 禁用工具优先于 tools
.hidden(false)
.build();
engine.getAgentManager().addAgent(definition); // 注册(同名覆盖)
engine.getAgentManager().addAgentIfAbsent(definition); // 仅在不存在时注册
// 构建并直接调用(可进一步定制)
ReActAgent subagent = engine.createSubagent(definition)
.defaultToolAdd(new OrderTool())
.build();
subagent.prompt("审查 src/Main.java")
.session(session) // 没有,则为临时会话
.options(o -> o.toolContextPut(HarnessEngine.ATTR_CWD, engine.getWorkspace()))
.call();
AgentDefinition.builder() 支持 name / description / systemPrompt / primary / hidden / toolsAdd(...) / disallowedToolsAdd(...),以及 metadata(Consumer<Metadata>) 直达元数据(例如 metadata(m -> m.addTools(ToolName.TOOL_BASH)))。engine.createSubagent(definition) 返回 ReActAgent.Builder,仍可自由定制。
工具名解析由 AgentFactory 完成,要点:
*= 公共工具集;**= 全量工具集(含 mcp/openapi/hitl/memory 等);pi=read/write/edit/bash(含异步变体);edit会隐式带上read与write;ls与list归一为ls;task/generate受subagentEnabled开关控制;hitl受hitlEnabled控制;- 代理级
disallowedTools与引擎级全局禁用都会在执行前短路; permissionRules会以ATTR_PERMISSION_CONTEXT属性注入代理,供权限/HITL 引擎在运行时与全局规则合并。
4、任务委派:task 与 multitask
TaskTalent 把子代理能力暴露为两个工具:
| 工具 | 说明 | 参数 |
|---|---|---|
task | 委派单个子任务(不支持并行) | agent_name、prompt、description |
multitask | 并行执行多个互不依赖的子任务 | tasks 列表,每项比 task 多一个 index(默认 1,用于结果匹配) |
委派时的关键行为:
- 上下文隔离:每个子任务都新建
ReActAgent(由定义 + 父会话选中的模型构建)与InMemoryAgentSession,只带prompt以及 toolContext 中的__cwd、__sessionId,因此子代理看不到父会话的历史,主代理必须在 prompt 中提供完整背景。 - 执行方式:无论父级是流式还是同步,
TaskTalent内部都调用子代理的.stream().blockLast()阻塞等待其终态;两种父模式的差别只在"是否把子事件包装成TaskWrapEvent注入父流"(见第 6 节)。 - 并发与上限:
multitask用CompletableFuture+ IO 线程池并行执行;单批建议数量取engine.getMultitaskMaxTasks()(默认 5,0 表示不限制),该值会写进工具指令里。 - 结果格式:全部完成后按顺序拼成
<multitask_results>,每个子结果是<task_result>片段,含<index>(仅 multitask)、<description>、<agent_name>、<result_status>(success/failure)与<result_content><![CDATA[...]]></result_content>。 - 失败处理:子代理执行异常不会向上抛,而是被转换成
ERROR: 任务执行失败: ...的失败结果;父 ReAct 是否继续由后续运行决定。未知名的agent_name会由AgentManager抛IllegalArgumentException。 - 成功文本:取子代理终态
RunEndEvent.getText(),同时把子代理的Metrics累加到父 trace。
5、动态生成子代理:generate
GenerateTalent 提供 generate 工具,用于在"当前可用子代理列表无匹配项"时创建一个垂直领域的虚拟专家:
generate(name, description, systemPrompt, tools?, skills?, saveToFile?)
name必须匹配^[a-zA-Z0-9_-]+$,否则直接返回 ERROR 文本;- 实现上先拷贝内置
general定义,再覆盖systemPrompt/name/description/enabled/tools,随后以addAgentIfAbsent注册(同名已存在则不覆盖); saveToFile=true时写入<当前工作目录>/.soloncode/agents/<name>.md(内容是toMarkdown()的 front matter + 正文),下次可被目录重新发现;skills参数写入的是已@Deprecated的metadata.skills字段,仅作兼容保留;新定义应改用tools。
6、委派时的父子事件可见性
父代理的流式入口是:
Flux<AgentEvent> events = engine.prompt(prompt)
.session(session)
.stream();
engine.prompt(...) 返回 ReActRequest,.stream() 返回父代理的 Flux<AgentEvent>。4.1 不再以旧版块对象作为流元素,也不存在从 HarnessEngine 直接开启流的入口。
TaskTalent 执行子任务时,内部始终通过子代理的 .stream() 运行并阻塞等待其终态,但子事件是否对父调用方可见取决于父请求:
- 父请求使用
.stream():父ReActTrace持有FluxSink。TaskTalent把子流中的每个AgentEvent包装为TaskWrapEvent,再写入父流;调用方可以实时观察子代理过程。 - 父请求使用
.call():父链路没有可供外部消费的FluxSink。TaskTalent仍在内部消费子 stream 并等待结果,但不会包装、转发子事件;父代理只取得task/multitask的最终工具结果。
因此,"内部使用子 stream"不等于"父调用方一定能看到子事件";只有父级 .stream() 才建立这条可见链路。
7、完整事件时序
正常的单个 task 调用可观察为:
RunStartEvent 父代理开始(未包装)
-> [ContextSizeEvent] 父上下文事件(可选)
-> ReasonStartEvent 父 Reason 开始
-> ReasonDeltaEvent* 父模型事件
-> ReasonEndEvent 父 Reason 结束并决定调用 task
-> ActionStartEvent 父 Action 开始
-> ToolCallStartEvent(task) 父调度工具开始(未包装)
-> TaskWrapEvent(RunStartEvent) 子代理开始
-> TaskWrapEvent([ContextSizeEvent]) 子上下文事件(可选)
-> TaskWrapEvent(ReasonStartEvent)
-> TaskWrapEvent(ReasonDeltaEvent)*
-> TaskWrapEvent(ReasonEndEvent)
-> [TaskWrapEvent(ActionStartEvent)
-> TaskWrapEvent(ToolCallStartEvent)
-> TaskWrapEvent(ToolCallEndEvent)
-> ...
-> TaskWrapEvent(ActionEndEvent)]* 子代理可有多轮 Reason / Action
-> TaskWrapEvent(RunEndEvent) 子任务正常终态
-> ToolCallEndEvent(task) 子任务结果回到父 ReAct(未包装)
-> ActionEndEvent
-> ...父代理继续 Reason / Action
-> RunEndEvent 父代理正常终态(未包装)
-> onComplete
上图给出正常 ReAct 主路径。子代理实际产生的其他事件也同样逐个包装,例如 PlanEvent、HITLPendingEvent、HITLDecidedEvent 和 ContextSizeEvent;异常或取消路径不保证出现后续阶段事件或结束事件。
multitask 的各子任务并行运行,不同 taskId 的 TaskWrapEvent 可以交错;同一个 taskId 内仍保持该子代理自身的事件顺序。TaskTalent 对共享 FluxSink 的 next 调用加锁,以避免多个子任务并发写入同一 sink。
注意两个不同的结束信号:
- 包装的子终态:
TaskWrapEvent.getRealEvent()是RunEndEvent,只表示该子任务结束。 - 未包装的父终态:父流中直接出现的
RunEndEvent,才表示整个 Harness 主请求正常结束。
父级 task / multitask 的 ToolCallStartEvent、ToolCallEndEvent 也是未包装事件,它们表示调度工具本身的处理边界,不是子代理内部工具的边界。
8、TaskWrapEvent 字段与解包规则
| API | 含义 |
|---|---|
getRealEvent() | 被包装的子代理原始 AgentEvent;读取具体类型和载荷时必须先解包 |
getParentRunId() | 调度子任务的父 ReAct runId |
getRunId() | 子代理事件自己的 runId,不是父 runId |
getTaskId() | 本次子任务的唯一 ID |
getTaskIndex() | multitask 的任务序号(取自任务入参 index,默认 1);单个 task 恒为 1 |
getTaskAgentName() | 子代理名称,例如 explore、bash |
getTaskDescription() | 子任务摘要 |
isMultitask() | 是否来自 multitask |
getText() | 委托给被包装事件的文本投影 |
TaskWrapEvent 不会把真实事件的 Java 类型"摊平"。例如,包装了 RunEndEvent 的对象仍然只是 TaskWrapEvent,event instanceof RunEndEvent 为 false。包装器自身的元数据也不能替代原事件元数据。需要读取 ReasonDeltaEvent.getChatEvent()、工具 callId、Trace、Metrics 或子终态时,必须先调用 getRealEvent()。
父子关联建议使用 parentRunId + taskId;agentName 可能重复,不能单独作为并行任务标识。
解包与投影的基本规则(完整的消费端适配示例见《harness - 子代理流式输出适配》):
AgentEvent event = outerEvent;
TaskWrapEvent taskEvent = null;
if (outerEvent instanceof TaskWrapEvent) {
taskEvent = (TaskWrapEvent) outerEvent;
event = taskEvent.getRealEvent(); // 必须解包后再判断具体类型
}
if (event instanceof ReasonDeltaEvent) {
ReasonDeltaEvent delta = (ReasonDeltaEvent) event;
ChatEvent chatEvent = delta.getChatEvent();
if (chatEvent != null && chatEvent.is(ChatEventType.TEXT_DELTA)) {
// 正文增量
} else if (chatEvent != null && chatEvent.is(ChatEventType.THINKING_DELTA)) {
// 思考增量
}
} else if (event instanceof ToolCallStartEvent) {
// 工具开始
} else if (event instanceof ToolCallEndEvent) {
// 工具结束
} else if (event instanceof RunEndEvent) {
// taskEvent != null → 子任务终态;否则为父请求终态
}
4.1 的投影要点:
- 流元素使用
AgentEvent和具体事件类; - 正文严格匹配
ChatEventType.TEXT_DELTA,思考严格匹配ChatEventType.THINKING_DELTA;仅用isThinking()只能区分思考分组,无法排除THINKING_START/THINKING_END边界; - 文本分片读取
ReasonDeltaEvent.getText(),工具结束文本读取ToolCallEndEvent.getText()(完整结果对象用getResult()); - 最终响应读取
RunEndEvent.getResponse(),最终文本可读取getText()。
9、同步终态、异常与取消
不能对完整事件流直接 blockLast() 后强制转换,也不能假定最后一个已收到对象一定是终态。同步等待父终态时,应先过滤未包装的父 RunEndEvent:
RunEndEvent end = engine.prompt(prompt)
.session(session)
.stream()
.ofType(RunEndEvent.class)
.blockFirst();
if (end != null) {
String answer = end.getText();
boolean abnormal = end.isAbnormal();
}
由于子终态外层类型是 TaskWrapEvent,上面的 ofType(RunEndEvent.class) 只会匹配未包装的父终态。若只需要最终结果且不需要任何过程事件,则直接使用:
ReActResponse response = engine.prompt(prompt)
.session(session)
.call();
三种结束情形必须分别处理:
- 正常或可展示的异常终态:流收到未包装的父
RunEndEvent,随后onComplete。通过RunEndEvent.isAbnormal()区分正常结果和 Agent 已转换的异常终态。包装的子RunEndEvent.isAbnormal()只描述对应子代理。 - 未处理异常:通过 Reactor
onError结束,不会再补发父RunEndEvent。子代理未处理异常也不保证产生包装的子RunEndEvent;TaskTalent会把任务失败转换成task/multitask的工具结果,父 ReAct 是否继续取决于后续运行。 - 主动取消:取消订阅后不再转发子事件,也不会补发包装的子终态或未包装的父终态。
take(...)等操作符可能让派生流正常完成,但对原始生产者仍属于 cancel。
engine.prompt(prompt)
.session(session)
.stream()
.subscribe(
this::handleEvent,
error -> handleStreamError(error),
this::handleComplete);
onComplete 只表示 Reactor 流完成;业务成功与否仍应以未包装父 RunEndEvent 是否存在及其 isAbnormal() 为准。审计工具执行时,还应同时观察 ToolCallEndEvent.getError() 和工具结果文本,因为部分工具错误会被转换为 observation,不一定成为 Reactor onError。
10、实现建议
SolonCode 的 WebStreamBuilder 可作为生产级适配参考。实现自己的输出层时建议:
- 首先识别
TaskWrapEvent,随后用getRealEvent()解包; - DTO 显式携带
parentRunId、子runId、taskId、taskIndex、taskAgentName、taskDescription和multitask; - 使用
parentRunId + taskId对并行子任务分组,不依赖事件的全局相邻关系; - 只把底层
TEXT_DELTA投影为正文,只把THINKING_DELTA投影为思考; - 将包装的子
RunEndEvent作为子任务完成信号,将未包装的父RunEndEvent作为主请求完成信号; - 单独处理
isAbnormal()、ReactoronError和 cancel,不伪造缺失的结束事件。