Solon v4.1.1

harness - 子代理定义与任务委派

</> markdown
2026年10月8日 上午9:17:34

子代理(Subagent)是 Harness 的"任务执行单元":主代理通过 task / multitask 工具把子任务委派给它们,子代理在隔离的会话中独立推理,只把最终结果回传。本文说明当前版本子代理"怎么定义、怎么被加载、怎么被委派",以及委派过程中父子事件的可见性语义。

1、子代理的来源与发现机制

子代理定义(.md 文件)来自三类位置:

  1. 内置挂载:引擎构造时自动注册的 @harness-agents(类型 AGENTS,来源是 classpath 的 META-INF/solon/ai/harness/agents/),包含 bash、explore、general、git-summary、plan 五个内置子代理。
  2. 目录挂载:harnessHome 下的 agents/ 或全局共享目录,通过 MountType.AGENTS 的挂载接入(如 @global-agents)。
  3. 运行时注册: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();

三种结束情形必须分别处理:

  1. 正常或可展示的异常终态:流收到未包装的父 RunEndEvent,随后 onComplete。通过 RunEndEvent.isAbnormal() 区分正常结果和 Agent 已转换的异常终态。包装的子 RunEndEvent.isAbnormal() 只描述对应子代理。
  2. 未处理异常:通过 Reactor onError 结束,不会再补发父 RunEndEvent。子代理未处理异常也不保证产生包装的子 RunEndEvent;TaskTalent 会把任务失败转换成 task / multitask 的工具结果,父 ReAct 是否继续取决于后续运行。
  3. 主动取消:取消订阅后不再转发子事件,也不会补发包装的子终态或未包装的父终态。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()、Reactor onError 和 cancel,不伪造缺失的结束事件。