Agent Scope Java 2.x 系列【37】Harness:子 Agent 进阶

文章目录

  • [1. 向用户暴露子 Agent](#1. 向用户暴露子 Agent)
    • [1.1 客户端:监听暴露事件并直接对话](#1.1 客户端:监听暴露事件并直接对话)
    • [1.2 怎么开启:agent.channel(...)](#1.2 怎么开启:agent.channel(...))
  • [2. 用代码控制是否暴露](#2. 用代码控制是否暴露)
    • [2.1 通过 RuntimeContext 按调用覆盖](#2.1 通过 RuntimeContext 按调用覆盖)
    • [2.2 通过声明设置按类型策略](#2.2 通过声明设置按类型策略)
  • [3. 跨重启与多副本](#3. 跨重启与多副本)
  • [4. 让 agent 自己写新的子 agent spec](#4. 让 agent 自己写新的子 agent spec)
  • [5. 子 Agent 流式](#5. 子 Agent 流式)
    • [5.1 消费事件流](#5.1 消费事件流)
    • [5.2 转成 SSE](#5.2 转成 SSE)
    • [5.3 行为边界](#5.3 行为边界)
    • [5.4 错误处理](#5.4 错误处理)
  • [6. 在 Plan Mode 下委派子 agent](#6. 在 Plan Mode 下委派子 agent)

1. 向用户暴露子 Agent

通常子 agent 对用户不可见 ,它们在幕后作为父 agent 的内部工具运行。

但有时我们想做"分支对话 ":父 agent 派出一个专家子 agent,然后让用户直接和这个专家继续聊 ,绕过父 agent。这就是 expose_to_user

agent 在推理时这样调用:

复制代码
agent_spawn agent_id="researcher" task="调研 AI 趋势" expose_to_user=true

它做了两件事:

  1. Gateway 里注册该子 agent,使其成为用户可寻址的入口
  2. 向流式事件流发出一个 SubagentExposedEvent ,携带 subagentId 句柄。

1.1 客户端:监听暴露事件并直接对话

用户客户端收到 SubagentExposedEvent 后,就能直接向子 agent 发消息,完全绕过父 agent

java 复制代码
import io.agentscope.core.event.SubagentExposedEvent;
import io.agentscope.harness.agent.gateway.channel.chatui.SendOptions;

// 1) 在事件流中监听"被暴露的子 agent"
chat.sendStream(SendOptions.userId("user-1"), "派一个研究员调查 AI 趋势")
    .doOnNext(event -> {
        if (event instanceof SubagentExposedEvent se) {
            se.getSubagentId();  // → 用来直接和子 agent 对话的句柄
            se.getAgentId();     // → 子 agent 类型(如 "researcher")
            se.getLabel();       // → 可选的人类可读名称
        }
    })
    .blockLast();

// 2) 直接向暴露的子 agent 发消息(不经过父 agent)
chat.sendToSubagent(subagentId, "重点关注 LLM agent").block();

1.2 怎么开启:agent.channel(...)

暴露能力依赖一个 Channel (内部 Gateway)。用 agent.channel(...) 一行接好,零配置

java 复制代码
HarnessAgent agent = HarnessAgent.builder()
    .name("orchestrator")
    .model("dashscope:qwen-plus")
    .build();

// channel() 创建内部 gateway 并自动接好 bridge ------ expose_to_user 直接可用
ChatUiChannel chat = agent.channel(ChatUiChannel.create());

⚠️ 没有绑定 Channel 时agent_spawn 里的 expose_to_user=true 会被静默忽略 ------子 agent 照常工作,只是不会暴露给用户。多 agent 场景用 GatewayBootstrap 接,见官方 GatewayBootstrap 下暴露子 Agent


2. 用代码控制是否暴露

完全依赖 LLMexpose_to_user=true 有时不够灵活。可以从应用代码侧 覆盖这个决策。最终生效值按以下优先级解析(从高到低):

  1. RuntimeContext 按调用覆盖 ------ 作用于当前这次调用里的所有 agent_spawn
  2. SubagentDeclaration 按类型策略 ------ 该子 agent 类型的静态默认值;
  3. LLM 传入的 expose_to_user 工具参数
  4. 以上都没表态 → 默认 false

2.1 通过 RuntimeContext 按调用覆盖

AgentSpawnTool.CTX_EXPOSE_TO_USER 这个 key 下放一个 Boolean(或其字符串形式):

java 复制代码
import io.agentscope.harness.agent.tool.AgentSpawnTool;

RuntimeContext ctx = RuntimeContext.builder()
    .userId("user-1")
    .put(AgentSpawnTool.CTX_EXPOSE_TO_USER, true)   // 强制开启;传 false 则禁止暴露
    .build();

该常量值经核实为 "agentscope.subagent.expose_to_user"

2.2 通过声明设置按类型策略

exposeToUser三态

  • TRUE 总是暴露
  • FALSE 永不暴露(即使 LLM 传了 expose_to_user=true 也被覆盖)
  • null(默认)则交给 context 覆盖、再交给 LLM 参数决定:
java 复制代码
SubagentDeclaration decl = SubagentDeclaration.builder()
    .name("researcher")
    .description("调研主题并返回汇总报告。")
    .exposeToUser(true)   // 这个子 agent 类型始终对用户可直接寻址
    .build();

或在 Markdown specfront matter 里(同样三态------不写表示"不表态"):

markdown 复制代码
---
description: 调研主题并返回汇总报告。
expose_to_user: true
---

这样无论模型怎么决定,你都能强制或禁止 暴露;两侧都不表态时,仍交给 LLM 自行选择。


3. 跨重启与多副本

默认情况下,暴露只存在于创建它的进程里subagentId 只在那个节点有效,重启即失效。要让暴露的子 agent任意副本、重启之后 都能解析,给 agent 配上 distributedStore(...) 即可:

java 复制代码
HarnessAgent agent = HarnessAgent.builder()
    .name("orchestrator")
    .model("dashscope:qwen-plus")
    .distributedStore(RedisDistributedStore.fromJedis(jedis))   // 一行接入分布式存储
    .build();

ChatUiChannel chat = agent.channel(ChatUiChannel.create());     // 恢复能力自动接好

subagentId 会持久化到后端,子 agent 自己的对话按 session 从分布式 AgentStateStore 重新加载,即使后续消息落到不同节点,用户面对的仍是同一个子 agent

agentGatewayBootstrap.distributedStore(...)(不传则继承 main agent 的)。生产部署建议------包括把某个 subagentId 路由回它活实例所在节点的"粘性路由"------见官方"上生产"。


4. 让 agent 自己写新的子 agent spec

agent_generate 工具(默认关闭 )可以让 LLM 起草一份新的子 agent spec,并直接写到 workspace/subagents/<name>.md

java 复制代码
// 开启方法(构建期):拿到 builder 内部的 SubagentsMiddleware 引用,
// 调用其 enableAgentGenerateTool() 打开 agent_generate 工具。

适合 agent 跑到一半发现自己需要一类新的助手"的场景。

⚠️ 生产环境慎用 :通常先让 agent 把方案写出来、人工 review 之后再落文件,避免模型自行写入未经审查的子 agent 定义。


5. 子 Agent 流式

agent 通过 agent_spawn/agent_send 同步 调用子 agent 时,子 agent 的中间事件会实时转发 到父的 streamEvents() 流中。每个子事件都带一个 source 字段/ 分隔的路径,如 "main/researcher"),父事件的 sourcenull

复制代码
caller
  └─ parent.streamEvents(msg, ctx)
        ├─ AGENT_START                                        ← 父 agent 启动
        ├─ TEXT_BLOCK_DELTA ...                                 ← 父推理
        ├─ TOOL_CALL_START "agent_spawn"
        │   [子 agent 创建]
        ├─ AGENT_START        (source="main/researcher")      ← 子启动
        ├─ TEXT_BLOCK_DELTA ... (source="main/researcher")      ← 子推理
        ├─ TOOL_CALL_START ...  (source="main/researcher")
        ├─ TOOL_RESULT_END ...  (source="main/researcher")
        ├─ AGENT_END          (source="main/researcher")      ← 子结束
        │   [agent_spawn 返回,子结果作为 TOOL_RESULT 传给父]
        ├─ TOOL_RESULT_END                                    ← 父收到工具结果
        ├─ TEXT_BLOCK_DELTA ...                                 ← 父第二轮推理
        └─ AGENT_END                                          ← 父结束

5.1 消费事件流

推荐 streamEvents()

java 复制代码
import io.agentscope.core.event.AgentEventType;
import io.agentscope.core.event.TextBlockDeltaEvent;
import io.agentscope.core.event.ToolCallStartEvent;

parent.streamEvents(new UserMessage(message), ctx)
    .doOnNext(event -> {
        String src = event.getSource();
        String prefix = (src != null) ? "[" + src + "] " : "";   // 子事件带来源前缀

        if (event.getType() == AgentEventType.TEXT_BLOCK_DELTA) {
            System.out.print(prefix + ((TextBlockDeltaEvent) event).getDelta());
        } else if (event.getType() == AgentEventType.TOOL_CALL_START) {
            System.out.println(prefix + "[tool] " + ((ToolCallStartEvent) event).getToolCallName());
        } else if (event.getType() == AgentEventType.AGENT_START) {
            if (src != null) System.out.println("── 子 agent 启动: " + src);
        } else if (event.getType() == AgentEventType.AGENT_END) {
            if (src != null) System.out.println("── 子 agent 结束: " + src);
        }
    })
    .blockLast();

按来源区分父子事件:

java 复制代码
events.filter(e -> e.getSource() == null).subscribe(...);                       // 只看父事件
events.filter(e -> e.getSource() != null).subscribe(...);                       // 只看子事件
events.filter(e -> e.getSource() != null
                && e.getSource().contains("researcher")).subscribe(...);        // 只看某子 agent

5.2 转成 SSE

把事件流映射成 SSE 推给前端。下面是文档式写法(Spring MVC 也支持直接返回 Flux<ServerSentEvent>):

java 复制代码
@GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> chat(@RequestParam String message,
                                          @RequestParam String sessionId) {
    RuntimeContext ctx = RuntimeContext.builder().sessionId(sessionId).build();
    return agent.streamEvents(new UserMessage(message), ctx)
            .map(event -> {
                Map<String, Object> payload = new LinkedHashMap<>();
                payload.put("type", event.getType().name());
                payload.put("id",   event.getId());
                if (event.getSource() != null) {
                    payload.put("source", event.getSource());   // 关键:把来源透给前端,便于分栏显示
                }
                if (event instanceof TextBlockDeltaEvent delta) {
                    payload.put("delta", delta.getDelta());
                } else if (event instanceof ToolCallStartEvent start) {
                    payload.put("toolName", start.getToolCallName());
                }
                return ServerSentEvent.<String>builder()
                        .data(objectMapper.writeValueAsString(payload))
                        .build();
            });
}

衔接【34】:那篇我们用的是 SseEmitter + 手动 subscribe(POST 端点)。这里返回 Flux<ServerSentEvent> 是等价的另一种写法(GET 端点、由 MVC 适配响应式返回值)。两者都可用;前端要做的额外一步,就是按 source 把子 agent 的输出单独分栏/缩进展示。

5.3 行为边界

场景 是否实时流转发?
streamEvents() + 同步本地子 agent(timeout_seconds > 0
call() 模式(非流式) ✗(子结果以 tool_result 字符串返回)
timeout_seconds = 0 后台任务 ✗(终态通过反向通知在父 agent 下一轮给出)
远程子 agent(Agent Protocol)

5.4 错误处理

agent 内部出错时,框架会把错误捕获并写成一条 TOOL_RESULT 给父不会把 onError 传播到父流 ,父流不会被子 agent 的失败打断。如果父流本身 出错(比如父的模型调用失败),按标准 Reactor 语义处理(onErrorResume 等)。


6. 在 Plan Mode 下委派子 agent

这一点把【34】【35】的计划模式和子 agent 串了起来:

agent 处于 Plan Modespawn 的子 agent,会自动继承只读限制 ------子 agentspawn 时就被置入 Plan Mode无法执行写操作

也就是说,安全边界在委派链上不会断 :父在只读规划阶段,派出去的子 agent 也只能调研、不能动手。配合【36】里讲的"权限 DENY 继承",整条委派链的安全约束是一致向下传递的。