四、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 <key>}(web_search_prime 端点常用)</li>
* <li>Query:URL 带 {@code ?Authorization=<key>}(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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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. 设计取舍与踩坑笔记
- 不用 AiServices :工具走 LangGraph4j
toolsFromObject+@Tool,与「图编排」同一套路,避免两套 Agent 运行时。 - 统筹用 AgentExecutorEx、子 Agent 用 AgentExecutor :只有统筹需要对
write_file/edit_file做approvalOn;子 Agent 每次task独立上下文,不挂 HITL。 - checkpoint 默认可落盘 :
CheckpointSaverProvider接口化,默认FileSystemSaver;TodoRepository/SessionLockProvider同样可替换。 - Listener 不用 ThreadLocal :流式模型回调可能换线程,改成
SessionListenerRegistry按sessionId索引;sessionId经图状态 +InvocationParameters注入@Tool。 -parameters编译 :否则工具 JSON schema 变成arg0/arg1,模型乱传参会导致write_fileNPE;配合空 path 校验与明确错误文案。- recursionLimit :复合题(todos + 多轮 research + 写文件 + 审批)会轻松超过默认 25,需在
CompileConfig显式提高。 - MCP vs REST :上层只依赖
WebSearchService;MCP 缺 Bean 时 REST@ConditionalOnMissingBean兜底。 - sessionId 路径安全 :
SessionIds校验 +normalize后必须仍在 sessions 根目录。 - 同会话排队 :
SessionLockProvider公平锁串行,后到的请求等上一轮结束,避免并发写 checkpoint。 - 工具日志 :统筹工具在方法首尾打
[Orchestrator] start/end(不另做 trace 抽象);子 Agent 仍走ToolTraceContext。 - HarnessToolingConfig 拆开 :避免
DeepAgentHarnessConfig↔OrchestratorTools循环依赖。 - 文件权限 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 内核。