harness - 子代理定义与任务委派
当主代理通过 task / multitask 委派子任务时,子代理内部产生的运行、推理和工具事件不会直接作为父事件出现。Solon AI 4.1 使用 AgentEvent 事件模型;Harness 会在父级流式调用中把每个子事件包装为 TaskWrapEvent。本文说明如何正确解包、区分父子终态并处理异常与取消。
1、4.1 的调用入口与可见性
Harness 的流式入口是:
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() 才建立这条可见链路。
2、完整事件时序
正常的单个 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 也是未包装事件,它们表示调度工具本身的处理边界,不是子代理内部工具的边界。
3、TaskWrapEvent 字段与解包规则
| API | 含义 |
|---|---|
getRealEvent() | 被包装的子代理原始 AgentEvent;读取具体类型和载荷时必须先解包 |
getParentRunId() | 调度子任务的父 ReAct runId |
getRunId() | 子代理事件自己的 runId,不是父 runId |
getTaskId() | 本次子任务的唯一 ID |
getTaskIndex() | multitask 的任务序号;单个 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 可能重复,不能单独作为并行任务标识。
4、消费端适配
下面的 4.1 示例把事件投影为字符串,重点展示解包、正文/思考过滤以及父子终态区分。生产环境通常应将相同字段投影到自己的 SSE 或 WebSocket DTO,而不是直接序列化运行态事件对象。
import org.noear.solon.ai.agent.AgentEvent;
import org.noear.solon.ai.agent.AgentSession;
import org.noear.solon.ai.agent.react.RunEndEvent;
import org.noear.solon.ai.agent.react.task.ReasonDeltaEvent;
import org.noear.solon.ai.agent.react.task.ToolCallEndEvent;
import org.noear.solon.ai.agent.react.task.ToolCallStartEvent;
import org.noear.solon.ai.chat.event.ChatEvent;
import org.noear.solon.ai.chat.event.ChatEventType;
import org.noear.solon.ai.harness.HarnessEngine;
import org.noear.solon.ai.harness.agent.TaskWrapEvent;
import reactor.core.publisher.Flux;
public class HarnessStreamAdapter {
public static Flux<String> adapt(HarnessEngine engine,
String prompt,
AgentSession session) {
return engine.prompt(prompt)
.session(session)
.stream()
.<String>handle((outerEvent, sink) -> {
TaskWrapEvent taskEvent = null;
AgentEvent event = outerEvent;
if (outerEvent instanceof TaskWrapEvent) {
taskEvent = (TaskWrapEvent) outerEvent;
event = taskEvent.getRealEvent(); // 必须解包后再判断具体类型
}
String source = taskEvent == null
? "parent:" + event.getRunId()
: "child:" + taskEvent.getParentRunId()
+ "/" + taskEvent.getTaskId()
+ "/" + taskEvent.getTaskIndex()
+ "/" + event.getRunId();
if (event instanceof ReasonDeltaEvent) {
ReasonDeltaEvent delta = (ReasonDeltaEvent) event;
ChatEvent chatEvent = delta.getChatEvent();
if (chatEvent == null || !delta.hasText()) {
return;
}
// 正文只取底层 TEXT_DELTA;边界、思考和媒体都不能混入。
if (chatEvent.is(ChatEventType.TEXT_DELTA)) {
sink.next(source + " text " + delta.getText());
// 思考同样只取底层 THINKING_DELTA。
} else if (chatEvent.is(ChatEventType.THINKING_DELTA)) {
sink.next(source + " thinking " + delta.getText());
}
} else if (event instanceof ToolCallStartEvent) {
ToolCallStartEvent start = (ToolCallStartEvent) event;
sink.next(source + " tool-start " + start.getCallId()
+ " " + start.getToolName() + " " + start.getArgs());
} else if (event instanceof ToolCallEndEvent) {
ToolCallEndEvent end = (ToolCallEndEvent) event;
sink.next(source + " tool-end " + end.getCallId()
+ " " + end.getToolName() + " " + end.getText());
} else if (event instanceof RunEndEvent) {
RunEndEvent end = (RunEndEvent) event;
if (taskEvent != null) {
sink.next(source + " child-done abnormal=" + end.isAbnormal());
} else {
sink.next(source + " parent-done abnormal=" + end.isAbnormal()
+ " " + end.getText());
}
}
});
}
}
4.1 不再提供旧版块对象及其增量便捷判断、消息和内容访问方式。对应写法是:
- 流元素使用
AgentEvent和具体事件类; ReasonDeltaEvent.getChatEvent()取得底层ChatEvent;- 正文严格匹配
ChatEventType.TEXT_DELTA; - 思考严格匹配
ChatEventType.THINKING_DELTA; - 文本分片读取
ReasonDeltaEvent.getText(); - 工具结束文本读取
ToolCallEndEvent.getText(),完整结果对象读取getResult(); - 最终响应读取
RunEndEvent.getResponse(),最终文本可读取getText()。
仅使用 isThinking() 可以区分思考分组,但无法排除 THINKING_START / THINKING_END 边界。要做正文或思考增量投影,必须检查底层 ChatEventType。
5、同步取得终态时先过滤
不能对完整事件流直接 blockLast() 后强制转换,也不能假定最后一个已收到对象一定是终态。同步等待 4.1 流式结果时,应先过滤未包装的父 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();
6、异常终态、未处理异常与取消
这三种情况必须分别处理:
- 正常或可展示的异常终态:流收到未包装的父
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。
7、实现建议
SolonCode 的 WebStreamBuilder 可作为生产级适配参考。实现自己的输出层时建议:
- 首先识别
TaskWrapEvent,随后用getRealEvent()解包; - DTO 显式携带
parentRunId、子runId、taskId、taskIndex、taskAgentName、taskDescription和multitask; - 使用
parentRunId + taskId对并行子任务分组,不依赖事件的全局相邻关系; - 只把底层
TEXT_DELTA投影为正文,只把THINKING_DELTA投影为思考; - 将包装的子
RunEndEvent作为子任务完成信号,将未包装的父RunEndEvent作为主请求完成信号; - 单独处理
isAbnormal()、ReactoronError和 cancel,不伪造缺失的结束事件。