用 Java 对齐 LangChain Deep Agents Harness(二)

四、Todo、文件系统、记忆与技能

src/main/java/cn/deepassistant/deepagents/todos/TodoListPrompts.java

作用: 对齐 Python TodoListMiddleware 的提示词

java 复制代码
package cn.deepassistant.deepagents.todos;

/**
 * 对齐 LangChain {@code TodoListMiddleware} 的提示词常量。
 *
 * <p>来源:Python {@code WRITE_TODOS_TOOL_DESCRIPTION} / {@code WRITE_TODOS_SYSTEM_PROMPT}。
 * 中文版保留相同行为约束,便于国内模型遵循。
 *
 * <ul>
 *   <li>{@link #TOOL_DESCRIPTION} ------ 写入 {@code @Tool},进入工具 schema</li>
 *   <li>{@link #SYSTEM_PROMPT} ------ 追加进统筹 system prompt(对应 middleware.wrap_model_call)</li>
 * </ul>
 */
public final class TodoListPrompts {

    private TodoListPrompts() {
    }

    /**
     * 对应 Python {@code WRITE_TODOS_TOOL_DESCRIPTION}。
     * <p>告诉模型「何时用 / 何时不用 / 状态怎么管 / 结束后还必须用正文回答用户」。
     */
    public static final String TOOL_DESCRIPTION = """
            使用本工具为当前工作会话创建并管理结构化任务清单,便于跟踪进度、组织复杂任务。

            仅在你认为有助于保持条理时使用。若用户请求很简单、不到 3 步,最好不要用本工具,直接完成任务。

            ## 何时使用

            1. 复杂多步任务 ------ 需要 3 个及以上不同步骤/动作
            2. 非平凡复杂任务 ------ 需要仔细规划或多项操作
            3. 用户明确要求 todo 列表
            4. 用户一次给出多项待办(编号或逗号分隔)
            5. 计划可能要根据前几步结果再修订

            ## 如何使用

            1. 开始做某项任务前,先把它标为 in_progress
            2. 完成后立即标为 completed,并补上过程中发现的后续任务
            3. 可更新未来任务:不再需要则删除,需要则新增;不要改动已经 completed 的项(除非纠正错误)
            4. 可一次更新多条。例如完成一项时,同时把下一项标为 in_progress
            5. 本工具是「完整替换」整份清单,不是增量 patch。每次传入当前完整 todos 数组

            ## 何时不要使用

            1. 只有一个直截了当的任务
            2. 任务太琐碎,跟踪没有收益
            3. 不到 3 个琐碎步骤就能完成
            4. 纯闲聊或纯信息问答

            ## 任务状态

            - pending:尚未开始
            - in_progress:正在进行(彼此无关且可并行的任务可以同时有多个 in_progress)
            - completed:已成功完成

            ## 管理要点

            - 边做边更新状态;完成一项就立刻标记,不要攒着批量完成
            - 先完成当前任务再开新任务(并行无关任务除外)
            - 不再相关的任务应从列表中移除
            - 重要:写下清单时,应立刻把第一项(或多项)标为 in_progress
            - 重要:除非全部完成,否则应始终至少有一个 in_progress
            - 只有真正做完才能标 completed;遇阻/部分完成/缺资源时保持 in_progress,并新增描述阻塞的任务
            - 任务要具体、可执行;复杂目标拆成小步骤

            ## 参数格式

            传入 JSON 数组字符串,每项含 content 与 status:
            [{"content":"联网调研 Deep Agents","status":"in_progress"},{"content":"汇总结论","status":"pending"}]

            ## 完成时的关键提醒

            write_todos 只跟踪工作,不交付答案。用户要的计算、摘要、对比、数据等,必须在最后一次 write_todos 之后,用正文消息写出来。把最后一项标 completed 本身并不是对用户的回答。

            不要在同一次模型回复里并行多次调用 write_todos(本工具每次整表替换,并行调用语义冲突)。
            """;

    /**
     * 对应 Python {@code WRITE_TODOS_SYSTEM_PROMPT}。
     * <p>由 Harness 追加进统筹系统提示,持续提醒模型正确使用 write_todos。
     */
    public static final String SYSTEM_PROMPT = """
            ## `write_todos`

            你可以使用 `write_todos` 工具管理、规划复杂目标,把大目标拆成小步骤。
            每完成一步就应立刻把对应 todo 标为 completed,不要攒多步再一起标记。
            对只需几步的简单目标,最好直接做完,不要用本工具(写 todo 也耗时间和 token)。

            ### 重要使用注意

            - 不要在同一次模型调用中并行多次调用 `write_todos`
            - 可以边做边修订清单:新信息可能带来新任务,或使旧任务失效
            - 本工具完整替换整份清单;每次传入完整的 todos JSON 数组

            ### 结束任务时

            全部做完后,在最后一次 `write_todos` 之后的消息里给出最终答案------不要只在同一轮工具调用里结束。
            最终消息应以用户要的实质内容开头(数据、计算、摘要、分析),用户要的是结果,不是「工作已完成」的确认。
            """;
}

src/main/java/cn/deepassistant/deepagents/todos/TodoItem.java

作用: 单条待办结构

java 复制代码
package cn.deepassistant.deepagents.todos;
/**
 * 单条 Todo ------ 对齐 Python {@code Todo} TypedDict。
 *
 * <pre>
 * {"content":"联网调研 Deep Agents","status":"in_progress"}
 * </pre>
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class TodoItem {

    /** 任务描述(给人看的一句话,必须非空) */
    private String content;

    /**
     * 状态字符串(wire 值)。对外 JSON 仍用 string,与 Python / 前端一致。
     * 内部校验走 {@link TodoStatus#from(String)}。
     */
    private String status;

    /** 规范化并校验;失败抛 IllegalArgumentException */
    public void normalizeAndValidate(int index) {
        if (content == null || content.isBlank()) {
            throw new IllegalArgumentException("todos[" + index + "].content 不能为空");
        }
        content = content.trim();
        TodoStatus st = TodoStatus.from(status);
        status = st.wire();
    }
}

src/main/java/cn/deepassistant/deepagents/todos/TodoStatus.java

作用: pending / in_progress / completed

java 复制代码
package cn.deepassistant.deepagents.todos;
/**
 * Todo 状态 ------ 对齐 Python {@code Literal["pending", "in_progress", "completed"]}。
 */
public enum TodoStatus {
    PENDING("pending"),
    IN_PROGRESS("in_progress"),
    COMPLETED("completed");

    private final String wire;

    TodoStatus(String wire) {
        this.wire = wire;
    }

    @JsonValue
    public String wire() {
        return wire;
    }

    /**
     * 解析模型可能写出的变体(Pending / IN_PROGRESS / done 等)。
     *
     * @throws IllegalArgumentException 无法识别时
     */
    @JsonCreator
    public static TodoStatus from(String raw) {
        if (raw == null || raw.isBlank()) {
            return PENDING;
        }
        String s = raw.trim().toLowerCase().replace('-', '_');
        return switch (s) {
            case "pending", "todo", "not_started" -> PENDING;
            case "in_progress", "inprogress", "doing", "working" -> IN_PROGRESS;
            case "completed", "complete", "done", "finished" -> COMPLETED;
            default -> throw new IllegalArgumentException(
                    "非法 status: " + raw + "(仅允许 pending | in_progress | completed)");
        };
    }
}

src/main/java/cn/deepassistant/deepagents/todos/TodoStore.java

作用: 按 session 外置 todos(委托 TodoRepository)

java 复制代码
package cn.deepassistant.deepagents.todos;
/**
 * 会话级任务清单存储 ------ 对齐 Python {@code PlanningState.todos} + {@code write_todos} 的「整表替换」语义。
 *
 * <p>Python 侧通过 {@code Command(update={"todos": todos})} 写入图状态;
 * Java AgentExecutor 无自定义 state 字段时,用本 Store 按 sessionId 外置等价状态。
 *
 * <p>另提供「同会话并行多次 write_todos」防护(对齐 {@code TodoListMiddleware.after_model}):
 *
 * <h2>为什么不是 ThreadLocal?</h2>
 * 早期实现用 {@code ThreadLocal<AtomicInteger>} 检测「重入」,但这只能防住
 * 「同一线程」的重入,防不住「同一 sessionId 在两个不同线程上并发调用」------
 * 而工具执行到底在哪个线程发生,是 LangGraph4j/LangChain4j 未公开保证的实现细节。
 * 改成按 {@code sessionId} 索引的 {@link AtomicBoolean} 后,无论并发调用发生在
 * 几个线程上,只要 sessionId 相同就能被正确检测为「并行写」。
 */
@Component
public class TodoStore {

    /** 真正的存储读写委托给这个可插拔接口(默认单机 ConcurrentHashMap,见 InMemoryTodoRepository) */
    private final TodoRepository repository;
    private final ObjectMapper objectMapper;

    /** sessionId → 是否有一次 write_todos 正在执行中(防止同会话并行写)。 */
    private final Map<String, AtomicBoolean> writing = new ConcurrentHashMap<>();

    public TodoStore(TodoRepository repository, ObjectMapper objectMapper) {
        this.repository = repository;
        this.objectMapper = objectMapper;
    }

    /**
     * 解析 JSON、校验、整表替换。
     *
     * @return 规范化后的列表
     */
    public List<TodoItem> replaceFromJson(String memoryId, String todosJson) throws Exception {
        List<TodoItem> raw = objectMapper.readValue(
                todosJson == null || todosJson.isBlank() ? "[]" : todosJson,
                new TypeReference<>() {
                });
        if (raw == null) {
            raw = List.of();
        }
        List<TodoItem> normalized = new ArrayList<>(raw.size());
        for (int i = 0; i < raw.size(); i++) {
            TodoItem item = raw.get(i);
            if (item == null) {
                throw new IllegalArgumentException("todos[" + i + "] 不能为 null");
            }
            item.normalizeAndValidate(i);
            normalized.add(item);
        }
        // 软约束提醒(不强制失败):未完成时应至少有一个 in_progress(与官方提示一致)
        // 真正强制由模型侧 system prompt 引导;这里只做存储
        replace(memoryId, normalized);
        return normalized;
    }

    public void replace(String memoryId, List<TodoItem> todos) {
        repository.replace(memoryId, todos);
    }

    public List<TodoItem> get(String memoryId) {
        return repository.get(memoryId);
    }

    public String toJson(String memoryId) {
        try {
            return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(get(memoryId));
        } catch (JsonProcessingException e) {
            return "[]";
        }
    }

    /** 对齐 Python ToolMessage:{@code Updated todo list to [...]} */
    public String formatUpdateMessage(List<TodoItem> todos) {
        try {
            return "Updated todo list to " + objectMapper.writeValueAsString(todos);
        } catch (JsonProcessingException e) {
            return "Updated todo list to " + todos;
        }
    }

    public void clear(String memoryId) {
        repository.clear(memoryId);
        writing.remove(memoryId);
    }

    /**
     * 进入 write_todos;若该 sessionId 已有一次调用在执行中(并行)则返回 false。
     * <p>无论最终在哪个线程执行,按 sessionId 判断,正确覆盖跨线程并发场景。
     */
    public boolean tryEnterWrite(String sessionId) {
        return writing.computeIfAbsent(sessionId, k -> new AtomicBoolean()).compareAndSet(false, true);
    }

    public void exitWrite(String sessionId) {
        AtomicBoolean flag = writing.get(sessionId);
        if (flag != null) {
            flag.set(false);
        }
    }

    /** 与 Python after_model 拒绝并行 write_todos 时返回的错误文案一致(中英对照) */
    public static String parallelCallError() {
        return "Error: The `write_todos` tool should never be called multiple times "
                + "in parallel. Please call it only once per model invocation to update "
                + "the todo list.(不要在同一次模型调用中并行多次调用 write_todos)";
    }
}

src/main/java/cn/deepassistant/deepagents/files/WorkspaceFileOperations.java

作用: 沙箱 workspace 读写编辑

java 复制代码
package cn.deepassistant.deepagents.files;
/**
 * 沙箱文件系统(对齐 Deep Agents 的 FilesystemBackend)。
 *
 * <p>所有路径相对 workspace 根目录;{@code null}/空白路径会返回明确错误,不再 NPE。
 */
public class WorkspaceFileOperations {

    private final Path root;

    public WorkspaceFileOperations(Path root) throws IOException {
        this.root = root.toAbsolutePath().normalize();
        Files.createDirectories(this.root);
    }

    public Path root() {
        return root;
    }

    public String listDir(String relativePath) throws IOException {
        Path dir = resolve(blankToDot(relativePath));
        if (!Files.isDirectory(dir)) {
            return "路径不是目录: " + relativePath;
        }
        try (Stream<Path> stream = Files.list(dir)) {
            String listing = stream
                    .sorted(Comparator.comparing(p -> p.getFileName().toString()))
                    .map(p -> (Files.isDirectory(p) ? "[DIR] " : "[FILE] ") + p.getFileName())
                    .collect(Collectors.joining("\n"));
            return listing.isBlank() ? "(空目录)" : listing;
        }
    }

    public String readFile(String relativePath) throws IOException {
        String pathErr = requireFilePath(relativePath, "read_file");
        if (pathErr != null) {
            return pathErr;
        }
        Path file = resolve(relativePath);
        if (!Files.exists(file) || !Files.isRegularFile(file)) {
            return "文件不存在: " + relativePath;
        }
        return Files.readString(file, StandardCharsets.UTF_8);
    }

    public String writeFile(String relativePath, String content) throws IOException {
        String pathErr = requireFilePath(relativePath, "write_file");
        if (pathErr != null) {
            return pathErr;
        }
        Path file = resolve(relativePath);
        if (Files.isDirectory(file)) {
            return "无法写入:目标是目录而非文件: " + relativePath;
        }
        Path parent = file.getParent();
        if (parent != null) {
            Files.createDirectories(parent);
        }
        Files.writeString(file, content == null ? "" : content, StandardCharsets.UTF_8,
                StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE);
        return "已写入: " + relativePath.trim();
    }

    public String editFile(String relativePath, String oldText, String newText) throws IOException {
        return editFile(relativePath, oldText, newText, false);
    }

    /**
     * @param replaceAll {@code true} 时替换文件内全部出现(对齐 Python {@code edit_file} 的
     *                   {@code replace_all} 参数);{@code false} 时只替换第一次出现(旧行为,保持兼容)。
     */
    public String editFile(String relativePath, String oldText, String newText, boolean replaceAll) throws IOException {
        String pathErr = requireFilePath(relativePath, "edit_file");
        if (pathErr != null) {
            return pathErr;
        }
        Path file = resolve(relativePath);
        if (!Files.exists(file) || !Files.isRegularFile(file)) {
            return "文件不存在: " + relativePath + "。请先 write_file 创建该文件。";
        }
        String content = Files.readString(file, StandardCharsets.UTF_8);
        if (oldText == null || oldText.isEmpty() || !content.contains(oldText)) {
            return "未找到要替换的文本: " + relativePath;
        }
        String replacement = newText == null ? "" : newText;
        String updated;
        int occurrences;
        if (replaceAll) {
            occurrences = countOccurrences(content, oldText);
            updated = content.replace(oldText, replacement);
        } else {
            occurrences = 1;
            int idx = content.indexOf(oldText);
            updated = content.substring(0, idx) + replacement + content.substring(idx + oldText.length());
        }
        Files.writeString(file, updated, StandardCharsets.UTF_8);
        return "已编辑: " + relativePath.trim() + "(替换 " + occurrences + " 处)";
    }

    private static int countOccurrences(String content, String needle) {
        int count = 0;
        int idx = 0;
        while ((idx = content.indexOf(needle, idx)) != -1) {
            count++;
            idx += needle.length();
        }
        return count;
    }

    /**
     * @return 错误文案;合法时返回 null
     */
    private static String requireFilePath(String relativePath, String op) {
        if (relativePath == null || relativePath.isBlank()) {
            return op + " 失败:path 不能为空。"
                    + " 请使用参数名 path(相对 workspace 的文件路径,如 reports/a.md),"
                    + "不要用 relativePath / file / filename 等别名。";
        }
        String t = relativePath.trim();
        if (".".equals(t) || "./".equals(t) || "/".equals(t)) {
            return op + " 失败:path 必须是文件路径,不能是目录: " + relativePath;
        }
        return null;
    }

    private static String blankToDot(String relativePath) {
        return relativePath == null || relativePath.isBlank() ? "." : relativePath;
    }

    private Path resolve(String relativePath) {
        if (relativePath == null || relativePath.isBlank()) {
            throw new IllegalArgumentException("path 不能为空");
        }
        String cleaned = relativePath.replace('\\', '/').trim();
        while (cleaned.startsWith("./")) {
            cleaned = cleaned.substring(2);
        }
        if (cleaned.startsWith("/")) {
            cleaned = cleaned.substring(1);
        }
        if (cleaned.isBlank()) {
            throw new IllegalArgumentException("path 无效: " + relativePath);
        }
        Path resolved = root.resolve(cleaned).normalize();
        if (!resolved.startsWith(root)) {
            throw new SecurityException("禁止访问 workspace 之外的路径: " + relativePath);
        }
        return resolved;
    }
}

src/main/java/cn/deepassistant/deepagents/files/FilesystemPermission.java

作用: 声明式文件权限规则(allow/deny/interrupt)

java 复制代码
package cn.deepassistant.deepagents.files;
/**
 * 声明式文件权限规则(对齐 Python Deep Agents 的 {@code FilesystemMiddleware.permissions})。
 *
 * @param pathGlob   相对 workspace 的 glob,如 {@code secrets/**}、{@code *.env}
 * @param operations 本规则覆盖哪些操作:{@code read}/{@code write}/{@code edit}/{@code list}
 * @param mode       {@code allow}|{@code deny}|{@code interrupt}
 *                   <ul>
 *                     <li>{@code allow}:明确放行,即使后面还有更宽的 deny 规则也不再检查</li>
 *                     <li>{@code deny}:直接拒绝,工具返回错误文案,不执行</li>
 *                     <li>{@code interrupt}:需要人工审批------受限于 LangGraph4j
 *                     {@code AgentExecutorEx} 的审批粒度是「按工具名」而不是「按路径」,
 *                     本项目里 {@code write_file}/{@code edit_file} 已经整体挂了审批
 *                     (见 {@link cn.deepassistant.deepagents.CreateDeepAgent}),
 *                     所以这里的 {@code interrupt} 目前退化为「必须命中审批工具」,
 *                     如果对应工具本身没开审批则按 {@code deny} 处理(安全优先)</li>
 *                   </ul>
 */
public record FilesystemPermission(String pathGlob, Set<String> operations, String mode) {

    public FilesystemPermission {
        if (pathGlob == null || pathGlob.isBlank()) {
            throw new IllegalArgumentException("FilesystemPermission.pathGlob 不能为空");
        }
        if (operations == null || operations.isEmpty()) {
            throw new IllegalArgumentException("FilesystemPermission.operations 不能为空");
        }
        if (mode == null || !(mode.equals("allow") || mode.equals("deny") || mode.equals("interrupt"))) {
            throw new IllegalArgumentException("FilesystemPermission.mode 必须是 allow/deny/interrupt");
        }
    }

    public static FilesystemPermission deny(String pathGlob, String... operations) {
        return new FilesystemPermission(pathGlob, Set.of(operations), "deny");
    }

    public static FilesystemPermission interrupt(String pathGlob, String... operations) {
        return new FilesystemPermission(pathGlob, Set.of(operations), "interrupt");
    }

    public static FilesystemPermission allow(String pathGlob, String... operations) {
        return new FilesystemPermission(pathGlob, Set.of(operations), "allow");
    }
}

src/main/java/cn/deepassistant/deepagents/files/FilesystemPermissionEvaluator.java

作用: 按 glob 求值文件操作是否允许

java 复制代码
package cn.deepassistant.deepagents.files;
/**
 * 按声明顺序求值 {@link FilesystemPermission} 规则表(对齐 Python
 * {@code FilesystemMiddleware} 的权限判定:第一条路径+操作都匹配的规则生效,
 * 都不匹配则默认放行)。
 */
@Component
public class FilesystemPermissionEvaluator {

    public enum Decision {ALLOW, DENY, INTERRUPT}

    /** glob → PathMatcher 缓存,避免每次调用都重新编译 */
    private final ConcurrentMap<String, PathMatcher> matcherCache = new ConcurrentHashMap<>();

    /**
     * @param relativePath workspace 相对路径(用 {@code /} 分隔)
     * @param operation    read|write|edit|list
     * @param rules        规则表;可为空(表示无限制,全部放行)
     */
    public Decision evaluate(String relativePath, String operation, List<FilesystemPermission> rules) {
        if (rules == null || rules.isEmpty() || relativePath == null) {
            return Decision.ALLOW;
        }
        String normalized = relativePath.replace('\\', '/');
        Path asPath = Path.of(normalized);
        for (FilesystemPermission rule : rules) {
            if (!appliesTo(rule.operations(), operation)) {
                continue;
            }
            if (matches(rule.pathGlob(), asPath)) {
                return switch (rule.mode()) {
                    case "allow" -> Decision.ALLOW;
                    case "deny" -> Decision.DENY;
                    case "interrupt" -> Decision.INTERRUPT;
                    default -> Decision.ALLOW;
                };
            }
        }
        return Decision.ALLOW;
    }

    private static boolean appliesTo(Set<String> operations, String operation) {
        return operations.contains(operation) || operations.contains("*");
    }

    private boolean matches(String glob, Path path) {
        PathMatcher matcher = matcherCache.computeIfAbsent(glob,
                g -> FileSystems.getDefault().getPathMatcher("glob:" + g));
        return matcher.matches(path);
    }
}

src/main/java/cn/deepassistant/deepagents/memory/MemoryStore.java

作用: 长期记忆(对齐 MemoryMiddleware)

java 复制代码
package cn.deepassistant.deepagents.memory;
/**
 * 长期记忆(对齐 Python Deep Agents 的 {@code MemoryMiddleware})。
 *
 * <p>Python 侧会把 {@code AGENTS.md} 等记忆文件的内容直接拼进系统提示,并允许模型
 * 通过工具更新它们,让"经验"能跨会话持续存在(不同于按 sessionId 隔离的 {@code TodoStore}
 * 和 {@link cn.deepassistant.memory.SessionStore}------那两个是"这一次对话"的状态,
 * 这里是"这个助手长期记住的东西")。
 *
 * <p>本类只做两件事:
 * <ol>
 *   <li>启动 / 每次取用时读取 {@code data/memories/*.md},拼成一段可以直接追加到系统提示的文本</li>
 *   <li>提供 {@link #update} 给 {@code update_memory} 工具用,让模型能写回新的偏好/结论</li>
 * </ol>
 */
@Slf4j
@Component
public class MemoryStore {

    private final Path dir;

    public MemoryStore(@Value("${agent.memories.dir:${user.dir}/data/memories}") String dirPath) throws IOException {
        this.dir = Path.of(dirPath).toAbsolutePath().normalize();
        Files.createDirectories(this.dir);
    }

    /**
     * 拼接所有记忆文件内容,供 {@code DeepAgentHarnessConfig} 追加进系统提示。
     * <p>每次调用都重新读盘(而不是缓存),这样 {@link #update} 写回后下一轮对话就能生效
     * ------当前实现里系统提示只在图编译时拼一次,重启进程后才会重新读取;
     * 如果需要"同一进程内立即生效",可以把这段文本改成运行时注入而不是编译期拼接(后续可扩展)。
     */
    public String loadAllForPrompt() {
        List<Path> files = listMemoryFiles();
        if (files.isEmpty()) {
            return "";
        }
        StringBuilder sb = new StringBuilder("\n\n## 长期记忆(data/memories,跨会话持续生效)\n");
        for (Path f : files) {
            try {
                String content = Files.readString(f, StandardCharsets.UTF_8).trim();
                if (!content.isEmpty()) {
                    sb.append("\n### ").append(f.getFileName()).append('\n').append(content).append('\n');
                }
            } catch (IOException e) {
                log.warn("[Memory] 读取记忆文件失败 {}: {}", f, e.getMessage());
            }
        }
        return sb.toString();
    }

    /** 供 /api 或工具展示当前有哪些记忆文件 */
    public List<String> listNames() {
        return listMemoryFiles().stream().map(p -> p.getFileName().toString()).toList();
    }

    public String read(String fileName) {
        Path file = resolve(fileName);
        if (file == null) {
            return "非法的记忆文件名: " + fileName;
        }
        if (!Files.exists(file)) {
            return "记忆文件不存在: " + fileName;
        }
        try {
            return Files.readString(file, StandardCharsets.UTF_8);
        } catch (IOException e) {
            return "读取失败: " + e.getMessage();
        }
    }

    /**
     * 更新(覆盖)一个记忆文件;文件名必须是 {@code *.md} 且不能包含路径分隔符
     * (记忆文件是扁平结构,不允许模型写到 memories 目录之外)。
     */
    public String update(String fileName, String content) {
        Path file = resolve(fileName);
        if (file == null) {
            return "update_memory 失败:非法文件名 " + fileName + "(只能是不含路径分隔符的 .md 文件名,如 preferences.md)";
        }
        try {
            Files.writeString(file, content == null ? "" : content, StandardCharsets.UTF_8,
                    StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE);
            return "已更新记忆文件: " + fileName;
        } catch (IOException e) {
            return "update_memory 失败: " + e.getMessage();
        }
    }

    private List<Path> listMemoryFiles() {
        try (Stream<Path> stream = Files.list(dir)) {
            return stream.filter(p -> p.toString().endsWith(".md"))
                    .sorted(Comparator.comparing(p -> p.getFileName().toString()))
                    .toList();
        } catch (IOException e) {
            log.warn("[Memory] 列出记忆目录失败: {}", e.getMessage());
            return List.of();
        }
    }

    /** @return 校验通过后的绝对路径;文件名非法(含路径分隔符 / 不是 .md / 逃出目录)时返回 null */
    private Path resolve(String fileName) {
        if (fileName == null || fileName.isBlank()) {
            return null;
        }
        String name = fileName.trim();
        if (name.contains("/") || name.contains("\\") || name.contains("..") || !name.endsWith(".md")) {
            return null;
        }
        Path resolved = dir.resolve(name).normalize();
        return resolved.startsWith(dir) ? resolved : null;
    }
}

src/main/java/cn/deepassistant/deepagents/skills/SkillStore.java

作用: 技能渐进式加载(目录进提示,read_skill 读全文)

java 复制代码
package cn.deepassistant.deepagents.skills;
/**
 * 技能渐进式加载(对齐 Python Deep Agents / Claude Skills 的 progressive disclosure)。
 *
 * <h2>目录规范</h2>
 * <pre>
 * data/skills/
 *   某技能目录/
 *     SKILL.md   ← 开头 --- frontmatter ---,含 name / description
 * </pre>
 *
 * <h2>为什么"渐进式"?</h2>
 * 如果把所有技能全文都拼进系统提示,技能一多就会把上下文撑爆。
 * 正确做法(也是 Claude/Deep Agents Skills 的核心思想):启动时只扫描
 * frontmatter(名字 + 一句话描述),拼进系统提示;模型真正需要某个技能时,
 * 自己调用 {@code read_skill(name)} 才读取该技能的完整正文。
 */
@Slf4j
@Component
public class SkillStore {

    public record SkillSummary(String name, String description, Path skillFile) {
    }

    private final Path dir;
    private final Map<String, SkillSummary> catalog = new ConcurrentHashMap<>();

    public SkillStore(@Value("${agent.skills.dir:${user.dir}/data/skills}") String dirPath) throws IOException {
        this.dir = Path.of(dirPath).toAbsolutePath().normalize();
        Files.createDirectories(this.dir);
    }

    @PostConstruct
    public void scan() {
        catalog.clear();
        try (Stream<Path> stream = Files.list(dir)) {
            List<Path> subdirs = stream.filter(Files::isDirectory)
                    .sorted(Comparator.comparing(p -> p.getFileName().toString()))
                    .toList();
            for (Path subdir : subdirs) {
                Path skillFile = subdir.resolve("SKILL.md");
                if (!Files.exists(skillFile)) {
                    continue;
                }
                try {
                    String content = Files.readString(skillFile, StandardCharsets.UTF_8);
                    Map<String, String> frontmatter = parseFrontmatter(content);
                    String name = frontmatter.getOrDefault("name", subdir.getFileName().toString());
                    String description = frontmatter.getOrDefault("description", "(无描述)");
                    catalog.put(name, new SkillSummary(name, description, skillFile));
                } catch (IOException e) {
                    log.warn("[Skill] 读取技能失败 {}: {}", skillFile, e.getMessage());
                }
            }
        } catch (IOException e) {
            log.warn("[Skill] 扫描技能目录失败: {}", e.getMessage());
        }
        log.info("[Skill] 已加载 {} 个技能: {}", catalog.size(), catalog.keySet());
    }

    /** 供 {@code DeepAgentHarnessConfig} 拼进系统提示:只有名字+一句话描述,不含正文。 */
    public String promptCatalog() {
        if (catalog.isEmpty()) {
            return "";
        }
        StringBuilder sb = new StringBuilder("\n\n## 可用技能(read_skill 按需读取全文,不要一次性假设已知全部细节)\n");
        for (SkillSummary s : catalog.values()) {
            sb.append("- ").append(s.name()).append(":").append(s.description()).append('\n');
        }
        return sb.toString();
    }

    /** {@code read_skill} 工具:按名字读取某技能 SKILL.md 全文。 */
    public String read(String name) {
        SkillSummary summary = catalog.get(name);
        if (summary == null) {
            return "未找到技能: " + name + "。可用技能: " + catalog.keySet();
        }
        try {
            return Files.readString(summary.skillFile(), StandardCharsets.UTF_8);
        } catch (IOException e) {
            return "读取技能失败: " + e.getMessage();
        }
    }

    /**
     * 解析形如:
     * <pre>
     * ---
     * name: xxx
     * description: yyy
     * ---
     * (正文...)
     * </pre>
     * 的简易 YAML frontmatter(只支持单行 key: value,不支持嵌套/多行值,够用即可)。
     */
    private static Map<String, String> parseFrontmatter(String content) {
        Map<String, String> map = new LinkedHashMap<>();
        if (content == null || !content.stripLeading().startsWith("---")) {
            return map;
        }
        String[] lines = content.split("\n", -1);
        int start = -1;
        int end = -1;
        for (int i = 0; i < lines.length; i++) {
            if (lines[i].trim().equals("---")) {
                if (start == -1) {
                    start = i;
                } else {
                    end = i;
                    break;
                }
            }
        }
        if (start == -1 || end == -1) {
            return map;
        }
        for (int i = start + 1; i < end; i++) {
            String line = lines[i];
            int idx = line.indexOf(':');
            if (idx > 0) {
                String key = line.substring(0, idx).trim();
                String value = line.substring(idx + 1).trim();
                map.put(key, value);
            }
        }
        return map;
    }
}

五、子 Agent 工具

src/main/java/cn/deepassistant/agent/tools/WebResearchTools.java

作用: research-agent:webSearch / webRead

java 复制代码
package cn.deepassistant.agent.tools;
/**
 * research-agent 专属工具:联网搜索 / 读网页。
 *
 * <p>本类只负责「暴露给模型的工具形状」;真正发 HTTP/MCP 请求的是 {@link WebSearchService}
 *(运行时可能是 MCP 实现,也可能是 REST 实现,由 {@code mcp.zhipu.enabled} 决定)。
 *
 * <p>{@link ToolTraceContext#trace} 负责把调用过程推到前端 SSE,避免每个工具手写 start/end。
 * {@code InvocationParameters ctx} 由 LangChain4j 自动注入(不进模型 schema),用来取回
 * sessionId------见 {@link cn.deepassistant.graph.SessionContext}。
 */
@Component
public class WebResearchTools {

    /** 具体实现由 Spring 按条件注入:ZhipuMcpWebSearchService 或 RestWebSearchService */
    @Autowired
    private WebSearchService webSearchService;

    @Autowired
    private ToolTraceContext traceContext;

    /**
     * 模型调用的搜索工具。
     * <p>{@code @Tool} 里的中文说明会进入工具 schema,直接影响模型何时选用它。
     */
    @Tool("联网搜索最新信息(默认智谱官网 MCP Web Search;也可回退 REST web_search)。适合新闻、政策、事实核查、多角度调研。")
    public String webSearch(@P("搜索关键词或问题,尽量具体") String query, InvocationParameters ctx) {
        return traceContext.trace("webSearch", Map.of("query", query == null ? "" : query), ctx,
                () -> webSearchService.search(query));
    }

    /**
     * 打开某个 URL 读正文(需要启用 MCP web_reader)。
     * <p>典型用法:先 webSearch 拿到链接,再 webRead 深挖。
     */
    @Tool("读取指定 URL 的网页正文(需启用 mcp.zhipu.enabled=true 并配置 web-reader-url)。")
    public String webRead(@P("网页 URL") String url, InvocationParameters ctx) {
        return traceContext.trace("webRead", Map.of("url", url == null ? "" : url), ctx,
                () -> webSearchService.readUrl(url));
    }
}

src/main/java/cn/deepassistant/agent/tools/CommonTools.java

作用: 时间与计算(也可被 general-purpose 使用)

java 复制代码
package cn.deepassistant.agent.tools;
/**
 * general-purpose 子 Agent 的工具集(时间 + 安全四则运算)。
 * <p>计算逻辑统一走 {@link SafeMathEval},与统筹工具保持一致。
 *
 * <p>{@code InvocationParameters ctx} 参数由 LangChain4j 自动注入,不会出现在模型可见的工具 schema 里;
 * 用它取回 sessionId 供 {@link ToolTraceContext#trace} 定位正确的 SSE 监听器。
 */
@Component
public class CommonTools {

    @Autowired
    private ToolTraceContext traceContext;

    @Tool("获取当前精确日期、时间与星期。")
    public String getCurrentDateTime(InvocationParameters ctx) {
        return traceContext.trace("getCurrentDateTime", Map.of(), ctx, () -> {
            LocalDateTime now = LocalDateTime.now();
            String weekday = now.getDayOfWeek().getDisplayName(TextStyle.FULL, Locale.CHINESE);
            return now.format(DateTimeFormatter.ofPattern("yyyy年MM月dd日 HH:mm:ss")) + "," + weekday;
        });
    }

    @Tool("计算数学表达式,支持加减乘除与括号。")
    public String calculate(@P("数学表达式,如 (3+5)*2") String expression, InvocationParameters ctx) {
        return traceContext.trace("calculate", Map.of("expression", expression == null ? "" : expression), ctx, () -> {
            try {
                return expression + " = " + SafeMathEval.evalToString(expression);
            } catch (Exception e) {
                return "计算失败: " + e.getMessage();
            }
        });
    }
}

六、联网集成(MCP / REST)

src/main/java/cn/deepassistant/integration/mcp/WebSearchService.java

作用: 联网抽象接口

java 复制代码
package cn.deepassistant.integration.mcp;

/**
 * 联网搜索能力的统一接口。
 *
 * <p>上层({@code WebResearchTools})只依赖本接口,不关心底层是 MCP 还是 REST。
 * Spring 通过 {@code @ConditionalOnProperty(mcp.zhipu.enabled)} 在运行时只激活其中一个实现:
 * <ul>
 *   <li>{@code true}  → {@link ZhipuMcpWebSearchService}</li>
 *   <li>{@code false} → {@link RestWebSearchService}</li>
 * </ul>
 */
public interface WebSearchService {

    /**
     * 按关键词/问题搜索,返回给模型阅读的文本(含标题、链接、摘要等)。
     */
    String search(String query);

    /**
     * 读取指定 URL 的网页正文。
     * <p>默认实现返回「未启用」提示;MCP 实现会覆盖为真正抓取。
     */
    default String readUrl(String url) {
        return "当前未启用网页读取能力: " + url;
    }
}

src/main/java/cn/deepassistant/integration/mcp/ZhipuMcpWebSearchService.java

作用: 默认:智谱 MCP Web Search / Reader

java 复制代码
package cn.deepassistant.integration.mcp;
/**
 * 智谱「官网 MCP Web Search」客户端实现。
 *
 * <h2>MCP 是什么?和 REST 有何不同?</h2>
 * <ul>
 *   <li><b>REST</b>:我们自己拼 HTTP JSON 调 {@code /paas/v4/web_search}(见 {@link RestWebSearchService})</li>
 *   <li><b>MCP</b>:智谱把搜索封装成符合 Model Context Protocol 的远程工具服务;
 *       我们用 LangChain4j 的 {@link McpClient} 先 {@code listTools},再 {@code executeTool}</li>
 * </ul>
 *
 * <h2>何时启用?</h2>
 * {@code mcp.zhipu.enabled=true}(默认)。关掉后由 {@link RestWebSearchService} 接管同一接口
 * {@link WebSearchService},上层 {@code WebResearchTools} 无感知切换。
 *
 * <h2>鉴权两种写法</h2>
 * <ol>
 *   <li>HTTP Header:{@code Authorization: Bearer &lt;key&gt;}(web_search_prime 端点常用)</li>
 *   <li>Query:URL 带 {@code ?Authorization=&lt;key&gt;}(MCP Broker 文档写法)</li>
 * </ol>
 * 本类两种都支持:URL 已带 Authorization 就不再加 Header;broker URL 缺 key 时自动拼接 query。
 */
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnProperty(name = "mcp.zhipu.enabled", havingValue = "true", matchIfMissing = true)
public class ZhipuMcpWebSearchService implements WebSearchService {

    private final ObjectMapper objectMapper;

    /** 搜索 MCP 端点,默认 web_search_prime */
    @Value("${mcp.zhipu.web-search-url}")
    private String searchUrl;

    /** 网页读取 MCP(可选);为空则 {@link #readUrl} 直接提示未配置 */
    @Value("${mcp.zhipu.web-reader-url:}")
    private String readerUrl;

    /** 通常与 LLM_API_KEY 相同;也可单独设 MCP_API_KEY */
    @Value("${mcp.zhipu.api-key:}")
    private String apiKey;

    private McpClient searchClient;
    private McpClient readerClient;

    /**
     * 实际要调用的工具名。
     * <p>不同智谱端点可能叫 {@code webSearchPrime} / {@code web_search} 等,
     * 所以启动时用 {@link #resolveToolNames} 动态探测,这里只是默认值。
     */
    private String searchToolName = "webSearchPrime";
    private String readerToolName = "webReader";

    /** Spring Bean 创建后立刻建连并 listTools。连不上会打 warn,真正 search 时再失败返回文案。 */
    @PostConstruct
    public void init() {
        this.searchClient = buildClient("zhipu-web-search", resolveUrl(searchUrl));
        resolveToolNames(searchClient, true);

        if (readerUrl != null && !readerUrl.isBlank()) {
            this.readerClient = buildClient("zhipu-web-reader", resolveUrl(readerUrl));
            resolveToolNames(readerClient, false);
        }
        log.info("[MCP] 智谱 Web Search 已启用 searchTool={} readerTool={} readerEnabled={}",
                searchToolName, readerToolName, readerClient != null);
    }

    /**
     * 创建单个 MCP Client。
     *
     * @param key 客户端本地标识(日志用)
     * @param url 已处理鉴权后的完整 URL
     */
    private McpClient buildClient(String key, String url) {
        Map<String, String> headers = new HashMap<>();
        // URL 已带 Authorization= 时走 query 鉴权,避免重复传两份凭证
        if (!urlContainsAuth(url) && apiKey != null && !apiKey.isBlank()) {
            headers.put("Authorization", "Bearer " + apiKey);
        }
        // Streamable HTTP:智谱 Remote MCP 使用的传输方式(相对旧的 SSE transport)
        McpTransport transport = StreamableHttpMcpTransport.builder()
                .url(url)
                .customHeaders(headers)
                // 默认关请求日志,避免 URL 里的 Authorization= 被打到日志
                .logRequests(false)
                .logResponses(false)
                .build();
        return new DefaultMcpClient.Builder()
                .key(key)
                .transport(transport)
                .build();
    }

    /**
     * 规范化 URL:若是 mcp-broker 且未带 Authorization,则自动拼上 API Key。
     */
    private String resolveUrl(String raw) {
        if (raw == null || raw.isBlank()) {
            return raw;
        }
        if (urlContainsAuth(raw) || apiKey == null || apiKey.isBlank()) {
            return raw;
        }
        if (raw.contains("mcp-broker") || raw.contains("Authorization=")) {
            String sep = raw.contains("?") ? "&" : "?";
            return raw + sep + "Authorization=" + apiKey;
        }
        return raw;
    }

    private static boolean urlContainsAuth(String url) {
        return url != null && url.toLowerCase().contains("authorization=");
    }

    /**
     * 向远端要工具清单,按名字启发式匹配 search / reader。
     * <p>listTools 失败不致命:保留默认工具名,真正调用时再试。
     */
    private void resolveToolNames(McpClient client, boolean search) {
        try {
            List<ToolSpecification> specs = client.listTools();
            for (ToolSpecification spec : specs) {
                String name = spec.name();
                String lower = name.toLowerCase();
                if (search && (lower.contains("search") || "webSearchPrime".equalsIgnoreCase(name))) {
                    searchToolName = name;
                }
                if (!search && (lower.contains("reader") || lower.contains("read"))) {
                    readerToolName = name;
                }
            }
            if (specs.isEmpty()) {
                log.warn("[MCP] 未列出任何工具,将按默认工具名调用");
            } else {
                log.info("[MCP] listTools({}): {}", search ? "search" : "reader",
                        specs.stream().map(ToolSpecification::name).toList());
            }
        } catch (Exception e) {
            log.warn("[MCP] listTools 失败,将按默认工具名直接调用: {}", e.getMessage());
        }
    }

    /**
     * 执行联网搜索。
     * <p>不同 MCP 工具对参数名要求不一(query / search_query / q),
     * 所以按几种 JSON 形态依次尝试,第一次成功即返回。
     */
    @Override
    public String search(String query) {
        if (query == null || query.isBlank()) {
            return "搜索词为空";
        }
        log.info("[MCP] search START query={}", query);
        long t0 = System.currentTimeMillis();
        Exception last = null;
        // 不同 MCP 工具参数名不一,用 Jackson 正确转义(含换行等控制字符)后依次尝试
        for (String key : List.of("query", "search_query", "q")) {
            try {
                String args = objectMapper.writeValueAsString(Map.of(key, query));
                ToolExecutionRequest request = ToolExecutionRequest.builder()
                        .id("mcp-search-" + System.currentTimeMillis())
                        .name(searchToolName)
                        .arguments(args)
                        .build();
                ToolExecutionResult result = searchClient.executeTool(request);
                if (result.isError()) {
                    last = new IllegalStateException(result.resultText());
                    continue;
                }
                String text = result.resultText() != null ? result.resultText() : String.valueOf(result.result());
                log.info("[MCP] search END   tool={} argKey={} elapsedMs={} resultChars={}",
                        searchToolName, key, System.currentTimeMillis() - t0,
                        text == null ? 0 : text.length());
                return text;
            } catch (Exception e) {
                last = e;
            }
        }
        log.warn("[MCP] search FAIL  elapsedMs={} error={}",
                System.currentTimeMillis() - t0, last == null ? "unknown" : last.getMessage());
        return "智谱 MCP 联网搜索失败: " + (last == null ? "unknown" : last.getMessage())
                + "。请检查 API Key / mcp.zhipu.* 配置,或设置 mcp.zhipu.enabled=false 回退 REST web_search。";
    }

    /** 读取网页正文;依赖 web-reader MCP,未配置时直接返回提示。 */
    @Override
    public String readUrl(String url) {
        if (readerClient == null) {
            return "未配置 mcp.zhipu.web-reader-url,无法读取网页: " + url;
        }
        log.info("[MCP] readUrl START url={}", url);
        long t0 = System.currentTimeMillis();
        try {
            String args = objectMapper.writeValueAsString(Map.of("url", url));
            ToolExecutionRequest request = ToolExecutionRequest.builder()
                    .id("mcp-reader-" + System.currentTimeMillis())
                    .name(readerToolName)
                    .arguments(args)
                    .build();
            ToolExecutionResult result = readerClient.executeTool(request);
            String text = result.resultText() != null ? result.resultText() : String.valueOf(result.result());
            log.info("[MCP] readUrl END   tool={} elapsedMs={} resultChars={}",
                    readerToolName, System.currentTimeMillis() - t0,
                    text == null ? 0 : text.length());
            return text;
        } catch (Exception e) {
            log.warn("[MCP] readUrl FAIL  elapsedMs={} error={}", System.currentTimeMillis() - t0, e.getMessage());
            return "智谱 MCP 网页读取失败: " + e.getMessage();
        }
    }

    /** 应用关闭时释放 MCP 连接。 */
    @PreDestroy
    public void destroy() {
        closeQuietly(searchClient);
        closeQuietly(readerClient);
    }

    private static void closeQuietly(McpClient client) {
        if (client == null) {
            return;
        }
        try {
            client.close();
        } catch (Exception ignored) {
            // 关闭阶段不再向上抛
        }
    }

}

src/main/java/cn/deepassistant/integration/mcp/RestWebSearchService.java

作用: 回退:REST /paas/v4/web_search

java 复制代码
package cn.deepassistant.integration.mcp;
/**
 * 智谱 Web Search 的 REST 回退实现。
 *
 * <p>当不存在其它 {@link WebSearchService} Bean 时启用(例如 {@code mcp.zhipu.enabled=false}
 * 或配置写成非法值导致 MCP Bean 未注册)。直接 {@code POST /paas/v4/web_search}。
 *
 * <p>REST 模式没有网页读取能力,{@link #readUrl} 会提示去开 MCP。
 */
@Slf4j
@Service
@ConditionalOnMissingBean(WebSearchService.class)
public class RestWebSearchService implements WebSearchService {

    @Value("${llm.base-url}")
    private String baseUrl;
    @Value("${llm.api-key}")
    private String apiKey;
    @Value("${llm.timeout-seconds:120}")
    private int timeoutSeconds;

    @Autowired
    private ObjectMapper objectMapper;

    /** JDK 自带 HttpClient,避免再引 OkHttp 等依赖 */
    private final HttpClient httpClient = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(20))
            .build();

    @Override
    public String search(String query) {
        if (query == null || query.isBlank()) {
            return "搜索词为空";
        }
        log.info("[REST] search START query={}", query);
        long t0 = System.currentTimeMillis();
        try {
            // base-url 可能带/不带尾斜杠,这里兼容两种写法
            String url = baseUrl.endsWith("/") ? baseUrl + "web_search" : baseUrl + "/web_search";
            String body = objectMapper.createObjectNode()
                    .put("search_query", query)
                    .put("search_engine", "search_pro") // 高阶引擎,结果更适合给 LLM
                    .put("count", 8)
                    .put("content_size", "medium")
                    .toString();

            HttpRequest request = HttpRequest.newBuilder()
                    .uri(URI.create(url))
                    .timeout(Duration.ofSeconds(timeoutSeconds))
                    .header("Authorization", "Bearer " + apiKey)
                    .header("Content-Type", "application/json")
                    .POST(HttpRequest.BodyPublishers.ofString(body))
                    .build();

            HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
            if (response.statusCode() >= 400) {
                log.warn("[REST] search FAIL  http={} elapsedMs={}",
                        response.statusCode(), System.currentTimeMillis() - t0);
                return "智谱 REST web_search 失败 HTTP " + response.statusCode() + ": " + response.body();
            }
            // 把原始 JSON 收成「1. 标题 / 链接 / 摘要」文本,方便模型阅读
            String formatted = format(response.body(), query);
            log.info("[REST] search END   elapsedMs={} resultChars={}",
                    System.currentTimeMillis() - t0, formatted.length());
            return formatted;
        } catch (Exception e) {
            log.error("[REST] search FAIL  elapsedMs={}", System.currentTimeMillis() - t0, e);
            return "联网搜索失败: " + e.getMessage();
        }
    }

    @Override
    public String readUrl(String url) {
        return "当前为 REST 模式,未启用网页读取。请设置 mcp.zhipu.enabled=true 使用智谱 web_reader MCP。"
                + " 目标 URL: " + url;
    }

    /**
     * 兼容智谱返回字段名的多种变体(search_result / search_results / data)。
     */
    private String format(String json, String query) throws Exception {
        JsonNode root = objectMapper.readTree(json);
        JsonNode items = root.path("search_result");
        if (!items.isArray()) {
            items = root.path("search_results");
        }
        if (!items.isArray()) {
            items = root.path("data");
        }
        List<String> lines = new ArrayList<>();
        lines.add("搜索引擎: zhipu-rest | 查询: " + query);
        lines.add("");
        if (!items.isArray() || items.isEmpty()) {
            // 结构未识别时把原始 JSON 截断返回,方便排障
            lines.add(json.length() > 2000 ? json.substring(0, 2000) + "..." : json);
            return String.join("\n", lines);
        }
        int i = 1;
        for (JsonNode item : items) {
            String title = text(item, "title", "name");
            String link = text(item, "link", "url");
            String snippet = text(item, "content", "snippet", "summary", "abstract");
            String site = text(item, "media", "site_name", "source");
            lines.add(i + ". " + (title.isBlank() ? "(无标题)" : title));
            if (!site.isBlank()) {
                lines.add("   来源: " + site);
            }
            if (!link.isBlank()) {
                lines.add("   链接: " + link);
            }
            if (!snippet.isBlank()) {
                lines.add("   摘要: " + (snippet.length() > 400 ? snippet.substring(0, 400) : snippet));
            }
            lines.add("");
            i++;
        }
        return String.join("\n", lines);
    }

    /** 按候选 key 顺序取第一个非空字段 */
    private static String text(JsonNode node, String... keys) {
        for (String k : keys) {
            JsonNode v = node.get(k);
            if (v != null && !v.isNull() && !v.asText().isBlank()) {
                return v.asText();
            }
        }
        return "";
    }
}

七、对话服务与 SSE API

src/main/java/cn/deepassistant/service/AssistantChatService.java

作用: 跑图 / 审批续跑、SSE 事件、历史落盘

java 复制代码
package cn.deepassistant.service;
/**
 * 对话编排:跑统筹图并把过程事件推给 SSE。
 *
 * <p>支持客户端取消({@code cancelled})。同一 session 串行排队:
 * 第二请求会等到上一轮结束后再跑(排队逻辑委托给 {@link SessionLockProvider},
 * 对齐"持久化可插拔化"------本类不再自己维护锁登记表)。
 *
 * <h2>新增:人工审批(对齐 Python HumanInTheLoopMiddleware)</h2>
 * 统筹图用 {@link AgentExecutorEx},{@code write_file}/{@code edit_file} 等工具执行前会挂起
 * 等待审批(见 {@code CreateDeepAgent#create} 的 {@code approvalOn})。挂起时 {@link #doChat}
 * 会提前结束当前这一轮:
 * <ol>
 *   <li>推送一个 {@code interrupt} SSE 事件,带上待审批的工具名 + 参数</li>
 *   <li>把 sessionId 记进 {@link #pendingApprovals},供 {@link #resume} 校验"确实有一个待审批"</li>
 *   <li>用户在前端点批准/拒绝后,前端调 {@code /api/assistant/resume} → {@link #resume}</li>
 *   <li>{@link #resume} 用 {@code GraphInput.resume(...)} 把 {@code APPROVAL_RESULT} 写回同一个
 *       checkpoint 线程,图从挂起点继续跑(可能又遇到下一个待审批工具,循环同样的处理)</li>
 * </ol>
 */
@Slf4j
@Service
public class AssistantChatService {

    @Autowired
    @Qualifier("orchestratorAgentGraph")
    private CompiledGraph<AgentExecutorEx.State> orchestratorGraph;
    @Autowired
    @Qualifier("orchestratorCheckpointSaver")
    private BaseCheckpointSaver orchestratorCheckpointSaver;
    @Autowired
    private SessionStore sessionStore;
    @Autowired
    private SessionListenerRegistry sessionListenerRegistry;
    @Autowired
    private SessionLockProvider sessionLockProvider;
    @Autowired
    private TodoStore todoStore;
    @Autowired
    private ObjectMapper objectMapper;

    /** sessionId → 是否有一个待审批的工具调用挂起中({@link #resume} 只在这里为 true 时才允许调用)。 */
    private final Map<String, Boolean> pendingApprovals = new ConcurrentHashMap<>();

    public String chat(String sessionId, String userMessage, Consumer<SseEvent> eventSink) {
        return chat(sessionId, userMessage, eventSink, new AtomicBoolean(false));
    }

    public String chat(String sessionId, String userMessage, Consumer<SseEvent> eventSink,
                       AtomicBoolean cancelled) {
        var session = sessionStore.getOrCreate(sessionId, userMessage);
        String id = session.getId();
        AtomicBoolean flag = cancelled == null ? new AtomicBoolean(false) : cancelled;

        try (var handle = sessionLockProvider.enter(id)) {
            if (flag.get()) {
                log.info("[AssistantChat] 排队期间客户端已取消,跳过执行 session={}", id);
                return "";
            }
            return doChat(id, userMessage, eventSink, flag);
        }
    }

    /** 是否有一个待审批工具调用挂起中,供 controller 在调用 resume 前做校验。 */
    public boolean hasPendingApproval(String sessionId) {
        return Boolean.TRUE.equals(pendingApprovals.get(sessionId));
    }

    /**
     * 人工审批后继续跑图(对齐 Python {@code interrupt_on} 的 resume)。
     *
     * @param approved true=批准执行该工具;false=拒绝(图会收到一条"DENIED"的工具结果,模型可以据此调整)
     */
    public String resume(String sessionId, boolean approved, Consumer<SseEvent> eventSink) {
        return resume(sessionId, approved, eventSink, new AtomicBoolean(false));
    }

    public String resume(String sessionId, boolean approved, Consumer<SseEvent> eventSink, AtomicBoolean cancelled) {
        String id = SessionIds.requireValid(sessionId);
        AtomicBoolean flag = cancelled == null ? new AtomicBoolean(false) : cancelled;
        if (!hasPendingApproval(id)) {
            throw new IllegalStateException("当前会话没有待审批的操作: " + id);
        }
        try (var handle = sessionLockProvider.enter(id)) {
            if (flag.get()) {
                return "";
            }
            return doResume(id, approved, eventSink, flag);
        }
    }

    private String doChat(String id, String userMessage, Consumer<SseEvent> eventSink, AtomicBoolean cancelled) {
        sessionStore.appendMessage(id, ChatMessageRecord.builder()
                .role("user")
                .content(userMessage)
                .timestamp(Instant.now())
                .build());

        List<Map<String, Object>> toolEvents = new ArrayList<>();
        DeepAgentFlowListener listener = bridgingListener(eventSink, toolEvents, cancelled);
        sessionListenerRegistry.register(id, listener);

        try {
            if (cancelled.get()) {
                return "";
            }
            listener.onUserMessage(id, userMessage);
            RunnableConfig config = RunnableConfig.builder().threadId(id).build();
            log.info("[Graph] START session={} msgChars={}", id, userMessage == null ? 0 : userMessage.length());

            var stream = orchestratorGraph.stream(Map.of(
                    "messages", UserMessage.from(userMessage),
                    SessionContext.SESSION_ID_KEY, id), config);

            GraphRunOutcome outcome = runGraph(id, stream, eventSink, config, cancelled);
            if (cancelled.get()) {
                return "";
            }
            return handleOutcome(id, outcome, eventSink, toolEvents, listener);
        } catch (Exception e) {
            log.error("[AssistantChat] 图执行失败 session={}: {}", id, e.getMessage(), e);
            if (listener != null) {
                listener.onError(id, e);
            }
            throw new RuntimeException(e.getMessage(), e);
        } finally {
            sessionListenerRegistry.unregister(id);
        }
    }

    private String doResume(String id, boolean approved, Consumer<SseEvent> eventSink, AtomicBoolean cancelled) {
        List<Map<String, Object>> toolEvents = new ArrayList<>();
        DeepAgentFlowListener listener = bridgingListener(eventSink, toolEvents, cancelled);
        sessionListenerRegistry.register(id, listener);
        try {
            RunnableConfig config = RunnableConfig.builder().threadId(id).build();
            String approvalResult = approved
                    ? AgentEx.ApprovalState.APPROVED.name()
                    : AgentEx.ApprovalState.REJECTED.name();
            log.info("[Graph] RESUME session={} approved={}", id, approved);

            var stream = orchestratorGraph.stream(
                    GraphInput.resume(Map.of(AgentEx.APPROVAL_RESULT, approvalResult)), config);

            GraphRunOutcome outcome = runGraph(id, stream, eventSink, config, cancelled);
            if (cancelled.get()) {
                return "";
            }
            return handleOutcome(id, outcome, eventSink, toolEvents, listener);
        } catch (Exception e) {
            log.error("[AssistantChat] resume 失败 session={}: {}", id, e.getMessage(), e);
            if (listener != null) {
                listener.onError(id, e);
            }
            throw new RuntimeException(e.getMessage(), e);
        } finally {
            sessionListenerRegistry.unregister(id);
        }
    }

    /**
     * 消费图的输出流:转发 token、记录节点走向;返回最终文本 + 是否在中断点停下。
     * <p>是否中断的判定用 {@link NodeOutput#isEND()}:正常结束时最后一个节点是 {@code END},
     * 被 {@code approvalOn} 挂起时最后一个节点是 {@code approval_<toolName>}(不是 END)。
     */
    private GraphRunOutcome runGraph(String id, Iterable<NodeOutput<AgentExecutorEx.State>> stream,
                                      Consumer<SseEvent> eventSink, RunnableConfig config, AtomicBoolean cancelled) {
        StringBuilder reply = new StringBuilder();
        NodeOutput<AgentExecutorEx.State> last = null;
        long t0 = System.currentTimeMillis();
        int nodeTicks = 0;

        for (NodeOutput<AgentExecutorEx.State> output : stream) {
            if (cancelled.get()) {
                log.info("[Graph] 客户端已取消,停止消费流 session={} tick={}", id, nodeTicks);
                break;
            }
            last = output;
            nodeTicks++;
            if (!(output instanceof StreamingOutput<?>)) {
                log.info("[Graph] node={} session={} tick={}", output.node(), id, nodeTicks);
            }
            if (output instanceof StreamingOutput<?> streaming) {
                if (!streaming.isStreamingEnd() && streaming.chunk() != null) {
                    String chunk = streaming.chunk();
                    reply.append(chunk);
                    eventSink.accept(new SseEvent("token", chunk));
                }
            }
        }

        if (cancelled.get()) {
            return new GraphRunOutcome("", false, last);
        }

        boolean interrupted = last != null && !last.isEND();
        if (reply.isEmpty() && !interrupted) {
            orchestratorGraph.stateOf(config)
                    .flatMap(snap -> snap.state().finalResponse())
                    .ifPresent(text -> {
                        reply.append(text);
                        eventSink.accept(new SseEvent("token", text));
                    });
        }
        log.info("[Graph] END   session={} elapsedMs={} ticks={} replyChars={} interrupted={}",
                id, System.currentTimeMillis() - t0, nodeTicks, reply.length(), interrupted);
        return new GraphRunOutcome(reply.toString(), interrupted, last);
    }

    /** 把一轮图执行的结果落库(正常回复 / 等待审批的占位消息),并返回展示给用户的文本。 */
    private String handleOutcome(String id, GraphRunOutcome outcome, Consumer<SseEvent> eventSink,
                                  List<Map<String, Object>> toolEvents, DeepAgentFlowListener listener) {
        if (outcome.interrupted()) {
            Map<String, Object> payload = buildInterruptPayload(outcome.last());
            pendingApprovals.put(id, Boolean.TRUE);
            eventSink.accept(new SseEvent("interrupt", toJsonish(payload)));

            Map<String, Object> stored = new LinkedHashMap<>();
            stored.put("type", "interrupt");
            stored.putAll(payload);
            toolEvents.add(stored);

            String note = "⏸ 等待人工审批:" + payload.getOrDefault("tool", "未知工具")
                    + "(请在前端批准或拒绝后继续)";
            sessionStore.appendMessage(id, ChatMessageRecord.builder()
                    .role("assistant")
                    .content(note)
                    .timestamp(Instant.now())
                    .events(new ArrayList<>(toolEvents))
                    .build());
            return note;
        }

        pendingApprovals.remove(id);
        String finalReply = outcome.reply();
        sessionStore.appendMessage(id, ChatMessageRecord.builder()
                .role("assistant")
                .content(finalReply)
                .timestamp(Instant.now())
                .events(new ArrayList<>(toolEvents))
                .build());
        listener.onAssistantReply(id, finalReply);
        return finalReply;
    }

    /**
     * 从中断点取出「待审批工具名 + 参数」。
     *
     * <p><b>为什么不用 {@code last.metadata(...)}?</b>
     * {@code AgentEx.ApprovalNodeAction#interrupt} 返回的 {@code InterruptionMetadata}
     * 是通过 langgraph4j 内部 {@code AsyncGenerator.Data.done(resultValue)} 传递的------
     * 这个"终止值"只存在于生成器的 {@code resultValue()} 里,而 {@code AgentExecutorEx} 用到的
     * {@code AsyncNodeGenerator} 并未实现 {@code HasResultValue},所以业务代码根本拿不到它;
     * 用 {@code for-each} 遍历 stream 时最后收到的 {@code NodeOutput} 只是中断前最后一个正常执行的节点
     * ({@code action_dispatcher}),它的 {@code metadataSupplier} 是 null。
     *
     * <p>好在 {@code action_dispatcher} 落盘时已经把待执行的 {@code TOOL_EXECUTION_REQUESTS}
     * 写进了图状态(见 {@code AgentExecutorEx#dispatchTools}),所以直接从
     * {@code last.state().toolExecutionRequests()} 里取第一条即可拿到同样的「工具名+参数」,
     * 和 {@code CreateDeepAgent#buildInterruptionMetadata} 里的逻辑等价,只是换了个取数据的入口。
     */
    private Map<String, Object> buildInterruptPayload(NodeOutput<AgentExecutorEx.State> last) {
        Map<String, Object> payload = new LinkedHashMap<>();
        payload.put("nodeId", last == null ? "" : last.node());
        if (last != null && last.state() != null) {
            try {
                last.state().toolExecutionRequests().stream().findFirst().ifPresent(req -> {
                    payload.put("tool", req.name());
                    payload.put("args", parseArgs(req.arguments()));
                });
            } catch (Exception e) {
                log.warn("[AssistantChat] 读取待审批工具详情失败: {}", e.getMessage());
            }
        }
        return payload;
    }

    /** 工具参数原文是一段 JSON 字符串;尽量解析成对象方便前端展示,解析失败就回退成原始字符串。 */
    private Object parseArgs(String rawJson) {
        if (rawJson == null || rawJson.isBlank()) {
            return Map.of();
        }
        try {
            return objectMapper.readValue(rawJson, Map.class);
        } catch (Exception e) {
            return rawJson;
        }
    }

    public void clearSessionMemory(String sessionId) {
        String id = SessionIds.requireValid(sessionId);
        try {
            RunnableConfig config = RunnableConfig.builder().threadId(id).build();
            orchestratorCheckpointSaver.release(config);
        } catch (Exception e) {
            log.warn("[AssistantChat] 清除 checkpoint 失败: {}", e.getMessage());
        }
        todoStore.clear(id);
        sessionStore.delete(id);
        sessionLockProvider.forget(id);
        pendingApprovals.remove(id);
    }

    /**
     * 落库事件类型与前端历史回放约定统一为:
     * {@code plan} / {@code tool} / {@code agent} / {@code interrupt}(不再用 tool_start 等细分名,避免回放丢事件)。
     */
    private DeepAgentFlowListener bridgingListener(
            Consumer<SseEvent> eventSink, List<Map<String, Object>> toolEvents, AtomicBoolean cancelled) {
        return new DeepAgentFlowListener() {
            private void emit(String sseEvent, Map<String, Object> payload, String storeType) {
                if (cancelled.get()) {
                    return;
                }
                eventSink.accept(new SseEvent(sseEvent, toJsonish(payload)));
                Map<String, Object> stored = new LinkedHashMap<>();
                stored.put("type", storeType);
                stored.putAll(payload);
                toolEvents.add(stored);
            }

            @Override
            public void onPlanUpdated(String sessionId, String todosJson) {
                if (cancelled.get()) {
                    return;
                }
                eventSink.accept(new SseEvent("plan", todosJson));
                toolEvents.add(Map.of("type", "plan", "todos", todosJson == null ? "" : todosJson));
            }

            @Override
            public void onToolStart(String sessionId, String toolName, Map<String, Object> args) {
                Map<String, Object> payload = new LinkedHashMap<>();
                payload.put("tool", toolName);
                payload.put("phase", "start");
                payload.put("args", args == null ? Map.of() : args);
                emit("tool", payload, "tool");
            }

            @Override
            public void onToolEnd(String sessionId, String toolName, String resultSummary) {
                Map<String, Object> payload = new LinkedHashMap<>();
                payload.put("tool", toolName);
                payload.put("phase", "end");
                payload.put("result", resultSummary == null ? "" : resultSummary);
                emit("tool", payload, "tool");
            }

            @Override
            public void onSubAgentStart(String sessionId, String agent, String description) {
                Map<String, Object> payload = new LinkedHashMap<>();
                payload.put("agent", agent == null ? "" : agent);
                payload.put("description", description == null ? "" : description);
                payload.put("phase", "start");
                emit("agent", payload, "agent");
            }

            @Override
            public void onSubAgentEnd(String sessionId, String agent, String resultSummary) {
                Map<String, Object> payload = new LinkedHashMap<>();
                payload.put("agent", agent == null ? "" : agent);
                payload.put("result", resultSummary == null ? "" : resultSummary);
                payload.put("phase", "end");
                emit("agent", payload, "agent");
            }

            @Override
            public void onError(String sessionId, Throwable error) {
                if (cancelled.get()) {
                    return;
                }
                log.error("[AssistantChat] onError session={} error={}", sessionId,
                        error == null ? "unknown" : error.getMessage());
            }
        };
    }

    private String toJsonish(Map<String, Object> map) {
        try {
            return objectMapper.writeValueAsString(map);
        } catch (Exception e) {
            return String.valueOf(map);
        }
    }

    public record SseEvent(String event, String data) {
    }

    /** 一轮图执行的产出:给用户看的文本、是否在审批点挂起、最后一个 NodeOutput(供提取中断详情)。 */
    private record GraphRunOutcome(String reply, boolean interrupted, NodeOutput<AgentExecutorEx.State> last) {
    }
}

src/main/java/cn/deepassistant/controller/AssistantController.java

作用: HTTP / SSE 接口(含 /assistant/resume)

java 复制代码
package cn.deepassistant.controller;
/**
 * HTTP API 入口。聊天走 SSE。
 *
 * <p>阻塞的 {@code graph.stream()} 放在 {@code Schedulers.boundedElastic()} 上执行,
 * 用 {@code Flux.create} 桥接;客户端断开时 {@code sink.onCancel} 置位以尽快停推送。
 * 同一 session 若已有一轮在跑,后到请求会排队等到上一轮结束后再执行。
 */
@Slf4j
@RestController
@RequestMapping("/api")
public class AssistantController {

    @Autowired
    private AssistantChatService chatService;
    @Autowired
    private SessionStore sessionStore;
    @Autowired
    private TaskDelegationService taskDelegationService;

    @PostMapping(value = "/assistant/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<String>> chat(@RequestBody ChatRequest request) {
        if (request.getMessage() == null || request.getMessage().isBlank()) {
            return Flux.just(sse("error", "消息不能为空"), sse("done", "[DONE]"));
        }

        final String sessionId;
        try {
            sessionId = SessionIds.normalizeOrCreate(request.getSessionId());
        } catch (IllegalArgumentException e) {
            return Flux.just(sse("error", e.getMessage()), sse("done", "[DONE]"));
        }

        log.info("[Chat] START session={} msgChars={} preview={}",
                sessionId, request.getMessage().length(), preview(request.getMessage()));
        Flux<ServerSentEvent<String>> sessionEvent = Flux.just(sse("session", sessionId));

        Flux<ServerSentEvent<String>> chatStream = Flux.<ServerSentEvent<String>>create(sink -> {
            AtomicBoolean cancelled = new AtomicBoolean(false);
            sink.onCancel(() -> {
                cancelled.set(true);
                log.info("[Chat] CANCEL session={}", sessionId);
            });
            sink.onDispose(() -> cancelled.set(true));
            long t0 = System.currentTimeMillis();
            try {
                chatService.chat(sessionId, request.getMessage(), event -> {
                    if (!cancelled.get()) {
                        sink.next(sse(event.event(), event.data()));
                    }
                }, cancelled);
                log.info("[Chat] END   session={} elapsedMs={} cancelled={}",
                        sessionId, System.currentTimeMillis() - t0, cancelled.get());
                if (!cancelled.get()) {
                    sink.next(sse("done", "[DONE]"));
                }
                sink.complete();
            } catch (Exception e) {
                log.error("[Chat] FAIL  session={} elapsedMs={}", sessionId, System.currentTimeMillis() - t0, e);
                if (!cancelled.get()) {
                    String msg = friendlyError(e);
                    sink.next(sse("error", msg));
                    sink.next(sse("token", "抱歉,本次请求失败:" + msg));
                    sink.next(sse("done", "[DONE]"));
                }
                sink.complete();
            }
        }).subscribeOn(Schedulers.boundedElastic());

        return Flux.concat(sessionEvent, chatStream);
    }

    /**
     * 人工审批后继续跑图(对齐 Python {@code interrupt_on} 的 resume)。
     * <p>前端收到 {@code interrupt} SSE 事件后,展示"批准/拒绝"卡片,用户操作后调用本接口,
     * 复用同一套 SSE 推流逻辑(token/plan/tool/agent/interrupt/done)。
     */
    @PostMapping(value = "/assistant/resume", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<String>> resume(@RequestBody ResumeRequest request) {
        final String sessionId;
        try {
            sessionId = SessionIds.requireValid(request.getSessionId());
        } catch (IllegalArgumentException e) {
            return Flux.just(sse("error", e.getMessage()), sse("done", "[DONE]"));
        }
        if (!chatService.hasPendingApproval(sessionId)) {
            return Flux.just(sse("error", "当前会话没有待审批的操作"), sse("done", "[DONE]"));
        }

        log.info("[Resume] START session={} approved={}", sessionId, request.isApproved());

        Flux<ServerSentEvent<String>> resumeStream = Flux.<ServerSentEvent<String>>create(sink -> {
            AtomicBoolean cancelled = new AtomicBoolean(false);
            sink.onCancel(() -> cancelled.set(true));
            sink.onDispose(() -> cancelled.set(true));
            long t0 = System.currentTimeMillis();
            try {
                chatService.resume(sessionId, request.isApproved(), event -> {
                    if (!cancelled.get()) {
                        sink.next(sse(event.event(), event.data()));
                    }
                }, cancelled);
                log.info("[Resume] END   session={} elapsedMs={} cancelled={}",
                        sessionId, System.currentTimeMillis() - t0, cancelled.get());
                if (!cancelled.get()) {
                    sink.next(sse("done", "[DONE]"));
                }
                sink.complete();
            } catch (Exception e) {
                log.error("[Resume] FAIL  session={} elapsedMs={}", sessionId, System.currentTimeMillis() - t0, e);
                if (!cancelled.get()) {
                    String msg = friendlyError(e);
                    sink.next(sse("error", msg));
                    sink.next(sse("token", "抱歉,续跑失败:" + msg));
                    sink.next(sse("done", "[DONE]"));
                }
                sink.complete();
            }
        }).subscribeOn(Schedulers.boundedElastic());

        return resumeStream;
    }

    @GetMapping("/sessions")
    public List<SessionSummary> listSessions() {
        return sessionStore.listSessions();
    }

    @GetMapping("/sessions/{id}")
    public SessionDetail getSession(@PathVariable String id) {
        SessionDetail detail = sessionStore.get(id);
        if (detail == null) {
            throw new IllegalArgumentException("会话不存在: " + id);
        }
        detail.setPendingApproval(chatService.hasPendingApproval(id));
        return detail;
    }

    @DeleteMapping("/sessions/{id}")
    public Map<String, Object> deleteSession(@PathVariable String id) {
        chatService.clearSessionMemory(id);
        return Map.of("ok", true, "id", id);
    }

    @PostMapping("/sessions")
    public SessionDetail createSession() {
        return sessionStore.getOrCreate(null, "新对话");
    }

    @GetMapping("/agents")
    public Map<String, Object> agents() {
        return Map.of(
                "agents", taskDelegationService.registeredAgents(),
                "harness", List.of(
                        "write_todos", "filesystem", "task",
                        "research-agent", "general-purpose"));
    }

    @GetMapping("/health")
    public Map<String, Object> health(
            @org.springframework.beans.factory.annotation.Value("${llm.model}") String model,
            @org.springframework.beans.factory.annotation.Value("${mcp.zhipu.enabled:true}") boolean mcpEnabled) {
        return Map.of(
                "status", "ok",
                "framework", "LangGraph4j-AgentExecutor",
                "edition", "slim",
                "model", model,
                "mcp_enabled", mcpEnabled,
                "version", "1.1.2");
    }

    @ExceptionHandler(IllegalArgumentException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, String> handleBadRequest(IllegalArgumentException e) {
        return Map.of("error", e.getMessage() == null ? "bad request" : e.getMessage());
    }

    @ExceptionHandler(SecurityException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, String> handleSecurity(SecurityException e) {
        return Map.of("error", e.getMessage() == null ? "forbidden path" : e.getMessage());
    }

    private static ServerSentEvent<String> sse(String event, String data) {
        return ServerSentEvent.<String>builder().event(event).data(data == null ? "" : data).build();
    }

    private static String friendlyError(Throwable e) {
        String raw = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
        if (raw.contains("401") || raw.contains("令牌") || raw.contains("Unauthorized")) {
            return "大模型鉴权失败,请设置环境变量 LLM_API_KEY 为有效的智谱 API Key 后重启。";
        }
        if (raw.length() > 300) {
            return raw.substring(0, 300) + "...";
        }
        return raw;
    }

    private static String preview(String s) {
        if (s == null) {
            return "";
        }
        String t = s.replace('\n', ' ').trim();
        return t.length() > 80 ? t.substring(0, 80) + "..." : t;
    }
}

src/main/java/cn/deepassistant/deepagents/flow/DeepAgentFlowListener.java

作用: 工具/子 Agent 轨迹回调接口

java 复制代码
package cn.deepassistant.deepagents.flow;
/**
 * Harness 执行过程的观察者接口(回调)。
 *
 * <p>谁实现?------ {@code AssistantChatService} 里的匿名实现,把事件转成 SSE 并写入会话历史。
 * <p>谁调用?------ {@code OrchestratorTools} / {@code ToolTraceContext} 在工具起止、计划更新、子 Agent 委派时调用。
 *
 * <p>全部做成 {@code default} 空实现,这样实现方只 override 自己关心的事件即可。
 */
public interface DeepAgentFlowListener {

    /** 收到用户消息时(当前精简版未强制使用) */
    default void onUserMessage(String memoryId, String message) {
    }

    /** {@code write_todos} 更新后,推送计划 JSON */
    default void onPlanUpdated(String memoryId, String todosJson) {
    }

    /** 某个工具开始执行 */
    default void onToolStart(String memoryId, String toolName, Map<String, Object> args) {
    }

    /** 某个工具结束(resultSummary 通常是截断后的摘要) */
    default void onToolEnd(String memoryId, String toolName, String resultSummary) {
    }

    /** {@code task} 开始委派某个子 Agent */
    default void onSubAgentStart(String memoryId, String subAgentType, String description) {
    }

    /** {@code task} 子 Agent 执行结束 */
    default void onSubAgentEnd(String memoryId, String subAgentType, String resultSummary) {
    }

    /** 助手最终回复(可选钩子) */
    default void onAssistantReply(String memoryId, String reply) {
    }

    /** 未捕获错误(可选钩子) */
    default void onError(String memoryId, Throwable error) {
    }
}

src/main/java/cn/deepassistant/deepagents/flow/ToolTraceContext.java

作用: 子 Agent 工具调用轨迹(start/end 日志 + SSE)

java 复制代码
package cn.deepassistant.deepagents.flow;
/**
 * 给「子 Agent 领域工具」用的轨迹上报助手(与 {@link cn.deepassistant.graph.SessionContext} 分工)。
 *
 * <h2>和 SessionContext 的区别</h2>
 * <ul>
 *   <li>{@code SessionContext}:从 {@code InvocationParameters} 里取 sessionId 的纯函数</li>
 *   <li>{@code ToolTraceContext}:子 Agent 工具(如 {@code WebResearchTools})用 {@link #trace} 包一层,少写样板代码</li>
 * </ul>
 *
 * <p>不再使用 ThreadLocal:sessionId 由调用方通过 {@code @Tool} 方法上的
 * {@code InvocationParameters} 参数传入,listener 通过 {@link SessionListenerRegistry} 按
 * sessionId 查找------两者都不依赖"工具执行在哪个线程"这一假设。
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class ToolTraceContext {

    private final SessionListenerRegistry sessionListenerRegistry;

    /**
     * 执行工具业务逻辑,并自动上报 start / end(或错误摘要)。
     *
     * @param toolName 展示在前端的工具名
     * @param args     入参摘要(不要塞超大文本)
     * @param ctx      当前调用的 {@code InvocationParameters}(由 LangChain4j 自动注入,取 sessionId 用)
     * @param action   真正的业务(如调用 MCP search)
     * @return action 的返回值原样透传
     */
    public <T> T trace(String toolName, Map<String, Object> args, InvocationParameters ctx, Supplier<T> action) {
        String id = SessionContext.sessionId(ctx);
        DeepAgentFlowListener l = sessionListenerRegistry.get(id);
        Map<String, Object> safeArgs = args == null ? Map.of() : args;
        log.info("[Tool] START name={} session={} args={}", toolName, id, safeArgs);
        if (l != null) {
            l.onToolStart(id, toolName, safeArgs);
        }
        long t0 = System.currentTimeMillis();
        try {
            T result = action.get();
            String summary = summarize(result);
            log.info("[Tool] END   name={} session={} elapsedMs={} summary={}",
                    toolName, id, System.currentTimeMillis() - t0, clip(summary, 300));
            if (l != null) {
                l.onToolEnd(id, toolName, summary);
            }
            return result;
        } catch (RuntimeException e) {
            log.warn("[Tool] FAIL  name={} session={} elapsedMs={} error={}",
                    toolName, id, System.currentTimeMillis() - t0, e.getMessage());
            if (l != null) {
                l.onToolEnd(id, toolName, "错误: " + e.getMessage());
            }
            throw e; // 继续抛,让上层决定是否转成工具错误文案
        }
    }

    /** SSE 上只展示前 4000 字,避免搜索全文刷屏 */
    private static String summarize(Object result) {
        if (result == null) {
            return "";
        }
        return clip(String.valueOf(result), 4000);
    }

    private static String clip(String s, int max) {
        if (s == null) {
            return "";
        }
        return s.length() > max ? s.substring(0, max) + "..." : s;
    }
}

八、会话持久化与模型 DTO

src/main/java/cn/deepassistant/memory/SessionStore.java

作用: 会话 JSON 落盘(防路径穿越)

java 复制代码
package cn.deepassistant.memory;
/**
 * 会话持久化:把聊天记录存成磁盘上的 JSON 文件。
 *
 * <p><b>安全:</b>所有 sessionId 必须经 {@link SessionIds} 校验,且文件路径
 * normalize 后必须仍在 sessions 根目录内,防止 {@code ../} 路径穿越。
 *
 * <p><b>锁粒度(对齐 {@code AssistantChatService.SessionGate} 的模式):</b>
 * 原来所有方法都 {@code synchronized(this)},不同 session 的读写会互相阻塞。
 * 现在改为按 sessionId 加锁({@link #lockFor});只有 {@link #listSessions()}/
 * {@link #upsertIndex}/{@link #readIndex}/{@link #writeIndex} 这些跨 session 共享
 * {@code sessions-index.json} 的操作才用一个独立的 {@link #indexLock}。
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class SessionStore {

    @Value("${agent.sessions.dir}")
    private String sessionsDir;

    private final ObjectMapper mapper;

    private Path dir;
    private Path indexPath;
    private final Map<String, SessionDetail> cache = new ConcurrentHashMap<>();
    /** 每个 sessionId 一把锁:保护该会话自己的 JSON 文件读写,不再和其它会话互相阻塞。 */
    private final Map<String, Object> fileLocks = new ConcurrentHashMap<>();
    /** 保护跨 session 共享的 {@code sessions-index.json}。 */
    private final Object indexLock = new Object();

    private Object lockFor(String id) {
        return fileLocks.computeIfAbsent(id, k -> new Object());
    }

    @PostConstruct
    public void init() throws IOException {
        dir = Path.of(sessionsDir).toAbsolutePath().normalize();
        Files.createDirectories(dir);
        indexPath = dir.resolve("sessions-index.json");
        if (!Files.exists(indexPath)) {
            writeIndex(new ArrayList<>());
        }
        log.info("[Session] 会话目录: {}", dir);
    }

    public List<SessionSummary> listSessions() {
        synchronized (indexLock) {
            return readIndex().stream()
                    .sorted(Comparator.comparing(SessionSummary::getUpdatedAt, Comparator.nullsLast(Comparator.reverseOrder())))
                    .collect(Collectors.toList());
        }
    }

    public SessionDetail getOrCreate(String sessionId, String firstUserMessage) {
        String id = SessionIds.normalizeOrCreate(sessionId);
        synchronized (lockFor(id)) {
            SessionDetail detail = load(id);
            if (detail == null) {
                Instant now = Instant.now();
                String title = buildTitle(firstUserMessage);
                detail = SessionDetail.builder()
                        .id(id)
                        .title(title)
                        .createdAt(now)
                        .updatedAt(now)
                        .messages(new ArrayList<>())
                        .build();
                cache.put(id, detail);
                persist(detail);
                upsertIndex(SessionSummary.builder()
                        .id(id)
                        .title(title)
                        .preview(previewOf(firstUserMessage))
                        .updatedAt(now)
                        .messageCount(0)
                        .build());
            }
            return detail;
        }
    }

    public SessionDetail get(String sessionId) {
        String id = SessionIds.requireValid(sessionId);
        synchronized (lockFor(id)) {
            return load(id);
        }
    }

    public void appendMessage(String sessionId, ChatMessageRecord record) {
        String id = SessionIds.requireValid(sessionId);
        synchronized (lockFor(id)) {
            SessionDetail detail = load(id);
            if (detail == null) {
                detail = getOrCreate(id, record.getContent());
            }
            detail.getMessages().add(record);
            detail.setUpdatedAt(Instant.now());
            if ("user".equals(record.getRole())
                    && detail.getMessages().stream().filter(m -> "user".equals(m.getRole())).count() == 1) {
                detail.setTitle(buildTitle(record.getContent()));
            }
            persist(detail);
            upsertIndex(SessionSummary.builder()
                    .id(detail.getId())
                    .title(detail.getTitle())
                    .preview(previewOf(record.getContent()))
                    .updatedAt(detail.getUpdatedAt())
                    .messageCount(detail.getMessages().size())
                    .build());
        }
    }

    public boolean delete(String sessionId) {
        String id = SessionIds.requireValid(sessionId);
        synchronized (lockFor(id)) {
            cache.remove(id);
            try {
                Files.deleteIfExists(sessionFile(id));
            } catch (IOException e) {
                log.warn("删除会话文件失败: {}", e.getMessage());
            }
            boolean removed;
            synchronized (indexLock) {
                List<SessionSummary> index = readIndex();
                removed = index.removeIf(s -> Objects.equals(s.getId(), id));
                writeIndex(index);
            }
            fileLocks.remove(id);
            return removed;
        }
    }

    private SessionDetail load(String id) {
        SessionDetail cached = cache.get(id);
        if (cached != null) {
            return cached;
        }
        Path file = sessionFile(id);
        if (!Files.exists(file)) {
            return null;
        }
        try {
            SessionDetail detail = mapper.readValue(file.toFile(), SessionDetail.class);
            cache.put(id, detail);
            return detail;
        } catch (IOException e) {
            log.warn("读取会话失败 {}: {}", id, e.getMessage());
            return null;
        }
    }

    private void persist(SessionDetail detail) {
        try {
            mapper.writerWithDefaultPrettyPrinter()
                    .writeValue(sessionFile(detail.getId()).toFile(), detail);
        } catch (IOException e) {
            log.warn("写入会话失败: {}", e.getMessage());
        }
    }

    /**
     * 解析会话文件路径,并强制落在 sessions 根目录内。
     */
    private Path sessionFile(String id) {
        // id 已由 SessionIds 校验;再做一层路径守护
        Path resolved = dir.resolve(id + ".json").normalize();
        if (!resolved.startsWith(dir)) {
            throw new SecurityException("禁止访问 sessions 目录之外的路径: " + id);
        }
        return resolved;
    }

    private List<SessionSummary> readIndex() {
        try {
            return mapper.readValue(indexPath.toFile(), new TypeReference<>() {
            });
        } catch (IOException e) {
            return new ArrayList<>();
        }
    }

    private void writeIndex(List<SessionSummary> index) {
        try {
            mapper.writerWithDefaultPrettyPrinter().writeValue(indexPath.toFile(), index);
        } catch (IOException e) {
            log.warn("写入会话索引失败: {}", e.getMessage());
        }
    }

    private void upsertIndex(SessionSummary summary) {
        synchronized (indexLock) {
            List<SessionSummary> index = readIndex();
            index.removeIf(s -> Objects.equals(s.getId(), summary.getId()));
            index.add(summary);
            writeIndex(index);
        }
    }

    private static String buildTitle(String message) {
        if (message == null || message.isBlank()) {
            return "新对话";
        }
        String t = message.replace('\n', ' ').trim();
        return t.length() > 24 ? t.substring(0, 24) + "..." : t;
    }

    private static String previewOf(String content) {
        if (content == null) {
            return "";
        }
        String t = content.replace('\n', ' ').trim();
        return t.length() > 60 ? t.substring(0, 60) + "..." : t;
    }
}

src/main/java/cn/deepassistant/model/ChatRequest.java

作用: 聊天请求体

java 复制代码
package cn.deepassistant.model;
/**
 * 前端 POST /api/assistant/chat 的请求体。
 */
@Data
public class ChatRequest {
    /** 会话 id;空则服务端新建并在 SSE session 事件里返回 */
    private String sessionId;
    /** 用户输入,必填 */
    private String message;
    /**
     * 前端是否展示工具轨迹的开关(主要给 UI 用)。
     * <p>服务端始终推送 tool/plan/agent 事件,由前端决定渲染与否。
     */
    private Boolean showTools;
}

src/main/java/cn/deepassistant/model/ResumeRequest.java

作用: 人工审批续跑请求体

java 复制代码
package cn.deepassistant.model;
/**
 * 前端 POST /api/assistant/resume 的请求体:对一个挂起中的人工审批做出决定。
 * <p>对应统筹图里 {@code approvalOn(write_file/edit_file, ...)} 挂起时推送的 {@code interrupt} 事件。
 */
@Data
public class ResumeRequest {
    /** 待续跑的会话 id(必须当前确实有一个挂起中的审批) */
    private String sessionId;
    /** true=批准执行该工具;false=拒绝(图会收到一条"DENIED"的工具结果,模型据此调整) */
    private boolean approved;
}

src/main/java/cn/deepassistant/model/ChatMessageRecord.java

作用: 单条消息记录

java 复制代码
package cn.deepassistant.model;
/**
 * 会话里的一条消息(落在 {@code {sessionId}.json})。
 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChatMessageRecord {
    /**
     * 角色:通常是 {@code user} / {@code assistant}。
     * <p>(历史兼容字段注释里还写了 tool/agent/plan,实际 tool 轨迹放在 {@link #events})
     */
    private String role;
    /** 消息正文 */
    private String content;
    private Instant timestamp;
    /**
     * 本轮助手回复附带的工具/计划/子 Agent 事件列表(便于历史回放)。
     * <p>每项大致形如 {@code {type: tool_start, tool: webSearch, ...}}。
     */
    @Builder.Default
    private List<Map<String, Object>> events = new ArrayList<>();
}

src/main/java/cn/deepassistant/model/SessionDetail.java

作用: 会话详情(含 pendingApproval)

java 复制代码
package cn.deepassistant.model;
/**
 * 单个会话的完整详情(对应磁盘上的 {@code {id}.json})。
 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SessionDetail {
    private String id;
    /** 侧边栏标题,通常取自首条用户消息前 24 字 */
    private String title;
    private Instant createdAt;
    private Instant updatedAt;
    /** 按时间顺序的消息列表 */
    @Builder.Default
    private List<ChatMessageRecord> messages = new ArrayList<>();
    /**
     * 是否有一个待人工审批的工具调用挂起中(对齐 Python interrupt_on 的 resume 场景)。
     * <p>由 controller 在读取详情时用 {@code AssistantChatService#hasPendingApproval} 现算,不落盘。
     */
    private boolean pendingApproval;
}

src/main/java/cn/deepassistant/model/SessionSummary.java

作用: 会话列表摘要

java 复制代码
package cn.deepassistant.model;
/**
 * 会话摘要(存在 {@code sessions-index.json},给侧边栏列表用,不含全部消息正文)。
 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SessionSummary {
    private String id;
    private String title;
    /** 最近一条消息的短预览 */
    private String preview;
    private Instant updatedAt;
    private int messageCount;
}

九、工具类与配置

src/main/java/cn/deepassistant/util/SessionIds.java

作用: sessionId 校验与规范化

java 复制代码
package cn.deepassistant.util;
/**
 * 会话 id 校验:防止客户端传入 {@code ../} 等路径穿越到 sessions 目录之外。
 */
public final class SessionIds {

    /** 允许 UUID,或「字母数字 + _-」且长度受限的自定义 id */
    private static final Pattern SAFE =
            Pattern.compile("^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$");

    private SessionIds() {
    }

    public static String newId() {
        return UUID.randomUUID().toString();
    }

    /**
     * @throws IllegalArgumentException 非法 id
     */
    public static String requireValid(String sessionId) {
        if (sessionId == null || sessionId.isBlank()) {
            throw new IllegalArgumentException("sessionId 为空");
        }
        String id = sessionId.trim();
        if (id.contains("..") || id.contains("/") || id.contains("\\") || id.indexOf('\0') >= 0) {
            throw new IllegalArgumentException("非法 sessionId");
        }
        if (!SAFE.matcher(id).matches()) {
            throw new IllegalArgumentException("非法 sessionId 格式");
        }
        return id;
    }

    /** 空则生成新 UUID,非空则校验 */
    public static String normalizeOrCreate(String sessionId) {
        if (sessionId == null || sessionId.isBlank()) {
            return newId();
        }
        return requireValid(sessionId);
    }
}

src/main/java/cn/deepassistant/util/SafeMathEval.java

作用: 安全表达式求值(无脚本引擎)

java 复制代码
package cn.deepassistant.util;

/**
 * 安全的四则运算表达式求值(递归下降)。
 * <p>不使用 ScriptEngine,避免任意代码执行;除零会抛出明确异常。
 */
public final class SafeMathEval {

    private SafeMathEval() {
    }

    public static double eval(String expression) {
        if (expression == null || expression.isBlank()) {
            throw new IllegalArgumentException("表达式为空");
        }
        return new Parser(expression.replaceAll("\\s+", "")).parse();
    }

    public static String evalToString(String expression) {
        double v = eval(expression);
        if (Double.isNaN(v) || Double.isInfinite(v)) {
            throw new ArithmeticException("计算结果非法(可能除以零)");
        }
        // 整数结果去掉多余 .0
        if (v == Math.rint(v) && Math.abs(v) < 1e15) {
            return String.valueOf((long) v);
        }
        return String.valueOf(v);
    }

    private static final class Parser {
        private final String s;
        private int pos = -1;
        private int ch;

        Parser(String s) {
            this.s = s;
        }

        void next() {
            ch = (++pos < s.length()) ? s.charAt(pos) : -1;
        }

        boolean eat(int c) {
            if (ch == c) {
                next();
                return true;
            }
            return false;
        }

        double parse() {
            next();
            double x = parseExpression();
            if (pos < s.length()) {
                throw new IllegalArgumentException("意外字符: " + (char) ch);
            }
            return x;
        }

        double parseExpression() {
            double x = parseTerm();
            for (; ; ) {
                if (eat('+')) x += parseTerm();
                else if (eat('-')) x -= parseTerm();
                else return x;
            }
        }

        double parseTerm() {
            double x = parseFactor();
            for (; ; ) {
                if (eat('*')) {
                    x *= parseFactor();
                } else if (eat('/')) {
                    double d = parseFactor();
                    if (d == 0.0) {
                        throw new ArithmeticException("除数不能为 0");
                    }
                    x /= d;
                } else {
                    return x;
                }
            }
        }

        double parseFactor() {
            if (eat('+')) return parseFactor();
            if (eat('-')) return -parseFactor();
            double x;
            int start = pos;
            if (eat('(')) {
                x = parseExpression();
                if (!eat(')')) {
                    throw new IllegalArgumentException("缺少右括号");
                }
            } else if ((ch >= '0' && ch <= '9') || ch == '.') {
                while ((ch >= '0' && ch <= '9') || ch == '.') next();
                x = Double.parseDouble(s.substring(start, pos));
            } else {
                throw new IllegalArgumentException("无法解析: " + s);
            }
            return x;
        }
    }
}

src/main/resources/application.yml

作用: 运行配置(密钥已脱敏)

yaml 复制代码
server:
  port: 8089

spring:
  application:
    name: deepagents-assistant-java
  mvc:
    async:
      request-timeout: 330000

# ── 大模型(OpenAI 兼容 · 智谱)────────────────────────────────────────────
llm:
  #base-url: https://open.bigmodel.cn/api/paas/v4/
  #api-key: ${LLM_API_KEY:YOUR_ZHIPU_API_KEY}
  #model: glm-4.7-flash
  base-url: https://llm-inr089bfe37yw1si.cn-beijing.maas.aliyuncs.com/compatible-mode/v1
  api-key: ${LLM_API_KEY:YOUR_LLM_API_KEY}
  model: qwen3.7-plus
  temperature: 0.3
  timeout-seconds: 120

# ── 智谱 MCP Web Search(默认开启)─────────────────────────────────────────
mcp:
  zhipu:
    enabled: true
    web-search-url: https://open.bigmodel.cn/api/mcp/web_search_prime/mcp
    web-reader-url: https://open.bigmodel.cn/api/mcp/web_reader/mcp
    api-key: ${MCP_API_KEY:${LLM_API_KEY:YOUR_ZHIPU_API_KEY}}

# ── Agent Harness 存储 ─────────────────────────────────────────────────────
agent:
  workspace: ${user.dir}/data/workspace
  sessions:
    dir: ${user.dir}/data/sessions
  chat-memory-max-messages: 48
  # LangGraph4j 图节点步数上限(默认 25 对复合任务不够:todos+research+写文件会触顶)
  recursion-limit: 80
  worker-recursion-limit: 40

logging:
  level:
    cn.deepassistant: INFO
    dev.langchain4j: debug

pom.xml

作用: 依赖与编译(-parameters 保留工具参数名)

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.5</version>
        <relativePath/>
    </parent>

    <groupId>cn.deepassistant</groupId>
    <artifactId>deepagents-assistant-java</artifactId>
    <version>1.0.0</version>
    <packaging>jar</packaging>
    <name>deepagents-assistant-java</name>
    <description>
        Deep Agents Harness(Java):LangGraph4j AgentExecutor + LangChain4j Tools,
        不使用 AiServices。模型/Agent 配置方式对齐 llm-service-graph。
    </description>

    <properties>
        <java.version>17</java.version>
        <langchain4j.version>1.12.2</langchain4j.version>
        <langgraph4j.version>1.8.20</langgraph4j.version>
        <langchain4j-mcp.version>1.12.2-beta22</langchain4j-mcp.version>
        <!-- LangGraph4j 需要 Jackson >= 2.16 -->
        <jackson-bom.version>2.17.2</jackson-bom.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.bsc.langgraph4j</groupId>
                <artifactId>langgraph4j-bom</artifactId>
                <version>${langgraph4j.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>

        <!-- LangChain4j:模型 + @Tool(不用 AiServices) -->
        <dependency>
            <groupId>dev.langchain4j</groupId>
            <artifactId>langchain4j</artifactId>
            <version>${langchain4j.version}</version>
        </dependency>
        <dependency>
            <groupId>dev.langchain4j</groupId>
            <artifactId>langchain4j-open-ai</artifactId>
            <version>${langchain4j.version}</version>
        </dependency>
        <dependency>
            <groupId>dev.langchain4j</groupId>
            <artifactId>langchain4j-mcp</artifactId>
            <version>${langchain4j-mcp.version}</version>
        </dependency>

        <!-- LangGraph4j:图编排 + 内置 ReAct AgentExecutor -->
        <dependency>
            <groupId>org.bsc.langgraph4j</groupId>
            <artifactId>langgraph4j-core</artifactId>
        </dependency>
        <dependency>
            <groupId>org.bsc.langgraph4j</groupId>
            <artifactId>langgraph4j-langchain4j</artifactId>
        </dependency>
        <dependency>
            <groupId>org.bsc.langgraph4j</groupId>
            <artifactId>langgraph4j-agent-executor</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.datatype</groupId>
            <artifactId>jackson-datatype-jsr310</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <!-- 保留方法参数名,LangChain4j @Tool schema 才能生成 path/content 而不是 arg0/arg1 -->
                    <parameters>true</parameters>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

十、前端

src/main/resources/static/index.html

作用: 深色对话 UI;工具轨迹 + 审批卡片;fetch 消费 SSE(token / tool / agent / interrupt / done / error)

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>小深 · DeepAgents Harness(Java)</title>
<style>
  :root {
    --bg: #0e1419;
    --surface: #162028;
    --surface2: #1c2a35;
    --border: #2a3b48;
    --accent: #2bb3a5;
    --accent2: #e8a54b;
    --text: #e7eef3;
    --text-dim: #8aa0b0;
    --user: #1e3a45;
    --ai: #1a242c;
    --tool: #243018;
    --danger: #e06c75;
    --ok: #6bcf7f;
    --font-display: "Iowan Old Style", "Palatino Linotype", Palatino, "Songti SC", serif;
    --font-body: "Avenir Next", "PingFang SC", "Hiragino Sans GB", sans-serif;
  }
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body {
    font-family: var(--font-body);
    background:
      radial-gradient(1200px 600px at 10% -10%, #1a3a40 0%, transparent 55%),
      radial-gradient(900px 500px at 100% 0%, #3a2a18 0%, transparent 50%),
      var(--bg);
    color: var(--text);
    height: 100vh;
    display: flex;
    overflow: hidden;
  }
  .sidebar {
    width: 280px;
    background: rgba(22, 32, 40, 0.92);
    border-right: 1px solid var(--border);
    display: flex;
    flex-direction: column;
    flex-shrink: 0;
  }
  .sidebar-header {
    padding: 18px 16px 12px;
    border-bottom: 1px solid var(--border);
  }
  .brand {
    font-family: var(--font-display);
    font-size: 22px;
    letter-spacing: 0.02em;
    color: var(--accent);
  }
  .brand small {
    display: block;
    font-family: var(--font-body);
    font-size: 12px;
    color: var(--text-dim);
    margin-top: 4px;
    font-weight: 400;
  }
  .sidebar-actions {
    display: flex;
    gap: 8px;
    margin-top: 12px;
  }
  .btn {
    background: var(--surface2);
    border: 1px solid var(--border);
    color: var(--text);
    padding: 7px 12px;
    border-radius: 8px;
    cursor: pointer;
    font-size: 13px;
  }
  .btn:hover { border-color: var(--accent); color: var(--accent); }
  .btn.primary {
    background: linear-gradient(135deg, #1f8f84, #2bb3a5);
    border: none;
    color: #061216;
    font-weight: 600;
  }
  .session-list {
    flex: 1;
    overflow-y: auto;
    padding: 8px;
  }
  .session-item {
    padding: 10px 12px;
    border-radius: 10px;
    cursor: pointer;
    margin-bottom: 4px;
    border: 1px solid transparent;
  }
  .session-item:hover { background: var(--surface2); }
  .session-item.active {
    background: var(--surface2);
    border-color: var(--accent);
  }
  .session-item .title { font-size: 13px; font-weight: 600; }
  .session-item .preview {
    font-size: 11px;
    color: var(--text-dim);
    margin-top: 4px;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  .session-item .meta {
    display: flex;
    justify-content: space-between;
    margin-top: 6px;
    font-size: 10px;
    color: var(--text-dim);
  }
  .del {
    color: var(--danger);
    background: none;
    border: none;
    cursor: pointer;
    font-size: 11px;
  }
  .main {
    flex: 1;
    display: flex;
    flex-direction: column;
    min-width: 0;
  }
  header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 12px 20px;
    border-bottom: 1px solid var(--border);
    background: rgba(22, 32, 40, 0.7);
    backdrop-filter: blur(8px);
  }
  .toggle {
    display: flex;
    align-items: center;
    gap: 8px;
    font-size: 13px;
    color: var(--text-dim);
  }
  .toggle input { accent-color: var(--accent); width: 16px; height: 16px; }
  .messages {
    flex: 1;
    overflow-y: auto;
    padding: 20px;
    display: flex;
    flex-direction: column;
    gap: 14px;
  }
  .msg {
    max-width: 820px;
    width: 100%;
    align-self: flex-start;
  }
  .msg.user { align-self: flex-end; }
  .bubble {
    padding: 12px 14px;
    border-radius: 14px;
    line-height: 1.55;
    font-size: 14px;
    white-space: pre-wrap;
    word-break: break-word;
  }
  .msg.user .bubble {
    background: var(--user);
    border: 1px solid #2d5563;
  }
  .msg.assistant .bubble {
    background: var(--ai);
    border: 1px solid var(--border);
  }
  .msg .role {
    font-size: 11px;
    color: var(--text-dim);
    margin-bottom: 4px;
  }
  .tool-block, .agent-block, .plan-block {
    margin-top: 8px;
    padding: 8px 10px;
    border-radius: 8px;
    font-size: 12px;
    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
    background: var(--tool);
    border: 1px solid #3a4a28;
    color: #c8d9a8;
    white-space: pre-wrap;
  }
  .agent-block { border-color: #3a4a5a; background: #1a2834; color: #9ec9e0; }
  .plan-block { border-color: #5a4a28; background: #2a2418; color: #e8c98a; }
  .approval-card {
    margin-top: 8px;
    padding: 12px 14px;
    border-radius: 10px;
    border: 1px solid var(--accent2);
    background: #2a2216;
    color: var(--text);
  }
  .approval-card .approval-title { font-weight: 600; color: var(--accent2); margin-bottom: 6px; font-size: 13px; }
  .approval-card pre {
    background: #1a1610;
    border-radius: 6px;
    padding: 8px;
    font-size: 12px;
    overflow-x: auto;
    margin: 6px 0 10px;
    white-space: pre-wrap;
    word-break: break-word;
  }
  .approval-actions { display: flex; gap: 8px; }
  .btn.approve { background: linear-gradient(135deg, #1f8f84, #2bb3a5); border: none; color: #061216; font-weight: 600; }
  .btn.reject { background: transparent; border: 1px solid var(--danger); color: var(--danger); }
  .approval-card.resolved { opacity: 0.6; }
  .approval-card .approval-result { margin-top: 8px; font-size: 12px; color: var(--text-dim); }
  .composer {
    padding: 14px 20px 18px;
    border-top: 1px solid var(--border);
    background: rgba(22, 32, 40, 0.85);
  }
  .hints {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    margin-bottom: 10px;
  }
  .hint {
    font-size: 12px;
    padding: 5px 10px;
    border-radius: 999px;
    border: 1px solid var(--border);
    color: var(--text-dim);
    cursor: pointer;
    background: transparent;
  }
  .hint:hover { border-color: var(--accent); color: var(--accent); }
  .input-row { display: flex; gap: 10px; }
  textarea {
    flex: 1;
    resize: none;
    height: 56px;
    border-radius: 12px;
    border: 1px solid var(--border);
    background: var(--surface2);
    color: var(--text);
    padding: 12px 14px;
    font-family: inherit;
    font-size: 14px;
  }
  textarea:focus { outline: none; border-color: var(--accent); }
  .empty {
    margin: auto;
    text-align: center;
    color: var(--text-dim);
    max-width: 420px;
  }
  .empty h2 {
    font-family: var(--font-display);
    color: var(--text);
    font-size: 28px;
    margin-bottom: 8px;
  }
  .menu-btn { display: none; }
  @media (max-width: 800px) {
    .sidebar {
      position: absolute;
      z-index: 20;
      height: 100%;
      transform: translateX(-100%);
      transition: transform .2s;
    }
    .sidebar.open { transform: translateX(0); }
    .menu-btn { display: inline-block; }
  }
</style>
</head>
<body>
<aside class="sidebar" id="sidebar">
  <div class="sidebar-header">
    <div class="brand">小深<small>精简 Harness · 统筹 / research / general</small></div>
    <div class="sidebar-actions">
      <button class="btn primary" id="btnNew">新对话</button>
    </div>
  </div>
  <div class="session-list" id="sessionList"></div>
</aside>

<section class="main">
  <header>
    <div style="display:flex;align-items:center;gap:10px;">
      <button class="btn menu-btn" id="btnMenu">菜单</button>
      <div>
        <div style="font-weight:600;" id="currentTitle">新对话</div>
        <div style="font-size:12px;color:var(--text-dim);" id="currentSession">未选择会话</div>
      </div>
    </div>
    <label class="toggle">
      <input type="checkbox" id="showTools" checked>
      显示工具调用
    </label>
  </header>

  <div class="messages" id="messages">
    <div class="empty" id="emptyState">
      <h2>今天想做点什么?</h2>
      <p>统筹 Agent 会拆解任务,派发给 research-agent(联网)或 general-purpose(通用),再汇总给你。</p>
    </div>
  </div>

  <div class="composer">
    <div class="hints">
      <button class="hint" data-q="搜索一下 Deep Agents 框架是什么,并总结核心能力">联网调研</button>
      <button class="hint" data-q="对比一下 LangGraph 和 Deep Agents 的区别,列出要点并附来源">多源对比</button>
      <button class="hint" data-q="现在几点?帮我算一下 (18+7)*3">时间与计算</button>
    </div>
    <div class="input-row">
      <textarea id="input" placeholder="输入消息,Enter 发送,Shift+Enter 换行"></textarea>
      <button class="btn primary" id="btnSend" style="min-width:88px;">发送</button>
    </div>
  </div>
</section>

<script>
const state = {
  sessionId: localStorage.getItem('pa_sessionId') || null,
  showTools: true,
  streaming: false,
  abort: null, // AbortController:切会话/新对话时取消上一次 SSE
  pendingApproval: false, // 有一个 interrupt 卡片等待用户批准/拒绝时,禁止继续发消息
};

const el = {
  list: document.getElementById('sessionList'),
  messages: document.getElementById('messages'),
  empty: document.getElementById('emptyState'),
  input: document.getElementById('input'),
  showTools: document.getElementById('showTools'),
  currentTitle: document.getElementById('currentTitle'),
  currentSession: document.getElementById('currentSession'),
  sidebar: document.getElementById('sidebar'),
  btnSend: document.getElementById('btnSend'),
};

el.showTools.checked = localStorage.getItem('pa_showTools') !== 'false';
state.showTools = el.showTools.checked;

el.showTools.addEventListener('change', () => {
  state.showTools = el.showTools.checked;
  localStorage.setItem('pa_showTools', state.showTools);
  document.querySelectorAll('.tool-block,.agent-block,.plan-block').forEach(n => {
    n.style.display = state.showTools ? 'block' : 'none';
  });
});

document.getElementById('btnMenu').onclick = () => el.sidebar.classList.toggle('open');
document.getElementById('btnNew').onclick = () => newChat();
document.getElementById('btnSend').onclick = () => send();
document.querySelectorAll('.hint').forEach(b => b.onclick = () => {
  el.input.value = b.dataset.q;
  send();
});
el.input.addEventListener('keydown', e => {
  if (e.key === 'Enter' && !e.shiftKey) {
    e.preventDefault();
    send();
  }
});

function escapeHtml(s) {
  return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}

function mdLite(text) {
  let t = escapeHtml(text);
  t = t.replace(/```([\s\S]*?)```/g, '<pre>$1</pre>');
  t = t.replace(/`([^`]+)`/g, '<code>$1</code>');
  t = t.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
  t = t.replace(/^### (.+)$/gm, '<div style="font-weight:700;margin:6px 0 2px;">$1</div>');
  t = t.replace(/\|(.+)\|/g, m => m); // keep tables as preformatted via whitespace
  return t;
}

function clearMessages() {
  el.messages.innerHTML = '';
  el.messages.appendChild(el.empty);
  el.empty.style.display = 'block';
}

function ensureEmptyHidden() {
  el.empty.style.display = 'none';
}

function appendBubble(role, content) {
  ensureEmptyHidden();
  const wrap = document.createElement('div');
  wrap.className = 'msg ' + role;
  wrap.innerHTML = `<div class="role">${role === 'user' ? '你' : '助手'}</div>
    <div class="bubble">${role === 'assistant' ? mdLite(content) : escapeHtml(content)}</div>
    <div class="extras"></div>`;
  el.messages.appendChild(wrap);
  el.messages.scrollTop = el.messages.scrollHeight;
  return wrap;
}

function appendMeta(wrap, cls, text) {
  if (!state.showTools) {
    const hidden = document.createElement('div');
    hidden.className = cls;
    hidden.style.display = 'none';
    hidden.textContent = text;
    wrap.querySelector('.extras').appendChild(hidden);
    return;
  }
  const div = document.createElement('div');
  div.className = cls;
  div.textContent = text;
  wrap.querySelector('.extras').appendChild(div);
  el.messages.scrollTop = el.messages.scrollHeight;
}

async function loadSessions() {
  const res = await fetch('/api/sessions');
  const list = await res.json();
  el.list.innerHTML = '';
  list.forEach(s => {
    const item = document.createElement('div');
    item.className = 'session-item' + (s.id === state.sessionId ? ' active' : '');
    item.innerHTML = `<div class="title">${escapeHtml(s.title || '未命名')}</div>
      <div class="preview">${escapeHtml(s.preview || '')}</div>
      <div class="meta"><span>${s.messageCount || 0} 条</span>
      <button class="del" data-id="${s.id}">删除</button></div>`;
    item.onclick = (e) => {
      if (e.target.classList.contains('del')) return;
      openSession(s.id);
    };
    item.querySelector('.del').onclick = async (e) => {
      e.stopPropagation();
      await fetch('/api/sessions/' + s.id, { method: 'DELETE' });
      if (state.sessionId === s.id) newChat();
      loadSessions();
    };
    el.list.appendChild(item);
  });
}

async function openSession(id) {
  if (state.abort) {
    state.abort.abort();
    state.abort = null;
  }
  state.streaming = false;
  state.sessionId = id;
  localStorage.setItem('pa_sessionId', id);
  const res = await fetch('/api/sessions/' + encodeURIComponent(id));
  if (!res.ok) {
    clearMessages();
    appendBubble('assistant', '加载会话失败(' + res.status + ')');
    return;
  }
  const detail = await res.json();
  if (detail && detail.error) {
    clearMessages();
    appendBubble('assistant', '加载会话失败:' + detail.error);
    return;
  }
  el.currentTitle.textContent = detail.title || '对话';
  el.currentSession.textContent = id;
  clearMessages();
  state.pendingApproval = false;
  el.btnSend.disabled = false;
  const messages = detail.messages || [];
  messages.forEach((m, idx) => {
    if (m.role === 'user' || m.role === 'assistant') {
      const wrap = appendBubble(m.role, m.content || '');
      const isLast = idx === messages.length - 1;
      (m.events || []).forEach(ev => {
        if (ev.type === 'plan') appendMeta(wrap, 'plan-block', '计划\n' + (ev.todos || ''));
        if (ev.type === 'tool' || ev.type === 'tool_start' || ev.type === 'tool_end') {
          appendMeta(wrap, 'tool-block', '工具\n' + JSON.stringify(ev));
        }
        if (ev.type === 'agent' || ev.type === 'agent_start' || ev.type === 'agent_end') {
          appendMeta(wrap, 'agent-block', '子Agent\n' + JSON.stringify(ev));
        }
        if (ev.type === 'interrupt') {
          if (isLast && detail.pendingApproval) {
            appendApprovalCard(wrap, ev);
          } else if (state.showTools) {
            appendMeta(wrap, 'plan-block', '⏸ 审批记录\n' + JSON.stringify(ev));
          }
        }
      });
    }
  });
  loadSessions();
  el.sidebar.classList.remove('open');
}

function newChat() {
  if (state.abort) {
    state.abort.abort();
    state.abort = null;
  }
  state.streaming = false;
  state.sessionId = null;
  state.pendingApproval = false;
  el.btnSend.disabled = false;
  localStorage.removeItem('pa_sessionId');
  el.currentTitle.textContent = '新对话';
  el.currentSession.textContent = '未选择会话';
  clearMessages();
  loadSessions();
}

/**
 * 消费一个 SSE 响应体,把每个 event/data 对交给 onEvent。
 * chat / resume 两个入口共用同一套解析逻辑。
 */
async function consumeSse(res, onEvent) {
  if (!res.body) {
    throw new Error('响应无正文');
  }
  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buf = '';
  let eventName = 'message';
  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    buf += decoder.decode(value, { stream: true });
    const chunks = buf.split('\n');
    buf = chunks.pop();
    for (const line of chunks) {
      if (line.startsWith('event:')) {
        eventName = line.slice(6).trim();
      } else if (line.startsWith('data:')) {
        onEvent(eventName, line.slice(5).trim());
        eventName = 'message';
      }
    }
  }
  if (buf.trim()) {
    for (const line of buf.split('\n')) {
      if (line.startsWith('event:')) eventName = line.slice(6).trim();
      else if (line.startsWith('data:')) onEvent(eventName, line.slice(5).trim());
    }
  }
}

async function send() {
  const text = el.input.value.trim();
  if (!text || state.streaming || state.pendingApproval) return;
  state.streaming = true;
  el.input.value = '';
  appendBubble('user', text);
  const aiWrap = appendBubble('assistant', '思考中...');
  const bubble = aiWrap.querySelector('.bubble');
  bubble.textContent = '';

  const abort = new AbortController();
  state.abort = abort;

  try {
    const res = await fetch('/api/assistant/chat', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ sessionId: state.sessionId, message: text, showTools: state.showTools }),
      signal: abort.signal
    });
    if (!res.ok) {
      bubble.textContent = '请求失败 HTTP ' + res.status;
      return;
    }
    await consumeSse(res, (event, data) => handleSse(event, data, aiWrap, bubble));
  } catch (e) {
    if (e.name !== 'AbortError') {
      bubble.textContent = '请求失败: ' + e.message;
    }
  } finally {
    if (state.abort === abort) state.abort = null;
    state.streaming = false;
    loadSessions();
  }
}

/** 用户在审批卡片上点了批准/拒绝:POST /api/assistant/resume,续跑同一个统筹图。 */
async function resumeApproval(card, approved) {
  if (!state.sessionId || state.streaming) return;
  card.querySelectorAll('button').forEach(b => b.disabled = true);
  state.pendingApproval = false;
  state.streaming = true;
  el.btnSend.disabled = false;
  el.btnSend.title = '';

  const aiWrap = card.closest('.msg');
  const bubble = aiWrap.querySelector('.bubble');
  const resultLine = document.createElement('div');
  resultLine.className = 'approval-result';
  resultLine.textContent = approved ? '✅ 已批准,继续执行...' : '🚫 已拒绝,让模型调整方案...';
  card.appendChild(resultLine);
  card.classList.add('resolved');

  const abort = new AbortController();
  state.abort = abort;
  try {
    const res = await fetch('/api/assistant/resume', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ sessionId: state.sessionId, approved }),
      signal: abort.signal
    });
    if (!res.ok) {
      resultLine.textContent += '(续跑请求失败 HTTP ' + res.status + ')';
      return;
    }
    await consumeSse(res, (event, data) => handleSse(event, data, aiWrap, bubble));
  } catch (e) {
    if (e.name !== 'AbortError') {
      resultLine.textContent += '(续跑失败: ' + e.message + ')';
    }
  } finally {
    if (state.abort === abort) state.abort = null;
    state.streaming = false;
    loadSessions();
  }
}

/** 渲染一个待审批卡片(write_file/edit_file 等破坏性工具执行前的人工确认)。 */
function appendApprovalCard(aiWrap, payload) {
  ensureEmptyHidden();
  const card = document.createElement('div');
  card.className = 'approval-card';
  const tool = payload.tool || '未知工具';
  const args = payload.args ? JSON.stringify(payload.args, null, 2) : '';
  card.innerHTML = `<div class="approval-title">⏸ 等待人工审批:${escapeHtml(tool)}</div>
    ${args ? `<pre>${escapeHtml(args)}</pre>` : ''}
    <div class="approval-actions">
      <button class="btn approve">批准执行</button>
      <button class="btn reject">拒绝</button>
    </div>`;
  card.querySelector('.approve').onclick = () => resumeApproval(card, true);
  card.querySelector('.reject').onclick = () => resumeApproval(card, false);
  aiWrap.querySelector('.extras').appendChild(card);
  el.messages.scrollTop = el.messages.scrollHeight;
  state.pendingApproval = true;
  el.btnSend.disabled = true;
  el.btnSend.title = '有待审批的操作,请先在上方批准或拒绝';
}

function handleSse(event, data, aiWrap, bubble) {
  if (event === 'session') {
    state.sessionId = data;
    localStorage.setItem('pa_sessionId', data);
    el.currentSession.textContent = data;
  } else if (event === 'token') {
    bubble.innerHTML = mdLite((bubble.dataset.raw || '') + data);
    bubble.dataset.raw = (bubble.dataset.raw || '') + data;
    el.messages.scrollTop = el.messages.scrollHeight;
  } else if (event === 'plan') {
    appendMeta(aiWrap, 'plan-block', '计划\n' + data);
  } else if (event === 'tool') {
    appendMeta(aiWrap, 'tool-block', '工具\n' + data);
  } else if (event === 'agent') {
    appendMeta(aiWrap, 'agent-block', '子Agent\n' + data);
  } else if (event === 'interrupt') {
    let payload = {};
    try { payload = JSON.parse(data); } catch (e) { /* 忽略解析失败,仍展示原文 */ }
    appendApprovalCard(aiWrap, payload);
  } else if (event === 'error') {
    bubble.textContent = (bubble.dataset.raw || '') + '\n[错误] ' + data;
  }
}

loadSessions();
if (state.sessionId) openSession(state.sessionId);
</script>
</body>
</html>

3. 设计取舍与踩坑笔记

  1. 不用 AiServices :工具走 LangGraph4j toolsFromObject + @Tool,与「图编排」同一套路,避免两套 Agent 运行时。
  2. 统筹用 AgentExecutorEx、子 Agent 用 AgentExecutor :只有统筹需要对 write_file/edit_fileapprovalOn;子 Agent 每次 task 独立上下文,不挂 HITL。
  3. checkpoint 默认可落盘CheckpointSaverProvider 接口化,默认 FileSystemSaverTodoRepository / SessionLockProvider 同样可替换。
  4. Listener 不用 ThreadLocal :流式模型回调可能换线程,改成 SessionListenerRegistrysessionId 索引;sessionId 经图状态 + InvocationParameters 注入 @Tool
  5. -parameters 编译 :否则工具 JSON schema 变成 arg0/arg1,模型乱传参会导致 write_file NPE;配合空 path 校验与明确错误文案。
  6. recursionLimit :复合题(todos + 多轮 research + 写文件 + 审批)会轻松超过默认 25,需在 CompileConfig 显式提高。
  7. MCP vs REST :上层只依赖 WebSearchService;MCP 缺 Bean 时 REST @ConditionalOnMissingBean 兜底。
  8. sessionId 路径安全SessionIds 校验 + normalize 后必须仍在 sessions 根目录。
  9. 同会话排队SessionLockProvider 公平锁串行,后到的请求等上一轮结束,避免并发写 checkpoint。
  10. 工具日志 :统筹工具在方法首尾打 [Orchestrator] start/end(不另做 trace 抽象);子 Agent 仍走 ToolTraceContext
  11. HarnessToolingConfig 拆开 :避免 DeepAgentHarnessConfigOrchestratorTools 循环依赖。
  12. 文件权限 glob :默认 deny *.env / secrets/**interrupt 粒度受限于框架「按工具名审批」,故写/编辑工具整体 HITL。

4. API 一览

方法 路径 说明
POST /api/assistant/chat SSE 对话
POST /api/assistant/resume 人工审批后续跑(approve/deny)
GET/POST /api/sessions 会话列表 / 新建
GET/DELETE /api/sessions/{id} 详情 / 删除
GET /api/agents 已注册子 Agent
GET /api/health 健康检查

SSE 事件:session / token / plan / tool / agent / interrupt / error / done


5. 结语

「小深」不是又一个聊天框包装,而是把 LangChain Deep Agents 的 Harness 分层落到 Java:

  • 统筹负责决策、规划、委派与交付
  • 子 Agent 负责专科执行(可并发)
  • 文件权限、人工审批、记忆/技能、可插拔持久化保证长任务可控
  • 工具与运行时负责可观测、可落盘、可续跑

如果你要扩展,最自然的切入点通常是:新增一个 SubAgentSpec(专科提示词 + 工具 Bean),或换一套 CheckpointSaverProvider 实现------不必改 ReAct 内核。


相关推荐
南京云森杉木桩2 小时前
水利木桩源头直供,质量可靠价格更优
大数据·python
SQL-First布道者2 小时前
持久层框架的评价标准:只有一个
java·spring boot·spring·tomcat·mybatis·spring jdbc
孔明click332 小时前
Sa-Token v1.46.0 发布 🚀,来看看有没有令你心动的功能!
java·sa-token·开源·springboot·权限认证
Aaron - Wistron2 小时前
Python基础教程2/4(复合数据结构)
python
小柯南敲键盘2 小时前
跨境电商图片翻译与视频字幕翻译工具推荐
python·音视频
m0_587383002 小时前
智慧场馆解决方案实战指南:从系统架构到落地部署全解析
java·spring boot·架构·系统架构
ZC跨境爬虫2 小时前
LeetCode 13. 罗马数字转整数(多解法详解 + Java Python 实现)
java·python·leetcode
省长2 小时前
Sa-Token v1.46.0 发布 🚀,来看看有没有令你心动的功能!
java·后端·开源
machnerrn2 小时前
智慧交通系列(一)-十字路口车辆闯红灯检测告警抓拍系统(附含数据+源码+模型)
人工智能·python·深度学习
现代野蛮人2 小时前
【深度学习实验】—— 利用 RNN 模型进行心脏病预测
pytorch·python·tensorflow·ml