用 Java 对齐 LangChain Deep Agents Harness (一)

用 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 可一键导入),或对照仓库补齐。

  1. 准备 JDK 17Maven 3.8+
  2. 建目录 deepagents-assistant-java/,把每个 ### \path`` 下的代码块保存为该相对路径(包名目录不要漏)。
  3. 只改配置里的密钥与模型接入点(本文已脱敏,不能直接拿占位符去调模型 ):
    • llm.base-url / llm.model:改成你自己的 OpenAI 兼容服务
    • llm.api-key:改成你的 Key,或 export LLM_API_KEY=...
    • 若启用智谱 MCP 搜索:再配 mcp.zhipu.api-key(或同样走 LLM_API_KEY
  4. mvn spring-boot:run,打开 http://localhost:8089

data/workspacedata/sessionsdata/checkpointsdata/memoriesdata/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&lt;State&gt;,再 {@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);
    }
}

相关推荐
重生之小比特3 分钟前
【Java SE】数据类型与变量
java·开发语言·python
苏渡苇4 分钟前
Spring Insight 里如何对 Span 进行清洗
java·spring boot·后端·spring·系统监控
find1star4 分钟前
LeetCode 54:螺旋矩阵——用四个边界模拟矩阵收缩
java·算法·leetcode·边缘计算·学习方法
Yeniden6 分钟前
Java 后端从零到企业级:第1篇 Java 基础语法——变量、数据类型与运算符
java·开发语言·apache
邪修king7 分钟前
Linux系统篇(二十三) 基础 IO:从“文件”到“文件描述符”,彻底理解重定向
android·java·linux
云雀衔光17 分钟前
MCP + 应用生成:让 AI 直接产出可交互的应用
java·人工智能·测试工具·microsoft·交互·ai编程
Nuanyt32 分钟前
JVM常见核心知识梳理02 类加载 字节码技术 双亲委派 Java内存模型 JMM 并发底层 volatile synchronized 常见排障与调优工具
java·开发语言·jvm
Shan120539 分钟前
经典算法题示例与详解:飞地的数量(二)
java·数据结构·算法
一嘴一个橘子41 分钟前
java - redis 缓存雪崩
java
小溪学编程1 小时前
Java ListIterator 接口详解:双向遍历与列表修改的利器
java·开发语言·windows