fix(agent): drop brittle output policing, add evidence-grounded long-task safeguards

This commit is contained in:
matevip 2026-05-04 11:55:44 +08:00
parent 3d50b9c132
commit 42d406ffc8
28 changed files with 1145 additions and 482 deletions

View File

@ -433,6 +433,13 @@ public class AgentGraphBuilder {
.addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_MODEL_NAME, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE)
// SourceEvidenceLedger: ActionNode 把每轮 ToolResponse 抽取出的
// (sourcePaths, sourceSymbols, failedPaths) merge 进这个 ledger
// 后续 ReasoningNode / FinalAnswerNode validateAnswer 校验
// 模型引用是否有真实证据漏注册时框架在多 node merge 时会偶发
// 丢这个键evidence_insufficient 检查会"静默地不生效"
// StateKeyRegistrationCoverageTest 专门兜这条
.addStrategy(MateClawStateKeys.SOURCE_EVIDENCE_LEDGER, KeyStrategy.REPLACE)
.build();
// Graph 拓扑
@ -588,6 +595,13 @@ public class AgentGraphBuilder {
.addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_MODEL_NAME, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE)
// SourceEvidenceLedger: ActionNode 把每轮 ToolResponse 抽取出的
// (sourcePaths, sourceSymbols, failedPaths) merge 进这个 ledger
// 后续 ReasoningNode / FinalAnswerNode validateAnswer 校验
// 模型引用是否有真实证据漏注册时框架在多 node merge 时会偶发
// 丢这个键evidence_insufficient 检查会"静默地不生效"
// StateKeyRegistrationCoverageTest 专门兜这条
.addStrategy(MateClawStateKeys.SOURCE_EVIDENCE_LEDGER, KeyStrategy.REPLACE)
.build();
StateGraph graph = new StateGraph("react-agent-v2", keyStrategyFactory)

View File

@ -36,6 +36,15 @@ public final class GraphEventPublisher {
*/
public static final String EVENT_TOOL_DIRECT_RESULT = "tool_direct_result";
/**
* Terminal {@link vip.mate.agent.graph.state.FinishReason} for the turn,
* emitted at FinalAnswerNode so channel-side accumulators can persist it
* into message metadata. Downstream filters (e.g. memory promotion gate)
* branch on this structured value instead of doing brittle text matching
* on the assistant content.
*/
public static final String EVENT_FINISH_REASON = "finish_reason";
/**
* 事件记录
*/
@ -189,6 +198,25 @@ public final class GraphEventPublisher {
return new GraphEvent(EVENT_PERF_SUMMARY, Map.copyOf(data), ts);
}
/**
* Terminal {@code finish_reason} event. Emitted from FinalAnswerNode so it
* rides through the same PENDING_EVENTS StreamDelta pipeline that
* channel-side accumulators consume a sibling SSE-only broadcast would
* bypass {@code ChatController.StreamAccumulator.accept(...)} and fail to
* persist the reason into message metadata.
*
* @param reason {@link vip.mate.agent.graph.state.FinishReason#getValue()}
* (e.g. {@code "incomplete"}, {@code "stopped"},
* {@code "evidence_insufficient"}, {@code "normal"}).
*/
public static GraphEvent finishReason(String reason) {
long ts = System.currentTimeMillis();
return new GraphEvent(EVENT_FINISH_REASON, Map.of(
"reason", reason != null ? reason : "",
"timestamp", ts
), ts);
}
// ===== 提取方法 =====
/**

View File

@ -72,6 +72,7 @@ public class ConversationWindowManager {
private static final int CONTENT_MAX = 6000;
private static final int CONTENT_HEAD = 4000;
private static final int CONTENT_TAIL = 1500;
private static final int OLD_TOOL_RESULT_SUMMARY_THRESHOLD = 500;
// ==================== 冷却机制 ====================
@ -137,6 +138,7 @@ public class ConversationWindowManager {
if (messages == null || messages.isEmpty()) {
return messages;
}
messages = pruneOldToolResultsForModelInput(messages);
int effectiveMax = (maxInputTokens != null && maxInputTokens > 0)
? maxInputTokens : properties.getDefaultMaxInputTokens();
@ -358,6 +360,84 @@ public class ConversationWindowManager {
// ==================== 工具结果处理 ====================
public List<Message> pruneOldToolResultsForModelInput(List<Message> messages) {
int latestToolResponseIndex = -1;
for (int i = messages.size() - 1; i >= 0; i--) {
if (messages.get(i) instanceof ToolResponseMessage) {
latestToolResponseIndex = i;
break;
}
}
if (latestToolResponseIndex <= 0) {
return messages;
}
List<Message> pruned = new ArrayList<>(messages);
java.util.Set<String> seenLargeOutputs = new java.util.HashSet<>();
int changed = 0;
for (int i = pruned.size() - 1; i >= 0; i--) {
if (!(pruned.get(i) instanceof ToolResponseMessage trm)) {
continue;
}
boolean keepFull = i == latestToolResponseIndex;
List<ToolResponseMessage.ToolResponse> newResponses = new ArrayList<>();
boolean messageChanged = false;
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
String data = r.responseData();
if (keepFull || data == null || data.length() <= OLD_TOOL_RESULT_SUMMARY_THRESHOLD) {
newResponses.add(r);
if (data != null && data.length() > OLD_TOOL_RESULT_SUMMARY_THRESHOLD) {
seenLargeOutputs.add(data);
}
continue;
}
String replacement;
if (seenLargeOutputs.contains(data)) {
replacement = "[" + r.name() + "] duplicate tool output omitted; same content appeared later.";
} else {
replacement = summarizeToolResponse(r.name(), data);
seenLargeOutputs.add(data);
}
newResponses.add(new ToolResponseMessage.ToolResponse(r.id(), r.name(), replacement));
messageChanged = true;
}
if (messageChanged) {
pruned.set(i, ToolResponseMessage.builder().responses(newResponses).build());
changed++;
}
}
if (changed > 0) {
log.info("[ConversationWindow] Pruned {} older tool response message(s) before model request", changed);
}
return changed > 0 ? pruned : messages;
}
private static String summarizeToolResponse(String toolName, String data) {
int chars = data.length();
int lines = data.isBlank() ? 0 : data.split("\\R", -1).length;
String firstLine = firstNonBlankLine(data);
if (firstLine.length() > 160) {
firstLine = firstLine.substring(0, 160) + "...";
}
StringBuilder sb = new StringBuilder();
sb.append('[').append(toolName).append("] previous tool output summarized for model context: ")
.append(chars).append(" chars, ").append(lines).append(" lines");
if (!firstLine.isBlank()) {
sb.append(". First line: ").append(firstLine);
}
return sb.toString();
}
private static String firstNonBlankLine(String data) {
for (String line : data.split("\\R")) {
String trimmed = line.trim();
if (!trimmed.isBlank()) {
return trimmed.replace('|', '/');
}
}
return "";
}
/**
* Phase 1 - Soft trim对工具结果做 head+tail 裁剪保留首尾各 200 字符
*/

View File

@ -272,6 +272,17 @@ public class NodeStreamingChatHelper {
// ==================== 重试配置 ====================
/**
* Soft upper bound on per-call thinking ({@code reasoning_content}) chars
* with zero visible content and zero tool calls. Beyond this the helper
* disposes the upstream subscription and returns a partial result so the
* graph can advance instead of streaming thinking forever. Calibrated
* against typical Claude/DeepSeek extended-thinking budgets well above
* normal long-form reasoning, low enough to bound a runaway loop in under
* ~30 seconds of wall clock.
*/
private static final int THINKING_ONLY_HARD_CAP_CHARS = 32768;
private static final int MAX_RETRIES = 5;
// RATE_LIMIT: fail fast to failover chain staying on the same
// provider during a rate-limit window wastes time without recovery.
@ -701,17 +712,12 @@ public class NodeStreamingChatHelper {
AtomicInteger cacheReadTokens = new AtomicInteger(0);
AtomicInteger cacheWriteTokens = new AtomicInteger(0);
// Cross-call repetition detectors: scoped to the conversation rather
// than this single LLM call so the sentence-level path can catch
// adjacent-iteration loops. Tracker returns a fresh detector when
// conversationId is unknown (tests, legacy callers); mocked trackers
// without stubs may return null, so we fall back defensively.
final RepetitionDetector contentRepDetector =
pickDetector(streamTracker != null ? streamTracker.getContentRepDetector(conversationId) : null);
final RepetitionDetector thinkingRepDetector =
pickDetector(streamTracker != null ? streamTracker.getThinkingRepDetector(conversationId) : null);
// 重复检测触发后设为 true外层轮询线程据此 dispose 订阅
AtomicBoolean repetitionTriggered = new AtomicBoolean(false);
// thinking-only soft cap 触发后设为 true外层轮询线程据此 dispose 订阅
// 注意内容流的字符级 / 句子级重复检测已整体移除参考 Hermes 思路
// agent 不替模型审核输出退化 max_tokens + max_iterations 兜底
// 仅保留 thinking-only 这条体积兜底处理 volcengine-plan provider
// thinking 通道堆字符不出 content 的死循环生产 trace c1eefa45
AtomicBoolean thinkingOnlyCapTriggered = new AtomicBoolean(false);
// Lifecycle events emitted at most once per call so consumers can
// pivot the UI between "thinking" and "drafting" without inspecting
@ -753,23 +759,14 @@ public class NodeStreamingChatHelper {
AssistantMessage msg = generation.getOutput();
lastAssistantMessage.set(msg);
// 重复已触发 跳过一切处理等外层 dispose
if (repetitionTriggered.get()) {
// thinking-only soft cap 已触发 跳过一切处理等外层 dispose
if (thinkingOnlyCapTriggered.get()) {
return;
}
// 1. 提取 content delta含重复检测
// 1. 提取 content delta
String contentDelta = msg.getText();
if (contentDelta != null && !contentDelta.isEmpty()) {
if (contentRepDetector.appendAndCheck(contentDelta)) {
log.warn("[{}] Content repetition detected, will cancel stream " +
"for conversation {}", phase, conversationId);
broadcastContentTruncated(conversationId,
contentRepDetector.lastTriggerReason(),
contentAccum.length());
repetitionTriggered.set(true);
return;
}
// First content delta closes the thinking phase if one
// was open, and arms first-token heartbeat relaxation.
if (broadcast && streamTracker != null
@ -789,18 +786,11 @@ public class NodeStreamingChatHelper {
}
}
// 2. 提取 thinking delta含重复检测
// 2. 提取 thinking delta. Do not cancel the stream for
// repeated thinking phrases: some models emit repetitive
// internal planning while still making valid tool progress.
String thinkingDelta = extractReasoningContent(msg);
if (thinkingDelta != null && !thinkingDelta.isEmpty()) {
if (thinkingRepDetector.appendAndCheck(thinkingDelta)) {
log.warn("[{}] Thinking repetition detected, will cancel stream " +
"for conversation {}", phase, conversationId);
broadcastContentTruncated(conversationId,
thinkingRepDetector.lastTriggerReason(),
thinkingAccum.length());
repetitionTriggered.set(true);
return;
}
// First-token signaling fires for thinking too UI
// shows "thinking" activity before any content streams.
if (broadcast && streamTracker != null
@ -832,6 +822,31 @@ public class NodeStreamingChatHelper {
accumulateToolCalls(msg.getToolCalls(), toolCallAccumulators);
}
// 4. Thinking-only no-progress guard. MUST run after both
// content delta and tool call accumulation, otherwise a
// chunk that carries thinking AND a tool_call together
// (some Anthropic / DeepSeek-thinking responses do this)
// would trip the guard before we observe the tool_call
// the user would see "INCOMPLETE: thinking-only" on a
// request that was actually about to dispatch a tool.
// Pattern-agnostic; fires on volume alone. Outer poll
// tears the subscription down within 500ms once the
// flag flips.
if (thinkingAccum.length() >= THINKING_ONLY_HARD_CAP_CHARS
&& contentAccum.length() == 0
&& toolCallAccumulators.isEmpty()
&& !msg.hasToolCalls()) {
log.warn("[{}] Thinking-only soft cap reached " +
"({} thinking chars, no content/tool yet) " +
"— disposing stream for conversation {}",
phase, thinkingAccum.length(), conversationId);
broadcastContentTruncated(conversationId,
"thinking_only_no_content",
thinkingAccum.length());
thinkingOnlyCapTriggered.set(true);
return;
}
// 4. 提取 token usage通常最后一个 chunk 携带完整 usage
if (chatResponse.getMetadata() != null && chatResponse.getMetadata().getUsage() != null) {
var usage = chatResponse.getMetadata().getUsage();
@ -857,14 +872,14 @@ public class NodeStreamingChatHelper {
try {
long deadlineMs = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(10);
while (!latch.await(500, TimeUnit.MILLISECONDS)) {
// 重复检测触发 立即 dispose 上游订阅停止消耗 tokens
if (repetitionTriggered.get()) {
log.warn("[{}] Repetition detected, disposing upstream subscription " +
"for conversation {}", phase, conversationId);
// thinking-only 软上限触发 立即 dispose 上游订阅停止消耗 tokens
if (thinkingOnlyCapTriggered.get()) {
log.warn("[{}] Stream guard tripped (thinking_only_no_content), disposing " +
"upstream subscription for conversation {}", phase, conversationId);
subscription.dispose();
if (broadcast) {
broadcastDelta(conversationId, "warning",
buildDeltaJson("检测到模型输出重复,已自动截断"));
buildDeltaJson("模型在思考阶段停留过久,已自动截断"));
}
// dispose latch 可能不会 countDown直接跳出
break;
@ -964,22 +979,21 @@ public class NodeStreamingChatHelper {
conversationId, phase, errorType);
}
// ===== 成功检查是否因重复被截断 =====
boolean truncatedByRepetition = repetitionTriggered.get();
if (truncatedByRepetition) {
log.warn("[{}] LLM output was truncated due to repetition detection for conversation {}",
// ===== 成功检查是否因 thinking-only 软上限被截断 =====
boolean truncatedByThinkingCap = thinkingOnlyCapTriggered.get();
if (truncatedByThinkingCap) {
log.warn("[{}] LLM stream disposed: thinking-only soft cap reached for conversation {}",
phase, conversationId);
// warning 已在 dispose 时广播无需重复
}
// RFC-009: guard against silent empty responses. Some providers return
// HTTP 200 with an empty body under soft-failure conditions (rate-limit
// capacity, context filter, upstream overload). Treat this as a failure
// signal so streamCallInternal can hand off to the fallback chain.
// Only fire when the primary wasn't truncated by our own repetition
// detector (which deliberately produces short content) and when there
// are no tool calls (tool-only responses are legitimately empty-text).
if (!truncatedByRepetition
// Only fire when the thinking-only cap didn't fire (which deliberately
// produces thinking-only output) and there are no tool calls
// (tool-only responses are legitimately empty-text).
if (!truncatedByThinkingCap
&& contentAccum.length() == 0
&& thinkingAccum.length() == 0
&& toolCallAccumulators.isEmpty()) {
@ -990,7 +1004,8 @@ public class NodeStreamingChatHelper {
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
promptTokens.get(), completionTokens.get(),
cacheReadTokens.get(), cacheWriteTokens.get(), phase,
truncatedByRepetition, truncatedByRepetition ? "output_truncated_repetition" : null);
truncatedByThinkingCap,
truncatedByThinkingCap ? "thinking_only_no_content" : null);
}
/** 组装 stopped partial 结果(用户主动停止,有已累积内容) */
@ -1484,8 +1499,7 @@ public class NodeStreamingChatHelper {
/**
* Broadcast a {@code content_truncated} lifecycle event so consumers can
* surface why the stream stopped early. {@code reason} is "char_pattern"
* or "sentence_repetition" depending on which detector fired.
* surface when the volume-based thinking-only soft cap stops the stream.
*/
private void broadcastContentTruncated(String conversationId, String reason, int truncatedChars) {
if (streamTracker == null || conversationId == null || conversationId.isEmpty()) {
@ -1493,7 +1507,7 @@ public class NodeStreamingChatHelper {
}
try {
streamTracker.broadcastObject(conversationId, "content_truncated", Map.of(
"reason", reason != null ? reason : "char_pattern",
"reason", reason != null ? reason : "thinking_only_no_content",
"truncatedChars", truncatedChars,
"timestamp", System.currentTimeMillis()
));
@ -1502,16 +1516,6 @@ public class NodeStreamingChatHelper {
}
}
/**
* Resolve a non-null {@link RepetitionDetector}. Returns the supplied
* detector when present; otherwise creates a fresh per-call instance so
* mocked trackers (Mockito returns null for unstubbed methods) and
* legacy code paths never trip a NullPointerException.
*/
private static RepetitionDetector pickDetector(RepetitionDetector candidate) {
return candidate != null ? candidate : new RepetitionDetector();
}
/**
* Best-effort character count of the outbound prompt for the
* {@code context_prepared} event. Cheaper than tokenizing and only used

View File

@ -1,352 +0,0 @@
package vip.mate.agent.graph;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 流式输出重复检测器
* <p>
* 检测 LLM 流式输出中的退化重复模式degenerate repetition
* 当检测到内容在滑动窗口内高度重复时返回 true调用方应截断 LLM
* <p>
* Two complementary detection paths run on every {@link #appendAndCheck}:
* <ol>
* <li>Character-level n-gram repetition (continuous "X X X X" loops),
* which catches the classic degenerate-output failure within a single
* LLM stream.</li>
* <li>Sentence-level Jaccard similarity over the trailing N sentences,
* which catches "two near-identical sentences ~5 sentences apart"
* a softer failure that the character path misses because the loop is
* not adjacent.</li>
* </ol>
*
* @author MateClaw Team
*/
@Slf4j
public class RepetitionDetector {
/** 滑动窗口大小(字符数) */
private static final int WINDOW_SIZE = 1024;
/** 最小重复片段长度 */
private static final int MIN_PATTERN_LEN = 8;
/** 最大检测的模式长度 */
private static final int MAX_PATTERN_LEN = 200;
/** 模式需要连续出现的最小次数才判定为重复 */
private static final int MIN_REPEATS = 4;
/** 已累积内容的最小长度才开始检测(避免误判短内容) */
private static final int MIN_CONTENT_LEN = 200;
// ===== Sentence-level detection =====
/**
* Number of trailing sentences to compare against the prior window. 3
* tail sentences plus a 10-sentence lookback is enough to catch "ABC
* (other) ABC (other)" duplication while keeping the cost bounded.
*/
private static final int SENTENCE_TAIL_COUNT = 3;
/**
* Number of historical sentences the tail compares against. Bumped from 10
* to 30 to catch markdown-list duplication where the model emits the same
* 4-6 item list twice with a transition sentence in between at 10 the
* tail of the second list could not see the corresponding sentence of the
* first list, leaving the duplicate undetected.
*/
private static final int SENTENCE_LOOKBACK = 30;
private static final double JACCARD_THRESHOLD = 0.85;
/**
* Lower bound on the per-sentence token count. Below this both the
* tail and the historical sentence are too short to make Jaccard
* meaningful (single phrases like "好的。" would otherwise false-positive).
*/
private static final int SENTENCE_MIN_TOKENS = 6;
/**
* Buffer must hold at least this many characters before sentence-level
* detection runs keeps the cost off the early hot path.
*/
private static final int SENTENCE_MIN_BUFFER = 1500;
/**
* Sentence delimiters: full-width Chinese punctuation plus ASCII end-of
* -sentence punctuation and newline. ASCII '.' '!' '?' are NOT treated as
* sentence boundaries when preceded by a digit that prevents markdown
* list ordinals like "1." / "2." / "3." from fragmenting a paragraph into
* noise sentences, which used to push the tail past SENTENCE_LOOKBACK and
* mask whole-list duplication. Decimal numbers (e.g. "1.5") are also
* spared by the same rule, which is the desired behavior.
*/
private static final Pattern SENTENCE_SPLIT =
Pattern.compile("[\\u3002\\uff01\\uff1f\\n]+|(?<!\\d)[.!?]+");
private final StringBuilder buffer = new StringBuilder();
private boolean repetitionDetected = false;
/**
* Marks repetition detected via the sentence path so callers can tell
* "char_pattern" from "sentence_repetition" in their warning broadcasts.
*/
private boolean lastTriggerWasSentence = false;
/**
* 追加新的 delta 并检测是否存在重复
*
* @param delta 新增的文本片段
* @return true 表示检测到退化重复调用方应截断流
*/
public boolean appendAndCheck(String delta) {
if (delta == null || delta.isEmpty() || repetitionDetected) {
return repetitionDetected;
}
buffer.append(delta);
// 内容太短不检测
if (buffer.length() < MIN_CONTENT_LEN) {
return false;
}
// Window trim policy: the char-level path only needs the last ~1024
// chars, but the sentence path benefits from a larger horizon so it
// can compare against the full 10-sentence lookback. Keep up to
// SENTENCE_MIN_BUFFER * 2 so the sentence detector has room.
int retainCap = Math.max(WINDOW_SIZE, SENTENCE_MIN_BUFFER * 2);
if (buffer.length() > retainCap * 2) {
buffer.delete(0, buffer.length() - retainCap);
}
// 在窗口尾部检测重复模式
String window = buffer.toString();
int windowLen = window.length();
// 从短模式到长模式扫描
for (int patternLen = MIN_PATTERN_LEN;
patternLen <= Math.min(MAX_PATTERN_LEN, windowLen / MIN_REPEATS);
patternLen++) {
// 取窗口末尾的 pattern
String pattern = window.substring(windowLen - patternLen);
// 向前数这个 pattern 连续出现了几次
int count = 1;
int pos = windowLen - patternLen * 2;
while (pos >= 0) {
String segment = window.substring(pos, pos + patternLen);
if (segment.equals(pattern)) {
count++;
pos -= patternLen;
} else {
break;
}
}
if (count >= MIN_REPEATS) {
// 排除装饰性重复代码缩进ASCII 图表Markdown 分隔线常见
if (isDecorativePattern(pattern)) {
continue;
}
repetitionDetected = true;
lastTriggerWasSentence = false;
log.warn("[RepetitionDetector] Detected degenerate repetition: " +
"pattern length={}, repeats={}, pattern preview=\"{}\"",
patternLen, count,
pattern.length() > 50 ? pattern.substring(0, 50) + "..." : pattern);
return true;
}
}
// Sentence-level path: cheap to skip until the buffer is long enough
// to actually contain multiple sentences worth comparing.
if (buffer.length() >= SENTENCE_MIN_BUFFER && checkSentenceRepetition(window)) {
repetitionDetected = true;
lastTriggerWasSentence = true;
return true;
}
return false;
}
/**
* Return true when one of the trailing {@link #SENTENCE_TAIL_COUNT}
* sentences is near-duplicate to one of the previous {@link #SENTENCE_LOOKBACK}
* sentences (Jaccard over token unigram sets). Both candidates must clear
* {@link #SENTENCE_MIN_TOKENS} so we don't false-positive on common short
* acknowledgements.
*/
private boolean checkSentenceRepetition(String window) {
String[] split = SENTENCE_SPLIT.split(window);
// Drop trailing whitespace-only segments produced by the splitter.
List<String> sentences = new ArrayList<>(split.length);
for (String s : split) {
String trimmed = s.trim();
if (!trimmed.isEmpty()) {
sentences.add(trimmed);
}
}
if (sentences.size() < 2) {
return false;
}
int total = sentences.size();
int tailStart = Math.max(0, total - SENTENCE_TAIL_COUNT);
int lookbackStart = Math.max(0, tailStart - SENTENCE_LOOKBACK);
for (int i = tailStart; i < total; i++) {
Set<String> tailTokens = tokenize(sentences.get(i));
if (tailTokens.size() < SENTENCE_MIN_TOKENS) continue;
for (int j = lookbackStart; j < tailStart; j++) {
Set<String> earlierTokens = tokenize(sentences.get(j));
if (earlierTokens.size() < SENTENCE_MIN_TOKENS) continue;
double jaccard = jaccard(tailTokens, earlierTokens);
if (jaccard >= JACCARD_THRESHOLD) {
String preview = sentences.get(i);
log.warn("[RepetitionDetector] Sentence-level repetition: " +
"tailIdx={}, earlierIdx={}, jaccard={}, preview=\"{}\"",
i, j, String.format("%.2f", jaccard),
preview.length() > 60 ? preview.substring(0, 60) + "..." : preview);
return true;
}
}
}
return false;
}
/**
* Cheap tokenizer that treats each Chinese char as its own token and
* splits ASCII / European text on whitespace. Producing token sets keeps
* Jaccard symmetric on lengths, which is the property we rely on.
*/
private static Set<String> tokenize(String sentence) {
Set<String> tokens = new HashSet<>();
StringBuilder asciiWord = new StringBuilder();
for (int i = 0; i < sentence.length(); i++) {
char c = sentence.charAt(i);
if (isCjk(c)) {
if (asciiWord.length() > 0) {
tokens.add(asciiWord.toString().toLowerCase());
asciiWord.setLength(0);
}
tokens.add(String.valueOf(c));
} else if (Character.isLetterOrDigit(c)) {
asciiWord.append(c);
} else {
if (asciiWord.length() > 0) {
tokens.add(asciiWord.toString().toLowerCase());
asciiWord.setLength(0);
}
}
}
if (asciiWord.length() > 0) {
tokens.add(asciiWord.toString().toLowerCase());
}
return tokens;
}
private static boolean isCjk(char c) {
return (c >= 0x4E00 && c <= 0x9FFF)
|| (c >= 0x3400 && c <= 0x4DBF)
|| (c >= 0xF900 && c <= 0xFAFF);
}
private static double jaccard(Set<String> a, Set<String> b) {
if (a.isEmpty() && b.isEmpty()) return 1.0;
int intersect = 0;
Set<String> smaller = a.size() <= b.size() ? a : b;
Set<String> larger = smaller == a ? b : a;
for (String t : smaller) {
if (larger.contains(t)) intersect++;
}
int union = a.size() + b.size() - intersect;
return union == 0 ? 0.0 : (double) intersect / union;
}
/**
* Mark a logical iteration boundary. Buffer is intentionally NOT cleared
* sentence-level detection across LLM call boundaries is the whole
* reason this detector lives on the conversation, not on a single call.
* The debug log lets fixtures observe that the boundary signal arrived.
*/
public void markIterationBoundary() {
log.debug("[RepetitionDetector] iteration boundary marked (buffer chars={})",
buffer.length());
}
/**
* Returns "sentence_repetition" when the most recent trigger was the
* Jaccard path; otherwise "char_pattern". Useful for warning broadcasts
* that need to differentiate the two failure modes.
*/
public String lastTriggerReason() {
if (!repetitionDetected) return null;
return lastTriggerWasSentence ? "sentence_repetition" : "char_pattern";
}
/** Number of characters currently held in the detector's buffer. */
public int bufferLength() {
return buffer.length();
}
/**
* 判断 pattern 是否为装饰性字符不应判定为退化重复
* <p>
* 排除场景
* <ul>
* <li>纯空白/缩进{@code " "}代码缩进</li>
* <li>单一重复字符{@code "────────"} {@code "════════"} {@code "--------"} {@code "********"}分隔线表格边框</li>
* <li>Box Drawing 字符族{@code "┌──────┐"} {@code "│ │"}ASCII 图表</li>
* </ul>
*/
private boolean isDecorativePattern(String pattern) {
if (pattern.isBlank()) {
return true; // 纯空白
}
// 统计不同的非空白字符种类
long distinctNonWhitespace = pattern.chars()
.filter(c -> !Character.isWhitespace(c))
.distinct()
.count();
// 只有 1-2 种不同的非空白字符 装饰性 "────────" "│ │"
if (distinctNonWhitespace <= 2) {
return true;
}
// 检查是否全部是 Box Drawing / 装饰字符
boolean allDecorative = pattern.chars().allMatch(c ->
Character.isWhitespace(c)
|| isBoxDrawing(c)
|| "─━│┃┄┅┆┇┈┉┊┋═║╌╍╎╏╔╗╚╝╠╣╦╩╬├┤┬┴┼┌┐└┘".indexOf(c) >= 0
|| "-=_*+|#~<>".indexOf(c) >= 0);
return allDecorative;
}
private boolean isBoxDrawing(int codePoint) {
// Unicode Box Drawing block: U+2500 U+257F
return codePoint >= 0x2500 && codePoint <= 0x257F;
}
/**
* 重置检测器状态
*/
public void reset() {
buffer.setLength(0);
repetitionDetected = false;
lastTriggerWasSentence = false;
}
/**
* 是否已检测到重复
*/
public boolean isRepetitionDetected() {
return repetitionDetected;
}
}

View File

@ -34,7 +34,7 @@ public class ReasoningDispatcher implements EdgeAction {
public String apply(OverAllState state) throws Exception {
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
// 1. 迭代超限检查最高优先级
// 1. 迭代超限检查
if (accessor.isLimitReached()) {
log.warn("[ReasoningDispatcher] Iteration limit reached ({}/{}), routing to limitExceededNode",
accessor.iterationCount(), accessor.maxIterations());

View File

@ -11,6 +11,7 @@ import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.graph.state.DirectToolOutput;
import vip.mate.agent.graph.state.SourceEvidenceLedger;
import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.tool.guard.ToolExecutionGuardHelper;
@ -334,6 +335,19 @@ public class ToolExecutionExecutor {
events.add(GraphEventPublisher.phase("action", Map.of("toolCount", effectiveCalls.size())));
// Shared collector for raw-stage SourceEvidenceLedger entries. Every
// PreparedToolCall built below points at this same AtomicReference;
// executeSingleTool does an atomic accumulateAndGet(merge) right
// after the raw tool result is in hand and BEFORE spill/truncate
// shrinks it. ActionNode then reads the final ledger off
// ToolExecutionResult.rawEvidenceLedger() instead of rebuilding it
// from the spill-compacted responses (which routinely lose the
// exact lines that mention the cited filenames see #4b38f04f
// production trace, where "ObservationNode.java" only appeared in
// a 30 KB grep result that got head/tail-cut to 4 KB).
java.util.concurrent.atomic.AtomicReference<SourceEvidenceLedger> rawEvidenceRef =
new java.util.concurrent.atomic.AtomicReference<>(SourceEvidenceLedger.empty());
// Phase 1: 顺序 Guard + 分段
List<PreparedToolCall> preparedCalls = new ArrayList<>();
ApprovalBarrier barrier = null;
@ -440,7 +454,7 @@ public class ToolExecutionExecutor {
// 4. 分类: concurrencySafe
boolean safe = isConcurrencySafe(toolName);
preparedCalls.add(new PreparedToolCall(toolCall, callback, arguments, safe, allResponses.size(),
conversationId, requesterId, workspaceBasePath, safeOrigin));
conversationId, requesterId, workspaceBasePath, safeOrigin, rawEvidenceRef));
// 占位Phase 2 填充
allResponses.add(null);
}
@ -465,7 +479,8 @@ public class ToolExecutionExecutor {
return new ToolExecutionResult(allResponses, events, hasApprovalPending,
barrier != null ? barrier.pendingId : null,
barrier != null ? barrier.toolName : null,
List.copyOf(directOutputs));
List.copyOf(directOutputs),
rawEvidenceRef.get());
}
/**
@ -543,15 +558,16 @@ public class ToolExecutionExecutor {
toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER);
}
// Phase 3 Layer 2: spill before truncation when storage is wired.
// Use the caller-supplied conversationId so spill files inherit the
// same per-conversation directory layout as the non-replay path.
// RFC-008 Layer 1 first, then Layer 2 match the non-replay path
// in executeSingleTool so behavior stays symmetric across approval
// replays. The caller-supplied conversationId scopes spill files
// into the same per-conversation directory layout.
result = truncateToolResult(result, MAX_TOOL_RESULT_CHARS);
if (resultStorage != null && result != null) {
String spillConv = conversationId != null && !conversationId.isEmpty() ? conversationId : "unknown";
result = resultStorage.persistIfOversized(
result, toolName, toolCall.id(), spillConv, workspaceBasePath);
}
result = truncateToolResult(result, MAX_TOOL_RESULT_CHARS);
log.info("[ToolExecutor] Pre-approved tool {} returned {} chars{}", toolName, rawLen,
result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : "");
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, result, true));
@ -751,15 +767,36 @@ public class ToolExecutionExecutor {
pc.toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER);
}
// RFC-008 Phase 3 Layer 2: spill oversized results to disk and replace
// Capture SourceEvidenceLedger from the RAW result, before truncate/
// spill compacts it. Building the ledger from the post-compact
// response (the old ActionNode behaviour) loses any file path that
// happens to fall outside the head/tail kept by truncateToolResult.
// Wrapping the raw string in a synthetic ToolResponseMessage.ToolResponse
// lets us reuse SourceEvidenceLedger.fromToolResponses verbatim no
// new parsing path to keep in sync.
if (result != null && pc.rawEvidenceCollector != null) {
SourceEvidenceLedger rawDelta = SourceEvidenceLedger.fromToolResponses(
java.util.List.of(new ToolResponseMessage.ToolResponse(
pc.toolCall.id(), toolName, result)));
if (rawDelta.hasEvidence()) {
pc.rawEvidenceCollector.accumulateAndGet(rawDelta, SourceEvidenceLedger::merge);
}
}
// RFC-008 Layer 1: hard truncation cap to prevent oversized results
// from inflating the prompt. Runs FIRST (before spill) so the spill
// store doesn't need to handle multi-MB writes for run-of-the-mill
// greps that happen to spit out a long stdout.
result = truncateToolResult(result, MAX_TOOL_RESULT_CHARS);
// RFC-008 Layer 2: spill oversized results to disk and replace
// with preview + path. Falls back to truncation when spilling is
// disabled or fails. Spill preserves the full output (read_file can
// retrieve it); truncation discards the tail.
// retrieve it); the Layer 1 truncation above already capped the
// inline portion, so this layer mostly catches near-cap residues.
if (resultStorage != null && result != null) {
result = resultStorage.persistIfOversized(
result, toolName, pc.toolCall.id(), pc.conversationId, pc.workspaceBasePath);
}
result = truncateToolResult(result, MAX_TOOL_RESULT_CHARS);
log.info("[ToolExecutor] Tool {} returned {} chars{}", toolName, rawLen,
result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : "");
events.add(GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, result, true));
@ -1023,7 +1060,26 @@ public class ToolExecutionExecutor {
String conversationId,
String requesterId,
String workspaceBasePath,
ChatOrigin origin
ChatOrigin origin,
/**
* Shared reference (one per execute() invocation) where each
* concurrent {@code executeSingleTool} merges a {@link SourceEvidenceLedger}
* built from the **raw** tool result, before the spill/truncate
* pipeline (Layer 2/Layer 1) shrinks the response that finally
* reaches the LLM.
*
* <p>Why the raw stage: a 30 KB grep output may carry the only
* mention of {@code ObservationNode.java}; once {@code truncateToolResult}
* head/tail-compacts to 4 KB that line is typically dropped. If the
* ledger is built from the compacted response (the old behaviour
* in {@code ActionNode}), the answer's later citation of
* {@code ObservationNode} gets flagged as evidence-insufficient
* even though the model did see the file in its tool result.
*
* <p>Atomic merge via {@code AtomicReference.accumulateAndGet}
* because parallel batches run on {@code TOOL_EXECUTOR}.
*/
java.util.concurrent.atomic.AtomicReference<SourceEvidenceLedger> rawEvidenceCollector
) {}
private record ApprovalBarrier(String pendingId, String toolName) {}
@ -1067,7 +1123,16 @@ public class ToolExecutionExecutor {
* in this batch. Non-empty list graph must short-circuit to
* FinalAnswerNode without re-entering the LLM.
*/
List<DirectToolOutput> directOutputs
List<DirectToolOutput> directOutputs,
/**
* Source evidence accumulated from the RAW (pre-spill, pre-truncate)
* tool results in this batch. ActionNode merges this into the
* graph-level ledger instead of re-parsing the spill-compacted
* {@link #responses} that compaction routinely drops the line
* mentioning a path the model later cites, which would surface
* as a false-positive evidence_insufficient.
*/
SourceEvidenceLedger rawEvidenceLedger
) {
/** Backwards-compatible constructor for callers that don't track direct outputs. */
public ToolExecutionResult(List<ToolResponseMessage.ToolResponse> responses,
@ -1075,7 +1140,19 @@ public class ToolExecutionExecutor {
boolean awaitingApproval,
String pendingId,
String barrierToolName) {
this(responses, events, awaitingApproval, pendingId, barrierToolName, List.of());
this(responses, events, awaitingApproval, pendingId, barrierToolName,
List.of(), SourceEvidenceLedger.empty());
}
/** Bridge for callers that pass directOutputs but predate the raw-ledger field. */
public ToolExecutionResult(List<ToolResponseMessage.ToolResponse> responses,
List<GraphEventPublisher.GraphEvent> events,
boolean awaitingApproval,
String pendingId,
String barrierToolName,
List<DirectToolOutput> directOutputs) {
this(responses, events, awaitingApproval, pendingId, barrierToolName,
directOutputs, SourceEvidenceLedger.empty());
}
public boolean hasDirectOutputs() {

View File

@ -29,6 +29,7 @@ import java.util.Set;
* per-result-threshold-chars: 16000
* per-turn-budget-chars: 32000
* preview-head-chars: 800
* excluded-tool-inline-chars: 4000
* storage-base-dir:
* </pre>
*/
@ -40,9 +41,9 @@ public class ToolResultProperties {
/**
* Layer 2 a single tool result larger than this is spilled to disk.
* Note: Layer 1 hard truncation ({@code MAX_TOOL_RESULT_CHARS=8000} in
* {@link ToolExecutionExecutor}) runs before this threshold is evaluated,
* so only results that survive Layer 1 can trigger a spill.
* The executor evaluates this against the raw result before applying the
* final inline cap, so oversized content is preserved before it is shortened
* for the model request.
*/
private int perResultThresholdChars = 16000; // was 4000 prevents WebSearch spill-to-disk
@ -56,6 +57,13 @@ public class ToolResultProperties {
/** Number of leading characters kept inline as a preview after spilling. */
private int previewHeadChars = 800;
/**
* Retrieval-style tools are not spilled, but their inline content still must
* fit the model context. When aggregate turn budget is exceeded and only
* excluded tools remain, their results are compacted to this size.
*/
private int excludedToolInlineChars = 2500;
/**
* Optional absolute path to override the default spill location.
* When blank, falls back to {@code <workspace>/.mateclaw/tool-results/} or
@ -93,6 +101,11 @@ public class ToolResultProperties {
this.previewHeadChars = previewHeadChars;
}
public int getExcludedToolInlineChars() { return excludedToolInlineChars; }
public void setExcludedToolInlineChars(int excludedToolInlineChars) {
this.excludedToolInlineChars = excludedToolInlineChars;
}
public String getStorageBaseDir() { return storageBaseDir; }
public void setStorageBaseDir(String storageBaseDir) {
this.storageBaseDir = storageBaseDir == null ? "" : storageBaseDir;

View File

@ -164,10 +164,12 @@ public class ToolResultStorage {
}
}
if (targetIdx < 0) {
// Nothing left to spill; remaining oversize is from excluded tools or
// already-spilled responses. Accept the over-budget state better than
// breaking the agent's retrieval path.
log.warn("[ToolResultStorage] aggregate still {} chars after spilling everything eligible (excluded tools may push past budget)",
int compactedIdx = compactLargestExcludedResult(mutable);
if (compactedIdx >= 0) {
aggregate = aggregateSize(mutable);
continue;
}
log.warn("[ToolResultStorage] aggregate still {} chars after spilling/compacting everything eligible",
aggregate);
break;
}
@ -191,6 +193,46 @@ public class ToolResultStorage {
return mutable;
}
private int compactLargestExcludedResult(List<ToolResponseMessage.ToolResponse> mutable) {
int targetIdx = -1;
int targetLen = props.getExcludedToolInlineChars();
for (int i = 0; i < mutable.size(); i++) {
ToolResponseMessage.ToolResponse r = mutable.get(i);
String body = r.responseData();
if (body == null || body.startsWith(SPILL_MARKER_PREFIX)) continue;
if (!isExcluded(r.name())) continue;
if (body.length() > targetLen) {
targetLen = body.length();
targetIdx = i;
}
}
if (targetIdx < 0) {
return -1;
}
ToolResponseMessage.ToolResponse target = mutable.get(targetIdx);
String compacted = compactInline(target.responseData(), target.name(), props.getExcludedToolInlineChars());
mutable.set(targetIdx, new ToolResponseMessage.ToolResponse(target.id(), target.name(), compacted));
log.info("[ToolResultStorage] compacted excluded tool result: tool={} chars={} -> {}",
target.name(), targetLen, compacted.length());
return targetIdx;
}
static String compactInline(String body, String toolName, int maxChars) {
if (body == null || body.length() <= maxChars) {
return body;
}
int markerBudget = 120;
int available = Math.max(200, maxChars - markerBudget);
int headLen = Math.max(100, (int) (available * 0.45));
int tailLen = Math.max(100, available - headLen);
if (headLen + tailLen >= body.length()) {
return body;
}
String marker = "\n\n... [tool result compacted for model context: tool="
+ toolName + ", original_chars=" + body.length() + "] ...\n\n";
return body.substring(0, headLen) + marker + body.substring(body.length() - tailLen);
}
private static int aggregateSize(List<ToolResponseMessage.ToolResponse> responses) {
int sum = 0;
for (ToolResponseMessage.ToolResponse r : responses) {

View File

@ -24,6 +24,14 @@ import static vip.mate.agent.graph.state.MateClawStateKeys.*;
* [ReAct] node=reasoning event=complete iteration=2 durationMs=1234 toolCallCount=3
* [ReAct] node=limit_exceeded event=complete iteration=10 finishReason=max_iterations_reached
* </pre>
* <p>
* Note: this listener is intentionally read-only / log-only. Surfacing
* graph state to channel-side accumulators (e.g. publishing the resolved
* {@code FinishReason} to message metadata) lives in {@code FinalAnswerNode}
* via a {@code finish_reason} GraphEvent that path goes through the
* PENDING_EVENTS StreamDelta pipeline that {@code ChatController.StreamAccumulator}
* actually consumes. A sibling sink that called {@code streamTracker.broadcastObject}
* here would only reach the browser SSE bus and bypass the accumulator entirely.
*
* @author MateClaw Team
*/

View File

@ -9,6 +9,7 @@ import org.springframework.ai.chat.messages.ToolResponseMessage;
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
import vip.mate.agent.graph.state.MateClawStateAccessor;
import vip.mate.agent.graph.state.MateClawStateKeys;
import vip.mate.agent.graph.state.SourceEvidenceLedger;
import java.util.*;
import java.util.concurrent.CancellationException;
@ -77,11 +78,23 @@ public class ActionNode implements NodeAction {
.responses(result.responses())
.build();
// Use the executor's raw-stage ledger instead of re-parsing the
// spill-compacted responses. ToolExecutionExecutor builds this
// ledger from the full pre-truncate text, so a 30 KB grep result
// whose head/tail-cut version no longer mentions a path will still
// contribute that path to the evidence pool. Falls back to empty
// for legacy executor stubs (tests, mocks) that didn't populate
// the new field fine, the merge with `accessor.sourceEvidenceLedger`
// is no-op in that case.
SourceEvidenceLedger rawLedger = result.rawEvidenceLedger() != null
? result.rawEvidenceLedger()
: SourceEvidenceLedger.empty();
MateClawStateAccessor.OutputBuilder output = MateClawStateAccessor.output()
.toolResults(result.responses())
.messages(List.of((Message) toolResponseMessage))
.currentPhase("action")
.events(result.events());
.events(result.events())
.sourceEvidenceLedger(accessor.sourceEvidenceLedger().merge(rawLedger));
if (result.awaitingApproval()) {
output.awaitingApproval(true);

View File

@ -3,9 +3,11 @@ package vip.mate.agent.graph.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import lombok.extern.slf4j.Slf4j;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.graph.state.DirectToolOutput;
import vip.mate.agent.graph.state.FinishReason;
import vip.mate.agent.graph.state.MateClawStateAccessor;
import vip.mate.agent.graph.state.SourceEvidenceLedger;
import java.util.List;
import java.util.Map;
@ -54,7 +56,9 @@ public class FinalAnswerNode implements NodeAction {
outputs.size(), assembled.length(), preservedThinking.length());
var builder = MateClawStateAccessor.output()
.finalAnswer(assembled)
.finishReason(FinishReason.RETURN_DIRECT);
.finishReason(FinishReason.RETURN_DIRECT)
.events(List.of(GraphEventPublisher.finishReason(
FinishReason.RETURN_DIRECT.getValue())));
if (!preservedThinking.isEmpty()) {
builder.finalThinking(preservedThinking);
}
@ -76,7 +80,9 @@ public class FinalAnswerNode implements NodeAction {
.finalAnswer(preservedContent)
.finishReason(FinishReason.NORMAL)
.contentStreamed(true)
.thinkingStreamed(true);
.thinkingStreamed(true)
.events(List.of(GraphEventPublisher.finishReason(
FinishReason.NORMAL.getValue())));
if (!preservedThinking.isEmpty()) {
builder.finalThinking(preservedThinking);
}
@ -133,10 +139,28 @@ public class FinalAnswerNode implements NodeAction {
}
}
SourceEvidenceLedger.Validation validation = accessor.sourceEvidenceLedger().validateAnswer(finalAnswer);
if (finishReason == FinishReason.NORMAL && !validation.valid()) {
finishReason = FinishReason.EVIDENCE_INSUFFICIENT;
finalAnswer = appendEvidenceWarning(finalAnswer, validation.unsupportedReferences());
log.warn("[FinalAnswerNode] Evidence insufficient for final answer, unsupportedReferences={}",
validation.unsupportedReferences());
}
// 不重置 CONTENT_STREAMED/THINKING_STREAMED保留上游节点的标志
var builder = MateClawStateAccessor.output()
.finalAnswer(finalAnswer)
.finishReason(finishReason);
.finishReason(finishReason)
// Emit the resolved FinishReason as a GraphEvent so it rides
// the PENDING_EVENTS StreamDelta pipeline that the channel-
// side accumulator subscribes to. A sibling SSE broadcast (e.g.
// streamTracker.broadcastObject) reaches the browser but never
// touches the accumulator, so toMetadataJson() would not see
// it and MemorySummarizationGate would lose the structured
// signal. APPEND-strategy on PENDING_EVENTS means this
// composes safely with any earlier events upstream nodes
// attached.
.events(List.of(GraphEventPublisher.finishReason(finishReason.getValue())));
if (!finalThinking.isEmpty()) {
builder.finalThinking(finalThinking);
@ -145,6 +169,12 @@ public class FinalAnswerNode implements NodeAction {
return builder.build();
}
private static String appendEvidenceWarning(String answer, List<String> unsupportedReferences) {
return answer + "\n\n[证据不足] 以下源码引用未出现在已读取/搜索到的工具证据中:"
+ String.join(", ", unsupportedReferences)
+ "。请继续读取相关文件后再下结论。";
}
/**
* RFC-052 §2.5: assemble the final answer from direct tool outputs.
* Single output verbatim full text. Multiple outputs each prefixed

View File

@ -25,6 +25,7 @@ import vip.mate.agent.context.RuntimeContextInjector;
import vip.mate.agent.graph.state.FinishReason;
import vip.mate.agent.graph.state.MateClawStateAccessor;
import vip.mate.agent.graph.state.MateClawStateKeys;
import vip.mate.agent.graph.state.SourceEvidenceLedger;
import vip.mate.channel.web.ChatStreamTracker;
@ -52,6 +53,10 @@ public class ReasoningNode implements NodeAction {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static MateClawStateAccessor.OutputBuilder reasonOutput() {
return MateClawStateAccessor.output();
}
/**
* 单次 LLM 调用的默认最大输出 token 防止退化输出无限生成
* <p>
@ -211,7 +216,7 @@ public class ReasoningNode implements NodeAction {
.toolCalls(List.of(toolCall))
.build();
return MateClawStateAccessor.output()
return reasonOutput()
.needsToolCall(true)
.toolCalls(List.of(toolCall))
.messages(List.of((Message) syntheticMsg))
@ -342,6 +347,9 @@ public class ReasoningNode implements NodeAction {
}
}
if (conversationWindowManager != null) {
messages = conversationWindowManager.pruneOldToolResultsForModelInput(messages);
}
promptMessages.addAll(messages);
// 请求级思考深度覆盖ThinkingLevelHolder AgentService 设置
@ -414,7 +422,7 @@ public class ReasoningNode implements NodeAction {
// 必须显式清零 needsToolCall/shouldSummarize防止前一轮残留标志导致误路由
log.info("[ReasoningNode] CancellationException during LLM call (user stopped before first token), " +
"returning empty answer with STOPPED, llmCallCount={}", nextLlmCallCount);
return MateClawStateAccessor.output()
return reasonOutput()
.finalAnswer("")
.needsToolCall(false)
.shouldSummarize(false)
@ -433,7 +441,7 @@ public class ReasoningNode implements NodeAction {
String partialThinking = result.thinking() != null ? result.thinking() : "";
log.info("[ReasoningNode] Stop with partial content ({} chars, thinking {} chars), flushing as final answer",
partialText.length(), partialThinking.length());
var builder = MateClawStateAccessor.output()
var builder = reasonOutput()
.finalAnswer(partialText)
.needsToolCall(false)
.shouldSummarize(false)
@ -448,13 +456,46 @@ public class ReasoningNode implements NodeAction {
return builder.build();
}
// Order matters: the partial-truncation branch MUST sit before
// hasFatalError(). hasFatalError() is "no text + no tool calls + non-
// null errorMessage", which is also the shape of a thinking-only cap
// result (text is empty by definition). Without this ordering the
// soft cap would be re-promoted to ERROR_FALLBACK and we'd lose the
// INCOMPLETE semantics.
if (result.partial() && "thinking_only_no_content".equals(result.errorMessage())) {
// Soft thinking-only loop: the helper disposed the upstream stream
// because the model accumulated >= THINKING_ONLY_HARD_CAP_CHARS of
// reasoning_content without emitting any visible content or tool
// calls. Treat as INCOMPLETE rather than fatal the thinking text
// has already been streamed and is preserved for the UI's collapse
// panel; the user gets a short fallback line they can retry from.
String partialThinking = result.thinking() != null ? result.thinking() : "";
log.warn("[ReasoningNode] Thinking-only soft cap hit ({} thinking chars, no content/tools); " +
"INCOMPLETE",
partialThinking.length());
var builder = reasonOutput()
.needsToolCall(false)
.shouldSummarize(false)
.finalAnswer("(模型在思考阶段停留过久且未给出最终答案,请重试或拆分问题。)")
.llmCallCount(nextLlmCallCount)
.finishReason(FinishReason.INCOMPLETE)
.contentStreamed(false)
.thinkingStreamed(true)
.mergeUsage(state, result);
if (!partialThinking.isEmpty()) {
builder.finalThinking(partialThinking);
}
return builder.build();
}
// Fatal error直接设置 finalAnswer 为错误文案 + ERROR_FALLBACK
// 不走 LimitExceededNode后者会再发一次 LLM 调用语义不对且对认证/配额错误会再失败
// ReasoningDispatcher 看到 !needsToolCall && !shouldSummarize finalAnswerNode
// FinalAnswerNode 检测到 existingAnswer 非空时直接使用finishReason 保持 ERROR_FALLBACK
if (result.hasFatalError()) {
log.error("[ReasoningNode] Fatal LLM error: {}", result.errorMessage());
return MateClawStateAccessor.output()
return reasonOutput()
.needsToolCall(false)
.shouldSummarize(false)
.finalAnswer("[错误] " + result.errorMessage())
@ -467,7 +508,8 @@ public class ReasoningNode implements NodeAction {
}
if (result.partial()) {
log.warn("[ReasoningNode] Partial LLM result ({} chars), treating as final answer", result.text().length());
int partialChars = result.text() != null ? result.text().length() : 0;
log.warn("[ReasoningNode] Partial LLM result ({} chars), treating as final answer", partialChars);
}
if (result.hasToolCalls()) {
@ -479,7 +521,7 @@ public class ReasoningNode implements NodeAction {
"toolCount", result.toolCalls().size()
));
return MateClawStateAccessor.output()
return reasonOutput()
.needsToolCall(true)
.shouldSummarize(false)
.toolCalls(result.toolCalls())
@ -501,6 +543,16 @@ public class ReasoningNode implements NodeAction {
"iteration", accessor.iterationCount(),
"answerChars", content != null ? content.length() : 0
));
SourceEvidenceLedger.Validation validation =
accessor.sourceEvidenceLedger().validateAnswer(content != null ? content : "");
boolean evidenceInsufficient = !validation.valid();
String finalAnswer = evidenceInsufficient
? evidenceWarning(validation.unsupportedReferences())
: (content != null ? content : "");
if (evidenceInsufficient) {
log.warn("[ReasoningNode] Evidence insufficient for final answer, unsupportedReferences={}",
validation.unsupportedReferences());
}
// Final-answer path: iteration ends in this same node because
// ReAct never re-enters the loop afterwards.
@ -510,14 +562,16 @@ public class ReasoningNode implements NodeAction {
content != null ? content.length() : 0,
result.thinking() != null ? result.thinking().length() : 0)
: null;
return MateClawStateAccessor.output()
return reasonOutput()
.needsToolCall(false)
.shouldSummarize(false)
.finalAnswer(content != null ? content : "")
.finalAnswer(finalAnswer)
.finalThinking(result.thinking())
.messages(List.of((Message) result.assistantMessage()))
.currentPhase("reasoning")
.contentStreamed(true)
.streamedContent(evidenceInsufficient ? (content != null ? content : "") : "")
.finishReason(evidenceInsufficient ? FinishReason.EVIDENCE_INSUFFICIENT : FinishReason.NORMAL)
.contentStreamed(!evidenceInsufficient)
.thinkingStreamed(!result.thinking().isEmpty())
.llmCallCount(nextLlmCallCount)
.mergeUsage(state, result)
@ -526,6 +580,12 @@ public class ReasoningNode implements NodeAction {
}
}
private static String evidenceWarning(List<String> unsupportedReferences) {
return "\n\n[证据不足] 以下源码引用未出现在已读取/搜索到的工具证据中:"
+ String.join(", ", unsupportedReferences)
+ "。请继续读取相关文件后再下结论。";
}
private AssistantMessage.ToolCall deserializeToolCall(String json) {
try {
@SuppressWarnings("unchecked")

View File

@ -210,6 +210,10 @@ public class StepExecutionNode implements NodeAction {
oaiOpts.setInternalToolExecutionEnabled(false);
ChatOptions options = oaiOpts;
if (conversationWindowManager != null) {
messages = conversationWindowManager.pruneOldToolResultsForModelInput(messages);
}
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCall(
chatModel, new Prompt(messages, options), conversationId,
"step_execution[" + stepIndex + "]");

View File

@ -19,6 +19,12 @@ public enum FinishReason {
/** 发生错误后降级回答 */
ERROR_FALLBACK("error_fallback"),
/** 响应未完整完成,需要继续生成或重试 */
INCOMPLETE("incomplete"),
/** 最终回答引用了未被工具结果验证的源码事实 */
EVIDENCE_INSUFFICIENT("evidence_insufficient"),
/** 用户主动停止 */
STOPPED("stopped"),

View File

@ -207,6 +207,10 @@ public final class MateClawStateAccessor {
return state.<List<DirectToolOutput>>value(DIRECT_TOOL_OUTPUTS).orElse(List.of());
}
public SourceEvidenceLedger sourceEvidenceLedger() {
return state.<SourceEvidenceLedger>value(SOURCE_EVIDENCE_LEDGER).orElse(SourceEvidenceLedger.empty());
}
// ===== 审批重放 =====
public String forcedToolCall() {
@ -405,6 +409,10 @@ public final class MateClawStateAccessor {
return put(DIRECT_TOOL_OUTPUTS, outputs);
}
public OutputBuilder sourceEvidenceLedger(SourceEvidenceLedger ledger) {
return put(SOURCE_EVIDENCE_LEDGER, ledger);
}
// ---- 审批重放 ----
public OutputBuilder forcedToolCall(String json) {
return put(FORCED_TOOL_CALL, json);

View File

@ -156,6 +156,9 @@ public final class MateClawStateKeys {
*/
public static final String DIRECT_TOOL_OUTPUTS = "direct_tool_outputs";
/** Source references observed from successful tool results during this run. */
public static final String SOURCE_EVIDENCE_LEDGER = "source_evidence_ledger";
// ===== RFC-063r: ChatOrigin propagation through the StateGraph =====
/**

View File

@ -0,0 +1,220 @@
package vip.mate.agent.graph.state;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import java.io.Serializable;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Tracks source references that were actually observed through tool results.
*/
public record SourceEvidenceLedger(
Set<String> sourcePaths,
Set<String> sourceSymbols,
Set<String> failedPaths
) implements Serializable {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final Pattern JAVA_PATH = Pattern.compile(
"(?:[A-Za-z]:)?[A-Za-z0-9_./\\\\-]+\\.java\\b");
private static final Pattern JAVA_FILE_REF = Pattern.compile("\\b[A-Za-z][A-Za-z0-9_]*\\.java\\b");
private static final Pattern JAVA_SYMBOL_REF = Pattern.compile(
"\\b[A-Z][A-Za-z0-9_]*(?:Controller|Service|ServiceImpl|Node|Tool|Parser|Resolver|Manager|Syncer|Mapper|Entity|Repository|Dispatcher|Executor|Accessor|Builder|Policy|Guard)\\b");
private static final Pattern DECLARED_TYPE = Pattern.compile(
"\\b(?:class|interface|enum|record)\\s+([A-Z][A-Za-z0-9_]*)\\b");
public SourceEvidenceLedger {
sourcePaths = Set.copyOf(sourcePaths == null ? Set.of() : sourcePaths);
sourceSymbols = Set.copyOf(sourceSymbols == null ? Set.of() : sourceSymbols);
failedPaths = Set.copyOf(failedPaths == null ? Set.of() : failedPaths);
}
public static SourceEvidenceLedger empty() {
return new SourceEvidenceLedger(Set.of(), Set.of(), Set.of());
}
public static SourceEvidenceLedger fromToolResponses(List<ToolResponseMessage.ToolResponse> responses) {
if (responses == null || responses.isEmpty()) {
return empty();
}
Builder builder = new Builder();
for (ToolResponseMessage.ToolResponse response : responses) {
String data = response.responseData();
if (data == null || data.isBlank()) {
continue;
}
if (isReadFileTool(response.name())) {
recordReadFile(data, builder);
} else {
recordPlainTextEvidence(data, builder);
}
}
return builder.build();
}
public SourceEvidenceLedger merge(SourceEvidenceLedger other) {
if (other == null || !other.hasEvidence()) {
return this;
}
Builder builder = new Builder();
sourcePaths.forEach(builder::sourcePath);
sourceSymbols.forEach(builder::symbol);
failedPaths.forEach(builder::failedPath);
other.sourcePaths.forEach(builder::sourcePath);
other.sourceSymbols.forEach(builder::symbol);
other.failedPaths.forEach(builder::failedPath);
return builder.build();
}
public SourceEvidenceLedger withSourcePath(String path) {
Builder builder = new Builder();
sourcePaths.forEach(builder::sourcePath);
sourceSymbols.forEach(builder::symbol);
failedPaths.forEach(builder::failedPath);
builder.sourcePath(path);
return builder.build();
}
public boolean hasEvidence() {
return !sourcePaths.isEmpty() || !sourceSymbols.isEmpty() || !failedPaths.isEmpty();
}
public boolean hasPath(String path) {
String normalized = normalizePath(path);
return sourcePaths.contains(normalized) || sourcePaths.stream().anyMatch(p -> p.endsWith("/" + normalized));
}
public boolean hasSymbol(String symbol) {
return sourceSymbols.contains(symbol);
}
public Validation validateAnswer(String answer) {
if (answer == null || answer.isBlank() || !hasEvidence()) {
return Validation.ok();
}
LinkedHashSet<String> unsupported = new LinkedHashSet<>();
LinkedHashSet<String> unsupportedFileStems = new LinkedHashSet<>();
Matcher fileMatcher = JAVA_FILE_REF.matcher(answer);
while (fileMatcher.find()) {
String ref = fileMatcher.group();
if (!hasFileName(ref)) {
unsupported.add(ref);
unsupportedFileStems.add(ref.substring(0, ref.length() - ".java".length()));
}
}
Matcher symbolMatcher = JAVA_SYMBOL_REF.matcher(answer);
while (symbolMatcher.find()) {
String ref = symbolMatcher.group();
if (!unsupportedFileStems.contains(ref) && !sourceSymbols.contains(ref) && !hasFileName(ref + ".java")) {
unsupported.add(ref);
}
}
return unsupported.isEmpty() ? Validation.ok() : new Validation(false, List.copyOf(unsupported));
}
private boolean hasFileName(String fileName) {
String normalized = normalizePath(fileName);
return sourcePaths.stream().anyMatch(p -> p.equals(normalized) || p.endsWith("/" + normalized));
}
private static boolean isReadFileTool(String name) {
if (name == null) {
return false;
}
String normalized = name.toLowerCase(Locale.ROOT).replace("-", "_");
return normalized.equals("read_file");
}
private static void recordReadFile(String data, Builder builder) {
try {
JsonNode root = MAPPER.readTree(data);
String filePath = root.path("filePath").asText("");
if (root.path("error").asBoolean(false)) {
builder.failedPath(filePath);
return;
}
builder.sourcePath(filePath);
String content = root.path("content").asText("");
recordSymbols(content, builder);
} catch (Exception ignored) {
recordPlainTextEvidence(data, builder);
}
}
private static void recordPlainTextEvidence(String text, Builder builder) {
Matcher matcher = JAVA_PATH.matcher(text);
while (matcher.find()) {
builder.sourcePath(matcher.group());
}
recordSymbols(text, builder);
}
private static void recordSymbols(String text, Builder builder) {
Matcher matcher = DECLARED_TYPE.matcher(text);
while (matcher.find()) {
builder.symbol(matcher.group(1));
}
}
private static String normalizePath(String path) {
if (path == null || path.isBlank()) {
return "";
}
String normalized = path.replace('\\', '/').trim();
while (normalized.contains("//")) {
normalized = normalized.replace("//", "/");
}
return normalized;
}
private static final class Builder {
private final LinkedHashSet<String> sourcePaths = new LinkedHashSet<>();
private final LinkedHashSet<String> sourceSymbols = new LinkedHashSet<>();
private final LinkedHashSet<String> failedPaths = new LinkedHashSet<>();
void sourcePath(String path) {
String normalized = normalizePath(path);
if (normalized.isBlank()) {
return;
}
sourcePaths.add(normalized);
String fileName = Path.of(normalized).getFileName() != null
? Path.of(normalized).getFileName().toString() : normalized;
if (fileName.endsWith(".java")) {
sourceSymbols.add(fileName.substring(0, fileName.length() - ".java".length()));
}
}
void symbol(String symbol) {
if (symbol != null && !symbol.isBlank()) {
sourceSymbols.add(symbol.trim());
}
}
void failedPath(String path) {
String normalized = normalizePath(path);
if (!normalized.isBlank()) {
failedPaths.add(normalized);
}
}
SourceEvidenceLedger build() {
return new SourceEvidenceLedger(sourcePaths, sourceSymbols, failedPaths);
}
}
public record Validation(boolean valid, List<String> unsupportedReferences) {
public static Validation ok() {
return new Validation(true, List.of());
}
}
}

View File

@ -1475,6 +1475,19 @@ public class ChatController {
private String runtimeProviderId = "";
private boolean awaitingApproval = false;
private String currentPhase = "";
/**
* Graph-emitted FinishReason for the turn (e.g. {@code "incomplete"},
* {@code "stopped"}, {@code "evidence_insufficient"}). Sourced from
* the {@code finish_reason} {@link vip.mate.agent.GraphEventPublisher}
* event that {@code FinalAnswerNode} attaches to its PENDING_EVENTS
* output same pipeline the SSE accumulator already drains, so the
* value is delivered alongside the assistant content (not via a
* sibling SSE-only broadcast that would bypass this accumulator).
* Persisted into message metadata so downstream filters
* (memory promotion gate) see a machine-readable status instead of
* having to guess from text. Empty string until the event arrives.
*/
private String finishReason = "";
private Long planId = null;
private List<String> planSteps = List.of();
private Integer currentPlanStep = null;
@ -1501,6 +1514,17 @@ public class ChatController {
finalizeRunningSegments("content", "thinking");
}
}
if ("finish_reason".equals(delta.eventType())) {
Object reason = delta.eventData().get("reason");
if (reason != null) {
// Last-write-wins: graph normally fires this exactly once
// at FinalAnswerNode completion. Replay paths that re-enter
// the graph after approval will emit a fresh value, which
// is the correct behavior the latest reason is what gets
// persisted with the assistant message.
finishReason = String.valueOf(reason);
}
}
accumulateToolEvent(delta.eventType(), delta.eventData(), conversationId);
try {
broadcastEvent(conversationId, delta.eventType(), delta.eventData());
@ -1774,6 +1798,14 @@ public class ChatController {
// historical messages as "data returned directly by tool".
metadata.put("directToolNames", directToolNames);
}
if (!finishReason.isEmpty()) {
// Surface graph FinishReason so MemorySummarizationGate and
// any other downstream consumer can branch on a structured
// status (e.g. skip INCOMPLETE / STOPPED / ERROR_FALLBACK
// turns from long-term memory promotion) instead of doing
// brittle text matching on the assistant content.
metadata.put("finishReason", finishReason);
}
return objectMapper.writeValueAsString(metadata);
} catch (Exception e) {
log.warn("Failed to serialize metadata: {}", e.getMessage());

View File

@ -7,7 +7,6 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import reactor.core.Disposable;
import vip.mate.agent.graph.RepetitionDetector;
import vip.mate.workspace.conversation.model.MessageContentPart;
import java.io.IOException;
@ -207,16 +206,6 @@ public class ChatStreamTracker {
*/
volatile boolean firstTokenReceived = false;
/**
* Cross-call repetition detectors scoped to the conversation, not the
* single LLM call. Sharing across {@code streamLLMChat} invocations
* lets the sentence-level path catch "the model produces N near-
* identical sentences across two consecutive iterations" — a common
* failure that the per-call detectors used to miss.
*/
volatile RepetitionDetector contentRepDetector = new RepetitionDetector();
volatile RepetitionDetector thinkingRepDetector = new RepetitionDetector();
/** 已广播的 pending approval ID 集合(用于幂等去重) */
final java.util.Set<String> broadcastedApprovalIds = java.util.concurrent.ConcurrentHashMap.newKeySet();
@ -786,28 +775,6 @@ public class ChatStreamTracker {
return null;
}
/**
* Conversation-scoped repetition detector for content deltas. Lazily
* instantiated (a tracker created without a registered conversation
* receives a fresh detector so callers never get null).
*/
public RepetitionDetector getContentRepDetector(String conversationId) {
RunState state = runs.get(conversationId);
if (state == null) {
return new RepetitionDetector();
}
return state.contentRepDetector;
}
/** Conversation-scoped repetition detector for thinking deltas. */
public RepetitionDetector getThinkingRepDetector(String conversationId) {
RunState state = runs.get(conversationId);
if (state == null) {
return new RepetitionDetector();
}
return state.thinkingRepDetector;
}
/**
* Diagnostic helper for the multi-node deployment edge case (issue #17):
* tells the caller whether a {@link RunState} for this conversation

View File

@ -0,0 +1,128 @@
package vip.mate.memory.service;
import vip.mate.workspace.conversation.model.MessageEntity;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Filters conversations that should not be promoted into long-term memory.
*/
final class MemorySummarizationGate {
private static final Pattern FINISH_REASON = Pattern.compile(
"\"(?:finishReason|finish_reason)\"\\s*:\\s*\"([^\"]+)\"");
private MemorySummarizationGate() {
}
static Decision evaluate(List<MessageEntity> messages) {
if (messages == null || messages.isEmpty()) {
return Decision.skip("empty conversation");
}
String latestUser = latestContent(messages, "user");
String latestAssistant = latestContent(messages, "assistant");
String latestAssistantMetadata = latestMetadata(messages, "assistant");
String finishReason = extractFinishReason(latestAssistantMetadata);
if (isNonDurableFinishReason(finishReason)) {
return Decision.skip("finishReason=" + finishReason + " is not durable memory input");
}
if (looksIncompleteOrUnsupported(latestAssistant)) {
return Decision.skip("assistant content is incomplete or evidence-insufficient");
}
if (isExplicitRememberRequest(latestUser)) {
return Decision.analyze();
}
if (looksLikeSourceAnalysis(latestUser)) {
return Decision.skip("source-analysis conversations are one-off work, not long-term memory");
}
return Decision.analyze();
}
private static boolean isNonDurableFinishReason(String finishReason) {
if (finishReason == null || finishReason.isBlank()) {
return false;
}
return switch (finishReason) {
case "normal", "return_direct" -> false;
default -> true;
};
}
private static boolean isExplicitRememberRequest(String text) {
String normalized = normalize(text);
return normalized.contains("记住") || normalized.contains("remember")
|| normalized.contains("保存到记忆") || normalized.contains("写入记忆");
}
private static boolean looksLikeSourceAnalysis(String text) {
String normalized = normalize(text);
boolean sourceIntent = normalized.contains("源码") || normalized.contains("代码")
|| normalized.contains("review") || normalized.contains("日志")
|| normalized.contains("debug") || normalized.contains("排查");
boolean analysisIntent = normalized.contains("分析") || normalized.contains("检查")
|| normalized.contains("看看") || normalized.contains("修复")
|| normalized.contains("问题") || normalized.contains("待修复");
return sourceIntent && analysisIntent;
}
private static boolean looksIncompleteOrUnsupported(String text) {
String normalized = normalize(text);
return normalized.contains("[证据不足]") || normalized.contains("证据不足")
|| normalized.contains("failed to generate a response")
|| normalized.contains("error_fallback")
|| normalized.contains("未确认")
|| normalized.contains("无法确认");
}
private static String latestContent(List<MessageEntity> messages, String role) {
for (int i = messages.size() - 1; i >= 0; i--) {
MessageEntity message = messages.get(i);
if (role.equals(message.getRole()) && message.getContent() != null) {
return message.getContent();
}
}
return "";
}
private static String latestMetadata(List<MessageEntity> messages, String role) {
for (int i = messages.size() - 1; i >= 0; i--) {
MessageEntity message = messages.get(i);
if (role.equals(message.getRole()) && message.getMetadata() != null) {
return message.getMetadata();
}
}
return "";
}
private static String extractFinishReason(String metadata) {
if (metadata == null || metadata.isBlank()) {
return "";
}
Matcher matcher = FINISH_REASON.matcher(metadata);
if (!matcher.find()) {
return "";
}
return matcher.group(1).trim().toLowerCase(Locale.ROOT);
}
private static String normalize(String text) {
return text == null ? "" : text.toLowerCase(Locale.ROOT);
}
record Decision(boolean shouldAnalyze, String reason) {
static Decision analyze() {
return new Decision(true, "eligible");
}
static Decision skip(String reason) {
return new Decision(false, reason);
}
}
}

View File

@ -88,6 +88,12 @@ public class MemorySummarizationService {
conversationId, messages.size());
return;
}
MemorySummarizationGate.Decision decision = MemorySummarizationGate.evaluate(messages);
if (!decision.shouldAnalyze()) {
log.info("[Memory] Conversation {} skipped by summarization gate: {}",
conversationId, decision.reason());
return;
}
// 2. 加载现有记忆文件内容
String profileContent = readFileContentSafe(agentId, "PROFILE.md");

View File

@ -32,6 +32,9 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
public class SkillFileTool {
private static final int DEFAULT_MAX_LINES = 200;
private static final int MAX_OUTPUT_CHARS = 8_000;
private final SkillRuntimeService runtimeService;
private final SkillFileAccessPolicy accessPolicy;
private final SkillUsageService usageService;
@ -59,6 +62,14 @@ public class SkillFileTool {
@JsonPropertyDescription("Relative file path (e.g., 'references/doc.md' or 'scripts/run.py')")
String filePath,
@JsonProperty(required = false)
@JsonPropertyDescription("Start line number (1-based). Omit to start from line 1")
Integer startLine,
@JsonProperty(required = false)
@JsonPropertyDescription("Maximum number of lines to read, default 300")
Integer maxLines,
@Nullable ToolContext ctx
) {
log.info("Reading skill file: skill={}, path={}", skillName, filePath);
@ -75,7 +86,18 @@ public class SkillFileTool {
log.info("Skill loaded: skill={}, path=SKILL.md, bytes={}, estimatedTokens={}",
skillName, skill.getContent().length(), TokenEstimator.estimateTokens(skill.getContent()));
recordLoaded(skill, "SKILL.md", skill.getContent(), ctx);
return skill.getContent();
// SKILL.md is the model's primary contract for using a skill
// pagination by default would let the model see only the first
// 200 lines / 8KB and silently miss later mandatory sections.
// Return the full content unless the caller explicitly requested
// pagination via startLine or maxLines. References / scripts are
// still paginated below because they can be large supplementary
// material the model loads on demand.
boolean paginationRequested = startLine != null || maxLines != null;
if (!paginationRequested) {
return skill.getContent();
}
return paginateSkillContent(skillName, "SKILL.md", skill.getContent(), startLine, maxLines);
}
return "Error: SKILL.md content not available";
}
@ -105,7 +127,7 @@ public class SkillFileTool {
log.info("Skill loaded: skill={}, path={}, bytes={}, estimatedTokens={}",
skillName, filePath, content.length(), TokenEstimator.estimateTokens(content));
recordLoaded(skill, filePath, content, ctx);
return content;
return paginateSkillContent(skillName, filePath, content, startLine, maxLines);
} catch (Exception e) {
log.error("Failed to read skill file {}/{}: {}", skillName, filePath, e.getMessage());
@ -113,6 +135,76 @@ public class SkillFileTool {
}
}
private String paginateSkillContent(String skillName, String filePath, String content,
Integer startLine, Integer maxLines) {
int safeStart = startLine == null || startLine <= 0 ? 1 : startLine;
int safeMaxLines = maxLines == null || maxLines <= 0
? DEFAULT_MAX_LINES
: Math.min(maxLines, DEFAULT_MAX_LINES);
String[] lines = content.split("\\R", -1);
if (safeStart > lines.length) {
return "Error: startLine " + safeStart + " exceeds total lines " + lines.length;
}
StringBuilder out = new StringBuilder();
int emitted = 0;
int lineIndex = safeStart - 1;
boolean truncated = false;
boolean longLineSplit = false;
while (lineIndex < lines.length && emitted < safeMaxLines) {
String rendered = lines[lineIndex] + "\n";
if (out.length() + rendered.length() > MAX_OUTPUT_CHARS) {
// P2 fix: a single line longer than MAX_OUTPUT_CHARS would
// otherwise loop forever the model gets a banner saying
// "next startLine=N" but N still points at the same long line,
// so the next call yields the same banner with zero content.
// Big JSON / minified scripts / base64 fixtures all hit this.
// When we have already emitted some shorter lines this round,
// stop and let the caller re-request from this line. When the
// FIRST attempted line is the over-long one, head-truncate it
// verbatim into the remaining budget so the model sees real
// content and can advance lineIndex on the next call.
if (emitted == 0) {
int budget = Math.max(0, MAX_OUTPUT_CHARS - out.length());
if (budget > 0) {
out.append(rendered, 0, Math.min(budget, rendered.length()));
}
emitted = 1;
lineIndex++;
longLineSplit = true;
}
truncated = true;
break;
}
out.append(rendered);
emitted++;
lineIndex++;
}
if (lineIndex < lines.length) {
truncated = true;
}
if (truncated) {
int nextLine = safeStart + emitted;
out.append("\n[Skill file truncated: skill=").append(skillName)
.append(", path=").append(filePath)
.append(", shownLines=").append(safeStart).append("-").append(nextLine - 1)
.append(", totalLines=").append(lines.length);
if (longLineSplit) {
// Tell the model the truncation crossed a single long line so
// it knows the displayed text for that line is not the whole
// line it should switch tools (e.g. an external read with a
// byte range) rather than just paginating again.
out.append(", note=\"line ").append(safeStart)
.append(" exceeds per-call budget; shown content is head-truncated\"");
}
out.append(". Continue with readSkillFile(skillName=\"").append(skillName)
.append("\", filePath=\"").append(filePath)
.append("\", startLine=").append(nextLine)
.append(", maxLines=").append(safeMaxLines).append(").]");
}
return out.toString();
}
private void recordLoaded(ResolvedSkill skill, String filePath, String content, @Nullable ToolContext ctx) {
ChatOrigin origin = ChatOrigin.from(ctx);
usageService.recordLoaded(

View File

@ -200,6 +200,7 @@ mate:
per-result-threshold-chars: 16000 # was 4000 — prevents WebSearch spill-to-disk
per-turn-budget-chars: 32000 # was 16000 — headroom for multi-tool turns
preview-head-chars: 800
excluded-tool-inline-chars: 2500
storage-base-dir: ""
# Retrieval-style tools that must NEVER be spilled. Spilling read_file's
# output causes a recursion: the agent reads the spill path, that read also

View File

@ -13,6 +13,8 @@
- **宁缺勿滥**:不确定是否值得记录时,选择不记录
- **区分稳定与临时**:反复出现的偏好/模式放 MEMORY.md一次性事件放 daily note
- **不记录对话本身**:不要把对话内容原样搬运,而是提炼关键信息
- **不记录一次性执行结果**:源码分析、代码 review、日志排查、debug 过程、任务完成情况和临时 TODO 不进入 MEMORY.md除非用户明确要求“记住”
- **不记录不完整结论**:如果助手回答表现为中断、证据不足、推测、未确认或错误兜底,不要写入任何记忆文件
- **不记录敏感信息**密码、API Key、Token 等绝对不能写入记忆
- **保持简洁**:每条记忆用一两句话概括
@ -33,4 +35,4 @@
- `daily_entry`: 字符串或 null。要追加到今日 daily note 的内容markdown 格式,以时间戳开头如 "## HH:mm ..."
- `memory_update`: 字符串或 null。MEMORY.md 的完整新内容(已合并现有内容,不是增量)。仅当有需要新增或修改的稳定信息时才填写
- `profile_update`: 字符串或 null。PROFILE.md 的完整新内容(已合并现有内容,不是增量)。仅当用户身份/偏好有显著变化时才填写
- `reason`: 简要说明判断理由
- `reason`: 简要说明判断理由

View File

@ -184,6 +184,43 @@
</template><!-- /传统合并渲染模式 -->
<!--
INCOMPLETE banner: graph emitted finishReason=incomplete after
the thinking-only soft cap stopped the stream.
Lives outside the segmented/traditional fork so both rendering
modes show it. Click "regenerate" reuses the existing emit path
that the error-card already relies on.
-->
<div v-if="isIncomplete" class="incomplete-card">
<div class="incomplete-card__header">
<el-icon class="incomplete-card__icon"><WarningFilled /></el-icon>
<span class="incomplete-card__title">{{ $t('chat.incompleteTitle') }}</span>
</div>
<p class="incomplete-card__description">{{ $t('chat.incompleteDescription') }}</p>
<div class="incomplete-card__footer">
<button class="incomplete-card__retry" type="button" @click="$emit('regenerate')">
<el-icon><RefreshRight /></el-icon>
{{ $t('chat.incompleteRetry') }}
</button>
</div>
</div>
<!--
EVIDENCE_INSUFFICIENT banner (info color, not warning). Run completed
fully the answer text is preserved above; the model just cited
source files / classes it didn't actually open. Without an explicit
card the trailing "[证据不足] …" line reads like a mid-answer cut.
No regenerate button: the user typically wants to either accept
the gap or follow up asking the model to read the listed files.
-->
<div v-if="isEvidenceInsufficient" class="evidence-card">
<div class="evidence-card__header">
<el-icon class="evidence-card__icon"><InfoFilled /></el-icon>
<span class="evidence-card__title">{{ $t('chat.evidenceTitle') }}</span>
</div>
<p class="evidence-card__description">{{ $t('chat.evidenceDescription') }}</p>
</div>
<!-- 附件列表 -->
<div v-if="attachments?.length" class="message-attachments">
<div
@ -315,6 +352,7 @@ import {
CloseBold,
CopyDocument,
Document,
InfoFilled,
Loading,
Microphone,
Opportunity,
@ -816,6 +854,32 @@ const toolCallsMeta = computed<ToolCallMeta[]>(() => {
return parsedMetadata.value?.toolCalls || []
})
/**
* True when the assistant turn was auto-truncated by the backend's
* thinking-only soft-cap and ended in INCOMPLETE.
* Surfaced as a banner with a "continue / regenerate" affordance so the
* user knows the answer ended early on purpose, not silently skipped.
*
* Reads `metadata.finishReason` set by the graph's FinalAnswerNode via
* the finish_reason GraphEvent StreamDelta accumulator pipeline.
*/
const isIncomplete = computed<boolean>(() => {
if (props.message.role !== 'assistant') return false
return parsedMetadata.value?.finishReason === 'incomplete'
})
/**
* True when the graph completed normally but {@code SourceEvidenceLedger}
* found unsupported references. The visible answer is full and persisted;
* the trailing "[证据不足] …" line just lists which file/class citations
* were never confirmed by an actual tool result. Without this banner the
* user often misreads that single line as a mid-answer cut.
*/
const isEvidenceInsufficient = computed<boolean>(() => {
if (props.message.role !== 'assistant') return false
return parsedMetadata.value?.finishReason === 'evidence_insufficient'
})
const browserActionsMeta = computed<BrowserAction[]>(() => {
return parsedMetadata.value?.browserActions || []
})
@ -1506,6 +1570,105 @@ watch(isGenerating, (generating) => {
border-color: color-mix(in srgb, var(--mc-danger) 50%, transparent);
}
/* ==================== INCOMPLETE 截断卡片(重复检测 / thinking-only 软上限) ==================== */
.incomplete-card {
margin-top: 8px;
padding: 12px 16px;
border-radius: 8px;
background: color-mix(in srgb, var(--mc-warning, #d97706) 8%, var(--mc-bg-elevated));
border: 1px solid color-mix(in srgb, var(--mc-warning, #d97706) 30%, transparent);
font-size: 13px;
max-width: 480px;
line-height: 1.5;
}
.incomplete-card__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.incomplete-card__icon {
flex-shrink: 0;
color: var(--mc-warning, #d97706);
}
.incomplete-card__title {
font-weight: 600;
color: var(--mc-warning, #d97706);
font-size: 14px;
}
.incomplete-card__description {
margin: 4px 0 8px;
color: var(--mc-text-primary);
font-size: 13px;
opacity: 0.85;
}
.incomplete-card__footer {
display: flex;
justify-content: flex-end;
}
.incomplete-card__retry {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--mc-warning, #d97706) 35%, transparent);
background: color-mix(in srgb, var(--mc-warning, #d97706) 10%, var(--mc-bg-elevated));
color: var(--mc-warning, #d97706);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.incomplete-card__retry:hover {
background: color-mix(in srgb, var(--mc-warning, #d97706) 18%, var(--mc-bg-elevated));
border-color: color-mix(in srgb, var(--mc-warning, #d97706) 55%, transparent);
}
/* ==================== EVIDENCE_INSUFFICIENT 提示卡info 调,非警告) ==================== */
.evidence-card {
margin-top: 8px;
padding: 10px 14px;
border-radius: 8px;
background: color-mix(in srgb, var(--mc-info, #0891b2) 6%, var(--mc-bg-elevated));
border: 1px solid color-mix(in srgb, var(--mc-info, #0891b2) 25%, transparent);
font-size: 12.5px;
max-width: 480px;
line-height: 1.5;
}
.evidence-card__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.evidence-card__icon {
flex-shrink: 0;
color: var(--mc-info, #0891b2);
}
.evidence-card__title {
font-weight: 600;
color: var(--mc-info, #0891b2);
font-size: 13.5px;
}
.evidence-card__description {
margin: 4px 0 0;
color: var(--mc-text-primary);
font-size: 12.5px;
opacity: 0.85;
}
/* ==================== 附件 ==================== */
.message-attachments {
display: flex;

View File

@ -257,6 +257,13 @@ export default {
// Per-iteration grouping
iterationEmpty: 'Iteration {index} interrupted (no output)',
contentRepetitionWarning: 'Repetitive content detected near the end (model artifact)',
// INCOMPLETE truncation card (finishReason=incomplete)
incompleteTitle: 'Answer auto-truncated after repeated output was detected',
incompleteDescription: 'After the visible text above, the model started repeating itself (or stalled in thinking with no output) and was cut short to avoid wasted tokens. Click below to regenerate the full answer, or refine your prompt to focus on the missing parts.',
incompleteRetry: 'Regenerate',
// EVIDENCE_INSUFFICIENT info card (finishReason=evidence_insufficient)
evidenceTitle: 'Run completed — some source references could not be verified',
evidenceDescription: 'The full answer is preserved above. The classes/files listed in the trailing "[evidence insufficient] …" line were not actually opened during this run; the model may have inferred them from naming. Ask a follow-up to have each one read before relying on those references.',
// Approval bar
approvalAllow: 'Allow',
approvalExecute: 'to execute?',

View File

@ -257,6 +257,13 @@ export default {
// 按轮次分组渲染
iterationEmpty: '第 {index} 轮被中断(无输出)',
contentRepetitionWarning: '检测到内容尾部重复(疑似模型输出 artifact',
// INCOMPLETE 截断卡片finishReason=incomplete
incompleteTitle: '回答因检测到重复输出已被自动截断',
incompleteDescription: '系统在你看到的部分之后检测到模型开始重复同一段内容(或在思考阶段无产出),已自动截断以避免无效输出。点下方按钮重新生成完整回答,或在输入框补充提示让模型聚焦剩余内容。',
incompleteRetry: '重新生成',
// EVIDENCE_INSUFFICIENT 提示卡finishReason=evidence_insufficient
evidenceTitle: '任务已完成,但部分源码引用未被验证',
evidenceDescription: '回答全文已保留并展示。底部「[证据不足] …」列出的类/文件并未在本次工具结果里被实际读取,模型可能基于命名推断。如需确认这些引用,请追问让模型逐一读取后再下结论。',
// 审批栏
approvalAllow: '允许',
approvalExecute: '执行?',