From c1fd39a1e267a96a9f265b86992c2b62502672dd Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 9 Jun 2026 17:55:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(agent/plan):=20=E5=88=86=E6=B5=81=E5=99=A8?= =?UTF-8?q?=E8=AF=81=E6=8D=AE=E9=97=B8=E9=97=A8=E9=98=B2=E6=AD=A2=E5=A4=8D?= =?UTF-8?q?=E6=9D=82=E4=BB=BB=E5=8A=A1=E4=B8=8D=E6=89=A7=E8=A1=8C=E5=B0=B1?= =?UTF-8?q?=E5=81=9C=E6=AD=A2(v2=20=E5=89=A5=E7=A6=BB=20memory-context)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../graph/plan/node/PlanGenerationNode.java | 93 +++++++++++++++ .../node/PlanGenerationEvidenceGateTest.java | 111 ++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationEvidenceGateTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java index a3f1fb74..a2eeb074 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java @@ -96,6 +96,74 @@ public class PlanGenerationNode implements NodeAction { - 多部分、多阶段、需要逐步推进的目标走(C);真正单一原子动作走(B);只有简单一问一答才用(A)。 """; + /** + * Evidence gate — action signals in the USER GOAL. When triage returns + * direct_answer (A) but the goal contains any of these, the model almost + * certainly mis-routed a tool-requiring task; accepting the direct answer + * would end the turn without ever executing a tool ("复杂任务不执行就停止"). + *

+ * The gate is deliberately biased toward executing: a false positive only + * costs one extra executor pass (which still produces the answer, with or + * without tools), whereas a false negative silently drops the whole task. + * Intentionally excludes very common bare temporal words (现在/当前/最新) + * to avoid downgrading genuine knowledge Q&A on every occurrence. + */ + private static final java.util.regex.Pattern GOAL_REQUIRES_EXECUTION = java.util.regex.Pattern.compile( + "读取|读一下|读一份|打开文件|查一下|检索|搜索|联网|下载|上传|抓取" + + "|记住|记一下|录入|保存|写入|存储|更新|删除|新建|创建|生成|画一[张幅]|画个" + + "|运行|执行|调用|跑一下|发送|发给|安排|提醒|预约" + + "|我的(记忆|文件|知识库|偏好|笔记|日程|目标)" + + "|你(现在|目前)?(挂载|加载|有哪些|支持哪些)|挂载了哪些|你的(技能|工具|MCP|插件)" + + "|帮我(做|改|查|建|写|发|跑|算|订|定|生成|整理|安排)" + + "|\\.(java|py|ts|js|vue|md|json|ya?ml|sql|csv|xml|txt|sh)\\b", + java.util.regex.Pattern.CASE_INSENSITIVE); + + /** + * Evidence gate — execution-promise phrasing in the direct answer itself. + * The model says it WILL act ("我先去读取…", "接下来调用…") rather than + * actually answering, which means the "direct answer" is really a plan + * preamble that would terminate before the action runs. Scoped to a verb + * whitelist so a normal narrative opener like "我来介绍一下杭州" is NOT caught. + */ + private static final java.util.regex.Pattern ANSWER_PROMISES_ACTION = java.util.regex.Pattern.compile( + "(我(先|这就|马上|稍后|接下来|现在)?(去|来)?|让我(先|来)?|接下来(我)?(会|要|将|需要)?|正在)" + + "(读取|读一下|查一下|查询|检索|搜索|联网|调用|执行|运行|获取|访问|查看一下" + + "|保存|记住|记录|写入|录入|创建|新建|生成|下载|上传|发送)"); + + /** + * Returns true when a triage {@code direct_answer} (A) should be overridden + * and routed through the executor as a single-step plan instead. Package- + * private and side-effect free so the gate's regex behavior is unit-testable + * without mocking the whole node. + * + * @param goal the user goal + * @param directAnswer the answer the triage model produced (may be null) + */ + static boolean shouldOverrideDirectAnswer(String goal, String directAnswer) { + String userAsk = stripInjectedContext(goal); + boolean goalNeedsExecution = userAsk != null && GOAL_REQUIRES_EXECUTION.matcher(userAsk).find(); + boolean answerPromisesAction = directAnswer != null && ANSWER_PROMISES_ACTION.matcher(directAnswer).find(); + return goalNeedsExecution || answerPromisesAction; + } + + /** + * Strips the injected {@code } wrapper that + * RuntimeContextInjector prepends to every goal, returning just the user's + * actual ask. Without this the gate matches on the injected memory/profile + * text (which contains filenames like {@code user.md} and memory keywords), + * firing on essentially every task and defeating the direct-answer fast path. + */ + static String stripInjectedContext(String goal) { + if (goal == null) { + return null; + } + int end = goal.lastIndexOf(""); + if (end >= 0) { + return goal.substring(end + "".length()).trim(); + } + return goal; + } + public PlanGenerationNode(ChatModel chatModel, PlanningService planningService, NodeStreamingChatHelper streamingHelper, ConversationWindowManager conversationWindowManager, @@ -250,6 +318,31 @@ public class PlanGenerationNode implements NodeAction { // Category (A): direct answer — push to client and terminate via DirectAnswerNode. String directAnswer = triage != null && triage.directAnswer() != null ? triage.directAnswer() : llmResponse; + + // Evidence gate: catch a mis-routed A that actually needs tools. + // Downgrading to a single-step plan keeps tool access; the cost of + // a false positive is one extra executor pass, while a missed + // misroute drops the whole task silently. + if (shouldOverrideDirectAnswer(goal, directAnswer)) { + log.warn("[PlanGeneration] Evidence gate overrode direct-answer route; " + + "downgrading to single-step plan so tools can execute (goal: {})", + goal.length() > 60 ? goal.substring(0, 60) + "..." : goal); + List gatedSteps = List.of(goal); + var gatedPlan = planningService.createPlan(agentId, goal, gatedSteps); + events.add(GraphEventPublisher.planCreated(gatedPlan.getId(), gatedSteps)); + return PlanStateAccessor.output() + .needsPlanning(true) + .planId(gatedPlan.getId()) + .planSteps(gatedSteps) + .planValid(true) + .currentStepIndex(0) + .currentPhase("plan_generated") + .thinkingStreamed(!result.thinking().isEmpty()) + .mergeUsage(state, result) + .events(events) + .build(); + } + log.info("[PlanGeneration] Direct-answer route taken (no tools, no planning)"); streamingHelper.broadcastContent(conversationId, directAnswer); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationEvidenceGateTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationEvidenceGateTest.java new file mode 100644 index 00000000..ef6ccd39 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationEvidenceGateTest.java @@ -0,0 +1,111 @@ +package vip.mate.agent.graph.plan.node; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pins the triage evidence gate ({@link PlanGenerationNode#shouldOverrideDirectAnswer}). + * + *

The gate catches the failure mode where the triage model returns a + * {@code direct_answer} (category A) for a task that actually needs tools — + * accepting it would end the turn via DirectAnswerNode without ever executing, + * which surfaces to users as "复杂任务不执行就停止". When fired, the node + * downgrades to a single-step plan so the executor still reaches the tools. + * + *

The gate is intentionally biased toward executing: a false positive costs + * one extra executor pass (which still answers), while a false negative drops + * the whole task. These tests lock both the must-override cases and the + * genuine-knowledge-Q&A cases that must NOT be downgraded. + */ +@DisplayName("PlanGeneration triage evidence gate") +class PlanGenerationEvidenceGateTest { + + @Test + @DisplayName("overrides when the goal contains clear action verbs") + void overridesOnActionGoal() { + // file / memory / search / generate actions all imply tools + assertTrue(PlanGenerationNode.shouldOverrideDirectAnswer("帮我读取 config.yml 并总结配置项", null)); + assertTrue(PlanGenerationNode.shouldOverrideDirectAnswer("记住我偏好简洁的回答", null)); + assertTrue(PlanGenerationNode.shouldOverrideDirectAnswer("搜索一下今天的 AI 新闻", null)); + assertTrue(PlanGenerationNode.shouldOverrideDirectAnswer("帮我生成一份周报模板", null)); + assertTrue(PlanGenerationNode.shouldOverrideDirectAnswer("查一下我的知识库里有没有这份文档", null)); + assertTrue(PlanGenerationNode.shouldOverrideDirectAnswer("你现在挂载了哪些技能和工具?", null)); + assertTrue(PlanGenerationNode.shouldOverrideDirectAnswer("打开 MessageBubble.vue 看看渲染逻辑", null)); + } + + @Test + @DisplayName("overrides when the answer is a plan preamble that promises action") + void overridesOnActionPromiseAnswer() { + assertTrue(PlanGenerationNode.shouldOverrideDirectAnswer( + "这个项目用了什么技术栈", "我先去读取项目的 pom.xml 再回答你。")); + assertTrue(PlanGenerationNode.shouldOverrideDirectAnswer( + "总结一下", "让我先检索一下相关记忆。")); + assertTrue(PlanGenerationNode.shouldOverrideDirectAnswer( + "汇总", "接下来我会调用搜索工具获取最新数据。")); + } + + @Test + @DisplayName("does NOT override genuine knowledge questions") + void keepsDirectAnswerForKnowledge() { + assertFalse(PlanGenerationNode.shouldOverrideDirectAnswer( + "什么是依赖注入?", "依赖注入是一种控制反转的实现方式……")); + assertFalse(PlanGenerationNode.shouldOverrideDirectAnswer( + "用一句话解释一下闭包", "闭包是函数与其词法作用域的组合。")); + // A normal narrative opener ("我来介绍…") must not be mistaken for an + // action promise — the verb after it is descriptive, not a tool call. + assertFalse(PlanGenerationNode.shouldOverrideDirectAnswer( + "介绍一下杭州", "我来介绍一下杭州这座城市的历史与风景。")); + assertFalse(PlanGenerationNode.shouldOverrideDirectAnswer( + "Java 和 Kotlin 的主要区别是什么", "两者的主要区别在于语法简洁性和空安全……")); + } + + @Test + @DisplayName("null-safe on missing goal / answer") + void nullSafe() { + assertFalse(PlanGenerationNode.shouldOverrideDirectAnswer(null, null)); + assertFalse(PlanGenerationNode.shouldOverrideDirectAnswer("普通问候", null)); + } + + // The goal handed to triage is wrapped by RuntimeContextInjector with a + // block that itself contains filenames (user.md, PROFILE.md) + // and memory keywords. The gate must match the user's ASK, not the wrapper — + // otherwise it fires on every task and kills the direct-answer fast path. + private static final String MEMORY_WRAPPER = + "\n" + + "The following is what you already know about this user.\n" + + "--- structured/user.md ---\n" + + "## preferred_answer_style\n用户喜欢简洁、分点的回答方式。\n" + + "--- PROFILE.md ---\n## 回答偏好\n- 用户喜欢简洁、分点的回答方式。\n" + + "\n\n"; + + @Test + @DisplayName("ignores the injected memory-context wrapper (no false positive)") + void ignoresInjectedWrapper() { + // Trivial knowledge question — the wrapper contains user.md / 偏好, but the + // real ask needs no tools, so the gate must NOT fire. + assertFalse(PlanGenerationNode.shouldOverrideDirectAnswer(MEMORY_WRAPPER + "1加1等于几", "2")); + assertFalse(PlanGenerationNode.shouldOverrideDirectAnswer(MEMORY_WRAPPER + "什么是闭包", "闭包是……")); + } + + @Test + @DisplayName("still fires on a real action ask even when wrapped") + void firesOnRealActionInsideWrapper() { + assertTrue(PlanGenerationNode.shouldOverrideDirectAnswer( + MEMORY_WRAPPER + "帮我读取 pom.xml 并总结依赖", null)); + assertTrue(PlanGenerationNode.shouldOverrideDirectAnswer( + MEMORY_WRAPPER + "搜索一下今天的新闻", null)); + } + + @Test + @DisplayName("stripInjectedContext returns the raw ask") + void stripsWrapper() { + assertEquals("1加1等于几", PlanGenerationNode.stripInjectedContext(MEMORY_WRAPPER + "1加1等于几")); + assertEquals("无包装直接问", PlanGenerationNode.stripInjectedContext("无包装直接问")); + assertNull(PlanGenerationNode.stripInjectedContext(null)); + } +}