mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(agent/plan): 分流器证据闸门防止复杂任务不执行就停止(v2 剥离 memory-context)
This commit is contained in:
parent
fb93f99425
commit
c1fd39a1e2
@ -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 ("复杂任务不执行就停止").
|
||||
* <p>
|
||||
* 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 <memory-context>…</memory-context>} 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("</memory-context>");
|
||||
if (end >= 0) {
|
||||
return goal.substring(end + "</memory-context>".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<String> 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);
|
||||
|
||||
@ -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}).
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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
|
||||
// <memory-context> 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 =
|
||||
"<memory-context>\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"
|
||||
+ "</memory-context>\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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user