fix(chat): persist planning reasoning, dedupe the plan summary, normalize metadata reads

This commit is contained in:
matevip 2026-08-06 07:09:32 -04:00
parent e8d5e73825
commit 99c18b968a
13 changed files with 254 additions and 14 deletions

View File

@ -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)

View File

@ -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);

View File

@ -155,6 +155,11 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
// 去重记录上一次已持久化的 step 结果和 thinking防止 PlanSummaryNode 重复 emit 上一步内容
AtomicReference<String> lastPersistedStepResult = new AtomicReference<>("");
AtomicReference<String> lastPersistedStepThinking = new AtomicReference<>("");
// 最终汇总同样需要游标FINAL_SUMMARY / FINAL_SUMMARY_THINKING 也是 REPLACE
// 一旦写入就会出现在此后每个 NodeOutput
AtomicReference<String> lastPersistedSummary = new AtomicReference<>("");
AtomicReference<String> lastPersistedSummaryThinking = new AtomicReference<>("");
AtomicReference<String> 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().<String>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().<String>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().<String>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));

View File

@ -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) {

View File

@ -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);
}

View File

@ -56,6 +56,15 @@ public final class PlanStateKeys {
/** 当前步骤的完整 thinking */
public static final String CURRENT_STEP_THINKING = "current_step_thinking";
/**
* 规划阶段的完整 thinking 决定整个计划长什么样的那次推理
* <p>
* 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";

View File

@ -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<String, Object> 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<String, Object> parsed = objectMapper.readValue(
MessageMetadataJson.normalize(rawMetadata),
new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {});
Object segs = parsed.get("segments");
if (segs instanceof java.util.List<?> list && !list.isEmpty()) {

View File

@ -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 "";
}

View File

@ -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.
* <p>
* The column is declared {@code JSON}. Read back through MyBatis, H2 hands it
* over as a JSON <em>string literal</em> 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.
* <p>
* The failure is always silent, never an exception the caller notices:
* <ul>
* <li>{@code readTree} yields a {@code TextNode}, so every field lookup misses
* and the metadata reads as absent rather than as unparsed;</li>
* <li>{@code readValue(.., Map.class)} throws, and these call sites all sit
* inside a best-effort {@code catch} that degrades instead of failing;</li>
* <li>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.</li>
* </ul>
* 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;
}
}
}

View File

@ -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());
}
}

View File

@ -47,6 +47,18 @@
<!-- ===== 分段式渲染模式Claude Code 风格===== -->
<template v-if="useSegmentedView">
<div class="segments-view">
<!-- The "full reasoning" preference is hiding earlier spans. Say so:
silently removing them reads as reasoning that went missing. -->
<button
v-if="hiddenThinkingCount > 0 && showThinking"
class="superseded-toggle"
type="button"
@click="earlyThinkingExpanded = true"
>
<el-icon><InfoFilled /></el-icon>
<span>{{ $t('chat.earlierThinkingCollapsed', { count: hiddenThinkingCount }) }}</span>
<span class="superseded-toggle__action">{{ $t('chat.expand') }}</span>
</button>
<template v-for="iter in groupedIterations" :key="iter.key">
<!-- Iteration interrupted before any output landed surface a chip
so the user knows the agent moved on instead of silently
@ -1098,15 +1110,23 @@ const parsedMetadata = computed(() => {
return raw
})
/** Per-message override of the "full reasoning" preference (the collapse banner). */
const earlyThinkingExpanded = ref(false)
/**
* Apply the "full reasoning" preference. When off, only the reasoning span
* that produced the answer survives the last one in the timeline. Everything
* is still persisted and still exported by the trajectory endpoint; this is
* purely how much of it the bubble shows. A running span is never dropped, so
* a live turn still shows the model thinking as it goes.
*
* Whatever this hides is announced by the banner above the timeline and can be
* expanded in place. Dropping spans with no trace is indistinguishable from
* losing them the reader sees reasoning that was there mid-turn simply gone,
* and a preference stuck in the off state has no symptom to follow back.
*/
function applyThinkingDetail(segs: MessageSegment[]): MessageSegment[] {
if (thinkingFull.value) return segs
if (thinkingFull.value || earlyThinkingExpanded.value) return segs
const keepIdx = segs.map((s, i) => (s.type === 'thinking' ? i : -1))
.filter(i => i >= 0)
.pop()
@ -1115,6 +1135,15 @@ function applyThinkingDetail(segs: MessageSegment[]): MessageSegment[] {
s.type !== 'thinking' || i === keepIdx || s.status === 'running')
}
/** How many reasoning spans the preference is currently hiding on this message. */
const hiddenThinkingCount = computed(() => {
if (thinkingFull.value || earlyThinkingExpanded.value) return 0
const all = (parsedMetadata.value?.segments as MessageSegment[] | undefined) || []
const total = all.filter(s => s.type === 'thinking').length
const shown = segments.value.filter(s => s.type === 'thinking').length
return Math.max(0, total - shown)
})
const segments = computed<MessageSegment[]>(() => {
if (props.message.role !== 'assistant') return []
const meta = parsedMetadata.value

View File

@ -533,6 +533,7 @@ export default {
contentRepetitionWarning: 'Repetitive content detected near the end (model artifact)',
supersededPreviewCollapsed: 'Collapsed: content drafted before tool execution (may not match actual results)',
supersededPreviewExpanded: 'Below is content drafted before tool execution (may not match actual results)',
earlierThinkingCollapsed: '{count} earlier reasoning span(s) collapsed — enable "Keep Full Reasoning" in settings to show them by default',
pendingReply: 'Preparing a reply…',
expand: 'Expand',
// INCOMPLETE truncation card (finishReason=incomplete)

View File

@ -533,6 +533,7 @@ export default {
contentRepetitionWarning: '检测到内容尾部重复(疑似模型输出 artifact',
supersededPreviewCollapsed: '已折叠:模型在工具执行前预写的内容(可能与实际结果不符)',
supersededPreviewExpanded: '以下是模型在工具执行前预写的内容(可能与实际结果不符)',
earlierThinkingCollapsed: '更早的 {count} 段思考已折叠(设置里开启「保留完整推理」可默认展开)',
pendingReply: '正在准备回复…',
expand: '展开',
// INCOMPLETE 截断卡片finishReason=incomplete