mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +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)。
|
- 多部分、多阶段、需要逐步推进的目标走(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,
|
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService,
|
||||||
NodeStreamingChatHelper streamingHelper,
|
NodeStreamingChatHelper streamingHelper,
|
||||||
ConversationWindowManager conversationWindowManager,
|
ConversationWindowManager conversationWindowManager,
|
||||||
@ -250,6 +318,31 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
// Category (A): direct answer — push to client and terminate via DirectAnswerNode.
|
// Category (A): direct answer — push to client and terminate via DirectAnswerNode.
|
||||||
String directAnswer = triage != null && triage.directAnswer() != null
|
String directAnswer = triage != null && triage.directAnswer() != null
|
||||||
? triage.directAnswer() : llmResponse;
|
? 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)");
|
log.info("[PlanGeneration] Direct-answer route taken (no tools, no planning)");
|
||||||
|
|
||||||
streamingHelper.broadcastContent(conversationId, directAnswer);
|
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