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 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
+ //