Solon v4.1.0

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

</> markdown
2026年9月7日 上午11:31:48

当主代理通过 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 持有 FluxSinkTaskTalent 把子流中的每个 AgentEvent 包装为 TaskWrapEvent,再写入父流;调用方可以实时观察子代理过程。
  • 父请求使用 .call():父链路没有可供外部消费的 FluxSinkTaskTalent 仍在内部消费子 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 主路径。子代理实际产生的其他事件也同样逐个包装,例如 PlanEventHITLPendingEventHITLDecidedEventContextSizeEvent;异常或取消路径不保证出现后续阶段事件或结束事件。

multitask 的各子任务并行运行,不同 taskIdTaskWrapEvent 可以交错;同一个 taskId 内仍保持该子代理自身的事件顺序。TaskTalent 对共享 FluxSinknext 调用加锁,以避免多个子任务并发写入同一 sink。

注意两个不同的结束信号:

  • 包装的子终态TaskWrapEvent.getRealEvent()RunEndEvent,只表示该子任务结束。
  • 未包装的父终态:父流中直接出现的 RunEndEvent,才表示整个 Harness 主请求正常结束。

父级 task / multitaskToolCallStartEventToolCallEndEvent 也是未包装事件,它们表示调度工具本身的处理边界,不是子代理内部工具的边界。

3、TaskWrapEvent 字段与解包规则

API含义
getRealEvent()被包装的子代理原始 AgentEvent;读取具体类型和载荷时必须先解包
getParentRunId()调度子任务的父 ReAct runId
getRunId()子代理事件自己的 runId,不是父 runId
getTaskId()本次子任务的唯一 ID
getTaskIndex()multitask 的任务序号;单个 task1
getTaskAgentName()子代理名称,例如 explorebash
getTaskDescription()子任务摘要
isMultitask()是否来自 multitask
getText()委托给被包装事件的文本投影

TaskWrapEvent 不会把真实事件的 Java 类型“摊平”。例如,包装了 RunEndEvent 的对象仍然只是 TaskWrapEventevent instanceof RunEndEventfalse。包装器自身的元数据也不能替代原事件元数据。需要读取 ReasonDeltaEvent.getChatEvent()、工具 callId、Trace、Metrics 或子终态时,必须先调用 getRealEvent()

父子关联建议使用 parentRunId + taskIdagentName 可能重复,不能单独作为并行任务标识。

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、异常终态、未处理异常与取消

这三种情况必须分别处理:

  1. 正常或可展示的异常终态:流收到未包装的父 RunEndEvent,随后 onComplete。通过 RunEndEvent.isAbnormal() 区分正常结果和 Agent 已转换的异常终态。包装的子 RunEndEvent.isAbnormal() 只描述对应子代理。
  2. 未处理异常:通过 Reactor onError 结束,不会再补发父 RunEndEvent。子代理未处理异常也不保证产生包装的子 RunEndEventTaskTalent 会把任务失败转换成 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

7、实现建议

SolonCode 的 WebStreamBuilder 可作为生产级适配参考。实现自己的输出层时建议:

  • 首先识别 TaskWrapEvent,随后用 getRealEvent() 解包;
  • DTO 显式携带 parentRunId、子 runIdtaskIdtaskIndextaskAgentNametaskDescriptionmultitask
  • 使用 parentRunId + taskId 对并行子任务分组,不依赖事件的全局相邻关系;
  • 只把底层 TEXT_DELTA 投影为正文,只把 THINKING_DELTA 投影为思考;
  • 将包装的子 RunEndEvent 作为子任务完成信号,将未包装的父 RunEndEvent 作为主请求完成信号;
  • 单独处理 isAbnormal()、Reactor onError 和 cancel,不伪造缺失的结束事件。