fix(plans): scrub injected context from persisted plan goal (#402)

This commit is contained in:
matevip 2026-06-22 17:54:27 +08:00
parent 1caa0dbece
commit 5ff58b00ad
4 changed files with 164 additions and 19 deletions

View File

@ -33,6 +33,7 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
@ -190,6 +191,48 @@ public class PlanGenerationNode implements NodeAction {
return goal;
}
/** Whole injected long-term-memory recall block (any casing). */
private static final Pattern MEMORY_CONTEXT_BLOCK =
Pattern.compile("(?is)<\\s*memory-context\\s*>.*?</\\s*memory-context\\s*>");
/** Stray open/close memory-context fence tags left after block removal. */
private static final Pattern MEMORY_CONTEXT_TAG =
Pattern.compile("(?i)</?\\s*memory-context\\s*>");
/** Marker that introduces the real instruction inside a scheduled-run wrapper. */
private static final String CRON_TASK_MARKER = "[任务指令]";
/** Suffix appended by a goal-driven re-plan pass; not part of the user's ask. */
private static final String FOLLOWUP_MARKER = "[Follow-up guidance]";
/**
* Recovers the user's actual request from the fully-assembled agent prompt so
* the persisted/displayed plan goal reads as the task itself, not the
* framework scaffolding wrapped around it. The graph receives the goal already
* enriched a {@code <memory-context></memory-context>} recall block is
* prepended for every turn, scheduled runs add a wrapper whose real payload
* sits after {@code [任务指令]}, and a re-plan pass appends a
* {@code [Follow-up guidance]} block. Persisting that verbatim left the Plan
* board showing "&lt;memory-context&gt; The following is what you…" instead of
* the user's goal. Strips, in order: the recall block, the scheduled-run
* preamble (keeping only the instruction body), and the follow-up suffix.
* Falls back to the raw goal if scrubbing would leave nothing.
*/
static String displayGoal(String goal) {
if (goal == null || goal.isBlank()) {
return goal == null ? "" : goal;
}
String s = MEMORY_CONTEXT_BLOCK.matcher(goal).replaceAll("");
s = MEMORY_CONTEXT_TAG.matcher(s).replaceAll("");
int task = s.lastIndexOf(CRON_TASK_MARKER);
if (task >= 0) {
s = s.substring(task + CRON_TASK_MARKER.length());
}
int followup = s.indexOf(FOLLOWUP_MARKER);
if (followup >= 0) {
s = s.substring(0, followup);
}
s = s.strip();
return s.isEmpty() ? goal.strip() : s;
}
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService,
NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager,
@ -260,7 +303,7 @@ public class PlanGenerationNode implements NodeAction {
if (goalService.findActiveByConversation(convId) != null) {
return null; // respect an existing goal (incl. re-plan passes)
}
String request = stripInjectedContext(accessor.goal()).strip();
String request = displayGoal(accessor.goal());
GoalCreateRequest req = new GoalCreateRequest();
req.setConversationId(convId);
req.setAgentId(origin.agentId());
@ -368,10 +411,16 @@ public class PlanGenerationNode implements NodeAction {
String agentId = state.value(MateClawStateKeys.AGENT_ID, "");
String conversationId = accessor.conversationId();
log.info("[PlanGeneration] Evaluating goal: {}", goal.length() > 100 ? goal.substring(0, 100) + "..." : goal);
// The graph's goal carries framework scaffolding (memory recall block,
// scheduled-run wrapper, follow-up suffix). Persist and display the
// scrubbed user request so the Plan board shows the actual task; the raw
// goal still feeds the triage LLM below.
String persistGoal = displayGoal(goal);
log.info("[PlanGeneration] Evaluating goal: {}", persistGoal.length() > 100 ? persistGoal.substring(0, 100) + "..." : persistGoal);
List<GraphEventPublisher.GraphEvent> events = new ArrayList<>();
events.add(GraphEventPublisher.phase("planning", Map.of("goal", goal)));
events.add(GraphEventPublisher.phase("planning", Map.of("goal", persistGoal)));
// Replay path: plan is already in state (injected by chatWithReplayStream); skip LLM.
Long existingPlanId = state.<Long>value(PlanStateKeys.PLAN_ID).orElse(null);
@ -508,8 +557,8 @@ public class PlanGenerationNode implements NodeAction {
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, conversationId, goal, gatedSteps);
List<String> gatedSteps = List.of(persistGoal);
var gatedPlan = planningService.createPlan(agentId, conversationId, persistGoal, gatedSteps);
events.add(GraphEventPublisher.planCreated(gatedPlan.getId(), gatedSteps));
return PlanStateAccessor.output()
.needsPlanning(true)
@ -547,7 +596,7 @@ public class PlanGenerationNode implements NodeAction {
// can still reach the tools. (Previous behavior dropped back to
// direct_answer, which silently stripped tool capability.)
log.warn("[PlanGeneration] needs_planning=true with empty steps; falling back to single-step plan");
steps = List.of(goal);
steps = List.of(persistGoal);
}
// Resolve any per-step agent delegation the planner asked for. Null
@ -555,7 +604,7 @@ public class PlanGenerationNode implements NodeAction {
List<Long> stepAgentIds = resolveStepAgents(steps,
triage != null ? triage.stepAgents() : null,
chatOrigin.workspaceId(), agentId);
var plan = planningService.createPlan(agentId, conversationId, goal, steps, stepAgentIds);
var plan = planningService.createPlan(agentId, conversationId, persistGoal, steps, stepAgentIds);
log.info("[PlanGeneration] Plan created: id={}, steps={} ({}){}",
plan.getId(), steps.size(), steps.size() == 1 ? "single-step" : "multi-step",
stepAgentIds != null ? ", per-step delegation=" + stepAgentIds : "");
@ -600,12 +649,12 @@ public class PlanGenerationNode implements NodeAction {
// answer. This preserves tool access on the failure path; the previous
// "direct answer" fallback silently degraded tool-requiring tasks.
try {
var plan = planningService.createPlan(agentId, conversationId, goal, List.of(goal));
events.add(GraphEventPublisher.planCreated(plan.getId(), List.of(goal)));
var plan = planningService.createPlan(agentId, conversationId, persistGoal, List.of(persistGoal));
events.add(GraphEventPublisher.planCreated(plan.getId(), List.of(persistGoal)));
return PlanStateAccessor.output()
.needsPlanning(true)
.planId(plan.getId())
.planSteps(List.of(goal))
.planSteps(List.of(persistGoal))
.planValid(true)
.currentStepIndex(0)
.currentPhase("plan_generated")

View File

@ -0,0 +1,76 @@
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.assertTrue;
/**
* Pins {@link PlanGenerationNode#displayGoal} the scrubber that recovers the
* user's actual request from the fully-assembled agent prompt before it is
* persisted as the plan goal.
*
* <p>The graph receives the goal already enriched: a {@code <memory-context>}
* recall block is prepended every turn, scheduled runs wrap the instruction in a
* preamble whose payload follows {@code [任务指令]}, and a re-plan pass appends a
* {@code [Follow-up guidance]} block. Persisting that verbatim left the Plan
* board showing "&lt;memory-context&gt; The following is what you…" instead of the
* task these tests lock the clean-up.
*/
@DisplayName("PlanGeneration displayGoal scrubber")
class PlanGenerationDisplayGoalTest {
private static final String MEMORY_WRAPPER =
"<memory-context>\n"
+ "The following is what you already know about this user.\n"
+ "## preferred_answer_style\n用户喜欢简洁、分点的回答方式。\n"
+ "</memory-context>\n\n";
@Test
@DisplayName("strips the injected memory-context block")
void stripsMemoryContext() {
assertEquals("帮我读取 pom.xml 并总结依赖",
PlanGenerationNode.displayGoal(MEMORY_WRAPPER + "帮我读取 pom.xml 并总结依赖"));
}
@Test
@DisplayName("keeps only the instruction body of a scheduled-run wrapper")
void unwrapsScheduledRunPrompt() {
String cron = MEMORY_WRAPPER
+ "[定时任务执行说明]\n本次对话由定时任务自动触发不是用户实时发来的消息。\n"
+ "- 请把下面的「任务指令」当作一个完整、独立的任务来执行。\n\n"
+ "[任务指令]\nqwen3-max 重试测试";
assertEquals("qwen3-max 重试测试", PlanGenerationNode.displayGoal(cron));
}
@Test
@DisplayName("drops the trailing follow-up guidance block")
void dropsFollowupSuffix() {
String withFollowup = MEMORY_WRAPPER
+ "整理本周的项目进展\n\n[Follow-up guidance]\n再补充一下风险项";
assertEquals("整理本周的项目进展", PlanGenerationNode.displayGoal(withFollowup));
}
@Test
@DisplayName("passes through a clean goal untouched")
void passesThroughCleanGoal() {
assertEquals("无包装直接问", PlanGenerationNode.displayGoal("无包装直接问"));
}
@Test
@DisplayName("falls back to the raw goal when scrubbing leaves nothing")
void fallsBackWhenEmpty() {
// A goal that is nothing but the recall block must not collapse to "".
String result = PlanGenerationNode.displayGoal(MEMORY_WRAPPER);
assertTrue(result.contains("memory-context"),
"scrubbing an all-wrapper goal should fall back to the raw text, not empty");
}
@Test
@DisplayName("null / blank safe")
void nullSafe() {
assertEquals("", PlanGenerationNode.displayGoal(null));
assertEquals(" ", PlanGenerationNode.displayGoal(" "));
}
}

View File

@ -219,12 +219,23 @@ function planTs(p: Plan): number {
return p.createTime ? new Date(p.createTime).getTime() : 0
}
// Drop the appended "[Follow-up guidance] ..." block so re-runs of one objective
// share a title and therefore a group.
// Recover the user's actual request for display/grouping. Plans created before
// the server-side scrub (and any not yet migrated) persisted the fully-assembled
// prompt: a <memory-context> recall block, a scheduled-run wrapper whose payload
// follows [], and a trailing [Follow-up guidance] block. Strip all three
// so the card shows the task and re-runs of one objective share a group. Mirrors
// the backend PlanGenerationNode.displayGoal scrubber; a no-op on clean goals.
function cleanGoal(goal?: string): string {
if (!goal) return ''
const i = goal.indexOf('[Follow-up guidance]')
return (i >= 0 ? goal.slice(0, i) : goal).trim()
let s = goal
.replace(/<\s*memory-context\s*>[\s\S]*?<\s*\/\s*memory-context\s*>/gi, '')
.replace(/<\/?\s*memory-context\s*>/gi, '')
const task = s.lastIndexOf('[任务指令]')
if (task >= 0) s = s.slice(task + '[任务指令]'.length)
const followup = s.indexOf('[Follow-up guidance]')
if (followup >= 0) s = s.slice(0, followup)
s = s.trim()
return s || goal.trim()
}
// Group a column's plans by cleaned goal, newest run first; groups ordered by

View File

@ -134,13 +134,22 @@ function statusLabel(status: string): string {
return t(`plans.col.${status}`, status)
}
// The persisted goal can carry an appended "[Follow-up guidance] ..." block
// (added when a goal follow-up re-enters planning). Strip it for display so the
// title reads as the original task, not the internal re-prompt.
// Recover the user's actual request for the title. Plans persisted before the
// server-side scrub carry the fully-assembled prompt a <memory-context> recall
// block, a scheduled-run wrapper whose payload follows [], and a trailing
// [Follow-up guidance] block. Strip all three so the title reads as the task.
// Mirrors the backend PlanGenerationNode.displayGoal scrubber; a no-op on clean goals.
function cleanGoal(goal: string): string {
if (!goal) return ''
const i = goal.indexOf('[Follow-up guidance]')
return (i >= 0 ? goal.slice(0, i) : goal).trim()
let s = goal
.replace(/<\s*memory-context\s*>[\s\S]*?<\s*\/\s*memory-context\s*>/gi, '')
.replace(/<\/?\s*memory-context\s*>/gi, '')
const task = s.lastIndexOf('[任务指令]')
if (task >= 0) s = s.slice(task + '[任务指令]'.length)
const followup = s.indexOf('[Follow-up guidance]')
if (followup >= 0) s = s.slice(0, followup)
s = s.trim()
return s || goal.trim()
}
function letter(name?: string): string {