From 99c18b968ad57dc199ac7ef90fc6ea1c231c5def Mon Sep 17 00:00:00 2001 From: matevip Date: Thu, 6 Aug 2026 07:09:32 -0400 Subject: [PATCH] fix(chat): persist planning reasoning, dedupe the plan summary, normalize metadata reads --- .../vip/mate/agent/AgentGraphBuilder.java | 1 + .../main/java/vip/mate/agent/BaseAgent.java | 12 ++- .../plan/StateGraphPlanExecuteAgent.java | 41 ++++++++-- .../graph/plan/node/PlanGenerationNode.java | 10 ++- .../graph/plan/state/PlanStateAccessor.java | 8 ++ .../agent/graph/plan/state/PlanStateKeys.java | 9 +++ .../vip/mate/channel/web/ChatController.java | 8 +- .../service/MemorySummarizationGate.java | 8 +- .../conversation/MessageMetadataJson.java | 58 ++++++++++++++ .../conversation/MessageMetadataJsonTest.java | 80 +++++++++++++++++++ .../src/components/chat/MessageBubble.vue | 31 ++++++- mateclaw-ui/src/i18n/locales/en-US.ts | 1 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 1 + 13 files changed, 254 insertions(+), 14 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/workspace/conversation/MessageMetadataJson.java create mode 100644 mateclaw-server/src/test/java/vip/mate/workspace/conversation/MessageMetadataJsonTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index a03d79e9..1f24ab66 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -702,6 +702,7 @@ public class AgentGraphBuilder { // Thinking 键 .addStrategy(PlanStateKeys.FINAL_SUMMARY_THINKING, KeyStrategy.REPLACE) .addStrategy(PlanStateKeys.CURRENT_STEP_THINKING, KeyStrategy.REPLACE) + .addStrategy(PlanStateKeys.PLAN_THINKING, KeyStrategy.REPLACE) // 流式防重键 .addStrategy(MateClawStateKeys.CONTENT_STREAMED, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.THINKING_STREAMED, KeyStrategy.REPLACE) diff --git a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java index f3fe7208..6f460ef8 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java @@ -21,6 +21,7 @@ import vip.mate.llm.routing.model.MultimodalRoutingDecision; import vip.mate.llm.service.ModelCapabilityService; import org.springframework.ai.chat.messages.ToolResponseMessage; import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.MessageMetadataJson; import vip.mate.workspace.conversation.model.MessageContentPart; import vip.mate.workspace.conversation.model.MessageEntity; @@ -811,8 +812,15 @@ public abstract class BaseAgent { if (msg == null) return List.of(); String metadata = msg.getMetadata(); if (metadata == null || metadata.isEmpty()) return List.of(); - if (!metadata.contains("\"directToolNames\"")) return List.of(); - java.util.regex.Matcher arrayMatcher = DIRECT_TOOL_NAMES_ARRAY.matcher(metadata); + // Guard on the bare key, not on `"directToolNames"`: the escaped form + // reads \"directToolNames\", where the quotes are no longer adjacent to + // the name, so a quoted guard exits early on every H2-backed row and the + // badge silently disappears. Bare-key matching holds for both forms and + // keeps the common case (no such key) allocation-free; the exact match + // then runs against normalized JSON. + if (!metadata.contains("directToolNames")) return List.of(); + java.util.regex.Matcher arrayMatcher = + DIRECT_TOOL_NAMES_ARRAY.matcher(MessageMetadataJson.normalize(metadata)); if (!arrayMatcher.find()) return List.of(); String inner = arrayMatcher.group(1); java.util.regex.Matcher nameMatcher = DIRECT_TOOL_NAMES_INNER.matcher(inner); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java index 3b75972d..850b90fd 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java @@ -155,6 +155,11 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS // 去重:记录上一次已持久化的 step 结果和 thinking,防止 PlanSummaryNode 重复 emit 上一步内容 AtomicReference lastPersistedStepResult = new AtomicReference<>(""); AtomicReference lastPersistedStepThinking = new AtomicReference<>(""); + // 最终汇总同样需要游标:FINAL_SUMMARY / FINAL_SUMMARY_THINKING 也是 REPLACE, + // 一旦写入就会出现在此后每个 NodeOutput 上。 + AtomicReference lastPersistedSummary = new AtomicReference<>(""); + AtomicReference lastPersistedSummaryThinking = new AtomicReference<>(""); + AtomicReference lastPersistedPlanThinking = new AtomicReference<>(""); return BaseAgent.routingStartupDelta(inputs).concatWith(compiledGraph.stream(inputs, config) .flatMapIterable(output -> { @@ -176,6 +181,17 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS boolean thinkingAlreadyStreamed = output.state() .value(MateClawStateKeys.THINKING_STREAMED, false); + // 2·0 规划阶段的推理。它先于计划本身发出,且是整轮唯一必然发生的 + // 一段推理 —— 步骤被派发到别处执行时,step / summary 两段 + // 根本不会产生,此前这一轮就一段思考都不落库。 + output.state().value(PlanStateKeys.PLAN_THINKING) + .filter(s -> !s.isEmpty()) + .filter(s -> !s.equals(lastPersistedPlanThinking.get())) + .ifPresent(planThinking -> { + lastPersistedPlanThinking.set(planThinking); + deltas.add(AgentService.StreamDelta.persistOnly(null, planThinking)); + }); + // 2a. 各步骤执行结果(StepExecutionNode 已通过 NodeStreamingChatHelper 直推 SSE, // 这里仅作为 persistOnly 送入 Accumulator,确保写入 mate_message) // 利用内容本身去重,避免 PlanSummaryNode 输出时重复 emit 上一步残留在 state 的值 @@ -199,17 +215,30 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS }); // 2b. 最终汇总(同样 thinking 先于 content) + // 两个 key 都是 REPLACE:值会滞留在后续每个 NodeOutput 里。 + // 没有游标时每批都会重发一次 —— 汇总正文被反复追加进 + // mate_message.content,而 thinking 会在正文之后再落一段, + // 于是气泡末尾挂出一个孤立的思考框。与 2a 的 step 级 + // 去重保持同一套写法。 output.state().value(PlanStateKeys.FINAL_SUMMARY_THINKING) .filter(s -> !s.isEmpty()) - .ifPresent(thinking -> deltas.add(thinkingAlreadyStreamed - ? AgentService.StreamDelta.persistOnly(null, thinking) - : new AgentService.StreamDelta(null, thinking))); + .filter(s -> !s.equals(lastPersistedSummaryThinking.get())) + .ifPresent(thinking -> { + lastPersistedSummaryThinking.set(thinking); + deltas.add(thinkingAlreadyStreamed + ? AgentService.StreamDelta.persistOnly(null, thinking) + : new AgentService.StreamDelta(null, thinking)); + }); output.state().value(PlanStateKeys.FINAL_SUMMARY) .filter(s -> !s.isEmpty()) - .ifPresent(summary -> deltas.add(contentAlreadyStreamed - ? AgentService.StreamDelta.persistOnly(summary, null) - : new AgentService.StreamDelta(summary, null))); + .filter(s -> !s.equals(lastPersistedSummary.get())) + .ifPresent(summary -> { + lastPersistedSummary.set(summary); + deltas.add(contentAlreadyStreamed + ? AgentService.StreamDelta.persistOnly(summary, null) + : new AgentService.StreamDelta(summary, null)); + }); // 3. 更新最新累计 token usage finalPromptTokens.set(output.state().value(MateClawStateKeys.PROMPT_TOKENS, 0)); 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 571e60e1..10b1021c 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 @@ -699,7 +699,8 @@ public class PlanGenerationNode implements NodeAction { .planValid(true) .currentStepIndex(0) .currentPhase("plan_generated") - .thinkingStreamed(!result.thinking().isEmpty()) + .planThinking(result.thinking()) + .thinkingStreamed(!result.thinking().isEmpty()) .mergeUsage(state, result) .events(events) .build(); @@ -714,6 +715,7 @@ public class PlanGenerationNode implements NodeAction { .directAnswer(directAnswer) .currentPhase("direct_answer") .contentStreamed(true) + .planThinking(result.thinking()) .thinkingStreamed(!result.thinking().isEmpty()) .mergeUsage(state, result) .events(events) @@ -755,7 +757,8 @@ public class PlanGenerationNode implements NodeAction { .directAnswer(announcement) .currentPhase("direct_answer") .contentStreamed(true) - .thinkingStreamed(!result.thinking().isEmpty()) + .planThinking(result.thinking()) + .thinkingStreamed(!result.thinking().isEmpty()) .mergeUsage(state, result) .events(events) .build(); @@ -799,7 +802,8 @@ public class PlanGenerationNode implements NodeAction { .currentStepIndex(0) .currentPhase("plan_generated") .contentStreamed(true) - .thinkingStreamed(!result.thinking().isEmpty()) + .planThinking(result.thinking()) + .thinkingStreamed(!result.thinking().isEmpty()) .mergeUsage(state, result) .events(events); if (autoGoal != null) { diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java index f041871e..7522377e 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java @@ -94,6 +94,10 @@ public final class PlanStateAccessor { return state.value(FINAL_SUMMARY_THINKING, ""); } + public String planThinking() { + return state.value(PLAN_THINKING, ""); + } + public String currentStepThinking() { return state.value(CURRENT_STEP_THINKING, ""); } @@ -225,6 +229,10 @@ public final class PlanStateAccessor { return put(FINAL_SUMMARY_THINKING, thinking); } + public OutputBuilder planThinking(String thinking) { + return put(PLAN_THINKING, thinking); + } + public OutputBuilder currentStepThinking(String thinking) { return put(CURRENT_STEP_THINKING, thinking); } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateKeys.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateKeys.java index ac4e720c..80435d0d 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateKeys.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateKeys.java @@ -56,6 +56,15 @@ public final class PlanStateKeys { /** 当前步骤的完整 thinking */ public static final String CURRENT_STEP_THINKING = "current_step_thinking"; + /** + * 规划阶段的完整 thinking —— 决定整个计划长什么样的那次推理。 + *

+ * It is the most consequential reasoning of the turn and the only one that + * exists when the steps are dispatched elsewhere instead of executed in + * this run, which is when the step / summary spans never happen at all. + */ + public static final String PLAN_THINKING = "plan_thinking"; + // ===== 节点名称 ===== public static final String PLAN_GENERATION_NODE = "plan_generation"; public static final String STEP_EXECUTION_NODE = "step_execution"; diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java index 3ee7d871..c8d865fb 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java @@ -25,6 +25,7 @@ import vip.mate.approval.PendingApproval; import vip.mate.approval.ResolveOutcome; import vip.mate.memory.event.ConversationCompletionPublisher; import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.MessageMetadataJson; import vip.mate.workspace.conversation.model.MessageContentPart; import vip.mate.workspace.conversation.model.MessageEntity; @@ -1707,7 +1708,12 @@ public class ChatController { String rawMetadata = savedAssistant.getMetadata(); if (rawMetadata != null && !rawMetadata.isBlank()) { try { - Map parsed = objectMapper.readValue(rawMetadata, + // Without the unwrap this readValue throws on the H2 profile + // and the catch below swallows it, so the superseded markers + // never ride the done payload and every client waits for a + // reload instead — a degradation with no symptom in the log. + Map parsed = objectMapper.readValue( + MessageMetadataJson.normalize(rawMetadata), new com.fasterxml.jackson.core.type.TypeReference>() {}); Object segs = parsed.get("segments"); if (segs instanceof java.util.List list && !list.isEmpty()) { diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/MemorySummarizationGate.java b/mateclaw-server/src/main/java/vip/mate/memory/service/MemorySummarizationGate.java index 9349244b..3eb30d82 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/service/MemorySummarizationGate.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/MemorySummarizationGate.java @@ -1,5 +1,6 @@ package vip.mate.memory.service; +import vip.mate.workspace.conversation.MessageMetadataJson; import vip.mate.workspace.conversation.model.MessageEntity; import java.util.List; @@ -105,7 +106,12 @@ final class MemorySummarizationGate { if (metadata == null || metadata.isBlank()) { return ""; } - Matcher matcher = FINISH_REASON.matcher(metadata); + // The pattern matches `"finishReason":"x"`, which the escaped form + // (`\"finishReason\":\"x\"`) does not contain — the gate would then see + // no reason at all and promote incomplete / stopped / errored turns + // into long-term memory, the exact guess-from-text behaviour the + // structured field exists to avoid. + Matcher matcher = FINISH_REASON.matcher(MessageMetadataJson.normalize(metadata)); if (!matcher.find()) { return ""; } diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/MessageMetadataJson.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/MessageMetadataJson.java new file mode 100644 index 00000000..0a148fca --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/MessageMetadataJson.java @@ -0,0 +1,58 @@ +package vip.mate.workspace.conversation; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Normalizes the raw {@code mate_message.metadata} column value into parseable JSON. + *

+ * The column is declared {@code JSON}. Read back through MyBatis, H2 hands it + * over as a JSON string literal — the whole document quoted and + * escaped — while MySQL and PostgreSQL return the object text directly. Code + * that parses the raw value therefore works in production and quietly stops + * working on the desktop/dev H2 profile. + *

+ * The failure is always silent, never an exception the caller notices: + *

    + *
  • {@code readTree} yields a {@code TextNode}, so every field lookup misses + * and the metadata reads as absent rather than as unparsed;
  • + *
  • {@code readValue(.., Map.class)} throws, and these call sites all sit + * inside a best-effort {@code catch} that degrades instead of failing;
  • + *
  • a regex over the raw text stops matching, because {@code "key":"value"} + * has become {@code \"key\":\"value\"} — the key still greps, so a + * {@code contains} guard passes and only the extraction comes up empty.
  • + *
+ * Call {@link #normalize(String)} before parsing or matching. + * + * @author MateClaw Team + */ +public final class MessageMetadataJson { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private MessageMetadataJson() { + } + + /** + * Return the metadata as plain JSON text, unwrapping one layer of string + * encoding when present. Returns the input unchanged when it is already + * plain JSON, blank, or not decodable — callers keep their existing + * behaviour for values this cannot improve. + */ + public static String normalize(String raw) { + if (raw == null) { + return null; + } + String json = raw.trim(); + if (json.length() < 2 || json.charAt(0) != '"' || json.charAt(json.length() - 1) != '"') { + return raw; + } + try { + String unwrapped = MAPPER.readValue(json, String.class); + return unwrapped != null ? unwrapped : raw; + } catch (Exception e) { + // Not a JSON string literal after all (e.g. truncated). Hand back + // the original so the caller's own error handling decides. + return raw; + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/conversation/MessageMetadataJsonTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/MessageMetadataJsonTest.java new file mode 100644 index 00000000..3ac63733 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/MessageMetadataJsonTest.java @@ -0,0 +1,80 @@ +package vip.mate.workspace.conversation; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.regex.Pattern; + +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 metadata normalization every reader of {@code mate_message.metadata} + * depends on, and the two failure shapes it exists to prevent. + */ +class MessageMetadataJsonTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String PLAIN = + "{\"finishReason\":\"incomplete\",\"directToolNames\":[\"readFile\"],\"segments\":[]}"; + + private static String asH2ReturnsIt(String json) throws Exception { + return MAPPER.writeValueAsString(json); + } + + @Test + @DisplayName("a JSON string literal is unwrapped to the document it holds") + void unwrapsStringLiteral() throws Exception { + assertEquals(PLAIN, MessageMetadataJson.normalize(asH2ReturnsIt(PLAIN))); + } + + @Test + @DisplayName("plain JSON passes through untouched") + void passesPlainJsonThrough() { + assertEquals(PLAIN, MessageMetadataJson.normalize(PLAIN)); + } + + @Test + @DisplayName("null, blank and undecodable values are handed back as-is") + void leavesUnusableValuesAlone() { + assertNull(MessageMetadataJson.normalize(null)); + assertEquals("", MessageMetadataJson.normalize("")); + assertEquals("{not json", MessageMetadataJson.normalize("{not json")); + // Opens like a string literal but cannot be decoded — the caller's own + // error handling should see the original, not a silently mangled value. + assertEquals("\"unterminated", MessageMetadataJson.normalize("\"unterminated")); + } + + @Test + @DisplayName("key-matching regexes miss the escaped form — the reason normalize exists") + void escapedFormDefeatsRegexes() throws Exception { + // Same patterns the finish-reason gate and the direct-tool-name reader use. + Pattern finishReason = Pattern.compile("\"(?:finishReason|finish_reason)\"\\s*:\\s*\"([^\"]+)\""); + Pattern directToolNames = Pattern.compile( + "\"directToolNames\"\\s*:\\s*\\[(\\s*\"[^\"]*\"\\s*(?:,\\s*\"[^\"]*\"\\s*)*)\\]"); + String wrapped = asH2ReturnsIt(PLAIN); + + assertTrue(wrapped.contains("finishReason"), + "the bare key still greps — a guard written that way keeps working"); + assertFalse(wrapped.contains("\"finishReason\""), + "a quoted guard does NOT: escaping puts a backslash between the quote and the name, " + + "so such a guard exits early and the reader never even reaches its pattern"); + assertFalse(finishReason.matcher(wrapped).find(), "escaped form must not match"); + assertFalse(directToolNames.matcher(wrapped).find(), "escaped form must not match"); + + String normalized = MessageMetadataJson.normalize(wrapped); + assertTrue(finishReason.matcher(normalized).find()); + assertTrue(directToolNames.matcher(normalized).find()); + } + + @Test + @DisplayName("normalized output is parseable as an object, not a text node") + void normalizedOutputParsesAsObject() throws Exception { + var node = MAPPER.readTree(MessageMetadataJson.normalize(asH2ReturnsIt(PLAIN))); + assertTrue(node.isObject(), "a text node is how this failure looks when unnoticed"); + assertEquals("incomplete", node.path("finishReason").asText()); + } +} diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue index afa1d693..46b56fc2 100644 --- a/mateclaw-ui/src/components/chat/MessageBubble.vue +++ b/mateclaw-ui/src/components/chat/MessageBubble.vue @@ -47,6 +47,18 @@