diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java index 33280fc7..fac3be5a 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java @@ -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*>.*?"); + /** Stray open/close memory-context fence tags left after block removal. */ + private static final Pattern MEMORY_CONTEXT_TAG = + Pattern.compile("(?i)"); + /** 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 } 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 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.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 gatedSteps = List.of(goal); - var gatedPlan = planningService.createPlan(agentId, conversationId, goal, gatedSteps); + List 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 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") diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationDisplayGoalTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationDisplayGoalTest.java new file mode 100644 index 00000000..181561d7 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationDisplayGoalTest.java @@ -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. + * + *

The graph receives the goal already enriched: a {@code } + * 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 = + "\n" + + "The following is what you already know about this user.\n" + + "## preferred_answer_style\n用户喜欢简洁、分点的回答方式。\n" + + "\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(" ")); + } +} diff --git a/mateclaw-ui/src/components/agents/PlanBoard.vue b/mateclaw-ui/src/components/agents/PlanBoard.vue index 515883dd..3a64e4ec 100644 --- a/mateclaw-ui/src/components/agents/PlanBoard.vue +++ b/mateclaw-ui/src/components/agents/PlanBoard.vue @@ -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 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 diff --git a/mateclaw-ui/src/components/agents/PlanDetailPanel.vue b/mateclaw-ui/src/components/agents/PlanDetailPanel.vue index fd075c77..8aad7c72 100644 --- a/mateclaw-ui/src/components/agents/PlanDetailPanel.vue +++ b/mateclaw-ui/src/components/agents/PlanDetailPanel.vue @@ -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 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 {