Agent Scope Java 2.x 系列【37】Harness:子 Agent 进阶
文章目录1. 向用户暴露子 Agent1.1 客户端监听暴露事件并直接对话1.2 怎么开启agent.channel(...)2. 用代码控制是否暴露2.1 通过 RuntimeContext 按调用覆盖2.2 通过声明设置按类型策略3. 跨重启与多副本4. 让 agent 自己写新的子 agent spec5. 子 Agent 流式5.1 消费事件流5.2 转成 SSE5.3 行为边界5.4 错误处理6. 在 Plan Mode 下委派子 agent1. 向用户暴露子 Agent通常子agent对用户不可见它们在幕后作为父agent的内部工具运行。但有时我们想做分支对话父agent派出一个专家子agent然后让用户直接和这个专家继续聊绕过父agent。这就是expose_to_user。主agent在推理时这样调用agent_spawn agent_idresearcher task调研 AI 趋势 expose_to_usertrue它做了两件事在Gateway里注册该子agent使其成为用户可寻址的入口向流式事件流发出一个SubagentExposedEvent携带subagentId句柄。1.1 客户端监听暴露事件并直接对话用户客户端收到SubagentExposedEvent后就能直接向子agent发消息完全绕过父agentimportio.agentscope.core.event.SubagentExposedEvent;importio.agentscope.harness.agent.gateway.channel.chatui.SendOptions;// 1) 在事件流中监听被暴露的子 agentchat.sendStream(SendOptions.userId(user-1),派一个研究员调查 AI 趋势).doOnNext(event-{if(eventinstanceofSubagentExposedEventse){se.getSubagentId();// → 用来直接和子 agent 对话的句柄se.getAgentId();// → 子 agent 类型如 researcherse.getLabel();// → 可选的人类可读名称}}).blockLast();// 2) 直接向暴露的子 agent 发消息不经过父 agentchat.sendToSubagent(subagentId,重点关注 LLM agent).block();1.2 怎么开启agent.channel(…)暴露能力依赖一个Channel内部Gateway。用agent.channel(...)一行接好零配置HarnessAgentagentHarnessAgent.builder().name(orchestrator).model(dashscope:qwen-plus).build();// channel() 创建内部 gateway 并自动接好 bridge —— expose_to_user 直接可用ChatUiChannelchatagent.channel(ChatUiChannel.create());⚠️没有绑定 Channel 时agent_spawn里的expose_to_usertrue会被静默忽略——子agent照常工作只是不会暴露给用户。多agent场景用GatewayBootstrap接见官方GatewayBootstrap下暴露子Agent。2. 用代码控制是否暴露完全依赖LLM传expose_to_usertrue有时不够灵活。可以从应用代码侧覆盖这个决策。最终生效值按以下优先级解析从高到低RuntimeContext按调用覆盖—— 作用于当前这次调用里的所有agent_spawnSubagentDeclaration按类型策略—— 该子 agent 类型的静态默认值LLM 传入的expose_to_user工具参数以上都没表态 → 默认false。2.1 通过 RuntimeContext 按调用覆盖在AgentSpawnTool.CTX_EXPOSE_TO_USER这个key下放一个Boolean或其字符串形式importio.agentscope.harness.agent.tool.AgentSpawnTool;RuntimeContextctxRuntimeContext.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_usertrue也被覆盖null默认则交给context覆盖、再交给LLM参数决定SubagentDeclarationdeclSubagentDeclaration.builder().name(researcher).description(调研主题并返回汇总报告。).exposeToUser(true)// 这个子 agent 类型始终对用户可直接寻址.build();或在Markdown spec的front matter里同样三态——不写表示不表态--- description: 调研主题并返回汇总报告。 expose_to_user: true ---这样无论模型怎么决定你都能强制或禁止暴露两侧都不表态时仍交给LLM自行选择。3. 跨重启与多副本默认情况下暴露只存在于创建它的进程里subagentId只在那个节点有效重启即失效。要让暴露的子agent在任意副本、重启之后都能解析给agent配上distributedStore(...)即可HarnessAgentagentHarnessAgent.builder().name(orchestrator).model(dashscope:qwen-plus).distributedStore(RedisDistributedStore.fromJedis(jedis))// 一行接入分布式存储.build();ChatUiChannelchatagent.channel(ChatUiChannel.create());// 恢复能力自动接好subagentId会持久化到后端子agent自己的对话按session从分布式AgentStateStore重新加载即使后续消息落到不同节点用户面对的仍是同一个子 agent。多agent的GatewayBootstrap传.distributedStore(...)不传则继承 main agent 的。生产部署建议——包括把某个subagentId路由回它活实例所在节点的粘性路由——见官方上生产。4. 让 agent 自己写新的子 agent specagent_generate工具默认关闭可以让LLM起草一份新的子agent spec并直接写到workspace/subagents/name.md// 开启方法构建期拿到 builder 内部的 SubagentsMiddleware 引用// 调用其 enableAgentGenerateTool() 打开 agent_generate 工具。适合agent跑到一半发现自己需要一类新的助手的场景。⚠️生产环境慎用通常先让agent把方案写出来、人工review之后再落文件避免模型自行写入未经审查的子agent定义。5. 子 Agent 流式父agent通过agent_spawn/agent_send同步调用子agent时子agent的中间事件会实时转发到父的streamEvents()流中。每个子事件都带一个source字段/分隔的路径如main/researcher父事件的source为null。caller └─ parent.streamEvents(msg, ctx) ├─ AGENT_START ← 父 agent 启动 ├─ TEXT_BLOCK_DELTA … ← 父推理 ├─ TOOL_CALL_START agent_spawn │ [子 agent 创建] ├─ AGENT_START (sourcemain/researcher) ← 子启动 ├─ TEXT_BLOCK_DELTA … (sourcemain/researcher) ← 子推理 ├─ TOOL_CALL_START … (sourcemain/researcher) ├─ TOOL_RESULT_END … (sourcemain/researcher) ├─ AGENT_END (sourcemain/researcher) ← 子结束 │ [agent_spawn 返回子结果作为 TOOL_RESULT 传给父] ├─ TOOL_RESULT_END ← 父收到工具结果 ├─ TEXT_BLOCK_DELTA … ← 父第二轮推理 └─ AGENT_END ← 父结束5.1 消费事件流推荐streamEvents()importio.agentscope.core.event.AgentEventType;importio.agentscope.core.event.TextBlockDeltaEvent;importio.agentscope.core.event.ToolCallStartEvent;parent.streamEvents(newUserMessage(message),ctx).doOnNext(event-{Stringsrcevent.getSource();Stringprefix(src!null)?[src] :;// 子事件带来源前缀if(event.getType()AgentEventType.TEXT_BLOCK_DELTA){System.out.print(prefix((TextBlockDeltaEvent)event).getDelta());}elseif(event.getType()AgentEventType.TOOL_CALL_START){System.out.println(prefix[tool] ((ToolCallStartEvent)event).getToolCallName());}elseif(event.getType()AgentEventType.AGENT_START){if(src!null)System.out.println(── 子 agent 启动: src);}elseif(event.getType()AgentEventType.AGENT_END){if(src!null)System.out.println(── 子 agent 结束: src);}}).blockLast();按来源区分父子事件events.filter(e-e.getSource()null).subscribe(…);// 只看父事件events.filter(e-e.getSource()!null).subscribe(…);// 只看子事件events.filter(e-e.getSource()!nulle.getSource().contains(researcher)).subscribe(…);// 只看某子 agent5.2 转成 SSE把事件流映射成SSE推给前端。下面是文档式写法Spring MVC也支持直接返回FluxServerSentEventGetMapping(value/chat,producesMediaType.TEXT_EVENT_STREAM_VALUE)publicFluxServerSentEventStringchat(RequestParamStringmessage,RequestParamStringsessionId){RuntimeContextctxRuntimeContext.builder().sessionId(sessionId).build();returnagent.streamEvents(newUserMessage(message),ctx).map(event-{MapString,ObjectpayloadnewLinkedHashMap();payload.put(type,event.getType().name());payload.put(id,event.getId());if(event.getSource()!null){payload.put(source,event.getSource());// 关键把来源透给前端便于分栏显示}if(eventinstanceofTextBlockDeltaEventdelta){payload.put(delta,delta.getDelta());}elseif(eventinstanceofToolCallStartEventstart){payload.put(toolName,start.getToolCallName());}returnServerSentEvent.Stringbuilder().data(objectMapper.writeValueAsString(payload)).build();});}衔接【34】那篇我们用的是SseEmitter 手动subscribePOST 端点。这里返回FluxServerSentEvent是等价的另一种写法GET端点、由MVC适配响应式返回值。两者都可用前端要做的额外一步就是按source把子agent的输出单独分栏/缩进展示。5.3 行为边界场景是否实时流转发streamEvents() 同步本地子 agenttimeout_seconds 0✔call()模式非流式✗子结果以tool_result字符串返回timeout_seconds 0后台任务✗终态通过反向通知在父 agent 下一轮给出远程子 agentAgent Protocol✗5.4 错误处理子agent内部出错时框架会把错误捕获并写成一条TOOL_RESULT给父不会把onError传播到父流父流不会被子agent的失败打断。如果父流本身出错比如父的模型调用失败按标准Reactor语义处理onErrorResume等。6. 在 Plan Mode 下委派子 agent这一点把【34】【35】的计划模式和子agent串了起来父agent处于Plan Mode时spawn的子agent会自动继承只读限制——子agent在spawn时就被置入Plan Mode无法执行写操作。也就是说安全边界在委派链上不会断父在只读规划阶段派出去的子agent也只能调研、不能动手。配合【36】里讲的权限DENY继承整条委派链的安全约束是一致向下传递的。