fix(agent/plan): 分流器证据闸门防止复杂任务不执行就停止(v2 剥离 memory-context)

This commit is contained in:
matevip 2026-06-09 17:55:26 +08:00
parent fb93f99425
commit c1fd39a1e2
2 changed files with 204 additions and 0 deletions

View File

@ -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);

View File

@ -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));
}
}