mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(plans): scrub injected context from persisted plan goal (#402)
This commit is contained in:
parent
1caa0dbece
commit
5ff58b00ad
@ -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 "<memory-context> 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")
|
||||
|
||||
@ -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 "<memory-context> 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(" "));
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user