用 Java 对齐 LangChain Deep Agents Harness
一篇面向工程师的架构博客:从「为什么需要 Harness」讲到「每个模块做什么、怎么写」。
项目:
deepagents-assistant-java· 技术栈:Spring Boot 3.2 + LangGraph4j AgentExecutorEx + LangChain4j@Tool(不使用 AiServices)对齐对象:LangChain Python
create_deep_agent
0. 写在前面
如果你做过 LLM Agent,大概经历过这些坑:
- 长任务中途「忘了自己在干什么」
- 调研过程把统筹上下文撑爆
- 写文件、规划、联网、汇总全堆在一个 Prompt 里,既难测又难控
Deep Agents 的核心答案不是「再换一个更强的模型」,而是加一层 Harness(智能体套件):
| 能力 | 作用 |
|---|---|
| 规划(todos) | 把多步任务显式化,边做边改状态 |
| 文件系统(带权限) | 中间产物落盘;可 deny .env / secrets/** |
| 子 Agent 委派(task / task_batch) | 专科活隔离上下文;独立子任务可并发 |
| 人工审批(HITL) | write_file / edit_file 中断,等人批准后再续跑 |
| 记忆 / 技能 | 跨会话记住偏好;技能目录渐进式加载 |
| 运行时(LangGraph) | 可持久化 checkpoint、可流式、可控步数、超阈值摘要 |
本项目是这套思想的 Java 实现 :一个统筹 Agent + research-agent + general-purpose(默认继承统筹工具),前端通过 SSE 看工具轨迹并处理审批卡片。
1. 系统架构
1.1 六层结构
┌─────────────────────────────────────────────────────────────────┐
│ L6 产品层 │
│ AssistantController(SSE /chat + /resume API) │
│ AssistantChatService(跑图/续跑图 + 推事件 + 审批状态机) │
│ SessionStore(对话历史落盘,按 sessionId 加锁) │
├─────────────────────────────────────────────────────────────────┤
│ L5 持久化可插拔层(deepagents.persistence) │
│ CheckpointSaverProvider(默认 FileSystemSaver) │
│ TodoRepository(默认内存)· SessionLockProvider(默认本地锁) │
├─────────────────────────────────────────────────────────────────┤
│ L4 统筹 Orchestrator │
│ CreateDeepAgent → orchestratorAgentGraph(AgentExecutorEx) │
│ StreamingChatModel + checkpointer(threadId=sessionId) │
│ approvalOn(write_file/edit_file) → 中断等待人工审批 │
│ SummarizingConversationContextPolicy(超阈值先摘要再裁剪) │
├─────────────────────────────────────────────────────────────────┤
│ L3 Harness 内置工具(OrchestratorTools) │
│ write_todos · list/read/write/edit_file(权限)· task/task_batch │
│ update_memory · read_skill · 时间计算 │
├─────────────────────────────────────────────────────────────────┤
│ L2 子 Agent(各自独立 AgentExecutor 子图,上下文隔离) │
│ research-agent → WebResearchTools(智谱 MCP/REST) │
│ general-purpose → 默认继承统筹全部工具(对齐 Python 语义) │
├─────────────────────────────────────────────────────────────────┤
│ L1 外部世界 │
│ 智谱大模型 · 智谱 Web Search · 本地 workspace/memories/skills │
└─────────────────────────────────────────────────────────────────┘
1.2 一次用户消息怎么走
智谱 MCP research-agent OrchestratorTools 统筹 AgentExecutorEx AssistantChatService AssistantController 浏览器 智谱 MCP research-agent OrchestratorTools 统筹 AgentExecutorEx AssistantChatService AssistantController 浏览器 #mermaid-svg-emoceZlHKrh5JC3I{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-emoceZlHKrh5JC3I .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-emoceZlHKrh5JC3I .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-emoceZlHKrh5JC3I .error-icon{fill:#552222;}#mermaid-svg-emoceZlHKrh5JC3I .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-emoceZlHKrh5JC3I .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-emoceZlHKrh5JC3I .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-emoceZlHKrh5JC3I .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-emoceZlHKrh5JC3I .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-emoceZlHKrh5JC3I .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-emoceZlHKrh5JC3I .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-emoceZlHKrh5JC3I .marker{fill:#333333;stroke:#333333;}#mermaid-svg-emoceZlHKrh5JC3I .marker.cross{stroke:#333333;}#mermaid-svg-emoceZlHKrh5JC3I svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-emoceZlHKrh5JC3I p{margin:0;}#mermaid-svg-emoceZlHKrh5JC3I .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-emoceZlHKrh5JC3I text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-emoceZlHKrh5JC3I .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-emoceZlHKrh5JC3I .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-emoceZlHKrh5JC3I .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-emoceZlHKrh5JC3I .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-emoceZlHKrh5JC3I #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-emoceZlHKrh5JC3I .sequenceNumber{fill:white;}#mermaid-svg-emoceZlHKrh5JC3I #sequencenumber{fill:#333;}#mermaid-svg-emoceZlHKrh5JC3I #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-emoceZlHKrh5JC3I .messageText{fill:#333;stroke:none;}#mermaid-svg-emoceZlHKrh5JC3I .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-emoceZlHKrh5JC3I .labelText,#mermaid-svg-emoceZlHKrh5JC3I .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-emoceZlHKrh5JC3I .loopText,#mermaid-svg-emoceZlHKrh5JC3I .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-emoceZlHKrh5JC3I .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-emoceZlHKrh5JC3I .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-emoceZlHKrh5JC3I .noteText,#mermaid-svg-emoceZlHKrh5JC3I .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-emoceZlHKrh5JC3I .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-emoceZlHKrh5JC3I .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-emoceZlHKrh5JC3I .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-emoceZlHKrh5JC3I .actorPopupMenu{position:absolute;}#mermaid-svg-emoceZlHKrh5JC3I .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-emoceZlHKrh5JC3I .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-emoceZlHKrh5JC3I .actor-man circle,#mermaid-svg-emoceZlHKrh5JC3I line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-emoceZlHKrh5JC3I :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt write_file/edit_file 需审批 POST /api/assistant/chat (SSE) chat(sessionId, message) stream(messages, threadId) write_todos / task / write_file ... interrupt SSE interrupt POST /api/assistant/resume resume(approved) Command.RESUME TaskDelegationService.delegate / task_batch webSearch / webRead 检索结果 结构化结论 tool result token / tool / agent 事件 SSE 推流 SessionStore 落盘
1.3 与 Python Deep Agents 的对应
| Python | 本项目 Java |
|---|---|
create_deep_agent(...) |
CreateDeepAgent.create(...) |
SubAgent |
SubAgentSpec |
TodoListMiddleware |
OrchestratorTools#write_todos + TodoListPrompts + TodoStore |
FilesystemMiddleware(permissions=...) |
WorkspaceFileOperations + FilesystemPermission + FilesystemPermissionEvaluator |
SubAgentMiddleware / task |
OrchestratorTools#task / #task_batch + TaskDelegationService |
HumanInTheLoopMiddleware(interrupt_on=...) |
AgentExecutorEx.approvalOn(...) + /api/assistant/resume |
MemoryMiddleware |
MemoryStore + update_memory |
SkillsMiddleware |
SkillStore + read_skill |
SummarizationMiddleware |
SummarizingConversationContextPolicy |
checkpointer + thread_id |
BaseCheckpointSaver(默认磁盘)+ RunnableConfig.threadId |
| LangGraph runtime | LangGraph4j AgentExecutorEx / AgentExecutor |
1.4 角色分工
| 角色 | 职责 | 典型工具 |
|---|---|---|
| 统筹(小深) | 理解目标、规划、委派、汇总对用户答复 | write_todos、files、task/task_batch、记忆/技能、时间/计算 |
| research-agent | 多角度联网调研、交叉验证、带来源结论 | webSearch、webRead |
| general-purpose | 默认可继承统筹全部工具,隔离上下文做通用子任务 | 与统筹相同(Python 语义) |
1.5 关键运行配置
- 端口:
8089 - 模型:OpenAI 兼容(
llm.*,当前示例qwen3.7-plus,也可切回智谱glm-4.7-flash) - 联网:默认 MCP(
mcp.zhipu.enabled=true),可回退 REST - 图步数:
agent.recursion-limit=80(统筹)、agent.worker-recursion-limit=40(子 Agent)------LangGraph4j 默认 25 对复合任务易触顶 - 工具日志:统筹工具打
[Orchestrator] start/end,子 Agent 工具打[Tool],委派打[Task] - 审批:
write_file/edit_file会中断,前端调/api/assistant/resume - checkpoint:默认落盘
data/checkpoints,进程重启不丢会话
启动:
bash
cd deepagents-assistant-java
export LLM_API_KEY=你的大模型API_Key
mvn spring-boot:run
# 打开 http://localhost:8089
1.6 用本文源码复现项目
仓库里的 45 个 .java + pom.xml + application.yml + index.html 下文均有对应内容;Java 为便于发布已省略 import 行,落盘后需自行补全依赖 import(IDE 可一键导入),或对照仓库补齐。
- 准备 JDK 17 与 Maven 3.8+。
- 建目录
deepagents-assistant-java/,把每个### \path`` 下的代码块保存为该相对路径(包名目录不要漏)。 - 只改配置里的密钥与模型接入点(本文已脱敏,不能直接拿占位符去调模型 ):
llm.base-url/llm.model:改成你自己的 OpenAI 兼容服务llm.api-key:改成你的 Key,或export LLM_API_KEY=...- 若启用智谱 MCP 搜索:再配
mcp.zhipu.api-key(或同样走LLM_API_KEY)
mvn spring-boot:run,打开 http://localhost:8089
data/workspace、data/sessions、data/checkpoints、data/memories、data/skills 启动时会自动建目录,不必预先拷贝。记忆/技能文件是运行时数据,缺省不影响启动。
2. 模块详解与源码
下面按「阅读顺序」展开每个模块:先说明职责,再贴核心源码 (Java 文件已省略 import,与仓库逻辑一致;配置中的密钥已脱敏)。
一、项目入口与总览
src/main/java/cn/deepassistant/DeepAssistantApplication.java
作用: Spring Boot 启动入口
java
package cn.deepassistant;
/**
* Spring Boot 启动类。
*
* <p>扫包范围:{@code cn.deepassistant} 及其子包下的 {@code @Configuration}/{@code @Service}/{@code @Component}。
* <p>启动后默认监听 {@code 8089}(见 {@code application.yml}),静态页在 {@code classpath:/static/index.html}。
*
* <pre>
* export LLM_API_KEY=你的智谱Key
* mvn spring-boot:run
* 打开 http://localhost:8089
* </pre>
*
* <p>学习入口:{@link cn.deepassistant.harness.HarnessOverview}
*/
@SpringBootApplication
public class DeepAssistantApplication {
public static void main(String[] args) {
SpringApplication.run(DeepAssistantApplication.class, args);
}
}
src/main/java/cn/deepassistant/harness/HarnessOverview.java
作用: 分层心智模型(导航注释类)
java
package cn.deepassistant.harness;
/**
* <b>从这里开始读代码</b>------Deep Agents Harness Java 版总览(本类无运行逻辑,只作导航)。
*
* <h2>一句话</h2>
* Harness = 在 ReAct 循环外包一层「规划 + 文件(带权限)+ 子 Agent 委派(含并发)+ 人工审批
* + 记忆/技能 + 可插拔持久化」,对齐 Python {@code create_deep_agent} 的能力面。
*
* <h2>分层(自上而下)</h2>
* <pre>
* ┌─────────────────────────────────────────────────────────────────┐
* │ L6 产品层 │
* │ AssistantController(SSE /chat + /resume API) │
* │ AssistantChatService(跑图/续跑图 + 推事件 + 审批状态机) │
* │ SessionStore(对话历史落盘,按 sessionId 加锁) │
* ├─────────────────────────────────────────────────────────────────┤
* │ L5 持久化可插拔层(deepagents.persistence) │
* │ CheckpointSaverProvider(默认 FileSystemSaver) │
* │ TodoRepository(默认内存)· SessionLockProvider(默认本地锁) │
* ├─────────────────────────────────────────────────────────────────┤
* │ L4 统筹 Orchestrator │
* │ CreateDeepAgent → orchestratorAgentGraph(AgentExecutorEx) │
* │ StreamingChatModel + checkpointer(threadId=sessionId) │
* │ approvalOn(write_file/edit_file) → 中断等待人工审批 │
* │ SummarizingConversationContextPolicy(超阈值先摘要再裁剪) │
* ├─────────────────────────────────────────────────────────────────┤
* │ L3 Harness 内置工具(OrchestratorTools) │
* │ write_todos · list/read/write/edit_file(权限)· task/task_batch │
* │ update_memory · read_skill · 时间计算 │
* ├─────────────────────────────────────────────────────────────────┤
* │ L2 子 Agent(各自独立 AgentExecutor 子图,上下文隔离) │
* │ research-agent → WebResearchTools(智谱 MCP/REST) │
* │ general-purpose → 默认继承统筹全部工具(对齐 Python 语义) │
* ├─────────────────────────────────────────────────────────────────┤
* │ L1 外部世界 │
* │ 智谱大模型 · 智谱 Web Search · 本地 workspace/memories/skills │
* └─────────────────────────────────────────────────────────────────┘
* </pre>
*
* <h2>推荐阅读顺序</h2>
* <ol>
* <li>本类(建立心智模型)</li>
* <li>{@link cn.deepassistant.deepagents.CreateDeepAgent}(怎么装配图,含审批/摘要策略)</li>
* <li>{@link cn.deepassistant.config.DeepAgentHarnessConfig}(声明了哪些子 Agent、审批工具、记忆/技能)</li>
* <li>{@link cn.deepassistant.graph.tools.OrchestratorTools}(task/task_batch/文件权限/记忆/技能工具)</li>
* <li>{@link cn.deepassistant.service.AssistantChatService}(一次用户消息如何变成 SSE,以及审批中断/续跑)</li>
* <li>{@link cn.deepassistant.deepagents.persistence.CheckpointSaverProvider}(持久化如何可插拔)</li>
* <li>{@link cn.deepassistant.integration.mcp.ZhipuMcpWebSearchService}(联网怎么接)</li>
* </ol>
*
* <h2>和 Python create_deep_agent 的对应</h2>
* <ul>
* <li>{@code create_deep_agent(...)} ↔ {@code CreateDeepAgent.create(...)}</li>
* <li>{@code SubAgent} ↔ {@code SubAgentSpec}</li>
* <li>{@code task} 工具 ↔ {@code OrchestratorTools#task}(并行版见 {@code #task_batch})</li>
* <li>{@code checkpointer + thread_id} ↔ {@code BaseCheckpointSaver + RunnableConfig.threadId}</li>
* <li>{@code HumanInTheLoopMiddleware(interrupt_on=...)} ↔ {@code AgentExecutorEx.approvalOn(...)}
* + {@code AssistantChatService#resume}</li>
* <li>{@code FilesystemMiddleware(permissions=...)} ↔ {@code FilesystemPermission} + {@code FilesystemPermissionEvaluator}</li>
* <li>{@code MemoryMiddleware} / {@code SkillsMiddleware} ↔ {@code MemoryStore} / {@code SkillStore}</li>
* <li>{@code SummarizationMiddleware} ↔ {@code SummarizingConversationContextPolicy}</li>
* </ul>
*/
public final class HarnessOverview {
private HarnessOverview() {
}
}
二、Deep Agents 装配层
src/main/java/cn/deepassistant/deepagents/CreateDeepAgent.java
作用: 对齐 Python create_deep_agent:编译统筹图(AgentExecutorEx + 审批)与子图
java
package cn.deepassistant.deepagents;
/**
* Java 版 {@code create_deep_agent}:把「统筹 Agent + 子 Agent」装配成可运行的图。
*
* <h2>先搞清几个概念</h2>
* <ol>
* <li><b>AgentExecutorEx</b>:LangGraph4j 内置的 ReAct 循环,比 {@code AgentExecutor} 多一层
* 「按工具名单独派发节点」的图结构,因此能对指定工具挂 {@code approvalOn}(人工审批/中断)。
* 统筹图用它;子 Agent 图不需要审批,仍用更简单的 {@code AgentExecutor}。</li>
* <li><b>CompiledGraph</b>:已经编译好的状态机;调用 {@code stream}/{@code invoke} 才会真正跑模型。</li>
* <li><b>Harness</b>:不是再写一套推理,而是把规划/文件/委派/审批等「长任务必备能力」绑在统筹 Agent 上。</li>
* </ol>
*
* <h2>与 Python Deep Agents 的对应关系</h2>
* <pre>
* Python create_deep_agent(...)
* ├ FilesystemMiddleware → 统筹工具 list/read/write/edit_file + FilesystemPermissionEvaluator
* ├ SubAgentMiddleware(task) → OrchestratorTools#task/#task_batch + TaskDelegationService
* ├ TodoListMiddleware → OrchestratorTools#write_todos + TodoListPrompts + TodoStore
* ├ HumanInTheLoopMiddleware → approvalOn(write_file/edit_file) + AssistantController#resume
* ├ MemoryMiddleware → MemoryStore(拼进系统提示 + update_memory 工具)
* ├ SkillsMiddleware(渐进式) → SkillStore(目录+一句话描述进提示,read_skill 按需读全文)
* ├ SummarizationMiddleware → SummarizingConversationContextPolicy(超阈值先摘要再裁剪)
* └ create_agent(...).compile → AgentExecutorEx.builder()...compile(checkpointer)
* </pre>
*
* <h2>本类只做「装配」,不做「对话」</h2>
* 对话入口在 {@code AssistantChatService}:那里才会 {@code orchestratorGraph.stream(...)}。
* 这里负责:编译每个子 Agent 图 → 注册进委派表 → 编译统筹图并挂上 checkpoint + 审批。
*/
public final class CreateDeepAgent {
/** 工具类:禁止 new,只通过静态方法使用。 */
private CreateDeepAgent() {
}
/**
* 装配完整 Harness,返回「统筹 Agent」的已编译图。
*
* <p><b>为什么统筹用 StreamingChatModel、子 Agent 用 ChatModel?</b>
* <ul>
* <li>统筹面向用户:需要 SSE 逐 token 推流,所以用流式模型。</li>
* <li>子 Agent 面向统筹:只返回一整段结果文本即可,同步 invoke 更简单。</li>
* </ul>
*
* <p><b>为什么子 Agent 不挂 checkpointer?</b>
* 每次 {@code task}/{@code task_batch} 都是一次独立子任务,用隔离的新上下文,避免把子 Agent
* 历史污染进下一次委派。多轮「用户 ↔ 统筹」记忆只存在统筹图的 {@code checkpointer} 里(按 threadId=sessionId)。
*
* @param streamingModel 统筹图用的流式模型
* @param workerModel 子 Agent 用的同步模型;同时复用作摘要模型({@link SummarizingConversationContextPolicy})
* @param systemPrompt 统筹的系统提示词(角色、委派策略、记忆、技能目录等已拼好的完整文本)
* @param orchestratorTools 统筹可调用的工具对象(含 @Tool 方法的 Spring Bean)
* @param subagents 子 Agent 规格列表;可为空,但建议至少含 research / general
* @param delegation 运行时委派表;本方法会往里 register
* @param checkpointer 统筹多轮记忆({@link BaseCheckpointSaver} 接口,可插拔存储实现);传 null 则无会话记忆
* @param maxMessages 统筹上下文最多保留多少条消息(超过后先摘要再裁剪,防止爆 context)
* @param orchestratorRecursionLimit 统筹图最大节点步数(LangGraph4j 默认 25,复合任务易触顶)
* @param workerRecursionLimit 子 Agent 图最大节点步数(多轮 webSearch/webRead 需要更高)
* @param approvalTools 需要人工审批才能执行的工具名列表(对齐 Python {@code interrupt_on});可为空/null 表示不启用审批
* @return 已 compile 的统筹 {@link CompiledGraph},可直接 stream/invoke
*/
public static CompiledGraph<AgentExecutorEx.State> create(
StreamingChatModel streamingModel,
ChatModel workerModel,
String systemPrompt,
Object orchestratorTools,
List<SubAgentSpec> subagents,
TaskDelegationService delegation,
BaseCheckpointSaver checkpointer,
int maxMessages,
int orchestratorRecursionLimit,
int workerRecursionLimit,
List<String> approvalTools) throws GraphStateException {
Objects.requireNonNull(streamingModel, "streamingModel");
Objects.requireNonNull(workerModel, "workerModel");
Objects.requireNonNull(orchestratorTools, "orchestratorTools");
Objects.requireNonNull(delegation, "delegation");
int orchLimit = Math.max(25, orchestratorRecursionLimit);
int workerLimit = Math.max(25, workerRecursionLimit);
// 复制一份,避免修改调用方传入的不可变 List.of(...)
List<SubAgentSpec> resolved = new ArrayList<>(subagents == null ? List.of() : subagents);
// 对齐 Python:若调用方没提供 general-purpose,自动补一个默认通用子 Agent
// ------继承统筹自己的全部工具,而不是空数组(见 ensureGeneralPurpose 注释)
ensureGeneralPurpose(resolved, orchestratorTools);
// catalog:子 Agent 名称 → 给统筹看的一句话说明(写进 system prompt,方便模型选对 subagent_type)
Map<String, String> catalog = new LinkedHashMap<>();
for (SubAgentSpec spec : resolved) {
// 1) 把每个 SubAgentSpec 编译成独立的 ReAct 子图
CompiledGraph<AgentExecutor.State> worker = compileWorker(workerModel, spec, workerLimit);
// 2) 按名字注册,供 OrchestratorTools#task/#task_batch 查找并 invoke
delegation.register(spec.name(), worker);
catalog.put(spec.name(), spec.description());
}
// 把「有哪些子 Agent」追加进系统提示,否则模型不知道 task 能派给谁
String prompt = appendSubagentCatalog(systemPrompt, catalog);
// 构建统筹 ReAct 图:
// - chatModel:流式,供前端 SSE
// - toolsFromObject:扫描 orchestratorTools 上的 @Tool,变成模型可调用的函数
// - conversationContextPolicy:超过 maxMessages 时先摘要再裁剪(对齐 SummarizationMiddleware)
// - approvalOn:对齐 HumanInTheLoopMiddleware,指定工具执行前挂起等待人工审批
var builder = AgentExecutorEx.builder()
.chatModel(streamingModel)
.toolsFromObject(orchestratorTools)
.systemMessage(SystemMessage.from(prompt))
.conversationContextPolicy(new SummarizingConversationContextPolicy(
Math.max(10, maxMessages), workerModel)); // 至少 10,避免窗口过小导致工具结果被裁掉
List<String> tools = approvalTools == null ? List.of() : approvalTools;
for (String toolName : tools) {
if (toolName == null || toolName.isBlank()) {
continue;
}
builder.approvalOn(toolName, CreateDeepAgent::buildInterruptionMetadata);
}
var stateGraph = builder.build();
CompileConfig.Builder compile = CompileConfig.builder()
.recursionLimit(orchLimit);
if (checkpointer != null) {
// 有 checkpointer:stream/invoke 时传入 RunnableConfig.threadId,即可恢复该会话历史
// (审批中断后的 resume 同样依赖它:AsyncNodeGenerator 会从 checkpoint 里读回挂起点)
compile.checkpointSaver(checkpointer);
}
return stateGraph.compile(compile.build());
}
/**
* 审批中断时附带的元数据:把「哪个工具、什么参数」暴露出来,供
* {@code AssistantChatService} 组装成 SSE {@code interrupt} 事件、供前端渲染审批卡片。
*/
private static InterruptionMetadata<AgentExecutorEx.State> buildInterruptionMetadata(
String nodeId, AgentExecutorEx.State state) {
var builder = InterruptionMetadata.builder(nodeId, state);
try {
state.toolExecutionRequests().stream().findFirst().ifPresent(req -> {
builder.addMetadata("pendingTool", req.name());
builder.addMetadata("pendingArgs", req.arguments());
});
} catch (Exception ignored) {
// 拿不到待执行工具详情时不影响中断本身发生,只是元数据少一点
}
return builder.build();
}
/**
* 编译单个子 Agent(worker)。
*
* <p>子图同样是 ReAct 循环,但用更简单的 {@code AgentExecutor}(无需审批能力):
* <ul>
* <li>使用同步 ChatModel(不流式)</li>
* <li>不挂 checkpoint(每次 task 独立)</li>
* <li>工具集来自 {@link SubAgentSpec#toolBeans()},比统筹更窄(专科)</li>
* </ul>
*/
public static CompiledGraph<AgentExecutor.State> compileWorker(
ChatModel model, SubAgentSpec spec) throws GraphStateException {
return compileWorker(model, spec, 60);
}
public static CompiledGraph<AgentExecutor.State> compileWorker(
ChatModel model, SubAgentSpec spec, int recursionLimit) throws GraphStateException {
var builder = AgentExecutor.builder()
.chatModel(model)
.systemMessage(SystemMessage.from(spec.systemPrompt()));
// 逐个注册,避免 toolsFromObject(Object[]) 被误当成「单个数组参数」的歧义
for (Object toolBean : spec.toolBeans()) {
if (toolBean != null) {
builder.toolsFromObject(toolBean);
}
}
// 无 checkpoint;提高 recursionLimit,避免多轮搜索触顶默认 25
return builder.build().compile(CompileConfig.builder()
.recursionLimit(Math.max(25, recursionLimit))
.build());
}
/**
* 若列表里还没有名为 {@code general-purpose} 的子 Agent,则补一个默认实现。
* <p>原因:Python Deep Agents 也会自动挂载 general-purpose,保证 {@code task} 至少有一个通用出口,
* 且 Python 语义上它默认继承主 Agent 的<b>全部</b>工具------这里对齐同样的语义,而不是给空数组
* (空数组会让它连时间/计算这类最基础的能力都没有,模型只能纯文本回复)。
*
* <p>注意:这意味着自动补齐的 general-purpose 也会拿到 {@code task}/{@code task_batch} 本身
* (因为它继承的是完整的统筹工具集),理论上存在"子 Agent 又委派子 Agent"的递归可能------
* Python 里同样存在这个语义,业务侧如果不想要,应显式提供一个只带受限工具集的 general-purpose
* {@link SubAgentSpec}(本项目的 {@code DeepAgentHarnessConfig} 就是这么做的:显式传了只带
* {@code commonTools} 的 general-purpose,因此这条自动补齐路径平时不会被触发)。
*/
private static void ensureGeneralPurpose(List<SubAgentSpec> specs, Object orchestratorTools) {
boolean hasGp = specs.stream().anyMatch(s -> "general-purpose".equals(s.name()));
if (!hasGp) {
specs.add(new SubAgentSpec(
"general-purpose",
"通用子 Agent:处理不需要联网调研的其他任务(时间、计算、拆解说明等)",
"""
你是 general-purpose 子 Agent。
完成统筹分配的单一子任务即可;优先用时间/计算类工具。
若实际需要联网调研,在结果中明确建议统筹改派 research-agent。
""",
new Object[]{orchestratorTools} // 对齐 Python:默认继承主 Agent 的全部工具
));
}
}
/**
* 把子 Agent 目录拼进系统提示末尾。
* <p>等价于 Python SubAgentMiddleware 往 task 工具描述里注入 {@code {available_agents}}:
* 模型必须「看见」有哪些名字可选,才会正确填写 {@code task.subagent_type}。
*/
private static String appendSubagentCatalog(String systemPrompt, Map<String, String> catalog) {
StringBuilder sb = new StringBuilder(systemPrompt == null ? "" : systemPrompt.trim());
sb.append("\n\n## 可用子 Agent(task/task_batch 的 subagent_type)\n");
for (Map.Entry<String, String> e : catalog.entrySet()) {
sb.append("- ").append(e.getKey()).append(":").append(e.getValue()).append('\n');
}
sb.append("""
选用规则:名称必须与上表完全一致。实时/多源信息 → research-agent;其余可隔离的通用子任务 → general-purpose。
简单问题直接答或用轻量工具,不必强行委派。多个互相独立的子任务优先用 task_batch 一次性并发委派。
""");
return sb.toString();
}
}
src/main/java/cn/deepassistant/deepagents/SubAgentSpec.java
作用: 子 Agent 规格:名称、描述、提示词、工具 Bean
java
package cn.deepassistant.deepagents;
/**
* 子 Agent 的「说明书」------声明式配置,不负责实际运行。
*
* <p>真正跑起来的是 {@link CreateDeepAgent#compileWorker} 根据本规格编译出的 {@code CompiledGraph}。
*
* <h2>对齐 Python</h2>
* Python Deep Agents 里子 Agent 大致长这样:
* <pre>
* {
* "name": "research-agent",
* "description": "给主 Agent 看的一句话,决定何时委派",
* "system_prompt": "子 Agent 自己的角色与工作流",
* "tools": [web_search, ...]
* }
* </pre>
* Java 没有 TypedDict,用 record 表达同样四件事;{@code tools} 变成 {@code toolBeans}
* (带 {@code @Tool} 注解方法的 Spring Bean 数组)。
*
* <h2>四个字段分别给谁看?</h2>
* <ul>
* <li>{@code name} ------ 统筹调用 {@code task(subagent_type=...)} 时的路由键</li>
* <li>{@code description} ------ 写进统筹的 system prompt,帮助模型「选对人」</li>
* <li>{@code systemPrompt} ------ 只注入子 Agent 自己的图,统筹看不到</li>
* <li>{@code toolBeans} ------ 子 Agent 可调用的工具;越专科越好,避免能力越界</li>
* </ul>
*
* @param name 唯一名称,如 {@code research-agent}
* @param description 给统筹看的能力描述(不是给用户看的)
* @param systemPrompt 子 Agent 系统提示词
* @param toolBeans 工具 Bean;可为 {@code new Object[0]}
*/
public record SubAgentSpec(
String name,
String description,
String systemPrompt,
Object[] toolBeans
) {
/**
* Compact constructor:在 record 正式赋值前做校验与默认值填充。
* <p>这里保证 name/systemPrompt 必填;toolBeans/description 允许空并给默认。
*/
public SubAgentSpec {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("SubAgentSpec.name 不能为空");
}
if (systemPrompt == null || systemPrompt.isBlank()) {
throw new IllegalArgumentException("SubAgentSpec.systemPrompt 不能为空");
}
if (toolBeans == null) {
toolBeans = new Object[0];
}
if (description == null) {
// 没有描述时至少用名字占位,避免 system prompt 里出现 "null"
description = name;
}
}
}
src/main/java/cn/deepassistant/config/DeepAgentHarnessConfig.java
作用: 声明 research / general-purpose、审批工具、记忆/技能,并调用 CreateDeepAgent 装配
java
package cn.deepassistant.config;
/**
* Spring 配置层:把「模型 + 工具 + 子 Agent 规格」交给 {@link CreateDeepAgent} 装配成 Bean。
*
* <h2>你应该怎么读这个类?</h2>
* <ol>
* <li>先看 {@link HarnessOverview} 理解分层</li>
* <li>再看本类末尾的 {@link #orchestratorAgentGraph} ------ 这里声明有哪些子 Agent</li>
* <li>然后跟 {@link CreateDeepAgent#create} 看图是怎么 compile 出来的</li>
* <li>最后看 {@link OrchestratorTools#task} 理解运行时如何跳到子 Agent</li>
* </ol>
*
* <h2>精简版角色</h2>
* <ul>
* <li><b>统筹</b>:规划、委派、汇总(唯一面对用户的 Agent)</li>
* <li><b>research-agent</b>:复杂联网查询</li>
* <li><b>general-purpose</b>:时间/计算等通用杂活</li>
* </ul>
*
* <p>{@code @RequiredArgsConstructor}:Lombok 为所有 {@code final} 字段生成构造器,Spring 构造注入。
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class DeepAgentHarnessConfig {
/**
* 统筹 Agent 的系统提示词(对应 Python {@code create_deep_agent(system_prompt=...)})。
*
* <p>设计原则:
* <ul>
* <li>只写「角色 + 决策策略 + 委派/汇总规范」,不写框架实现细节(如 LangGraph4j)</li>
* <li>{@code write_todos} 细则交给 {@link TodoListPrompts#SYSTEM_PROMPT},此处只点到为止,避免多处重复</li>
* <li>可用子 Agent 目录 / 长期记忆 / 技能目录 都由装配时动态追加,这里不写死</li>
* </ul>
*/
private static final String ORCHESTRATOR_PROMPT = """
你是私人智能助手「小深」的统筹 Agent:负责理解目标、必要时规划与委派,并汇总成对用户的最终答复。
## 行为风格
- 简洁直接;不要客套开场(「好的!」「我来帮你...」),不要预告「我现在去做 X」------直接调用工具或作答
- 用清晰中文回答;条目化优于长段落
- 准确性优先:不确定就标明不确定性,不要编造事实、数据或链接
- 缺推进下一步所必需的关键信息时,只追问最少的一点;已给出的信息不要重复问
- 长任务可简短汇报进度(一句:已完成什么 / 下一步做什么),但不要中途停下只解释计划
## 怎么选路径(按优先级)
1. **直接回答**:闲聊、定义解释、已有上下文足够的问题 ------ 不用工具
2. **轻量工具**:只需当前时间或算术 ------ 用 getCurrentDateTime / calculate
3. **规划**:≥3 步、多目标、或用户明确要求清单 ------ 用 write_todos(细则见下方 write_todos 专节);简单任务不要为了规划而规划
4. **委派**:需要联网调研 / 隔离上下文的专科活 ------ 用 task;多个互相独立的子任务用 task_batch 一次性并发委派;你自己不要假装已联网搜索
5. **工作区**:需要落盘长文、草稿、中间结果 ------ 用 list_dir / read_file / write_file / edit_file(write_file/edit_file 会触发人工审批,请确保内容已经想清楚再调用)
6. **记忆/技能**:用户明确要求"记住"某个偏好或结论 ------ 用 update_memory;需要某个技能细节 ------ 先看系统提示的技能目录,需要时用 read_skill 读全文
## 如何写好 task / task_batch 委派
- description 必须写清:目标、约束、期望输出格式(例如「分点结论 + 来源链接」)
- 多个子任务彼此没有依赖关系时,用一次 task_batch 并发委派,而不是多次调用 task;有依赖关系才用多次 task 顺序委派
- subagent_type 必须使用系统提示「可用子 Agent」里的名字(常见:research-agent / general-purpose)
- 收到子 Agent 结果后:综合、去噪、核对冲突;用你的话写最终答复,不要整段粘贴原始工具输出
- 子 Agent 失败或结果不足:换描述重试一次,或改派,或如实告知用户卡点------不要用同一方式无限重试
## 交付要求
- 最终答复面向用户:先给结论/答案,再补依据与来源;工具调用与 todo 更新不是答案本身
- 有 todos 时,完成前对照清单;全部完成后仍须输出实质内容
- 必须通过平台函数调用使用工具,禁止在正文里用 JSON/伪代码假装调工具
""" + TodoListPrompts.SYSTEM_PROMPT;
/**
* research-agent 自己的系统提示词。
* <p>只在子图里生效;统筹看不到这段。这样可以把「多角度搜索」流程写细,而不撑爆统筹上下文。
*/
private static final String RESEARCH_PROMPT = """
你是 research-agent------复杂联网查询专科子 Agent。
## 工作流(必须遵守)
1. 把用户子任务拆成 2~5 个可检索角度(不同关键词 / 时间 / 来源侧重点)
2. 对每个角度调用 webSearch(智谱 Web Search MCP/REST);需要深读时用 webRead
3. 交叉比对多源结果,标出一致点与冲突点
4. 输出结构化结论:
- 核心结论(分点)
- 证据与来源链接(真实来自工具结果,禁止编造)
- 时效性说明(何时的信息)
- 不确定性 / 仍待核实项
## 约束
- 只完成统筹分配的这一个子任务
- 搜索词尽量具体;必要时换关键词重搜,不要用同一词盲目重试超过 2 次
- 没有检索到就如实说明,不要编造链接或数据
""";
/** 子 Agent 同步模型(由 {@link ModelConfig#chatModel()} 提供);同时复用作摘要模型 */
private final ChatModel chatModel;
/** 统筹流式模型(由 {@link ModelConfig#streamingChatModel()} 提供) */
private final StreamingChatModel streamingChatModel;
/** research-agent 的联网工具(webSearch / webRead) */
private final WebResearchTools webResearchTools;
/** general-purpose(以及 research 备用)的时间/计算工具 */
private final CommonTools commonTools;
/** 统筹专用工具:todos / 文件 / task 委派 / memory / skills */
private final OrchestratorTools orchestratorTools;
/** 子 Agent 名称 → 已编译子图 的注册表;启动时由 CreateDeepAgent 填入 */
private final TaskDelegationService taskDelegationService;
/** 长期记忆:拼进系统提示 */
private final MemoryStore memoryStore;
/** 技能目录:拼进系统提示(渐进式加载,正文靠 read_skill 按需读取) */
private final SkillStore skillStore;
/** 沙箱工作区根目录,统筹 write_file 等工具都落在这里 */
@Value("${agent.workspace}")
private String workspace;
/** 统筹对话窗口保留的最大消息条数,防止 context 无限增长(超过后先摘要再裁剪) */
@Value("${agent.chat-memory-max-messages:48}")
private int chatMemoryMaxMessages;
/**
* 统筹图 recursionLimit(LangGraph4j 默认 25)。
* <p>复合任务含多次 write_todos + task + 文件写入时,节点步数很容易超过 25。
*/
@Value("${agent.recursion-limit:120}")
private int orchestratorRecursionLimit;
/** 子 Agent 图 recursionLimit;research 多轮 webSearch/webRead 时需要更高 */
@Value("${agent.worker-recursion-limit:80}")
private int workerRecursionLimit;
/** 需要人工审批才能执行的工具名(逗号分隔);对齐 Python interrupt_on。留空表示不启用审批。 */
@Value("#{'${agent.approval-tools:write_file,edit_file}'.split(',')}")
private List<String> approvalTools;
/**
* 创建名为 {@code orchestratorAgentGraph} 的 Spring Bean。
* <p>{@code AssistantChatService} 通过 {@code @Qualifier("orchestratorAgentGraph")} 注入它,
* 然后对用户消息执行 {@code stream(...)}。
*
* @param checkpointSaver 统筹图的 checkpoint 存储(按 sessionId 隔离多轮;可插拔实现见
* {@code CheckpointSaverProvider}/{@code PersistenceConfig})
*/
@Bean
@Qualifier("orchestratorAgentGraph")
public CompiledGraph<AgentExecutorEx.State> orchestratorAgentGraph(
@Qualifier("orchestratorCheckpointSaver") BaseCheckpointSaver checkpointSaver) throws Exception {
// 确保工作区目录存在(文件工具依赖它)
Path ws = Path.of(workspace).toAbsolutePath().normalize();
Files.createDirectories(ws);
/*
* 声明两个子 Agent(精简版只保留这两个)。
*
* 参数含义见 SubAgentSpec:
* name → task(subagent_type=?) 的合法取值
* description → 追加进统筹 system prompt,帮助模型选型
* systemPrompt → 只给子 Agent 自己
* toolBeans → 子 Agent 能用的 @Tool Bean
*
* research 同时挂上 commonTools:调研过程中偶尔要算时间差/简单数也不必再回统筹。
*/
List<SubAgentSpec> subagents = List.of(
new SubAgentSpec(
"research-agent",
"复杂联网调研:多角度搜索、交叉验证、带来源的结论摘要(智谱 Web Search)",
RESEARCH_PROMPT,
new Object[]{webResearchTools, commonTools}),
new SubAgentSpec(
"general-purpose",
"通用子 Agent:时间、计算、说明拆解等不需要联网的任务",
"""
你是 general-purpose。优先用 getCurrentDateTime / calculate 完成任务。
若任务实际需要联网调研,在结果中建议统筹改派 research-agent。
""",
new Object[]{commonTools})
);
// 长期记忆 + 技能目录拼进系统提示(对齐 Python MemoryMiddleware / 渐进式 Skills)
String prompt = ORCHESTRATOR_PROMPT + memoryStore.loadAllForPrompt() + skillStore.promptCatalog();
// 一站式装配:注册子图 + 编译统筹图(详见 CreateDeepAgent 类注释)
CompiledGraph<AgentExecutorEx.State> graph = CreateDeepAgent.create(
streamingChatModel,
chatModel,
prompt,
orchestratorTools,
subagents,
taskDelegationService,
checkpointSaver,
chatMemoryMaxMessages,
orchestratorRecursionLimit,
workerRecursionLimit,
approvalTools);
log.info("[Harness] 精简版就绪 workspace={} subAgents={} recursionLimit={}/{} approvalTools={}",
ws, taskDelegationService.registeredAgents(),
orchestratorRecursionLimit, workerRecursionLimit, approvalTools);
return graph;
}
}
src/main/java/cn/deepassistant/config/HarnessToolingConfig.java
作用: 沙箱文件、文件权限规则、task_batch 线程池(拆开以避免循环依赖)
java
package cn.deepassistant.config;
/**
* Harness 底层基础设施 Bean:沙箱文件、文件权限规则、task_batch 线程池。
*
* <h2>为什么单独拆一个配置类?</h2>
* {@code OrchestratorTools} 需要这些 Bean 作为构造参数,而 {@code DeepAgentHarnessConfig}
* 又需要注入 {@code OrchestratorTools} 才能编译图。如果把这些 {@code @Bean} 方法直接放进
* {@code DeepAgentHarnessConfig},会形成"构造 DeepAgentHarnessConfig 需要先有
* OrchestratorTools → 需要先有 DeepAgentHarnessConfig 的 @Bean 方法产出"的循环依赖。
* 拆到独立配置类后,依赖方向变成单向:{@code HarnessToolingConfig} → 被
* {@code OrchestratorTools}/{@code TaskDelegationService} 使用 → 被
* {@code DeepAgentHarnessConfig} 使用,没有环。
*/
@Configuration
public class HarnessToolingConfig {
/** 沙箱工作区根目录,统筹 write_file 等工具、子 Agent 结果落盘都在这里 */
@Value("${agent.workspace}")
private String workspace;
/** task_batch 用的并发度 */
@Value("${agent.task-batch-parallelism:4}")
private int taskBatchParallelism;
/** 统筹/子 Agent 结果落盘共用的沙箱文件操作(Spring 单例) */
@Bean
public WorkspaceFileOperations workspaceFileOperations() throws IOException {
Path ws = Path.of(workspace).toAbsolutePath().normalize();
Files.createDirectories(ws);
return new WorkspaceFileOperations(ws);
}
/**
* 声明式文件权限规则(对齐问题 5 的 permissions;求值逻辑见 {@code FilesystemPermissionEvaluator})。
* <p>先给几条防御性示例:禁止读写 {@code .env}/{@code secrets/**};其余默认放行。
* 未来可以改成从 application.yml 读取一份列表,而不是写死在代码里。
*/
@Bean
public List<FilesystemPermission> filesystemPermissions() {
return List.of(
FilesystemPermission.deny("**/*.env", "read", "write", "edit"),
FilesystemPermission.deny("secrets/**", "read", "write", "edit", "list"),
FilesystemPermission.deny(".env", "read", "write", "edit"));
}
/**
* task_batch 专用线程池(对齐问题 2:一次工具调用内部并发委派多个子 Agent)。
* <p>独立于 WebFlux 的 {@code boundedElastic} 调度器,避免互相饥饿;线程数可配置。
*/
@Bean
public ExecutorService taskBatchExecutor() {
AtomicInteger seq = new AtomicInteger();
ThreadFactory factory = r -> {
Thread t = new Thread(r, "task-batch-" + seq.getAndIncrement());
t.setDaemon(true);
return t;
};
return Executors.newFixedThreadPool(Math.max(1, taskBatchParallelism), factory);
}
}
src/main/java/cn/deepassistant/config/PersistenceConfig.java
作用: 可插拔 checkpoint:默认磁盘 FileSystemSaver
java
package cn.deepassistant.config;
/**
* 持久化可插拔化的装配点(对齐问题 10 的方案:接口化 + 默认单机实现 + 预留扩展点)。
*
* <p>{@link CheckpointSaverProvider} 目前只有一个实现------磁盘版
* {@link FileSystemCheckpointSaverProvider},取代原来纯内存的 {@code MemorySaver}:
* 进程重启不再丢会话。{@code TodoRepository}/{@code SessionLockProvider} 的默认实现
* 直接标了 {@code @Component}({@code InMemoryTodoRepository}/{@code LocalSessionLockProvider}),
* Spring 会自动发现,不需要在这里手写 {@code @Bean}。
*
* <p>未来要接 Redis/数据库:新写一个实现类,把这里的 {@code @Bean} 换掉(或者用
* {@code @ConditionalOnProperty} 按配置切换),{@code DeepAgentHarnessConfig}/
* {@code AssistantChatService}/{@code TodoStore} 都只依赖接口,完全不用改。
*/
@Configuration
public class PersistenceConfig {
@Value("${agent.checkpoints.dir:${user.dir}/data/checkpoints}")
private String checkpointsDir;
@Bean
public CheckpointSaverProvider checkpointSaverProvider() {
Path dir = Path.of(checkpointsDir).toAbsolutePath().normalize();
return new FileSystemCheckpointSaverProvider(dir);
}
/**
* 统筹图实际使用的 checkpoint saver 单例。
* <p>单独暴露成 Bean(而不是每次都调 {@code provider.get()})是因为
* {@code DeepAgentHarnessConfig} 和 {@code AssistantChatService} 都要用同一个实例
* (前者编译图时挂上它,后者需要在 {@code clearSessionMemory} 时调用它的 {@code release})。
*/
@Bean
@Qualifier("orchestratorCheckpointSaver")
public BaseCheckpointSaver orchestratorCheckpointSaver(CheckpointSaverProvider provider) {
return provider.get();
}
}
src/main/java/cn/deepassistant/config/ModelConfig.java
作用: ChatModel / StreamingChatModel / ObjectMapper
java
package cn.deepassistant.config;
/**
* 大模型与基础设施 Bean。
*
* <h2>为什么要两个模型 Bean?</h2>
* <ul>
* <li>{@link ChatModel}:同步完整响应 ------ 给子 Agent {@code invoke} 用</li>
* <li>{@link StreamingChatModel}:边生成边吐 token ------ 给统筹图 SSE 用</li>
* </ul>
* 智谱提供 OpenAI 兼容接口,所以这里用 {@code OpenAiChatModel} / {@code OpenAiStreamingChatModel},
* 只要改 {@code llm.base-url} 就能指向智谱。
*
* <p><b>刻意不用</b> {@code AiServices.create(...)}:Agent 循环交给 LangGraph4j {@code AgentExecutor},
* 工具用 {@code @Tool} + {@code toolsFromObject},和 llm-service-graph 同一套路。
*/
@Configuration
public class ModelConfig {
/** 从环境变量 LLM_API_KEY 或 application.yml 注入;为空则启动失败 */
@Value("${llm.api-key}")
private String apiKey;
@Value("${llm.base-url:https://open.bigmodel.cn/api/paas/v4/}")
private String baseUrl;
@Value("${llm.model:glm-4-flash}")
private String chatModelName;
@Value("${llm.temperature:0.3}")
private double temperature;
@Value("${llm.timeout-seconds:120}")
private int timeoutSeconds;
/**
* 同步模型:子 Agent 整段生成完再返回。
* <p>打开 logRequests/Responses 方便本地排查 tool-call 格式问题。
*/
@Bean
public ChatModel chatModel() {
requireKey();
return OpenAiChatModel.builder()
.baseUrl(baseUrl)
.apiKey(apiKey)
.modelName(chatModelName)
.temperature(temperature)
.timeout(Duration.ofSeconds(timeoutSeconds))
.logRequests(true)
.logResponses(true)
.build();
}
/**
* 流式模型:统筹 AgentExecutor 用它把 token 推到 StreamingOutput。
* <p>日志默认关掉,避免刷屏。
*/
@Bean
public StreamingChatModel streamingChatModel() {
requireKey();
return OpenAiStreamingChatModel.builder()
.baseUrl(baseUrl)
.apiKey(apiKey)
.modelName(chatModelName)
.temperature(temperature)
.timeout(Duration.ofSeconds(timeoutSeconds))
.logRequests(false)
.logResponses(false)
.build();
}
/** 全局 Jackson;支持 Java 8 时间类型,日期写成 ISO 字符串而不是时间戳 */
@Bean
@Primary
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return mapper;
}
private void requireKey() {
if (apiKey == null || apiKey.isBlank()) {
throw new IllegalStateException("未配置 llm.api-key / LLM_API_KEY");
}
}
}
src/main/java/cn/deepassistant/deepagents/persistence/CheckpointSaverProvider.java
作用: checkpoint 存储可插拔入口
java
package cn.deepassistant.deepagents.persistence;
/**
* 统筹图 checkpoint 存储的可插拔入口(对齐 Python Deep Agents 可替换的 {@code checkpointer})。
*
* <p>默认实现见 {@code FileSystemCheckpointSaverProvider}(磁盘持久化,进程重启不丢会话)。
* 未来要接 Redis/数据库只需新增一个实现类并换掉 Spring Bean,
* {@code AssistantChatService}/{@code DeepAgentHarnessConfig} 都只依赖本接口,不依赖具体实现。
*/
public interface CheckpointSaverProvider {
BaseCheckpointSaver get();
}
src/main/java/cn/deepassistant/deepagents/persistence/FileSystemCheckpointSaverProvider.java
作用: 默认:把 checkpoint 落到 data/checkpoints
java
package cn.deepassistant.deepagents.persistence;
/**
* 单机默认实现:把 checkpoint 落到磁盘({@code FileSystemSaver},LangGraph4j 自带)。
*
* <p>相比原来的 {@code MemorySaver}:进程重启后会话历史不会丢失。
* 仍然不支持多实例共享(多副本部署时每个实例看到的是自己本地磁盘),
* 但已经把"检查点从哪里来"这件事收敛到 {@link CheckpointSaverProvider} 接口后面------
* 以后要上 Redis/数据库,只需要新写一个实现类,{@code DeepAgentHarnessConfig}/
* {@code AssistantChatService} 完全不用改。
*/
@Slf4j
public class FileSystemCheckpointSaverProvider implements CheckpointSaverProvider {
private final BaseCheckpointSaver saver;
public FileSystemCheckpointSaverProvider(Path targetFolder) {
this.saver = new FileSystemSaver(targetFolder, AgentExecutorEx.Serializers.JSON.object());
log.info("[Persistence] checkpoint 落盘目录: {}", targetFolder);
}
@Override
public BaseCheckpointSaver get() {
return saver;
}
}
src/main/java/cn/deepassistant/deepagents/persistence/TodoRepository.java
作用: todos 存储接口
java
package cn.deepassistant.deepagents.persistence;
/**
* Todo 清单的存储后端(对齐 Python {@code PlanningState.todos} 外置存储的可插拔化)。
*
* <p>{@code TodoStore} 只负责 JSON 解析/校验/并发写保护这些"业务规则",
* 真正的存储读写委托给本接口------默认是进程内 {@code ConcurrentHashMap}
* (见 {@code InMemoryTodoRepository}),未来要做到"多实例共享 todo 状态"
* 只需要新写一个 Redis/数据库实现,{@code TodoStore} 完全不用改。
*/
public interface TodoRepository {
List<TodoItem> get(String sessionId);
void replace(String sessionId, List<TodoItem> todos);
void clear(String sessionId);
}
src/main/java/cn/deepassistant/deepagents/persistence/InMemoryTodoRepository.java
作用: 默认:内存 todos
java
package cn.deepassistant.deepagents.persistence;
/**
* 单机默认实现:进程内 {@code ConcurrentHashMap}。
* <p>进程重启会丢------这是当前学习项目接受的取舍;接口本身已经为多实例部署做好了扩展点。
*/
@Component
public class InMemoryTodoRepository implements TodoRepository {
private final Map<String, List<TodoItem>> byMemory = new ConcurrentHashMap<>();
@Override
public List<TodoItem> get(String sessionId) {
return byMemory.getOrDefault(sessionId, Collections.emptyList());
}
@Override
public void replace(String sessionId, List<TodoItem> todos) {
byMemory.put(sessionId, todos == null ? List.of() : List.copyOf(todos));
}
@Override
public void clear(String sessionId) {
byMemory.remove(sessionId);
}
}
src/main/java/cn/deepassistant/deepagents/persistence/SessionLockProvider.java
作用: 同会话串行锁接口
java
package cn.deepassistant.deepagents.persistence;
/**
* 按 sessionId 排队的锁(对齐"同一会话的多轮请求必须串行执行,不同会话互不阻塞")。
*
* <p>{@code AssistantChatService} 用它保证同一个 session 的第二个请求会排队等第一个跑完,
* 避免并发写同一份 checkpoint。默认实现 {@code LocalSessionLockProvider} 是进程内
* {@code ReentrantLock} 登记表;多实例部署时天然失效(每个实例只能锁住自己进程内的请求),
* 但接口本身已经为分布式锁(Redis {@code SETNX}/数据库行锁)留好了替换点。
*/
public interface SessionLockProvider {
/**
* 进入某个 sessionId 的临界区(会阻塞直到轮到自己)。
* <p>用 try-with-resources:{@code try (var h = provider.enter(id)) { ... }},
* {@code close()} 保证一定会释放锁并归还引用计数。
*/
Handle enter(String sessionId);
/** 显式清理某 sessionId 的登记(如删除会话时),避免无用条目常驻。 */
void forget(String sessionId);
interface Handle extends AutoCloseable {
@Override
void close();
}
}
src/main/java/cn/deepassistant/deepagents/persistence/LocalSessionLockProvider.java
作用: 默认:进程内公平 ReentrantLock
java
package cn.deepassistant.deepagents.persistence;
/**
* 单机默认实现:进程内公平锁登记表 + 引用计数。
*
* <p>这是从原来 {@code AssistantChatService.SessionGate} 原样搬上来的逻辑:
* <ul>
* <li>{@code ReentrantLock(true)}------公平锁,保证同 session 的多个排队请求按到达顺序执行</li>
* <li>引用计数------有人在跑/在排队时条目留在 map 里;最后一人结束后移除,
* 避免历史会话的锁条目在进程里无限堆积</li>
* </ul>
*/
@Component
public class LocalSessionLockProvider implements SessionLockProvider {
private final ConcurrentHashMap<String, Gate> gates = new ConcurrentHashMap<>();
@Override
public Handle enter(String sessionId) {
Gate gate = gates.compute(sessionId, (k, existing) -> {
Gate g = existing != null ? existing : new Gate();
g.refs++;
return g;
});
gate.lock.lock();
return () -> {
gate.lock.unlock();
gates.compute(sessionId, (k, current) -> {
if (current == null) {
return null;
}
current.refs--;
return current.refs <= 0 ? null : current;
});
};
}
@Override
public void forget(String sessionId) {
gates.remove(sessionId);
}
private static final class Gate {
private final ReentrantLock lock = new ReentrantLock(true);
/** 仅在 ConcurrentHashMap.compute(同一 key) 内读写,天然线程安全 */
private int refs;
}
}
src/main/java/cn/deepassistant/deepagents/context/SummarizingConversationContextPolicy.java
作用: 超阈值先摘要再裁剪(对齐 SummarizationMiddleware)
java
package cn.deepassistant.deepagents.context;
/**
* 摘要式上下文裁剪(对齐 Python Deep Agents 的 {@code SummarizationMiddleware})。
*
* <p>{@link MessageWindowConversationContextPolicy}(LangGraph4j 内置)超过窗口大小就直接
* <b>丢弃</b>最旧的消息------早期上下文彻底消失。Python 版做得更好:被淘汰的那一段先用一次
* 轻量模型调用做摘要,合成一条 {@code SystemMessage} 留下来,模型仍能记得"早期发生过什么",
* 只是不再逐字保留。
*
* <h2>策略</h2>
* <ol>
* <li>消息数 ≤ {@code maxMessages} 时原样返回,不做任何事</li>
* <li>超过时:保留开头的 {@code SystemMessage}(若有)+ 最近若干条消息,
* 中间那一段整体摘要成一条新的 {@code SystemMessage} 插在两者之间</li>
* <li>为避免摘要把 {@code AiMessage(toolCall)} 和它对应的 {@code ToolExecutionResultMessage}
* 拆开导致"孤儿工具结果",摘要/保留的边界会向后微调,让工具调用与其结果同进同退
* (与 {@link MessageWindowConversationContextPolicy} 的做法一致)</li>
* <li>调用摘要模型失败(超时/异常/返回空)时降级为纯滑窗裁剪,绝不能让摘要失败拖垮整个请求</li>
* </ol>
*/
@Slf4j
public class SummarizingConversationContextPolicy implements ConversationContextPolicy<ChatMessage> {
private final int maxMessages;
private final ChatModel summarizerModel;
private final MessageWindowConversationContextPolicy fallback;
public SummarizingConversationContextPolicy(int maxMessages, ChatModel summarizerModel) {
if (maxMessages < 3) {
throw new IllegalArgumentException("maxMessages 太小,摘要策略至少需要 3(系统消息+摘要+最近一条)");
}
this.maxMessages = maxMessages;
this.summarizerModel = summarizerModel;
this.fallback = new MessageWindowConversationContextPolicy(maxMessages);
}
@Override
public <S extends MessagesState<ChatMessage>> List<ChatMessage> filter(S state, RunnableConfig config) {
List<ChatMessage> source = state.messages();
if (source.size() <= maxMessages) {
return source;
}
try {
List<ChatMessage> summarized = summarizeAndTrim(source);
if (summarized != null) {
return summarized;
}
} catch (Exception e) {
log.warn("[Context] 摘要式裁剪失败,降级为纯滑窗裁剪: {}", e.getMessage());
}
return fallback.filter(state, config);
}
/** @return 摘要后的消息列表;无需摘要(没有可淘汰的中间段)时返回 null,交给调用方走降级路径 */
private List<ChatMessage> summarizeAndTrim(List<ChatMessage> source) {
LinkedList<ChatMessage> working = new LinkedList<>(source);
boolean leadingSystem = !working.isEmpty() && working.get(0) instanceof SystemMessage;
int startIdx = leadingSystem ? 1 : 0;
int total = working.size();
// 预留:leading system(可选) + 1 条合成摘要 system + 至少 1 条最近消息
int reserved = (leadingSystem ? 2 : 1) + 1;
int keepRecent = Math.max(1, maxMessages - reserved);
int recentStart = Math.max(startIdx, total - keepRecent);
// 边界微调:不能让 ToolExecutionResultMessage 孤儿化------它前面必须有对应的 AiMessage(toolCall)
while (recentStart < total && working.get(recentStart) instanceof ToolExecutionResultMessage) {
recentStart++;
}
if (recentStart <= startIdx) {
return null; // 没有可摘要的中间段,直接走降级
}
List<ChatMessage> evicted = new ArrayList<>(working.subList(startIdx, recentStart));
if (evicted.isEmpty()) {
return null;
}
String summaryText = summarize(evicted);
if (summaryText == null || summaryText.isBlank()) {
return null;
}
List<ChatMessage> result = new ArrayList<>();
if (leadingSystem) {
result.add(working.get(0));
}
result.add(SystemMessage.from("以下是早期对话的摘要(原文已省略以节省上下文):\n" + summaryText));
result.addAll(working.subList(recentStart, total));
return result;
}
private String summarize(List<ChatMessage> evicted) {
String transcript = evicted.stream()
.map(SummarizingConversationContextPolicy::render)
.collect(Collectors.joining("\n"));
String prompt = "请用中文简要总结以下对话片段:关键信息、已达成的结论、仍待处理的事项。"
+ "控制在 200 字以内,直接给结论,不要复述这段提示:\n\n" + transcript;
return summarizerModel.chat(prompt);
}
private static String render(ChatMessage m) {
if (m instanceof UserMessage um) {
return "用户: " + clip(um.singleText());
}
if (m instanceof AiMessage am) {
return am.hasToolExecutionRequests()
? "助手: [调用工具 " + am.toolExecutionRequests().stream()
.map(r -> r.name()).collect(Collectors.joining(", ")) + "]"
: "助手: " + clip(am.text());
}
if (m instanceof ToolExecutionResultMessage trm) {
return "工具结果(" + trm.toolName() + "): " + clip(trm.text());
}
if (m instanceof SystemMessage sm) {
return "系统: " + clip(sm.text());
}
return String.valueOf(m);
}
private static String clip(String s) {
if (s == null) {
return "";
}
return s.length() > 500 ? s.substring(0, 500) + "..." : s;
}
}
三、统筹工具与任务委派
src/main/java/cn/deepassistant/graph/tools/OrchestratorTools.java
作用: write_todos / 文件(带权限)/ task / task_batch / 记忆 / 技能 / 时间计算
java
package cn.deepassistant.graph.tools;
/**
* 统筹 Agent 的全部内置工具。
*
* <h2>@Tool 是怎么被调用的?</h2>
* <ol>
* <li>{@code CreateDeepAgent} 把本 Bean 传给 {@code AgentExecutor.toolsFromObject(this)}</li>
* <li>AgentExecutor 扫描所有带 {@code @Tool} 的方法,生成工具 schema 给大模型</li>
* <li>模型在 ReAct 的 action 节点决定调用哪个工具 → 框架反射调用本类方法</li>
* <li>方法返回值作为 ToolMessage 再喂回模型,继续推理</li>
* </ol>
*
* <p><b>不经过</b> LangChain4j {@code AiServices}。工具描述写在 {@code @Tool("...")} 里,
* 参数说明写在 {@code @P("...")} 里------这两段文字会被发给模型,务必写清楚。
*
* <h2>关于 {@code InvocationParameters ctx} 参数</h2>
* 每个工具方法末尾都有一个 {@code InvocationParameters ctx} 参数:这不是模型填的参数
* (LangChain4j 生成工具 schema 时会自动跳过 {@code InvocationParameters} 类型),
* 而是框架看到图状态里有 {@code sessionId} 字段后自动注入的调用态上下文,
* 用来在"不知道当前在哪个线程执行"的前提下,仍然能准确拿到当前会话 id 与监听器
* (见 {@link SessionContext}、{@link SessionListenerRegistry})。
*
* <h2>能力(对齐 Deep Agents Harness)</h2>
* <ul>
* <li>{@link #write_todos} ------ 任务规划(对齐 TodoListMiddleware)</li>
* <li>{@link #list_dir}/{@link #read_file}/{@link #write_file}/{@link #edit_file} ------ 沙箱文件(带声明式权限)</li>
* <li>{@link #task} ------ 委派单个子 Agent</li>
* <li>{@link #task_batch} ------ 并发委派多个互相独立的子 Agent(对齐 Python 并行 task 调用)</li>
* <li>{@link #getCurrentDateTime}/{@link #calculate} ------ 轻量直接处理,不必委派</li>
* </ul>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OrchestratorTools {
/** 按 sessionId 存任务清单 */
private final TodoStore todoStore;
/** 真正执行子 Agent 图的地方 */
private final TaskDelegationService taskDelegationService;
/** 会话级监听器查找表,取代原 ThreadLocal(见类注释) */
private final SessionListenerRegistry sessionListenerRegistry;
/** 路径安全的沙箱文件操作(Spring 单例 Bean,见 DeepAgentHarnessConfig#workspaceFileOperations) */
private final WorkspaceFileOperations files;
/** 声明式文件权限规则求值器 */
private final FilesystemPermissionEvaluator permissionEvaluator;
/** 文件权限规则表(见 DeepAgentHarnessConfig#filesystemPermissions) */
private final List<FilesystemPermission> filesystemPermissions;
/** task_batch 用的专用线程池,避免和 WebFlux boundedElastic 互相饥饿(唯一的 ExecutorService bean,见 DeepAgentHarnessConfig#taskBatchExecutor) */
private final ExecutorService taskBatchExecutor;
private final ObjectMapper objectMapper;
/** 长期记忆(data/memories/*.md),对齐 Python MemoryMiddleware */
private final MemoryStore memoryStore;
/** 技能渐进式加载(每个技能目录下的 SKILL.md) */
private final SkillStore skillStore;
// ══════════════════════════════════════════════════════════════
// 1) 规划:对齐 Python TodoListMiddleware.write_todos
// ══════════════════════════════════════════════════════════════
/**
* 完整替换当前会话的 todo 列表(对齐 Python {@code Command(update={"todos": todos})})。
*
* <p>工具描述来自 {@link TodoListPrompts#TOOL_DESCRIPTION}(对应 WRITE_TODOS_TOOL_DESCRIPTION)。
* 系统侧提示由 Harness 注入 {@link TodoListPrompts#SYSTEM_PROMPT}。
*
* <p>并行防护:同 sessionId 并发调用(无论在哪个线程)会返回与 Python {@code after_model} 相同的错误文案。
*/
@Tool(TodoListPrompts.TOOL_DESCRIPTION)
public String write_todos(
@P("完整 todos JSON 数组,每项含 content 与 status(pending|in_progress|completed)。本工具整表替换,不是增量修改。")
String todosJson,
InvocationParameters ctx) {
String sid = SessionContext.sessionId(ctx);
log.info("[Orchestrator] start write_todos session={} todosJson={}", sid, todosJson);
emitToolStart(ctx, "write_todos", Map.of("todosJson", nz(todosJson)));
// 对齐 TodoListMiddleware.after_model:禁止同会话并行多次 write_todos
if (!todoStore.tryEnterWrite(sid)) {
String err = TodoStore.parallelCallError();
log.info("[Orchestrator] end write_todos session={} result={}", sid, err);
emitToolEnd(ctx, "write_todos", err);
return err;
}
try {
List<TodoItem> todos = todoStore.replaceFromJson(sid, todosJson);
// 软提示:未全部完成却没有 in_progress 时提醒模型(不失败,与官方「应至少有一个 in_progress」一致)
String softHint = softGuidance(todos);
String pretty = todoStore.toJson(sid);
// Python ToolMessage: "Updated todo list to {todos}"
String toolMsg = todoStore.formatUpdateMessage(todos);
if (!softHint.isEmpty()) {
toolMsg = toolMsg + "\n\n" + softHint;
}
// 额外附上可读 JSON,方便模型与前端
toolMsg = toolMsg + "\n" + pretty;
DeepAgentFlowListener l = listenerFor(ctx);
if (l != null) {
l.onPlanUpdated(sid, pretty);
}
log.info("[Orchestrator] end write_todos session={} result={}", sid, truncate(toolMsg, 300));
emitToolEnd(ctx, "write_todos", truncate(toolMsg));
return toolMsg;
} catch (Exception e) {
String msg = "write_todos 失败: " + e.getMessage()
+ "。请传入合法 JSON 数组,如 "
+ "[{\"content\":\"步骤1\",\"status\":\"in_progress\"},{\"content\":\"步骤2\",\"status\":\"pending\"}]";
log.error("[Orchestrator] end write_todos session={} result={}", sid, msg);
emitToolEnd(ctx, "write_todos", msg);
return msg;
} finally {
todoStore.exitWrite(sid);
}
}
/**
* 官方提示要求:除非全部 completed,否则应至少有一个 in_progress。
* 这里只返回提醒字符串,不阻断写入(模型可能在修正清单的中途短暂违反)。
*/
private static String softGuidance(List<TodoItem> todos) {
if (todos == null || todos.isEmpty()) {
return "";
}
boolean allDone = todos.stream().allMatch(t -> "completed".equals(t.getStatus()));
if (allDone) {
return "提醒:所有 todo 已 completed。请在下一条正文消息中给出用户要的最终答案(write_todos 本身不是答案)。";
}
boolean anyInProgress = todos.stream().anyMatch(t -> "in_progress".equals(t.getStatus()));
if (!anyInProgress) {
String pending = todos.stream()
.filter(t -> "pending".equals(t.getStatus()))
.map(TodoItem::getContent)
.collect(Collectors.joining(";"));
return "提醒:清单尚未全部完成,但没有 in_progress 项。"
+ " 请把下一项标为 in_progress 再继续。待办:" + pending;
}
return "";
}
// ══════════════════════════════════════════════════════════════
// 2) 沙箱文件:把大段中间结果落到磁盘,减轻上下文压力(context offload)
// 每个操作先过一遍 FilesystemPermissionEvaluator(对齐 Python permissions)
// ══════════════════════════════════════════════════════════════
@Tool("列出 workspace 沙箱目录。参数名必须是 path(相对路径,默认可用 \".\")。")
public String list_dir(@P("相对路径,参数名 path,例如 . 或 reports") String path, InvocationParameters ctx) {
log.info("[Orchestrator] start list_dir path={}", path);
emitToolStart(ctx, "list_dir", Map.of("path", nz(path)));
String denied = checkPermission(path, "list");
if (denied != null) {
emitToolEnd(ctx, "list_dir", denied);
return denied;
}
try {
String r = files.listDir(path);
log.info("[Orchestrator] end list_dir result={}", truncate(r, 300));
emitToolEnd(ctx, "list_dir", truncate(r));
return r;
} catch (Exception e) {
String msg = "list_dir 失败: " + e.getMessage();
log.error("[Orchestrator] end list_dir result={}", msg);
emitToolEnd(ctx, "list_dir", msg);
return msg;
}
}
@Tool("读取 workspace 沙箱文件。参数名必须是 path。")
public String read_file(@P("相对文件路径,参数名 path,例如 reports/a.md") String path, InvocationParameters ctx) {
log.info("[Orchestrator] start read_file path={}", path);
emitToolStart(ctx, "read_file", Map.of("path", nz(path)));
String denied = checkPermission(path, "read");
if (denied != null) {
emitToolEnd(ctx, "read_file", denied);
return denied;
}
try {
String r = files.readFile(path);
log.info("[Orchestrator] end read_file result={}", truncate(r, 300));
emitToolEnd(ctx, "read_file", truncate(r));
return r;
} catch (Exception e) {
String msg = "read_file 失败: " + e.getMessage();
log.error("[Orchestrator] end read_file result={}", msg);
emitToolEnd(ctx, "read_file", msg);
return msg;
}
}
@Tool("""
写入(覆盖)workspace 沙箱文件。
参数名必须严格为:path(相对文件路径,如 reports/deepagents_vs_langgraph.md)、content(文件全文)。
禁止使用 relativePath/file/filename 等别名;path 与 content 都不可为空。
父目录不存在时会自动创建。此工具会触发人工审批,请确保内容已经准备好。
""")
public String write_file(
@P("相对文件路径,JSON 字段名必须是 path") String path,
@P("要写入的完整文本内容,JSON 字段名必须是 content") String content,
InvocationParameters ctx) {
// 容错:部分模型会把路径误塞进 content、把正文塞进 path(极短 path + 很长 content 时常见)
String fixedPath = path;
String fixedContent = content;
if ((fixedPath == null || fixedPath.isBlank()) && fixedContent != null && looksLikePath(fixedContent)) {
fixedPath = fixedContent.trim();
fixedContent = "";
}
log.info("[Orchestrator] start write_file path={} contentChars={}",
fixedPath, fixedContent == null ? 0 : fixedContent.length());
emitToolStart(ctx, "write_file", Map.of(
"path", nz(fixedPath),
"contentChars", String.valueOf(fixedContent == null ? 0 : fixedContent.length())));
String denied = checkPermission(fixedPath, "write");
if (denied != null) {
emitToolEnd(ctx, "write_file", denied);
return denied;
}
try {
String r = files.writeFile(fixedPath, fixedContent);
log.info("[Orchestrator] end write_file result={}", r);
emitToolEnd(ctx, "write_file", r);
return r;
} catch (Exception e) {
String msg = "write_file 失败: " + e.getMessage();
log.error("[Orchestrator] end write_file result={}", msg);
emitToolEnd(ctx, "write_file", msg);
return msg;
}
}
@Tool("""
在已存在的 workspace 文件中替换一段文本。
参数名必须严格为:path、oldText、newText,可选 replaceAll(true 时替换全部出现,默认仅替换第一次出现)。
文件不存在时请先 write_file。此工具会触发人工审批。
""")
public String edit_file(
@P("相对文件路径,JSON 字段名必须是 path") String path,
@P("要被替换的旧文本,JSON 字段名必须是 oldText") String oldText,
@P("替换后的新文本,JSON 字段名必须是 newText") String newText,
@P(value = "true 表示替换文件内全部出现;省略或 false 表示只替换第一次出现", required = false) Boolean replaceAll,
InvocationParameters ctx) {
log.info("[Orchestrator] start edit_file path={} replaceAll={}", path, replaceAll);
emitToolStart(ctx, "edit_file", Map.of("path", nz(path), "replaceAll", String.valueOf(Boolean.TRUE.equals(replaceAll))));
String denied = checkPermission(path, "edit");
if (denied != null) {
emitToolEnd(ctx, "edit_file", denied);
return denied;
}
try {
String r = files.editFile(path, oldText, newText, Boolean.TRUE.equals(replaceAll));
log.info("[Orchestrator] end edit_file result={}", r);
emitToolEnd(ctx, "edit_file", r);
return r;
} catch (Exception e) {
String msg = "edit_file 失败: " + e.getMessage();
log.error("[Orchestrator] end edit_file result={}", msg);
emitToolEnd(ctx, "edit_file", msg);
return msg;
}
}
/**
* 权限求值:deny 直接返回错误文案;interrupt 在当前实现里等价于「必须已经是审批工具」
* (write_file/edit_file 已在 {@code CreateDeepAgent} 里整体挂了 {@code approvalOn}),
* 因此这里遇到 interrupt 规则时只做一次防御性兜底(正常不会走到,因为真正的 UI 中断
* 发生在图执行层,而不是工具方法内部)。
*
* @return 非 null 时表示应拒绝执行,直接把该字符串作为工具结果返回
*/
private String checkPermission(String relativePath, String operation) {
var decision = permissionEvaluator.evaluate(relativePath, operation, filesystemPermissions);
return switch (decision) {
case ALLOW -> null;
case DENY -> operation + " 被权限规则拒绝: " + relativePath;
case INTERRUPT -> null; // 交给图层的 approvalOn 处理,工具方法内部不重复拦截
};
}
/** 粗判是否像相对文件路径(用于纠错误绑参) */
private static boolean looksLikePath(String s) {
String t = s.trim();
if (t.length() > 200 || t.contains("\n")) {
return false;
}
return t.contains("/") || t.endsWith(".md") || t.endsWith(".txt") || t.endsWith(".json");
}
// ══════════════════════════════════════════════════════════════
// 3) task / task_batch:Deep Agents 的灵魂 ------ 用隔离上下文的子 Agent 做专科事
// ══════════════════════════════════════════════════════════════
/**
* 委派子任务。
*
* <p><b>调用链:</b>
* {@code 统筹模型决定调 task}
* → 本方法
* → {@link TaskDelegationService#delegate}
* → {@code researchAgentGraph.invoke(...)} 或 {@code generalAgentGraph.invoke(...)}
* → 子 Agent 跑完自己的 ReAct,返回最终文本
* → 文本作为本工具的返回值,再回到统筹模型继续汇总
*
* <p><b>为什么要隔离?</b>
* 子 Agent 的搜索结果可能很长;若全塞进统筹历史,很快爆 token。
* 隔离后统筹只看到「子任务结论摘要」,而不是全部中间工具输出
* (结果超长时 {@link TaskDelegationService} 会自动落盘,见 {@code agent.task-result-inline-limit})。
*/
@Tool("""
将子任务委派给专科子 Agent(对齐 Deep Agents 的 task 工具;进程内 invoke 隔离上下文的子图)。
必须通过函数调用使用,不要在正文假装调用。
description 要写清目标、约束与期望输出格式;subagent_type 只能是 research-agent 或 general-purpose。
若有多个互相独立、可以同时进行的子任务,请用 task_batch 一次性并发委派,而不要多次调用 task。
""")
public String task(
@P("子任务描述(目标/约束/期望输出)") String description,
@P("子 Agent 名称,如 research-agent") String subagent_type,
InvocationParameters ctx) {
String sid = SessionContext.sessionId(ctx);
log.info("[Orchestrator] start task session={} type={} description={}",
sid, subagent_type, description);
emitToolStart(ctx, "task", Map.of(
"subagent_type", nz(subagent_type),
"description", nz(description)));
DeepAgentFlowListener l = listenerFor(ctx);
if (l != null) {
// 前端「子Agent」区块:开始
l.onSubAgentStart(sid, nz(subagent_type), nz(description));
}
try {
// 同步阻塞直到子 Agent 跑完;统筹在这期间不会并行想下一步
String result = taskDelegationService.delegate(nz(subagent_type), nz(description), sid);
if (l != null) {
l.onSubAgentEnd(sid, nz(subagent_type), truncate(result));
}
log.info("[Orchestrator] end task session={} type={} result={}",
sid, subagent_type, truncate(result, 300));
emitToolEnd(ctx, "task", truncate(result));
return result; // 完整结果(或落盘摘要)返回给统筹模型
} catch (Exception e) {
String msg = "task 委派失败: " + e.getMessage();
if (l != null) {
l.onSubAgentEnd(sid, nz(subagent_type), msg);
}
log.error("[Orchestrator] end task session={} type={} result={}", sid, subagent_type, msg);
emitToolEnd(ctx, "task", msg);
return msg;
}
}
/**
* 并发委派多个互相独立的子任务(对齐"一次模型调用里并行多个 task"的能力)。
*
* <h2>为什么不能靠 LangGraph4j 自动并行?</h2>
* {@code LC4jToolService#execute} 对同一次模型响应里的多个工具调用是<b>顺序</b>执行的
* (框架层的 for 循环,非文档化但可通过阅读源码确认)。要做到真正并行,
* 只能在<b>单个工具方法内部</b>用专用线程池并发发起多次子图 invoke------
* 这正是本方法存在的原因:对模型来说它仍然是"一次工具调用",
* 但内部真的并行跑了多个子 Agent。
*/
@Tool("""
并发委派多个互相独立的子任务给子 Agent(一次调用内部并行执行,等待全部完成后一起返回)。
仅当多个子任务彼此没有依赖关系时使用;有依赖关系的任务请用多次 task 顺序委派。
subtasks 必须是 JSON 数组,每项形如 {"description":"...","subagent_type":"research-agent"}。
""")
public String task_batch(
@P("JSON 数组,每项 {description, subagent_type}") String subtasks,
InvocationParameters ctx) {
String sid = SessionContext.sessionId(ctx);
List<TaskSpec> specs;
try {
specs = parseSubtasks(subtasks);
} catch (Exception e) {
return "task_batch 参数解析失败: " + e.getMessage()
+ "。请传入 JSON 数组,如 [{\"description\":\"...\",\"subagent_type\":\"research-agent\"}]";
}
if (specs.isEmpty()) {
return "task_batch 失败:subtasks 不能为空";
}
String batchId = UUID.randomUUID().toString().substring(0, 8);
log.info("[Orchestrator] start task_batch session={} batchId={} count={}", sid, batchId, specs.size());
emitToolStart(ctx, "task_batch", Map.of(
"count", String.valueOf(specs.size()), "batchId", batchId));
DeepAgentFlowListener l = listenerFor(ctx);
List<CompletableFuture<String>> futures = new ArrayList<>(specs.size());
for (TaskSpec spec : specs) {
if (l != null) {
l.onSubAgentStart(sid, nz(spec.subagentType), nz(spec.description) + " [batch " + batchId + "]");
}
futures.add(CompletableFuture.supplyAsync(
() -> taskDelegationService.delegate(nz(spec.subagentType), nz(spec.description), sid),
taskBatchExecutor));
}
StringBuilder combined = new StringBuilder();
for (int i = 0; i < futures.size(); i++) {
TaskSpec spec = specs.get(i);
String result;
try {
result = futures.get(i).join();
} catch (Exception e) {
result = "子任务执行异常: " + e.getMessage();
}
if (l != null) {
l.onSubAgentEnd(sid, nz(spec.subagentType), truncate(result) + " [batch " + batchId + "]");
}
combined.append("### 子任务 ").append(i + 1)
.append("(").append(nz(spec.subagentType)).append(" · ").append(nz(spec.description)).append(")\n")
.append(result).append("\n\n");
}
String finalResult = combined.toString().trim();
log.info("[Orchestrator] end task_batch session={} batchId={} resultChars={}",
sid, batchId, finalResult.length());
emitToolEnd(ctx, "task_batch", truncate(finalResult));
return finalResult;
}
private List<TaskSpec> parseSubtasks(String json) throws Exception {
if (json == null || json.isBlank()) {
return List.of();
}
List<TaskSpec> specs = objectMapper.readValue(json, new TypeReference<>() {
});
return specs == null ? List.of() : specs;
}
/** task_batch 的单个子任务;字段命名对齐 task(description, subagent_type) 的参数名 */
private static final class TaskSpec {
public String description;
@JsonProperty("subagent_type")
public String subagentType;
}
// ══════════════════════════════════════════════════════════════
// 4) 轻量工具:简单问题直接答,避免「问现在几点还去委派」
// ══════════════════════════════════════════════════════════════
@Tool("获取当前精确日期、时间与星期。")
public String getCurrentDateTime(InvocationParameters ctx) {
log.info("[Orchestrator] start getCurrentDateTime");
emitToolStart(ctx, "getCurrentDateTime", Map.of());
LocalDateTime now = LocalDateTime.now();
String weekday = now.getDayOfWeek().getDisplayName(TextStyle.FULL, Locale.CHINESE);
String r = now.format(DateTimeFormatter.ofPattern("yyyy年MM月dd日 HH:mm:ss")) + "," + weekday;
log.info("[Orchestrator] end getCurrentDateTime result={}", r);
emitToolEnd(ctx, "getCurrentDateTime", r);
return r;
}
@Tool("精确计算数学表达式,支持加减乘除与括号。")
public String calculate(@P("数学表达式,如 (3+5)*2") String expression, InvocationParameters ctx) {
log.info("[Orchestrator] start calculate expression={}", expression);
emitToolStart(ctx, "calculate", Map.of("expression", nz(expression)));
try {
String r = expression + " = " + SafeMathEval.evalToString(expression);
log.info("[Orchestrator] end calculate result={}", r);
emitToolEnd(ctx, "calculate", r);
return r;
} catch (Exception e) {
String msg = "计算失败: " + e.getMessage();
log.error("[Orchestrator] end calculate result={}", msg);
emitToolEnd(ctx, "calculate", msg);
return msg;
}
}
// ══════════════════════════════════════════════════════════════
// 5) Memory / Skills:跨会话长期记忆 + 渐进式技能加载
// ══════════════════════════════════════════════════════════════
@Tool("""
更新(覆盖)一个长期记忆文件,用于记录跨会话都该记住的用户偏好/结论/约定。
fileName 必须是不含路径分隔符的 .md 文件名(如 preferences.md);content 是新的全文。
这不是当前会话的 workspace 文件,而是所有会话共享的长期记忆,请谨慎写入。
""")
public String update_memory(
@P("记忆文件名,如 preferences.md,只能是文件名不能带路径") String fileName,
@P("要写入的完整记忆内容(Markdown)") String content,
InvocationParameters ctx) {
log.info("[Orchestrator] start update_memory file={}", fileName);
emitToolStart(ctx, "update_memory", Map.of("fileName", nz(fileName)));
String r = memoryStore.update(fileName, content);
log.info("[Orchestrator] end update_memory result={}", r);
emitToolEnd(ctx, "update_memory", r);
return r;
}
@Tool("读取某个技能的完整说明(SKILL.md 全文)。仅在系统提示的技能目录里出现过的名字才有效。")
public String read_skill(@P("技能名称,必须与系统提示「可用技能」列表里的名字完全一致") String name, InvocationParameters ctx) {
log.info("[Orchestrator] start read_skill name={}", name);
emitToolStart(ctx, "read_skill", Map.of("name", nz(name)));
String r = skillStore.read(name);
log.info("[Orchestrator] end read_skill resultChars={}", r == null ? 0 : r.length());
emitToolEnd(ctx, "read_skill", truncate(r));
return r;
}
// ── SSE 轨迹:让前端看到「正在调哪个工具」────────
private DeepAgentFlowListener listenerFor(InvocationParameters ctx) {
return sessionListenerRegistry.get(SessionContext.sessionId(ctx));
}
private void emitToolStart(InvocationParameters ctx, String name, Map<String, Object> args) {
DeepAgentFlowListener l = listenerFor(ctx);
if (l != null) {
l.onToolStart(SessionContext.sessionId(ctx), name, args == null ? Map.of() : args);
}
}
private void emitToolEnd(InvocationParameters ctx, String name, String summary) {
DeepAgentFlowListener l = listenerFor(ctx);
if (l != null) {
l.onToolEnd(SessionContext.sessionId(ctx), name, summary);
}
}
/** null → 空串,避免 Map.of 因 null value 抛 NPE */
private static String nz(String s) {
return s == null ? "" : s;
}
/** SSE / 日志用的短摘要,避免把整篇搜索结果刷到前端 */
private static String truncate(String s) {
return truncate(s, 4000);
}
private static String truncate(String s, int max) {
if (s == null) {
return "";
}
return s.length() > max ? s.substring(0, max) + "..." : s;
}
}
src/main/java/cn/deepassistant/graph/TaskDelegationService.java
作用: subagent_type → 子图 invoke;task_batch 并发委派
java
package cn.deepassistant.graph;
/**
* 子 Agent「路由表 + 执行器」。
*
* <h2>它解决什么问题?</h2>
* 统筹模型调用 {@code task(description, subagent_type)} 时,只知道一个名字字符串。
* 本服务负责:名字 → 已编译的子图 → {@code invoke} → 拿回最终文本。
*
* <h2>生命周期</h2>
* <ol>
* <li>启动时:{@link cn.deepassistant.deepagents.CreateDeepAgent} 对每个
* {@code SubAgentSpec} 编译子图,再 {@link #register} 进来</li>
* <li>运行时:{@code OrchestratorTools#task}/{@code #task_batch} 调用 {@link #delegate}</li>
* </ol>
*
* <p>对齐 Python Deep Agents 的 {@code SubAgentMiddleware}:主 Agent 只有一个 {@code task} 工具,
* 真正跑哪个子 Agent 由 {@code subagent_type} 决定。
*/
@Slf4j
@Service
public class TaskDelegationService {
/**
* 名称 → 子图。
* <p>用 ConcurrentHashMap:虽然当前是同步委派,但注册发生在启动、调用发生在请求线程,分开更稳妥。
*/
private final Map<String, CompiledGraph<AgentExecutor.State>> agents = new ConcurrentHashMap<>();
private final WorkspaceFileOperations files;
/** 子 Agent 结果超过这个字符数就自动落盘(对齐 Python 的 context offloading) */
@Value("${agent.task-result-inline-limit:6000}")
private int inlineLimit;
/** 落盘后仍在工具结果里保留的预览字符数 */
@Value("${agent.task-result-preview-chars:1500}")
private int previewChars;
public TaskDelegationService(WorkspaceFileOperations files) {
this.files = files;
}
/**
* 注册一个子 Agent 图。同名会覆盖(后注册的生效)。
*/
public void register(String name, CompiledGraph<AgentExecutor.State> graph) {
if (name == null || name.isBlank() || graph == null) {
throw new IllegalArgumentException("register 需要非空 name 与 graph");
}
agents.put(name, graph);
log.info("[Harness] 注册子 Agent 图: {}", name);
}
/** 供 /api/agents 展示当前有哪些子 Agent。 */
public Set<String> registeredAgents() {
return Collections.unmodifiableSet(agents.keySet());
}
/** 兼容旧调用点:不带 sessionId 时按 "default" 处理(子 Agent 自己的 SSE 轨迹仍能工作,只是归到默认会话)。 */
public String delegate(String subagentType, String description) {
return delegate(subagentType, description, "default");
}
/**
* 同步执行指定子 Agent,返回其最终自然语言结果(超长时自动落盘并返回摘要)。
*
* <p><b>输入如何进子图?</b>
* AgentExecutor 约定状态里有 {@code messages} 字段;这里除了塞一条 UserMessage=description,
* 还把 {@code sessionId} 一起放进初始状态------子 Agent 自己的工具
* ({@code WebResearchTools}/{@code CommonTools})借此通过 {@code InvocationParameters}
* 拿到 sessionId,从而向正确的会话上报 SSE 事件(见 {@link SessionContext})。
*
* <p><b>输出如何取出?</b>
* {@code invoke} 返回 Optional<State>,再 {@code finalResponse()} 取 ReAct 循环结束后的助手回复。
*
* <p>失败时返回错误字符串而不是抛异常,让统筹模型能读到失败原因并决定重试或改派。
*/
public String delegate(String subagentType, String description, String sessionId) {
String type = normalize(subagentType);
CompiledGraph<AgentExecutor.State> graph = agents.get(type);
if (graph == null) {
log.warn("[Task] unknown type={} available={}", subagentType, agents.keySet());
return "未知子 Agent: " + subagentType
+ "。可用: " + agents.keySet()
+ "(research-agent | general-purpose)";
}
String task = description == null ? "" : description.trim();
if (task.isEmpty()) {
return "task 委派失败:description 为空";
}
String sid = sessionId == null || sessionId.isBlank() ? "default" : sessionId;
log.info("[Task] START type={} session={} desc={}", type, sid, truncate(task));
long t0 = System.currentTimeMillis();
try {
String result = graph.invoke(Map.of(
"messages", dev.langchain4j.data.message.UserMessage.from(task),
SessionContext.SESSION_ID_KEY, sid))
.flatMap(AgentExecutor.State::finalResponse)
.orElse("(子 Agent " + type + " 未返回明确结果)");
log.info("[Task] END type={} elapsedMs={} resultChars={} preview={}",
type, System.currentTimeMillis() - t0,
result == null ? 0 : result.length(),
truncate(result == null ? "" : result));
return offloadIfTooLong(result, type);
} catch (Exception e) {
log.error("[Task] FAIL type={} elapsedMs={} error={}",
type, System.currentTimeMillis() - t0, e.getMessage(), e);
return "子 Agent " + type + " 执行失败: " + e.getMessage();
}
}
/**
* 结果超过 {@code inlineLimit} 时落盘到 workspace 的 {@code tasks/} 目录,
* 只在工具结果里留一段预览 + 文件引用(对齐 Python Deep Agents 的 context offloading:
* 大内容进沙箱文件系统,主 Agent 上下文只保留摘要,需要时可用 read_file 深挖)。
*/
private String offloadIfTooLong(String result, String subagentType) {
if (result == null || result.length() <= inlineLimit) {
return result;
}
String relPath = "tasks/" + subagentType + "-" + UUID.randomUUID().toString().substring(0, 8) + ".md";
try {
files.writeFile(relPath, result);
String preview = result.substring(0, Math.min(previewChars, result.length()));
return preview + "\n\n...(结果过长已截断,共 " + result.length() + " 字。完整内容已落盘到 workspace 文件 `"
+ relPath + "`,可用 read_file 查看全文)";
} catch (Exception e) {
log.warn("[Task] 结果落盘失败,改为直接截断返回: {}", e.getMessage());
return result.substring(0, Math.min(inlineLimit, result.length()))
+ "\n\n...(结果过长且落盘失败,已截断)";
}
}
/**
* 把模型可能写出的简写/别名归一成注册名。
* <p>用「包含关系」模糊匹配而不是精确枚举:模型措辞多变时(如 "researcher"、
* "general_agent")也能路由成功,减少因为用词差异导致的委派失败。
*/
private static String normalize(String name) {
if (name == null) {
return "";
}
String n = name.trim().toLowerCase().replace(' ', '_').replace('-', '_');
if (n.contains("research")) {
return "research-agent";
}
if (n.contains("general") || n.equals("gp")) {
return "general-purpose";
}
return name.trim().toLowerCase();
}
private static String truncate(String s) {
return s.length() > 120 ? s.substring(0, 120) + "..." : s;
}
}
src/main/java/cn/deepassistant/graph/SessionContext.java
作用: 把 sessionId 放进图状态,供 @Tool 经 InvocationParameters 取回
java
package cn.deepassistant.graph;
/**
* 从 LangChain4j {@link InvocationParameters} 里取回当前 {@code sessionId} 的小工具类。
*
* <h2>取代了什么?</h2>
* 早期版本用 {@code ThreadLocal<String>} 在跑图前 {@code bind}、工具方法里读回。
* 这依赖"工具执行一定在原始请求线程上"这个未被 LangGraph4j 公开契约保证的假设。
*
* <h2>现在怎么工作?</h2>
* <ol>
* <li>{@code AssistantChatService} 把 {@code sessionId} 作为一个普通字段放进图的初始状态
* ({@code Map.of("messages", ..., SessionContext.SESSION_ID_KEY, sessionId)})</li>
* <li>LangGraph4j 的 {@code AgentExecutor#executeTool} 会把整张图状态 {@code state.data()}
* 包成 {@code InvocationParameters} 传给工具执行器</li>
* <li>LangChain4j 的 {@code DefaultToolExecutor} 发现 {@code @Tool} 方法有一个
* {@code InvocationParameters} 类型的参数,就自动注入------这个参数<b>不会</b>出现在
* 给模型的工具 schema 里({@code ToolSpecifications} 生成时显式跳过了这个类型)</li>
* <li>工具方法内用本类的 {@link #sessionId(InvocationParameters)} 取回 sessionId,
* 无论该方法实际在哪个线程被调用,结果都是准确的</li>
* </ol>
*/
public final class SessionContext {
/** 图状态 / InvocationParameters 里存放 sessionId 的 key。 */
public static final String SESSION_ID_KEY = "sessionId";
private SessionContext() {
}
/**
* @param ctx 工具方法里自动注入的调用态参数;可能为 null(比如单测直接调工具方法时)
* @return 当前会话 id;取不到时返回 {@code "default"}
*/
public static String sessionId(InvocationParameters ctx) {
String id = ctx == null ? null : ctx.get(SESSION_ID_KEY);
return id == null || id.isBlank() ? "default" : id;
}
}
src/main/java/cn/deepassistant/graph/SessionListenerRegistry.java
作用: 按 sessionId 注册 FlowListener,避免 ThreadLocal 跨线程丢失
java
package cn.deepassistant.graph;
/**
* 会话级 {@link DeepAgentFlowListener} 注册表 ------ 取代原来的 ThreadLocal 传递方式。
*
* <h2>为什么不用 ThreadLocal?</h2>
* 统筹图用 {@code StreamingChatModel},其回调可能发生在与发起请求不同的线程上
* (具体取决于底层 HTTP 客户端 / LangGraph4j 版本的实现细节,这是未文档化的行为)。
* ThreadLocal 一旦发生线程切换就会读到默认值,导致 SSE 事件静默丢失、
* 甚至多会话状态串号。
*
* <p>本注册表按 {@code sessionId}(而不是线程)索引 listener,
* {@code sessionId} 通过图状态({@link SessionContext#SESSION_ID_KEY})显式传递,
* 由 LangChain4j 的 {@code InvocationParameters} 机制自动注入到 {@code @Tool} 方法里,
* 因此无论工具实际在哪个线程执行都能查到正确的 listener。
*
* <p>生命周期:{@code AssistantChatService} 在一次对话开始时 {@link #register},
* 结束时(无论成功/失败/取消)在 {@code finally} 里 {@link #unregister}。
*/
@Component
public class SessionListenerRegistry {
private final Map<String, DeepAgentFlowListener> listeners = new ConcurrentHashMap<>();
public void register(String sessionId, DeepAgentFlowListener listener) {
if (sessionId != null && !sessionId.isBlank() && listener != null) {
listeners.put(sessionId, listener);
}
}
public void unregister(String sessionId) {
if (sessionId != null) {
listeners.remove(sessionId);
}
}
/** @return 可能为 null(未注册 / sessionId 为空时) */
public DeepAgentFlowListener get(String sessionId) {
return sessionId == null ? null : listeners.get(sessionId);
}
}