mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +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.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -190,6 +191,48 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
return goal;
|
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,
|
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService,
|
||||||
NodeStreamingChatHelper streamingHelper,
|
NodeStreamingChatHelper streamingHelper,
|
||||||
ConversationWindowManager conversationWindowManager,
|
ConversationWindowManager conversationWindowManager,
|
||||||
@ -260,7 +303,7 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
if (goalService.findActiveByConversation(convId) != null) {
|
if (goalService.findActiveByConversation(convId) != null) {
|
||||||
return null; // respect an existing goal (incl. re-plan passes)
|
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();
|
GoalCreateRequest req = new GoalCreateRequest();
|
||||||
req.setConversationId(convId);
|
req.setConversationId(convId);
|
||||||
req.setAgentId(origin.agentId());
|
req.setAgentId(origin.agentId());
|
||||||
@ -368,10 +411,16 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
String agentId = state.value(MateClawStateKeys.AGENT_ID, "");
|
String agentId = state.value(MateClawStateKeys.AGENT_ID, "");
|
||||||
String conversationId = accessor.conversationId();
|
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<>();
|
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.
|
// Replay path: plan is already in state (injected by chatWithReplayStream); skip LLM.
|
||||||
Long existingPlanId = state.<Long>value(PlanStateKeys.PLAN_ID).orElse(null);
|
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; "
|
log.warn("[PlanGeneration] Evidence gate overrode direct-answer route; "
|
||||||
+ "downgrading to single-step plan so tools can execute (goal: {})",
|
+ "downgrading to single-step plan so tools can execute (goal: {})",
|
||||||
goal.length() > 60 ? goal.substring(0, 60) + "..." : goal);
|
goal.length() > 60 ? goal.substring(0, 60) + "..." : goal);
|
||||||
List<String> gatedSteps = List.of(goal);
|
List<String> gatedSteps = List.of(persistGoal);
|
||||||
var gatedPlan = planningService.createPlan(agentId, conversationId, goal, gatedSteps);
|
var gatedPlan = planningService.createPlan(agentId, conversationId, persistGoal, gatedSteps);
|
||||||
events.add(GraphEventPublisher.planCreated(gatedPlan.getId(), gatedSteps));
|
events.add(GraphEventPublisher.planCreated(gatedPlan.getId(), gatedSteps));
|
||||||
return PlanStateAccessor.output()
|
return PlanStateAccessor.output()
|
||||||
.needsPlanning(true)
|
.needsPlanning(true)
|
||||||
@ -547,7 +596,7 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
// can still reach the tools. (Previous behavior dropped back to
|
// can still reach the tools. (Previous behavior dropped back to
|
||||||
// direct_answer, which silently stripped tool capability.)
|
// direct_answer, which silently stripped tool capability.)
|
||||||
log.warn("[PlanGeneration] needs_planning=true with empty steps; falling back to single-step plan");
|
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
|
// 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,
|
List<Long> stepAgentIds = resolveStepAgents(steps,
|
||||||
triage != null ? triage.stepAgents() : null,
|
triage != null ? triage.stepAgents() : null,
|
||||||
chatOrigin.workspaceId(), agentId);
|
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={} ({}){}",
|
log.info("[PlanGeneration] Plan created: id={}, steps={} ({}){}",
|
||||||
plan.getId(), steps.size(), steps.size() == 1 ? "single-step" : "multi-step",
|
plan.getId(), steps.size(), steps.size() == 1 ? "single-step" : "multi-step",
|
||||||
stepAgentIds != null ? ", per-step delegation=" + stepAgentIds : "");
|
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
|
// answer. This preserves tool access on the failure path; the previous
|
||||||
// "direct answer" fallback silently degraded tool-requiring tasks.
|
// "direct answer" fallback silently degraded tool-requiring tasks.
|
||||||
try {
|
try {
|
||||||
var plan = planningService.createPlan(agentId, conversationId, goal, List.of(goal));
|
var plan = planningService.createPlan(agentId, conversationId, persistGoal, List.of(persistGoal));
|
||||||
events.add(GraphEventPublisher.planCreated(plan.getId(), List.of(goal)));
|
events.add(GraphEventPublisher.planCreated(plan.getId(), List.of(persistGoal)));
|
||||||
return PlanStateAccessor.output()
|
return PlanStateAccessor.output()
|
||||||
.needsPlanning(true)
|
.needsPlanning(true)
|
||||||
.planId(plan.getId())
|
.planId(plan.getId())
|
||||||
.planSteps(List.of(goal))
|
.planSteps(List.of(persistGoal))
|
||||||
.planValid(true)
|
.planValid(true)
|
||||||
.currentStepIndex(0)
|
.currentStepIndex(0)
|
||||||
.currentPhase("plan_generated")
|
.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
|
return p.createTime ? new Date(p.createTime).getTime() : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop the appended "[Follow-up guidance] ..." block so re-runs of one objective
|
// Recover the user's actual request for display/grouping. Plans created before
|
||||||
// share a title — and therefore a group.
|
// 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 {
|
function cleanGoal(goal?: string): string {
|
||||||
if (!goal) return ''
|
if (!goal) return ''
|
||||||
const i = goal.indexOf('[Follow-up guidance]')
|
let s = goal
|
||||||
return (i >= 0 ? goal.slice(0, i) : goal).trim()
|
.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
|
// 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)
|
return t(`plans.col.${status}`, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The persisted goal can carry an appended "[Follow-up guidance] ..." block
|
// Recover the user's actual request for the title. Plans persisted before the
|
||||||
// (added when a goal follow-up re-enters planning). Strip it for display so the
|
// server-side scrub carry the fully-assembled prompt — a <memory-context> recall
|
||||||
// title reads as the original task, not the internal re-prompt.
|
// 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 {
|
function cleanGoal(goal: string): string {
|
||||||
if (!goal) return ''
|
if (!goal) return ''
|
||||||
const i = goal.indexOf('[Follow-up guidance]')
|
let s = goal
|
||||||
return (i >= 0 ? goal.slice(0, i) : goal).trim()
|
.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 {
|
function letter(name?: string): string {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user