mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 19:45:08 +08:00
fix(agent): prevent ReAct degenerate repetition and harden loop termination
This commit is contained in:
parent
3de643dca9
commit
99befe0d25
@ -339,6 +339,7 @@ public class AgentGraphBuilder {
|
|||||||
.addStrategy(MateClawStateKeys.TOOL_CALLS, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.TOOL_CALLS, KeyStrategy.REPLACE)
|
||||||
.addStrategy(MateClawStateKeys.TOOL_RESULTS, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.TOOL_RESULTS, KeyStrategy.REPLACE)
|
||||||
.addStrategy(MateClawStateKeys.TOOL_CALL_COUNT, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.TOOL_CALL_COUNT, KeyStrategy.REPLACE)
|
||||||
|
.addStrategy(MateClawStateKeys.LLM_CALL_COUNT, KeyStrategy.REPLACE)
|
||||||
// 控制流
|
// 控制流
|
||||||
.addStrategy(MateClawStateKeys.FINAL_ANSWER, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.FINAL_ANSWER, KeyStrategy.REPLACE)
|
||||||
.addStrategy(MateClawStateKeys.NEEDS_TOOL_CALL, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.NEEDS_TOOL_CALL, KeyStrategy.REPLACE)
|
||||||
|
|||||||
@ -7,12 +7,15 @@ import org.springframework.ai.chat.model.ChatResponse;
|
|||||||
import org.springframework.ai.chat.prompt.Prompt;
|
import org.springframework.ai.chat.prompt.Prompt;
|
||||||
import vip.mate.channel.web.ChatStreamTracker;
|
import vip.mate.channel.web.ChatStreamTracker;
|
||||||
|
|
||||||
|
import reactor.core.Disposable;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.CancellationException;
|
import java.util.concurrent.CancellationException;
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.util.concurrent.CountDownLatch;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
@ -236,9 +239,15 @@ public class NodeStreamingChatHelper {
|
|||||||
AtomicInteger promptTokens = new AtomicInteger(0);
|
AtomicInteger promptTokens = new AtomicInteger(0);
|
||||||
AtomicInteger completionTokens = new AtomicInteger(0);
|
AtomicInteger completionTokens = new AtomicInteger(0);
|
||||||
|
|
||||||
|
// 重复检测器:检测 LLM 退化输出(如不断重复同一句话)
|
||||||
|
RepetitionDetector contentRepDetector = new RepetitionDetector();
|
||||||
|
RepetitionDetector thinkingRepDetector = new RepetitionDetector();
|
||||||
|
// 重复检测触发后设为 true,外层轮询线程据此 dispose 订阅
|
||||||
|
AtomicBoolean repetitionTriggered = new AtomicBoolean(false);
|
||||||
|
|
||||||
CountDownLatch latch = new CountDownLatch(1);
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
|
||||||
chatModel.stream(prompt)
|
Disposable subscription = chatModel.stream(prompt)
|
||||||
.doOnNext(chatResponse -> {
|
.doOnNext(chatResponse -> {
|
||||||
if (chatResponse == null || chatResponse.getResults() == null || chatResponse.getResults().isEmpty()) {
|
if (chatResponse == null || chatResponse.getResults() == null || chatResponse.getResults().isEmpty()) {
|
||||||
return;
|
return;
|
||||||
@ -247,18 +256,35 @@ public class NodeStreamingChatHelper {
|
|||||||
AssistantMessage msg = generation.getOutput();
|
AssistantMessage msg = generation.getOutput();
|
||||||
lastAssistantMessage.set(msg);
|
lastAssistantMessage.set(msg);
|
||||||
|
|
||||||
// 1. 提取 content delta
|
// 重复已触发 → 跳过一切处理(等外层 dispose)
|
||||||
|
if (repetitionTriggered.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 提取 content delta(含重复检测)
|
||||||
String contentDelta = msg.getText();
|
String contentDelta = msg.getText();
|
||||||
if (contentDelta != null && !contentDelta.isEmpty()) {
|
if (contentDelta != null && !contentDelta.isEmpty()) {
|
||||||
|
if (contentRepDetector.appendAndCheck(contentDelta)) {
|
||||||
|
log.warn("[{}] Content repetition detected, will cancel stream " +
|
||||||
|
"for conversation {}", phase, conversationId);
|
||||||
|
repetitionTriggered.set(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
contentAccum.append(contentDelta);
|
contentAccum.append(contentDelta);
|
||||||
if (broadcast) {
|
if (broadcast) {
|
||||||
broadcastDelta(conversationId, "content_delta", contentDelta);
|
broadcastDelta(conversationId, "content_delta", contentDelta);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 提取 thinking delta(从 properties 中的 reasoningContent)
|
// 2. 提取 thinking delta(含重复检测)
|
||||||
String thinkingDelta = extractReasoningContent(msg);
|
String thinkingDelta = extractReasoningContent(msg);
|
||||||
if (thinkingDelta != null && !thinkingDelta.isEmpty()) {
|
if (thinkingDelta != null && !thinkingDelta.isEmpty()) {
|
||||||
|
if (thinkingRepDetector.appendAndCheck(thinkingDelta)) {
|
||||||
|
log.warn("[{}] Thinking repetition detected, will cancel stream " +
|
||||||
|
"for conversation {}", phase, conversationId);
|
||||||
|
repetitionTriggered.set(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
thinkingAccum.append(thinkingDelta);
|
thinkingAccum.append(thinkingDelta);
|
||||||
if (broadcast) {
|
if (broadcast) {
|
||||||
broadcastDelta(conversationId, "thinking_delta", thinkingDelta);
|
broadcastDelta(conversationId, "thinking_delta", thinkingDelta);
|
||||||
@ -287,12 +313,25 @@ public class NodeStreamingChatHelper {
|
|||||||
latch::countDown
|
latch::countDown
|
||||||
);
|
);
|
||||||
|
|
||||||
// 阻塞等待流完成(节点本身是同步 NodeAction),每 500ms 检查一次停止标志
|
// 阻塞等待流完成(节点本身是同步 NodeAction),每 500ms 检查一次停止/重复标志
|
||||||
try {
|
try {
|
||||||
long deadlineMs = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(10);
|
long deadlineMs = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(10);
|
||||||
while (!latch.await(500, TimeUnit.MILLISECONDS)) {
|
while (!latch.await(500, TimeUnit.MILLISECONDS)) {
|
||||||
|
// 重复检测触发 → 立即 dispose 上游订阅,停止消耗 tokens
|
||||||
|
if (repetitionTriggered.get()) {
|
||||||
|
log.warn("[{}] Repetition detected, disposing upstream subscription " +
|
||||||
|
"for conversation {}", phase, conversationId);
|
||||||
|
subscription.dispose();
|
||||||
|
if (broadcast) {
|
||||||
|
broadcastDelta(conversationId, "warning",
|
||||||
|
buildDeltaJson("检测到模型输出重复,已自动截断"));
|
||||||
|
}
|
||||||
|
// dispose 后 latch 可能不会 countDown,直接跳出
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (streamTracker.isStopRequested(conversationId)) {
|
if (streamTracker.isStopRequested(conversationId)) {
|
||||||
// 不直接抛异常 — 先检查是否已有累积内容,有则返回 partial stopped result
|
// 用户主动停止 — 也 dispose 上游
|
||||||
|
subscription.dispose();
|
||||||
boolean hasContent = !contentAccum.isEmpty() || !thinkingAccum.isEmpty()
|
boolean hasContent = !contentAccum.isEmpty() || !thinkingAccum.isEmpty()
|
||||||
|| !toolCallAccumulators.isEmpty();
|
|| !toolCallAccumulators.isEmpty();
|
||||||
if (hasContent) {
|
if (hasContent) {
|
||||||
@ -309,11 +348,13 @@ public class NodeStreamingChatHelper {
|
|||||||
throw new CancellationException("Stream stopped by user");
|
throw new CancellationException("Stream stopped by user");
|
||||||
}
|
}
|
||||||
if (System.currentTimeMillis() > deadlineMs) {
|
if (System.currentTimeMillis() > deadlineMs) {
|
||||||
|
subscription.dispose();
|
||||||
log.warn("[{}] Stream call timed out for conversation {}", phase, conversationId);
|
log.warn("[{}] Stream call timed out for conversation {}", phase, conversationId);
|
||||||
return buildErrorResult("LLM 调用超时", conversationId, phase);
|
return buildErrorResult("LLM 调用超时", conversationId, phase);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
|
subscription.dispose();
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
return buildErrorResult("LLM 调用被中断", conversationId, phase);
|
return buildErrorResult("LLM 调用被中断", conversationId, phase);
|
||||||
}
|
}
|
||||||
@ -367,9 +408,16 @@ public class NodeStreamingChatHelper {
|
|||||||
conversationId, phase, errorType);
|
conversationId, phase, errorType);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 成功 =====
|
// ===== 成功(检查是否因重复被截断) =====
|
||||||
|
boolean truncatedByRepetition = repetitionTriggered.get();
|
||||||
|
if (truncatedByRepetition) {
|
||||||
|
log.warn("[{}] LLM output was truncated due to repetition detection for conversation {}",
|
||||||
|
phase, conversationId);
|
||||||
|
// warning 已在 dispose 时广播,无需重复
|
||||||
|
}
|
||||||
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
|
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
|
||||||
promptTokens.get(), completionTokens.get(), phase, false, null);
|
promptTokens.get(), completionTokens.get(), phase,
|
||||||
|
truncatedByRepetition, truncatedByRepetition ? "output_truncated_repetition" : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 组装 stopped partial 结果(用户主动停止,有已累积内容) */
|
/** 组装 stopped partial 结果(用户主动停止,有已累积内容) */
|
||||||
|
|||||||
@ -0,0 +1,112 @@
|
|||||||
|
package vip.mate.agent.graph;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 流式输出重复检测器
|
||||||
|
* <p>
|
||||||
|
* 检测 LLM 流式输出中的退化重复模式(degenerate repetition),
|
||||||
|
* 当检测到内容在滑动窗口内高度重复时返回 true,调用方应截断 LLM 流。
|
||||||
|
* <p>
|
||||||
|
* 算法:维护一个滑动窗口缓冲区,每次追加新 delta 后,
|
||||||
|
* 检查窗口尾部是否存在连续重复的 n-gram 模式。
|
||||||
|
*
|
||||||
|
* @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;
|
||||||
|
|
||||||
|
private final StringBuilder buffer = new StringBuilder();
|
||||||
|
private boolean repetitionDetected = 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保持窗口大小
|
||||||
|
if (buffer.length() > WINDOW_SIZE * 2) {
|
||||||
|
buffer.delete(0, buffer.length() - WINDOW_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在窗口尾部检测重复模式
|
||||||
|
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) {
|
||||||
|
repetitionDetected = true;
|
||||||
|
log.warn("[RepetitionDetector] Detected degenerate repetition: " +
|
||||||
|
"pattern length={}, repeats={}, pattern preview=\"{}\"",
|
||||||
|
patternLen, count,
|
||||||
|
pattern.length() > 50 ? pattern.substring(0, 50) + "..." : pattern);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置检测器状态
|
||||||
|
*/
|
||||||
|
public void reset() {
|
||||||
|
buffer.setLength(0);
|
||||||
|
repetitionDetected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否已检测到重复
|
||||||
|
*/
|
||||||
|
public boolean isRepetitionDetected() {
|
||||||
|
return repetitionDetected;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,46 +8,74 @@ import vip.mate.agent.graph.state.MateClawStateAccessor;
|
|||||||
import static vip.mate.agent.graph.state.MateClawStateKeys.*;
|
import static vip.mate.agent.graph.state.MateClawStateKeys.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 推理路由(4 路分支)
|
* 推理路由(分支优先级)
|
||||||
* <p>
|
|
||||||
* 根据 ReasoningNode 产出的状态决定下一步去向:
|
|
||||||
* <ol>
|
* <ol>
|
||||||
* <li>迭代超限 → limitExceededNode(最高优先级)</li>
|
* <li>迭代超限 → limitExceededNode</li>
|
||||||
|
* <li>可直接回答(含 fatal error 自带的错误文案) → finalAnswerNode</li>
|
||||||
|
* <li>LLM 调用次数超限 → limitExceededNode(仅拦截继续循环的路径)</li>
|
||||||
* <li>需要工具调用 → actionNode</li>
|
* <li>需要工具调用 → actionNode</li>
|
||||||
* <li>需要总结压缩 → summarizingNode</li>
|
* <li>需要总结压缩 → summarizingNode</li>
|
||||||
* <li>可直接回答 → finalAnswerNode</li>
|
* <li>兜底 → finalAnswerNode</li>
|
||||||
* </ol>
|
* </ol>
|
||||||
|
* <p>
|
||||||
|
* 注意:ReasoningNode 的 fatal error 路径会自行设置 finalAnswer(错误文案)+
|
||||||
|
* finishReason(ERROR_FALLBACK),因此会命中分支 2 直接走 finalAnswerNode,
|
||||||
|
* 不需要也不应该路由到 LimitExceededNode(后者会再发一次 LLM 调用)。
|
||||||
*
|
*
|
||||||
* @author MateClaw Team
|
* @author MateClaw Team
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class ReasoningDispatcher implements EdgeAction {
|
public class ReasoningDispatcher implements EdgeAction {
|
||||||
|
|
||||||
|
/** LLM 调用次数的安全倍数上限(相对于 maxIterations) */
|
||||||
|
private static final int LLM_CALL_MULTIPLIER = 3;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String apply(OverAllState state) throws Exception {
|
public String apply(OverAllState state) throws Exception {
|
||||||
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
|
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
|
||||||
|
|
||||||
// 1. 超限检查优先
|
// 1. 迭代超限检查(最高优先级)
|
||||||
if (accessor.isLimitReached()) {
|
if (accessor.isLimitReached()) {
|
||||||
log.warn("[ReasoningDispatcher] Iteration limit reached ({}/{}), routing to limitExceededNode",
|
log.warn("[ReasoningDispatcher] Iteration limit reached ({}/{}), routing to limitExceededNode",
|
||||||
accessor.iterationCount(), accessor.maxIterations());
|
accessor.iterationCount(), accessor.maxIterations());
|
||||||
return LIMIT_EXCEEDED_NODE;
|
return LIMIT_EXCEEDED_NODE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 工具调用
|
// 2. 可直接回答 → finalAnswerNode
|
||||||
|
// 覆盖以下场景:
|
||||||
|
// - LLM 正常产出最终回答 (needsToolCall=false, finalAnswer 非空)
|
||||||
|
// - fatal error (ReasoningNode 设置了 finalAnswer=错误文案 + finishReason=ERROR_FALLBACK)
|
||||||
|
// - 用户停止无内容 (finalAnswer="" + finishReason=STOPPED)
|
||||||
|
// 不受 llm_call_count 限制 — 已有结果不应丢弃。
|
||||||
|
if (!accessor.needsToolCall() && !accessor.shouldSummarize()) {
|
||||||
|
log.debug("[ReasoningDispatcher] Routing to finalAnswerNode (direct answer)");
|
||||||
|
return FINAL_ANSWER_NODE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. LLM 调用次数超限 — 仅拦截继续循环(工具调用/总结)的路径
|
||||||
|
int llmCallCount = accessor.llmCallCount();
|
||||||
|
int llmCallLimit = accessor.maxIterations() * LLM_CALL_MULTIPLIER;
|
||||||
|
if (llmCallCount >= llmCallLimit) {
|
||||||
|
log.warn("[ReasoningDispatcher] LLM call count limit reached ({}/{}), " +
|
||||||
|
"routing to limitExceededNode instead of continuing loop",
|
||||||
|
llmCallCount, llmCallLimit);
|
||||||
|
return LIMIT_EXCEEDED_NODE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 工具调用
|
||||||
if (accessor.needsToolCall()) {
|
if (accessor.needsToolCall()) {
|
||||||
log.debug("[ReasoningDispatcher] Routing to actionNode (tool call needed)");
|
log.debug("[ReasoningDispatcher] Routing to actionNode (tool call needed)");
|
||||||
return ACTION_NODE;
|
return ACTION_NODE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 需要总结(上下文过长,最终回答前先压缩)
|
// 5. 需要总结
|
||||||
if (accessor.shouldSummarize()) {
|
if (accessor.shouldSummarize()) {
|
||||||
log.info("[ReasoningDispatcher] Routing to summarizingNode (observation context too large)");
|
log.info("[ReasoningDispatcher] Routing to summarizingNode (observation context too large)");
|
||||||
return SUMMARIZING_NODE;
|
return SUMMARIZING_NODE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 直接回答
|
// 6. 兜底
|
||||||
log.debug("[ReasoningDispatcher] Routing to finalAnswerNode (direct answer)");
|
log.debug("[ReasoningDispatcher] Routing to finalAnswerNode (fallback)");
|
||||||
return FINAL_ANSWER_NODE;
|
return FINAL_ANSWER_NODE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -79,18 +79,27 @@ public class FinalAnswerNode implements NodeAction {
|
|||||||
finalAnswer.length(), finishReason);
|
finalAnswer.length(), finishReason);
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// 异常兜底:使用 summarizedContext
|
// 无 draft 也无 finalAnswer
|
||||||
String summary = accessor.summarizedContext();
|
// 先检查是否用户主动停止(无内容的 STOPPED 是合法终止,不是错误)
|
||||||
if (!summary.isEmpty()) {
|
if (parseFinishReason(existingReason) == FinishReason.STOPPED) {
|
||||||
finalAnswer = summary;
|
finalAnswer = "";
|
||||||
finalThinking = currentThinking;
|
|
||||||
finishReason = FinishReason.SUMMARIZED;
|
|
||||||
log.warn("[FinalAnswerNode] No finalAnswer or draft found, falling back to summarizedContext");
|
|
||||||
} else {
|
|
||||||
finalAnswer = "未能生成回答,请重试。";
|
|
||||||
finalThinking = "";
|
finalThinking = "";
|
||||||
finishReason = FinishReason.ERROR_FALLBACK;
|
finishReason = FinishReason.STOPPED;
|
||||||
log.error("[FinalAnswerNode] No answer source available, returning fallback");
|
log.info("[FinalAnswerNode] User stopped before any content was generated, preserving STOPPED");
|
||||||
|
} else {
|
||||||
|
// 异常兜底:使用 summarizedContext
|
||||||
|
String summary = accessor.summarizedContext();
|
||||||
|
if (!summary.isEmpty()) {
|
||||||
|
finalAnswer = summary;
|
||||||
|
finalThinking = currentThinking;
|
||||||
|
finishReason = FinishReason.SUMMARIZED;
|
||||||
|
log.warn("[FinalAnswerNode] No finalAnswer or draft found, falling back to summarizedContext");
|
||||||
|
} else {
|
||||||
|
finalAnswer = "未能生成回答,请重试。";
|
||||||
|
finalThinking = "";
|
||||||
|
finishReason = FinishReason.ERROR_FALLBACK;
|
||||||
|
log.error("[FinalAnswerNode] No answer source available, returning fallback");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -76,6 +76,13 @@ public class ObservationNode implements NodeAction {
|
|||||||
List<String> updatedHistory = new ArrayList<>(existingHistory);
|
List<String> updatedHistory = new ArrayList<>(existingHistory);
|
||||||
updatedHistory.add(combinedObservation);
|
updatedHistory.add(combinedObservation);
|
||||||
|
|
||||||
|
// 检测重复观察:最近 N 条完全相同则强制终止
|
||||||
|
boolean duplicateObservation = detectDuplicateObservations(existingHistory, combinedObservation, 3);
|
||||||
|
if (duplicateObservation) {
|
||||||
|
log.warn("[ObservationNode] Detected {} consecutive identical observations, " +
|
||||||
|
"forcing limit exceeded to break loop", 3);
|
||||||
|
}
|
||||||
|
|
||||||
// 判断是否需要 summarize
|
// 判断是否需要 summarize
|
||||||
boolean shouldSummarize = observationProcessor.needsSummarizing(
|
boolean shouldSummarize = observationProcessor.needsSummarizing(
|
||||||
existingHistory, combinedObservation);
|
existingHistory, combinedObservation);
|
||||||
@ -89,11 +96,34 @@ public class ObservationNode implements NodeAction {
|
|||||||
existingHistory.size(), combinedObservation.length(), newToolCallCount);
|
existingHistory.size(), combinedObservation.length(), newToolCallCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
return MateClawStateAccessor.output()
|
var builder = MateClawStateAccessor.output()
|
||||||
.iterationCount(nextIteration)
|
.iterationCount(nextIteration)
|
||||||
.put(OBSERVATION_HISTORY, updatedHistory)
|
.put(OBSERVATION_HISTORY, updatedHistory)
|
||||||
.shouldSummarize(shouldSummarize)
|
.shouldSummarize(shouldSummarize)
|
||||||
.toolCallCount(newToolCallCount)
|
.toolCallCount(newToolCallCount);
|
||||||
.build();
|
|
||||||
|
// 重复观察时标记错误,让 ObservationDispatcher 路由到 limitExceededNode
|
||||||
|
if (duplicateObservation) {
|
||||||
|
builder.put(ERROR, "连续 3 次工具调用返回相同结果,已强制终止循环");
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测最近 N 条观察是否与当前观察完全相同
|
||||||
|
*/
|
||||||
|
private boolean detectDuplicateObservations(List<String> history, String current, int threshold) {
|
||||||
|
if (history.size() < threshold - 1 || current == null || current.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 检查 history 的最后 (threshold-1) 条是否都与 current 相同
|
||||||
|
int start = history.size() - (threshold - 1);
|
||||||
|
for (int i = start; i < history.size(); i++) {
|
||||||
|
if (!current.equals(history.get(i))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,23 +50,36 @@ public class ReasoningNode implements NodeAction {
|
|||||||
|
|
||||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
/** 单次 LLM 调用的默认最大输出 token 数,防止退化输出无限生成 */
|
||||||
|
private static final int DEFAULT_MAX_OUTPUT_TOKENS = 4096;
|
||||||
|
|
||||||
private final ChatModel chatModel;
|
private final ChatModel chatModel;
|
||||||
private final List<ToolCallback> toolCallbacks;
|
private final List<ToolCallback> toolCallbacks;
|
||||||
private final String reasoningEffort;
|
private final String reasoningEffort;
|
||||||
private final NodeStreamingChatHelper streamingHelper;
|
private final NodeStreamingChatHelper streamingHelper;
|
||||||
private final ConversationWindowManager conversationWindowManager;
|
private final ConversationWindowManager conversationWindowManager;
|
||||||
private final ChatStreamTracker streamTracker;
|
private final ChatStreamTracker streamTracker;
|
||||||
|
private final int maxOutputTokens;
|
||||||
|
|
||||||
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
|
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
|
||||||
NodeStreamingChatHelper streamingHelper,
|
NodeStreamingChatHelper streamingHelper,
|
||||||
ConversationWindowManager conversationWindowManager,
|
ConversationWindowManager conversationWindowManager,
|
||||||
ChatStreamTracker streamTracker) {
|
ChatStreamTracker streamTracker) {
|
||||||
|
this(chatModel, toolSet, reasoningEffort, streamingHelper, conversationWindowManager,
|
||||||
|
streamTracker, DEFAULT_MAX_OUTPUT_TOKENS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
|
||||||
|
NodeStreamingChatHelper streamingHelper,
|
||||||
|
ConversationWindowManager conversationWindowManager,
|
||||||
|
ChatStreamTracker streamTracker, int maxOutputTokens) {
|
||||||
this.chatModel = chatModel;
|
this.chatModel = chatModel;
|
||||||
this.toolCallbacks = toolSet.callbacks();
|
this.toolCallbacks = toolSet.callbacks();
|
||||||
this.reasoningEffort = reasoningEffort;
|
this.reasoningEffort = reasoningEffort;
|
||||||
this.streamingHelper = streamingHelper;
|
this.streamingHelper = streamingHelper;
|
||||||
this.conversationWindowManager = conversationWindowManager;
|
this.conversationWindowManager = conversationWindowManager;
|
||||||
this.streamTracker = streamTracker;
|
this.streamTracker = streamTracker;
|
||||||
|
this.maxOutputTokens = maxOutputTokens > 0 ? maxOutputTokens : DEFAULT_MAX_OUTPUT_TOKENS;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
|
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
|
||||||
@ -75,25 +88,19 @@ public class ReasoningNode implements NodeAction {
|
|||||||
this(chatModel, toolSet, reasoningEffort, streamingHelper, conversationWindowManager, null);
|
this(chatModel, toolSet, reasoningEffort, streamingHelper, conversationWindowManager, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @deprecated */
|
||||||
* @deprecated Use {@link #ReasoningNode(ChatModel, AgentToolSet, String, NodeStreamingChatHelper)} instead
|
|
||||||
*/
|
|
||||||
@Deprecated
|
@Deprecated
|
||||||
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort) {
|
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort) {
|
||||||
this(chatModel, toolSet, reasoningEffort, null, null);
|
this(chatModel, toolSet, reasoningEffort, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @deprecated */
|
||||||
* @deprecated Use {@link #ReasoningNode(ChatModel, AgentToolSet, String, NodeStreamingChatHelper)} instead
|
|
||||||
*/
|
|
||||||
@Deprecated
|
@Deprecated
|
||||||
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet) {
|
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet) {
|
||||||
this(chatModel, toolSet, null, null, null);
|
this(chatModel, toolSet, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @deprecated */
|
||||||
* @deprecated Use {@link #ReasoningNode(ChatModel, AgentToolSet, String, NodeStreamingChatHelper)} instead
|
|
||||||
*/
|
|
||||||
@Deprecated
|
@Deprecated
|
||||||
public ReasoningNode(ChatModel chatModel, List<ToolCallback> toolCallbacks) {
|
public ReasoningNode(ChatModel chatModel, List<ToolCallback> toolCallbacks) {
|
||||||
this.chatModel = chatModel;
|
this.chatModel = chatModel;
|
||||||
@ -102,6 +109,7 @@ public class ReasoningNode implements NodeAction {
|
|||||||
this.streamingHelper = null;
|
this.streamingHelper = null;
|
||||||
this.conversationWindowManager = null;
|
this.conversationWindowManager = null;
|
||||||
this.streamTracker = null;
|
this.streamTracker = null;
|
||||||
|
this.maxOutputTokens = DEFAULT_MAX_OUTPUT_TOKENS;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -109,14 +117,14 @@ public class ReasoningNode implements NodeAction {
|
|||||||
public Map<String, Object> apply(OverAllState state) throws Exception {
|
public Map<String, Object> apply(OverAllState state) throws Exception {
|
||||||
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
|
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
|
||||||
|
|
||||||
// ======= 取消检查 =======
|
// ======= 取消检查(LLM 调用前,尚未计数) =======
|
||||||
String conversationId = accessor.conversationId();
|
String conversationId = accessor.conversationId();
|
||||||
if (streamTracker != null && streamTracker.isStopRequested(conversationId)) {
|
if (streamTracker != null && streamTracker.isStopRequested(conversationId)) {
|
||||||
log.info("[ReasoningNode] Stop requested, aborting LLM call");
|
log.info("[ReasoningNode] Stop requested before LLM call, aborting");
|
||||||
throw new CancellationException("Stream stopped by user");
|
throw new CancellationException("Stream stopped by user");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ======= forced_tool_call 检测:审批通过后的重放 =======
|
// ======= forced_tool_call 检测:审批通过后的重放(不计入 LLM 调用) =======
|
||||||
String forcedToolCallJson = accessor.forcedToolCall();
|
String forcedToolCallJson = accessor.forcedToolCall();
|
||||||
if (!forcedToolCallJson.isEmpty()) {
|
if (!forcedToolCallJson.isEmpty()) {
|
||||||
try {
|
try {
|
||||||
@ -124,7 +132,6 @@ public class ReasoningNode implements NodeAction {
|
|||||||
|
|
||||||
AssistantMessage.ToolCall toolCall = deserializeToolCall(forcedToolCallJson);
|
AssistantMessage.ToolCall toolCall = deserializeToolCall(forcedToolCallJson);
|
||||||
|
|
||||||
// 构造合成的 AssistantMessage
|
|
||||||
AssistantMessage syntheticMsg = AssistantMessage.builder()
|
AssistantMessage syntheticMsg = AssistantMessage.builder()
|
||||||
.content("")
|
.content("")
|
||||||
.toolCalls(List.of(toolCall))
|
.toolCalls(List.of(toolCall))
|
||||||
@ -135,10 +142,10 @@ public class ReasoningNode implements NodeAction {
|
|||||||
.toolCalls(List.of(toolCall))
|
.toolCalls(List.of(toolCall))
|
||||||
.messages(List.of((Message) syntheticMsg))
|
.messages(List.of((Message) syntheticMsg))
|
||||||
.iterationCount(accessor.iterationCount() + 1)
|
.iterationCount(accessor.iterationCount() + 1)
|
||||||
.forcedToolCall("") // 清空,防止下一轮再触发
|
.forcedToolCall("")
|
||||||
.currentPhase("forced_replay")
|
.currentPhase("forced_replay")
|
||||||
.contentStreamed(true) // 无 content 需要流式推送
|
.contentStreamed(true)
|
||||||
.thinkingStreamed(true) // 无 thinking 需要流式推送
|
.thinkingStreamed(true)
|
||||||
.events(List.of(GraphEventPublisher.phase("forced_replay", Map.of(
|
.events(List.of(GraphEventPublisher.phase("forced_replay", Map.of(
|
||||||
"toolName", toolCall.name(),
|
"toolName", toolCall.name(),
|
||||||
"iteration", accessor.iterationCount() + 1))))
|
"iteration", accessor.iterationCount() + 1))))
|
||||||
@ -146,18 +153,26 @@ public class ReasoningNode implements NodeAction {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[ReasoningNode] Failed to deserialize forced_tool_call, falling through to normal LLM: {}",
|
log.error("[ReasoningNode] Failed to deserialize forced_tool_call, falling through to normal LLM: {}",
|
||||||
e.getMessage());
|
e.getMessage());
|
||||||
// 不 return,清空 forcedToolCall 后走正常 LLM 流程
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ======= forced_tool_call 检测结束 =======
|
|
||||||
|
|
||||||
|
// ======= 构建 Prompt =======
|
||||||
String systemPrompt = accessor.systemPrompt();
|
String systemPrompt = accessor.systemPrompt();
|
||||||
List<Message> messages = accessor.messages();
|
List<Message> messages = accessor.messages();
|
||||||
|
|
||||||
// 构建 Prompt,附带工具定义但禁用内部工具执行
|
// 消息列表膨胀防护
|
||||||
|
final int MAX_LOOP_MESSAGES = 40;
|
||||||
|
if (messages.size() > MAX_LOOP_MESSAGES) {
|
||||||
|
log.warn("[ReasoningNode] Messages list too large ({} messages), trimming to {} for conversation {}",
|
||||||
|
messages.size(), MAX_LOOP_MESSAGES, conversationId);
|
||||||
|
List<Message> trimmed = new ArrayList<>(MAX_LOOP_MESSAGES);
|
||||||
|
trimmed.addAll(messages.subList(0, Math.min(4, messages.size())));
|
||||||
|
trimmed.addAll(messages.subList(messages.size() - (MAX_LOOP_MESSAGES - 4), messages.size()));
|
||||||
|
messages = trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
List<Message> promptMessages = new ArrayList<>();
|
List<Message> promptMessages = new ArrayList<>();
|
||||||
promptMessages.add(new SystemMessage(systemPrompt));
|
promptMessages.add(new SystemMessage(systemPrompt));
|
||||||
// 注入运行时上下文(当前时间),让 LLM 在推理阶段即可感知真实日期
|
|
||||||
promptMessages.add(new UserMessage(RuntimeContextInjector.buildContextMessage()));
|
promptMessages.add(new UserMessage(RuntimeContextInjector.buildContextMessage()));
|
||||||
promptMessages.addAll(messages);
|
promptMessages.addAll(messages);
|
||||||
|
|
||||||
@ -166,6 +181,7 @@ public class ReasoningNode implements NodeAction {
|
|||||||
OpenAiChatOptions oaiOpts = OpenAiChatOptions.builder()
|
OpenAiChatOptions oaiOpts = OpenAiChatOptions.builder()
|
||||||
.toolCallbacks(toolCallbacks)
|
.toolCallbacks(toolCallbacks)
|
||||||
.reasoningEffort(reasoningEffort)
|
.reasoningEffort(reasoningEffort)
|
||||||
|
.maxTokens(maxOutputTokens)
|
||||||
.build();
|
.build();
|
||||||
oaiOpts.setInternalToolExecutionEnabled(false);
|
oaiOpts.setInternalToolExecutionEnabled(false);
|
||||||
options = oaiOpts;
|
options = oaiOpts;
|
||||||
@ -173,50 +189,78 @@ public class ReasoningNode implements NodeAction {
|
|||||||
options = ToolCallingChatOptions.builder()
|
options = ToolCallingChatOptions.builder()
|
||||||
.toolCallbacks(toolCallbacks)
|
.toolCallbacks(toolCallbacks)
|
||||||
.internalToolExecutionEnabled(false)
|
.internalToolExecutionEnabled(false)
|
||||||
|
.maxTokens(maxOutputTokens)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
Prompt prompt = new Prompt(promptMessages, options);
|
Prompt prompt = new Prompt(promptMessages, options);
|
||||||
|
|
||||||
log.debug("[ReasoningNode] Calling LLM with {} messages, {} tool definitions, iteration {}/{}",
|
// ======= LLM 调用区域 =======
|
||||||
|
// nextLlmCallCount 在首次 streamCall 之前计算。
|
||||||
|
// 所有退出路径(正常、stopped、fatal error、CancellationException)都必须写回此值。
|
||||||
|
// PTL compact retry 会再 +1。
|
||||||
|
int nextLlmCallCount = accessor.llmCallCount() + 1;
|
||||||
|
log.debug("[ReasoningNode] Calling LLM with {} messages, {} tool definitions, iteration {}/{}, llmCallCount={}",
|
||||||
promptMessages.size(), toolCallbacks.size(),
|
promptMessages.size(), toolCallbacks.size(),
|
||||||
accessor.iterationCount(), accessor.maxIterations());
|
accessor.iterationCount(), accessor.maxIterations(), nextLlmCallCount);
|
||||||
|
|
||||||
// 构建 phase 事件
|
|
||||||
GraphEventPublisher.GraphEvent phaseEvent = GraphEventPublisher.phase("reasoning",
|
GraphEventPublisher.GraphEvent phaseEvent = GraphEventPublisher.phase("reasoning",
|
||||||
Map.of("iteration", accessor.iterationCount()));
|
Map.of("iteration", accessor.iterationCount()));
|
||||||
|
|
||||||
// 流式 LLM 调用:content/thinking 增量实时推送给前端
|
NodeStreamingChatHelper.StreamResult result;
|
||||||
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCall(
|
try {
|
||||||
chatModel, prompt, conversationId, "reasoning");
|
result = streamingHelper.streamCall(chatModel, prompt, conversationId, "reasoning");
|
||||||
|
|
||||||
// PTL 处理:压缩后重试(由 Node 层负责,因为 helper 不知道哪些消息可压缩)
|
// PTL 处理:压缩后重试
|
||||||
if (result.isPromptTooLong() && conversationWindowManager != null) {
|
if (result.isPromptTooLong() && conversationWindowManager != null) {
|
||||||
log.warn("[ReasoningNode] Prompt too long, attempting compaction and retry");
|
log.warn("[ReasoningNode] Prompt too long, attempting compaction and retry");
|
||||||
List<Message> compactedMessages = conversationWindowManager.compactForRetry(messages);
|
List<Message> compactedMessages = conversationWindowManager.compactForRetry(messages);
|
||||||
if (compactedMessages != null && compactedMessages.size() < messages.size()) {
|
if (compactedMessages != null && compactedMessages.size() < messages.size()) {
|
||||||
List<Message> retryPromptMessages = new ArrayList<>();
|
List<Message> retryPromptMessages = new ArrayList<>();
|
||||||
retryPromptMessages.add(new SystemMessage(systemPrompt));
|
retryPromptMessages.add(new SystemMessage(systemPrompt));
|
||||||
retryPromptMessages.addAll(compactedMessages);
|
retryPromptMessages.add(new UserMessage(RuntimeContextInjector.buildContextMessage()));
|
||||||
Prompt retryPrompt = new Prompt(retryPromptMessages, options);
|
retryPromptMessages.addAll(compactedMessages);
|
||||||
log.info("[ReasoningNode] Retrying with compacted messages: {} -> {} messages",
|
Prompt retryPrompt = new Prompt(retryPromptMessages, options);
|
||||||
messages.size(), compactedMessages.size());
|
log.info("[ReasoningNode] Retrying with compacted messages: {} -> {} messages",
|
||||||
result = streamingHelper.streamCall(chatModel, retryPrompt, conversationId, "reasoning_compact_retry");
|
messages.size(), compactedMessages.size());
|
||||||
} else {
|
// compact retry 是第 2 次 LLM 调用,先递增再调用
|
||||||
log.warn("[ReasoningNode] Compaction did not reduce messages, cannot retry");
|
nextLlmCallCount++;
|
||||||
|
result = streamingHelper.streamCall(chatModel, retryPrompt, conversationId, "reasoning_compact_retry");
|
||||||
|
} else {
|
||||||
|
log.warn("[ReasoningNode] Compaction did not reduce messages, cannot retry");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (CancellationException ce) {
|
||||||
|
// "调用已发出但尚未产出内容时用户停止" — streamHelper 抛 CancellationException。
|
||||||
|
// 返回空 finalAnswer + STOPPED,让 FinalAnswerNode 按 STOPPED 语义处理。
|
||||||
|
// 必须显式清零 needsToolCall/shouldSummarize,防止前一轮残留标志导致误路由。
|
||||||
|
log.info("[ReasoningNode] CancellationException during LLM call (user stopped before first token), " +
|
||||||
|
"returning empty answer with STOPPED, llmCallCount={}", nextLlmCallCount);
|
||||||
|
return MateClawStateAccessor.output()
|
||||||
|
.finalAnswer("")
|
||||||
|
.needsToolCall(false)
|
||||||
|
.shouldSummarize(false)
|
||||||
|
.llmCallCount(nextLlmCallCount)
|
||||||
|
.finishReason(FinishReason.STOPPED)
|
||||||
|
.contentStreamed(true)
|
||||||
|
.thinkingStreamed(true)
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户主动停止且有部分内容:设为 finalAnswer + finalThinking 让 accumulator 持久化
|
// ======= 处理 StreamResult =======
|
||||||
|
|
||||||
|
// 用户主动停止且有部分内容
|
||||||
if (result.stopped() && result.hasAnyContent()) {
|
if (result.stopped() && result.hasAnyContent()) {
|
||||||
String partialText = result.text() != null ? result.text() : "";
|
String partialText = result.text() != null ? result.text() : "";
|
||||||
String partialThinking = result.thinking() != null ? result.thinking() : "";
|
String partialThinking = result.thinking() != null ? result.thinking() : "";
|
||||||
log.info("[ReasoningNode] Stop with partial content ({} chars, thinking {} chars), " +
|
log.info("[ReasoningNode] Stop with partial content ({} chars, thinking {} chars), flushing as final answer",
|
||||||
"flushing as final answer",
|
|
||||||
partialText.length(), partialThinking.length());
|
partialText.length(), partialThinking.length());
|
||||||
var builder = MateClawStateAccessor.output()
|
var builder = MateClawStateAccessor.output()
|
||||||
.finalAnswer(partialText)
|
.finalAnswer(partialText)
|
||||||
|
.needsToolCall(false)
|
||||||
|
.shouldSummarize(false)
|
||||||
.contentStreamed(true)
|
.contentStreamed(true)
|
||||||
|
.llmCallCount(nextLlmCallCount)
|
||||||
.mergeUsage(state, result)
|
.mergeUsage(state, result)
|
||||||
.finishReason(FinishReason.STOPPED);
|
.finishReason(FinishReason.STOPPED);
|
||||||
if (!partialThinking.isEmpty()) {
|
if (!partialThinking.isEmpty()) {
|
||||||
@ -226,59 +270,68 @@ public class ReasoningNode implements NodeAction {
|
|||||||
return builder.build();
|
return builder.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 错误处理:无任何可用内容时直接终止图执行。
|
// Fatal error:直接设置 finalAnswer 为错误文案 + ERROR_FALLBACK,
|
||||||
// NodeStreamingChatHelper 已广播结构化 error 事件,这里不能再把错误文本当成正常 final answer。
|
// 不走 LimitExceededNode(后者会再发一次 LLM 调用,语义不对且对认证/配额错误会再失败)。
|
||||||
|
// ReasoningDispatcher 看到 !needsToolCall && !shouldSummarize → finalAnswerNode,
|
||||||
|
// FinalAnswerNode 检测到 existingAnswer 非空时直接使用,finishReason 保持 ERROR_FALLBACK。
|
||||||
if (result.hasFatalError()) {
|
if (result.hasFatalError()) {
|
||||||
log.error("[ReasoningNode] Fatal LLM error: {}", result.errorMessage());
|
log.error("[ReasoningNode] Fatal LLM error: {}", result.errorMessage());
|
||||||
throw new IllegalStateException(result.errorMessage());
|
return MateClawStateAccessor.output()
|
||||||
|
.needsToolCall(false)
|
||||||
|
.shouldSummarize(false)
|
||||||
|
.finalAnswer("[错误] " + result.errorMessage())
|
||||||
|
.llmCallCount(nextLlmCallCount)
|
||||||
|
.finishReason(FinishReason.ERROR_FALLBACK)
|
||||||
|
.contentStreamed(true)
|
||||||
|
.thinkingStreamed(true)
|
||||||
|
.mergeUsage(state, result)
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.partial()) {
|
if (result.partial()) {
|
||||||
// 有部分内容 — 当作最终回答处理(LLM 已经回答了大部分)
|
|
||||||
log.warn("[ReasoningNode] Partial LLM result ({} chars), treating as final answer", result.text().length());
|
log.warn("[ReasoningNode] Partial LLM result ({} chars), treating as final answer", result.text().length());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.hasToolCalls()) {
|
if (result.hasToolCalls()) {
|
||||||
// LLM 请求工具调用
|
|
||||||
log.info("[ReasoningNode] LLM requested {} tool call(s): {}",
|
log.info("[ReasoningNode] LLM requested {} tool call(s): {}",
|
||||||
result.toolCalls().size(),
|
result.toolCalls().size(),
|
||||||
result.toolCalls().stream().map(AssistantMessage.ToolCall::name).toList());
|
result.toolCalls().stream().map(AssistantMessage.ToolCall::name).toList());
|
||||||
|
|
||||||
return MateClawStateAccessor.output()
|
return MateClawStateAccessor.output()
|
||||||
.needsToolCall(true)
|
.needsToolCall(true)
|
||||||
|
.shouldSummarize(false)
|
||||||
.toolCalls(result.toolCalls())
|
.toolCalls(result.toolCalls())
|
||||||
.messages(List.of((Message) result.assistantMessage()))
|
.messages(List.of((Message) result.assistantMessage()))
|
||||||
.currentPhase("reasoning")
|
.currentPhase("reasoning")
|
||||||
.currentThinking(result.thinking())
|
.currentThinking(result.thinking())
|
||||||
// 暂存已流式推送的 content/thinking,供 AWAITING_APPROVAL 路径持久化
|
|
||||||
.streamedContent(result.text() != null ? result.text() : "")
|
.streamedContent(result.text() != null ? result.text() : "")
|
||||||
.streamedThinking(result.thinking())
|
.streamedThinking(result.thinking())
|
||||||
.contentStreamed(true)
|
.contentStreamed(true)
|
||||||
.thinkingStreamed(!result.thinking().isEmpty())
|
.thinkingStreamed(!result.thinking().isEmpty())
|
||||||
|
.llmCallCount(nextLlmCallCount)
|
||||||
.mergeUsage(state, result)
|
.mergeUsage(state, result)
|
||||||
.events(List.of(phaseEvent))
|
.events(List.of(phaseEvent))
|
||||||
.build();
|
.build();
|
||||||
} else {
|
} else {
|
||||||
// LLM 给出最终回答
|
|
||||||
String content = result.text();
|
String content = result.text();
|
||||||
log.info("[ReasoningNode] LLM produced final answer ({} chars)", content != null ? content.length() : 0);
|
log.info("[ReasoningNode] LLM produced final answer ({} chars)", content != null ? content.length() : 0);
|
||||||
|
|
||||||
return MateClawStateAccessor.output()
|
return MateClawStateAccessor.output()
|
||||||
.needsToolCall(false)
|
.needsToolCall(false)
|
||||||
|
.shouldSummarize(false)
|
||||||
.finalAnswer(content != null ? content : "")
|
.finalAnswer(content != null ? content : "")
|
||||||
.finalThinking(result.thinking())
|
.finalThinking(result.thinking())
|
||||||
.messages(List.of((Message) result.assistantMessage()))
|
.messages(List.of((Message) result.assistantMessage()))
|
||||||
.currentPhase("reasoning")
|
.currentPhase("reasoning")
|
||||||
.contentStreamed(true)
|
.contentStreamed(true)
|
||||||
.thinkingStreamed(!result.thinking().isEmpty())
|
.thinkingStreamed(!result.thinking().isEmpty())
|
||||||
|
.llmCallCount(nextLlmCallCount)
|
||||||
.mergeUsage(state, result)
|
.mergeUsage(state, result)
|
||||||
.events(List.of(phaseEvent))
|
.events(List.of(phaseEvent))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 反序列化 JSON 为 ToolCall
|
|
||||||
*/
|
|
||||||
private AssistantMessage.ToolCall deserializeToolCall(String json) {
|
private AssistantMessage.ToolCall deserializeToolCall(String json) {
|
||||||
try {
|
try {
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
|
|||||||
@ -74,6 +74,10 @@ public final class MateClawStateAccessor {
|
|||||||
return state.value(TOOL_CALL_COUNT, 0);
|
return state.value(TOOL_CALL_COUNT, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int llmCallCount() {
|
||||||
|
return state.value(LLM_CALL_COUNT, 0);
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 观察历史 =====
|
// ===== 观察历史 =====
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
@ -273,6 +277,10 @@ public final class MateClawStateAccessor {
|
|||||||
return put(TOOL_CALL_COUNT, count);
|
return put(TOOL_CALL_COUNT, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public OutputBuilder llmCallCount(int count) {
|
||||||
|
return put(LLM_CALL_COUNT, count);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 观察 ----
|
// ---- 观察 ----
|
||||||
public OutputBuilder observationHistory(String observation) {
|
public OutputBuilder observationHistory(String observation) {
|
||||||
return put(OBSERVATION_HISTORY, List.of(observation));
|
return put(OBSERVATION_HISTORY, List.of(observation));
|
||||||
|
|||||||
@ -127,6 +127,10 @@ public final class MateClawStateKeys {
|
|||||||
/** 取消标志:外部请求停止时设为 true,各节点在入口处检查 */
|
/** 取消标志:外部请求停止时设为 true,各节点在入口处检查 */
|
||||||
public static final String STOP_REQUESTED = "stop_requested";
|
public static final String STOP_REQUESTED = "stop_requested";
|
||||||
|
|
||||||
|
// ===== LLM 调用计数(REPLACE 策略)=====
|
||||||
|
/** 累计 LLM 调用次数(每次 ReasoningNode 调用 LLM 时递增,独立于迭代计数) */
|
||||||
|
public static final String LLM_CALL_COUNT = "llm_call_count";
|
||||||
|
|
||||||
// ===== Token Usage 累计(REPLACE 策略,节点内累加后写回)=====
|
// ===== Token Usage 累计(REPLACE 策略,节点内累加后写回)=====
|
||||||
public static final String PROMPT_TOKENS = "prompt_tokens";
|
public static final String PROMPT_TOKENS = "prompt_tokens";
|
||||||
public static final String COMPLETION_TOKENS = "completion_tokens";
|
public static final String COMPLETION_TOKENS = "completion_tokens";
|
||||||
|
|||||||
@ -0,0 +1,114 @@
|
|||||||
|
package vip.mate.agent.graph;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RepetitionDetector 单元测试
|
||||||
|
*/
|
||||||
|
class RepetitionDetectorTest {
|
||||||
|
|
||||||
|
private RepetitionDetector detector;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
detector = new RepetitionDetector();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("正常文本不触发重复检测")
|
||||||
|
void shouldNotTriggerForNormalText() {
|
||||||
|
assertFalse(detector.appendAndCheck("Hello, world! This is a normal response. "));
|
||||||
|
assertFalse(detector.appendAndCheck("It contains various sentences and ideas. "));
|
||||||
|
assertFalse(detector.appendAndCheck("No repetition should be detected here. "));
|
||||||
|
assertFalse(detector.appendAndCheck("The detector only flags degenerate patterns. "));
|
||||||
|
assertFalse(detector.isRepetitionDetected());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("短文本不触发检测(低于最小内容长度)")
|
||||||
|
void shouldNotTriggerForShortText() {
|
||||||
|
assertFalse(detector.appendAndCheck("短"));
|
||||||
|
assertFalse(detector.appendAndCheck("短"));
|
||||||
|
assertFalse(detector.appendAndCheck("短"));
|
||||||
|
assertFalse(detector.isRepetitionDetected());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("连续重复相同片段触发检测")
|
||||||
|
void shouldTriggerForRepeatedPattern() {
|
||||||
|
// 构造足够长的前缀以超过最小检测长度
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("这是一段正常的开头文本。".repeat(5));
|
||||||
|
detector.appendAndCheck(sb.toString());
|
||||||
|
|
||||||
|
// 现在重复同一模式多次
|
||||||
|
String pattern = "不吃香菜,喝冰美式。";
|
||||||
|
boolean triggered = false;
|
||||||
|
for (int i = 0; i < 20; i++) {
|
||||||
|
if (detector.appendAndCheck(pattern)) {
|
||||||
|
triggered = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue(triggered, "Should detect repetition after many identical appends");
|
||||||
|
assertTrue(detector.isRepetitionDetected());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("检测到重复后持续返回 true")
|
||||||
|
void shouldKeepReturningTrueAfterDetection() {
|
||||||
|
// 直接构造重复内容
|
||||||
|
String pattern = "重复片段测试内容。";
|
||||||
|
StringBuilder bulk = new StringBuilder();
|
||||||
|
bulk.append("正常的前缀内容,长度足够。".repeat(5));
|
||||||
|
for (int i = 0; i < 20; i++) {
|
||||||
|
bulk.append(pattern);
|
||||||
|
}
|
||||||
|
detector.appendAndCheck(bulk.toString());
|
||||||
|
|
||||||
|
// 后续调用应该继续返回 true
|
||||||
|
assertTrue(detector.appendAndCheck("任何新内容"));
|
||||||
|
assertTrue(detector.isRepetitionDetected());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("reset 后重新检测")
|
||||||
|
void shouldResetState() {
|
||||||
|
// 先触发检测
|
||||||
|
String pattern = "重复片段测试。";
|
||||||
|
StringBuilder bulk = new StringBuilder("前缀".repeat(50));
|
||||||
|
for (int i = 0; i < 20; i++) {
|
||||||
|
bulk.append(pattern);
|
||||||
|
}
|
||||||
|
detector.appendAndCheck(bulk.toString());
|
||||||
|
|
||||||
|
// reset
|
||||||
|
detector.reset();
|
||||||
|
assertFalse(detector.isRepetitionDetected());
|
||||||
|
assertFalse(detector.appendAndCheck("正常的新内容"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("null 和空字符串不触发也不异常")
|
||||||
|
void shouldHandleNullAndEmpty() {
|
||||||
|
assertFalse(detector.appendAndCheck(null));
|
||||||
|
assertFalse(detector.appendAndCheck(""));
|
||||||
|
assertFalse(detector.isRepetitionDetected());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Unicode 中文重复模式正确检测")
|
||||||
|
void shouldDetectChineseRepetition() {
|
||||||
|
StringBuilder sb = new StringBuilder("初始化内容填充。".repeat(10));
|
||||||
|
String pattern = "已记住。以后涉及点餐时我会提醒你:";
|
||||||
|
for (int i = 0; i < 20; i++) {
|
||||||
|
sb.append(pattern);
|
||||||
|
}
|
||||||
|
boolean triggered = detector.appendAndCheck(sb.toString());
|
||||||
|
assertTrue(triggered, "Should detect Chinese character repetition");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,206 @@
|
|||||||
|
package vip.mate.agent.graph.edge;
|
||||||
|
|
||||||
|
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Nested;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static vip.mate.agent.graph.state.MateClawStateKeys.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ReasoningDispatcher 扩展测试:LLM 调用计数、fatal error 路由、停止语义
|
||||||
|
*/
|
||||||
|
class ReasoningDispatcherLlmCallCountTest {
|
||||||
|
|
||||||
|
private ReasoningDispatcher dispatcher;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
dispatcher = new ReasoningDispatcher();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== LLM 调用计数 =====
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("LLM 调用计数限制")
|
||||||
|
class LlmCallCountLimit {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("达到上限但有最终回答 → 放行到 finalAnswerNode")
|
||||||
|
void shouldAllowFinalAnswerEvenWhenLlmCallLimitReached() throws Exception {
|
||||||
|
OverAllState state = new OverAllState(Map.of(
|
||||||
|
CURRENT_ITERATION, 5, MAX_ITERATIONS, 10,
|
||||||
|
LLM_CALL_COUNT, 30, NEEDS_TOOL_CALL, false
|
||||||
|
));
|
||||||
|
assertEquals(FINAL_ANSWER_NODE, dispatcher.apply(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("达到上限且需要工具调用 → limitExceededNode")
|
||||||
|
void shouldBlockToolCallWhenLlmCallLimitReached() throws Exception {
|
||||||
|
OverAllState state = new OverAllState(Map.of(
|
||||||
|
CURRENT_ITERATION, 5, MAX_ITERATIONS, 10,
|
||||||
|
LLM_CALL_COUNT, 30, NEEDS_TOOL_CALL, true
|
||||||
|
));
|
||||||
|
assertEquals(LIMIT_EXCEEDED_NODE, dispatcher.apply(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("达到上限且需要总结 → limitExceededNode")
|
||||||
|
void shouldBlockSummarizeWhenLlmCallLimitReached() throws Exception {
|
||||||
|
OverAllState state = new OverAllState(Map.of(
|
||||||
|
CURRENT_ITERATION, 5, MAX_ITERATIONS, 10,
|
||||||
|
LLM_CALL_COUNT, 30,
|
||||||
|
NEEDS_TOOL_CALL, false, SHOULD_SUMMARIZE, true
|
||||||
|
));
|
||||||
|
assertEquals(LIMIT_EXCEEDED_NODE, dispatcher.apply(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("未达上限且需要工具调用 → actionNode")
|
||||||
|
void shouldAllowToolCallWhenUnderLimit() throws Exception {
|
||||||
|
OverAllState state = new OverAllState(Map.of(
|
||||||
|
CURRENT_ITERATION, 5, MAX_ITERATIONS, 10,
|
||||||
|
LLM_CALL_COUNT, 15, NEEDS_TOOL_CALL, true
|
||||||
|
));
|
||||||
|
assertEquals(ACTION_NODE, dispatcher.apply(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("计数未设置时默认 0 → 不触发")
|
||||||
|
void shouldUseDefaultWhenMissing() throws Exception {
|
||||||
|
OverAllState state = new OverAllState(Map.of(
|
||||||
|
CURRENT_ITERATION, 0, MAX_ITERATIONS, 10,
|
||||||
|
NEEDS_TOOL_CALL, true
|
||||||
|
));
|
||||||
|
assertEquals(ACTION_NODE, dispatcher.apply(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("迭代超限优先于 LLM 调用计数")
|
||||||
|
void iterationLimitTakesPrecedence() throws Exception {
|
||||||
|
OverAllState state = new OverAllState(Map.of(
|
||||||
|
CURRENT_ITERATION, 10, MAX_ITERATIONS, 10,
|
||||||
|
LLM_CALL_COUNT, 5, NEEDS_TOOL_CALL, true
|
||||||
|
));
|
||||||
|
assertEquals(LIMIT_EXCEEDED_NODE, dispatcher.apply(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("计数恰好 limit-1 时不触发(边界值)")
|
||||||
|
void shouldNotTriggerAtLimitMinusOne() throws Exception {
|
||||||
|
OverAllState state = new OverAllState(Map.of(
|
||||||
|
CURRENT_ITERATION, 5, MAX_ITERATIONS, 10,
|
||||||
|
LLM_CALL_COUNT, 29, NEEDS_TOOL_CALL, true
|
||||||
|
));
|
||||||
|
assertEquals(ACTION_NODE, dispatcher.apply(state));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Fatal error 路由 =====
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("Fatal error 路由语义")
|
||||||
|
class FatalErrorRouting {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("fatal error 带 finalAnswer 时走 finalAnswerNode(不走 limitExceededNode)")
|
||||||
|
void fatalErrorWithFinalAnswerGoesToFinalAnswerNode() throws Exception {
|
||||||
|
OverAllState state = new OverAllState(Map.of(
|
||||||
|
CURRENT_ITERATION, 2, MAX_ITERATIONS, 10,
|
||||||
|
FINAL_ANSWER, "[错误] 认证失败",
|
||||||
|
NEEDS_TOOL_CALL, false,
|
||||||
|
SHOULD_SUMMARIZE, false,
|
||||||
|
FINISH_REASON, "error_fallback"
|
||||||
|
));
|
||||||
|
assertEquals(FINAL_ANSWER_NODE, dispatcher.apply(state),
|
||||||
|
"Fatal error with finalAnswer should go to finalAnswerNode, not limitExceededNode");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("fatal error 叠加旧 SHOULD_SUMMARIZE=true 时仍走 finalAnswerNode(stale-state 防护)")
|
||||||
|
void fatalErrorWithStaleShouldSummarize() throws Exception {
|
||||||
|
// 模拟:前一轮 ObservationNode 设了 shouldSummarize=true,
|
||||||
|
// 但本轮 ReasoningNode fatal error 显式清零了 shouldSummarize=false。
|
||||||
|
// 验证不会因为残留标志被送去 summarizingNode / limitExceededNode。
|
||||||
|
OverAllState state = new OverAllState(Map.of(
|
||||||
|
CURRENT_ITERATION, 2, MAX_ITERATIONS, 10,
|
||||||
|
FINAL_ANSWER, "[错误] 模型不可用",
|
||||||
|
NEEDS_TOOL_CALL, false,
|
||||||
|
SHOULD_SUMMARIZE, false, // ReasoningNode 显式清零
|
||||||
|
FINISH_REASON, "error_fallback"
|
||||||
|
));
|
||||||
|
assertEquals(FINAL_ANSWER_NODE, dispatcher.apply(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("如果 SHOULD_SUMMARIZE 未被清零(stale=true),fatal error 会误路由(此为防御性对照测试)")
|
||||||
|
void demonstrateStaleShouldSummarizeWouldMisroute() throws Exception {
|
||||||
|
// 对照:如果 shouldSummarize 残留 true,即使有 finalAnswer 也会被送去 summarizingNode
|
||||||
|
// 这证明 ReasoningNode 必须显式清零
|
||||||
|
OverAllState state = new OverAllState(Map.of(
|
||||||
|
CURRENT_ITERATION, 2, MAX_ITERATIONS, 10,
|
||||||
|
FINAL_ANSWER, "[错误] 模型不可用",
|
||||||
|
NEEDS_TOOL_CALL, false,
|
||||||
|
SHOULD_SUMMARIZE, true // 残留!
|
||||||
|
));
|
||||||
|
// 会走到 llmCallCount check → summarizingNode,不是 finalAnswerNode
|
||||||
|
assertNotEquals(FINAL_ANSWER_NODE, dispatcher.apply(state),
|
||||||
|
"Stale shouldSummarize=true would misroute — proves ReasoningNode must clear it");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 用户停止语义 =====
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("用户停止路由语义")
|
||||||
|
class StoppedRouting {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("用户停止无内容时走 finalAnswerNode(STOPPED 语义不被吞)")
|
||||||
|
void stoppedWithNoContentGoesToFinalAnswerNode() throws Exception {
|
||||||
|
OverAllState state = new OverAllState(Map.of(
|
||||||
|
CURRENT_ITERATION, 2, MAX_ITERATIONS, 10,
|
||||||
|
FINAL_ANSWER, "",
|
||||||
|
NEEDS_TOOL_CALL, false,
|
||||||
|
SHOULD_SUMMARIZE, false,
|
||||||
|
FINISH_REASON, "stopped"
|
||||||
|
));
|
||||||
|
assertEquals(FINAL_ANSWER_NODE, dispatcher.apply(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("cancel 叠加旧 NEEDS_TOOL_CALL=true 时仍走 finalAnswerNode(stale-state 防护)")
|
||||||
|
void cancelWithStaleNeedsToolCall() throws Exception {
|
||||||
|
// 模拟:前一轮 ReasoningNode 设了 needsToolCall=true(请求工具调用),
|
||||||
|
// 但本轮 cancel 显式清零了 needsToolCall=false。
|
||||||
|
OverAllState state = new OverAllState(Map.of(
|
||||||
|
CURRENT_ITERATION, 3, MAX_ITERATIONS, 10,
|
||||||
|
FINAL_ANSWER, "",
|
||||||
|
NEEDS_TOOL_CALL, false, // ReasoningNode 显式清零
|
||||||
|
SHOULD_SUMMARIZE, false, // ReasoningNode 显式清零
|
||||||
|
FINISH_REASON, "stopped"
|
||||||
|
));
|
||||||
|
assertEquals(FINAL_ANSWER_NODE, dispatcher.apply(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("如果 NEEDS_TOOL_CALL 未被清零(stale=true),cancel 会误路由(防御性对照测试)")
|
||||||
|
void demonstrateStaleNeedsToolCallWouldMisroute() throws Exception {
|
||||||
|
OverAllState state = new OverAllState(Map.of(
|
||||||
|
CURRENT_ITERATION, 3, MAX_ITERATIONS, 10,
|
||||||
|
FINAL_ANSWER, "",
|
||||||
|
NEEDS_TOOL_CALL, true, // 残留!
|
||||||
|
SHOULD_SUMMARIZE, false,
|
||||||
|
FINISH_REASON, "stopped"
|
||||||
|
));
|
||||||
|
// 会走到 actionNode,不是 finalAnswerNode
|
||||||
|
assertEquals(ACTION_NODE, dispatcher.apply(state),
|
||||||
|
"Stale needsToolCall=true would misroute to actionNode");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
package vip.mate.agent.graph.node;
|
||||||
|
|
||||||
|
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static vip.mate.agent.graph.state.MateClawStateKeys.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FinalAnswerNode 单元测试
|
||||||
|
*/
|
||||||
|
class FinalAnswerNodeTest {
|
||||||
|
|
||||||
|
private FinalAnswerNode node;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
node = new FinalAnswerNode();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("正常 finalAnswer 直接使用")
|
||||||
|
void shouldUseExistingFinalAnswer() throws Exception {
|
||||||
|
OverAllState state = new OverAllState(Map.of(
|
||||||
|
FINAL_ANSWER, "这是最终回答"
|
||||||
|
));
|
||||||
|
Map<String, Object> result = node.apply(state);
|
||||||
|
assertEquals("这是最终回答", result.get(FINAL_ANSWER));
|
||||||
|
assertEquals("normal", result.get(FINISH_REASON));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("finalAnswerDraft 优先于 finalAnswer")
|
||||||
|
void draftTakesPrecedence() throws Exception {
|
||||||
|
OverAllState state = new OverAllState(Map.of(
|
||||||
|
FINAL_ANSWER, "旧回答",
|
||||||
|
FINAL_ANSWER_DRAFT, "新草稿"
|
||||||
|
));
|
||||||
|
Map<String, Object> result = node.apply(state);
|
||||||
|
assertEquals("新草稿", result.get(FINAL_ANSWER));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("ERROR_FALLBACK finalAnswer 保留错误文案和 finishReason")
|
||||||
|
void shouldPreserveErrorFallbackAnswer() throws Exception {
|
||||||
|
OverAllState state = new OverAllState(Map.of(
|
||||||
|
FINAL_ANSWER, "[错误] 认证失败: Invalid API Key",
|
||||||
|
FINISH_REASON, "error_fallback"
|
||||||
|
));
|
||||||
|
Map<String, Object> result = node.apply(state);
|
||||||
|
assertEquals("[错误] 认证失败: Invalid API Key", result.get(FINAL_ANSWER));
|
||||||
|
assertEquals("error_fallback", result.get(FINISH_REASON));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("STOPPED 且无内容时保留 STOPPED 语义(不降级为 ERROR_FALLBACK)")
|
||||||
|
void shouldPreserveStoppedWhenNoContent() throws Exception {
|
||||||
|
OverAllState state = new OverAllState(Map.of(
|
||||||
|
FINAL_ANSWER, "",
|
||||||
|
FINISH_REASON, "stopped"
|
||||||
|
));
|
||||||
|
Map<String, Object> result = node.apply(state);
|
||||||
|
// 关键断言:finishReason 保持 STOPPED,不被改成 ERROR_FALLBACK
|
||||||
|
assertEquals("stopped", result.get(FINISH_REASON),
|
||||||
|
"Empty finalAnswer with STOPPED should not become ERROR_FALLBACK");
|
||||||
|
// finalAnswer 保持空(用户停止且无内容是合法的)
|
||||||
|
assertEquals("", result.get(FINAL_ANSWER));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("STOPPED 且无 finalAnswer 键时也保留 STOPPED 语义")
|
||||||
|
void shouldPreserveStoppedWhenNoFinalAnswerKey() throws Exception {
|
||||||
|
OverAllState state = new OverAllState(Map.of(
|
||||||
|
FINISH_REASON, "stopped"
|
||||||
|
));
|
||||||
|
Map<String, Object> result = node.apply(state);
|
||||||
|
assertEquals("stopped", result.get(FINISH_REASON));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("无任何内容且非 STOPPED 时降级为 ERROR_FALLBACK")
|
||||||
|
void shouldFallbackWhenNoContentAndNotStopped() throws Exception {
|
||||||
|
OverAllState state = new OverAllState(Map.of());
|
||||||
|
Map<String, Object> result = node.apply(state);
|
||||||
|
assertEquals("未能生成回答,请重试。", result.get(FINAL_ANSWER));
|
||||||
|
assertEquals("error_fallback", result.get(FINISH_REASON));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
package vip.mate.agent.graph.node;
|
||||||
|
|
||||||
|
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import vip.mate.agent.graph.observation.ObservationProcessor;
|
||||||
|
import vip.mate.config.GraphObservationProperties;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static vip.mate.agent.graph.state.MateClawStateKeys.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ObservationNode 重复观察检测单元测试
|
||||||
|
*/
|
||||||
|
class ObservationNodeDuplicateTest {
|
||||||
|
|
||||||
|
private ObservationNode createNode() {
|
||||||
|
return new ObservationNode(new ObservationProcessor(new GraphObservationProperties()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 OverAllState,包含指定的观察历史和工具结果
|
||||||
|
*/
|
||||||
|
private OverAllState buildState(List<String> observationHistory, int iteration, int maxIter) {
|
||||||
|
Map<String, Object> stateMap = new HashMap<>();
|
||||||
|
stateMap.put(CURRENT_ITERATION, iteration);
|
||||||
|
stateMap.put(MAX_ITERATIONS, maxIter);
|
||||||
|
stateMap.put(OBSERVATION_HISTORY, new ArrayList<>(observationHistory));
|
||||||
|
stateMap.put(TOOL_RESULTS, List.of());
|
||||||
|
stateMap.put(TOOL_CALL_COUNT, 0);
|
||||||
|
return new OverAllState(stateMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("空观察不应触发重复检测(空结果是合法的边界情况)")
|
||||||
|
void shouldNotTriggerForEmptyObservations() throws Exception {
|
||||||
|
ObservationNode node = createNode();
|
||||||
|
// TOOL_RESULTS 为空时 combinedObservation = "",
|
||||||
|
// detectDuplicateObservations 对空字符串返回 false(by design)
|
||||||
|
List<String> history = List.of("", "");
|
||||||
|
OverAllState state = buildState(history, 2, 10);
|
||||||
|
|
||||||
|
Map<String, Object> result = node.apply(state);
|
||||||
|
assertNull(result.get(ERROR), "Empty observations should not trigger duplicate detection");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("观察历史少于阈值时不触发重复检测")
|
||||||
|
void shouldNotTriggerWhenHistoryTooShort() throws Exception {
|
||||||
|
ObservationNode node = createNode();
|
||||||
|
// 只有 1 条历史,threshold=3 需要至少 2 条匹配
|
||||||
|
List<String> history = List.of("");
|
||||||
|
OverAllState state = buildState(history, 1, 10);
|
||||||
|
|
||||||
|
Map<String, Object> result = node.apply(state);
|
||||||
|
assertNull(result.get(ERROR));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("不同的历史观察不触发重复检测")
|
||||||
|
void shouldNotTriggerWhenHistoryDiffers() throws Exception {
|
||||||
|
ObservationNode node = createNode();
|
||||||
|
List<String> history = List.of("result A", "result B");
|
||||||
|
OverAllState state = buildState(history, 2, 10);
|
||||||
|
|
||||||
|
Map<String, Object> result = node.apply(state);
|
||||||
|
assertNull(result.get(ERROR));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("空历史不触发重复检测")
|
||||||
|
void shouldNotTriggerWithEmptyHistory() throws Exception {
|
||||||
|
ObservationNode node = createNode();
|
||||||
|
OverAllState state = buildState(List.of(), 0, 10);
|
||||||
|
|
||||||
|
Map<String, Object> result = node.apply(state);
|
||||||
|
assertNull(result.get(ERROR));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,175 @@
|
|||||||
|
package vip.mate.agent.graph.node;
|
||||||
|
|
||||||
|
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||||
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
|
import org.springframework.ai.tool.ToolCallback;
|
||||||
|
import vip.mate.agent.AgentToolSet;
|
||||||
|
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
||||||
|
import vip.mate.channel.web.ChatStreamTracker;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.CancellationException;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
import static vip.mate.agent.graph.state.MateClawStateKeys.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ReasoningNode 输出 map 断言测试。
|
||||||
|
* <p>
|
||||||
|
* 验证每条退出路径的输出 map 都包含 needsToolCall + shouldSummarize 的显式值,
|
||||||
|
* 防止 stale-state 导致 ReasoningDispatcher 误路由。
|
||||||
|
*/
|
||||||
|
class ReasoningNodeOutputTest {
|
||||||
|
|
||||||
|
private ChatModel chatModel;
|
||||||
|
private NodeStreamingChatHelper streamingHelper;
|
||||||
|
private ChatStreamTracker streamTracker;
|
||||||
|
private AgentToolSet toolSet;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
chatModel = mock(ChatModel.class);
|
||||||
|
streamingHelper = mock(NodeStreamingChatHelper.class);
|
||||||
|
streamTracker = mock(ChatStreamTracker.class);
|
||||||
|
toolSet = mock(AgentToolSet.class);
|
||||||
|
when(toolSet.callbacks()).thenReturn(List.of());
|
||||||
|
when(streamTracker.isStopRequested(anyString())).thenReturn(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReasoningNode createNode() {
|
||||||
|
return new ReasoningNode(chatModel, toolSet, null, streamingHelper, null, streamTracker);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建包含前一轮残留标志的 stale state */
|
||||||
|
private OverAllState buildStaleState() {
|
||||||
|
Map<String, Object> map = new HashMap<>();
|
||||||
|
map.put(CONVERSATION_ID, "test-conv");
|
||||||
|
map.put(SYSTEM_PROMPT, "you are a helper");
|
||||||
|
map.put(USER_MESSAGE, "hello");
|
||||||
|
map.put(MESSAGES, List.of());
|
||||||
|
map.put(CURRENT_ITERATION, 3);
|
||||||
|
map.put(MAX_ITERATIONS, 10);
|
||||||
|
map.put(LLM_CALL_COUNT, 5);
|
||||||
|
map.put(FORCED_TOOL_CALL, "");
|
||||||
|
// 前一轮残留标志
|
||||||
|
map.put(NEEDS_TOOL_CALL, true);
|
||||||
|
map.put(SHOULD_SUMMARIZE, true);
|
||||||
|
return new OverAllState(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 控制流标志断言辅助 =====
|
||||||
|
|
||||||
|
private void assertControlFlagsCleared(Map<String, Object> output, String path) {
|
||||||
|
assertEquals(false, output.get(NEEDS_TOOL_CALL),
|
||||||
|
path + ": needsToolCall should be explicitly false");
|
||||||
|
assertEquals(false, output.get(SHOULD_SUMMARIZE),
|
||||||
|
path + ": shouldSummarize should be explicitly false");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertLlmCallCountWritten(Map<String, Object> output, String path) {
|
||||||
|
assertNotNull(output.get(LLM_CALL_COUNT),
|
||||||
|
path + ": llmCallCount should be written");
|
||||||
|
assertTrue((int) output.get(LLM_CALL_COUNT) > 0,
|
||||||
|
path + ": llmCallCount should be positive");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 正常 final answer =====
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("正常 final answer 路径:output 包含 needsToolCall=false + shouldSummarize=false")
|
||||||
|
void normalFinalAnswer_clearsControlFlags() throws Exception {
|
||||||
|
NodeStreamingChatHelper.StreamResult result = new NodeStreamingChatHelper.StreamResult(
|
||||||
|
"回答内容", "", new AssistantMessage("回答内容"),
|
||||||
|
List.of(), false, 100, 50);
|
||||||
|
when(streamingHelper.streamCall(any(), any(), anyString(), anyString())).thenReturn(result);
|
||||||
|
|
||||||
|
Map<String, Object> output = createNode().apply(buildStaleState());
|
||||||
|
|
||||||
|
assertControlFlagsCleared(output, "normalFinalAnswer");
|
||||||
|
assertLlmCallCountWritten(output, "normalFinalAnswer");
|
||||||
|
assertEquals("回答内容", output.get(FINAL_ANSWER));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 工具调用 =====
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("tool call 路径:output 包含 needsToolCall=true + shouldSummarize=false")
|
||||||
|
void toolCall_clearsShouldSummarize() throws Exception {
|
||||||
|
AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall("id1", "function", "search", "{}");
|
||||||
|
AssistantMessage msg = AssistantMessage.builder().content("").toolCalls(List.of(tc)).build();
|
||||||
|
NodeStreamingChatHelper.StreamResult result = new NodeStreamingChatHelper.StreamResult(
|
||||||
|
"", "", msg, List.of(tc), true, 100, 50);
|
||||||
|
when(streamingHelper.streamCall(any(), any(), anyString(), anyString())).thenReturn(result);
|
||||||
|
|
||||||
|
Map<String, Object> output = createNode().apply(buildStaleState());
|
||||||
|
|
||||||
|
assertEquals(true, output.get(NEEDS_TOOL_CALL));
|
||||||
|
assertEquals(false, output.get(SHOULD_SUMMARIZE),
|
||||||
|
"toolCall: shouldSummarize should be explicitly false");
|
||||||
|
assertLlmCallCountWritten(output, "toolCall");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Fatal error =====
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("fatal error 路径:output 包含 needsToolCall=false + shouldSummarize=false + finalAnswer=错误文案")
|
||||||
|
void fatalError_clearsControlFlags() throws Exception {
|
||||||
|
NodeStreamingChatHelper.StreamResult result = new NodeStreamingChatHelper.StreamResult(
|
||||||
|
"", "", new AssistantMessage(""),
|
||||||
|
List.of(), false, 0, 0, false, "认证失败: Invalid API Key",
|
||||||
|
NodeStreamingChatHelper.ErrorType.AUTH_ERROR);
|
||||||
|
when(streamingHelper.streamCall(any(), any(), anyString(), anyString())).thenReturn(result);
|
||||||
|
|
||||||
|
Map<String, Object> output = createNode().apply(buildStaleState());
|
||||||
|
|
||||||
|
assertControlFlagsCleared(output, "fatalError");
|
||||||
|
assertLlmCallCountWritten(output, "fatalError");
|
||||||
|
String answer = (String) output.get(FINAL_ANSWER);
|
||||||
|
assertNotNull(answer);
|
||||||
|
assertTrue(answer.contains("认证失败"), "Fatal error answer should contain error message");
|
||||||
|
assertEquals("error_fallback", output.get(FINISH_REASON));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== CancellationException (no content stop) =====
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("CancellationException 路径:output 包含 needsToolCall=false + shouldSummarize=false + STOPPED")
|
||||||
|
void cancellation_clearsControlFlags() throws Exception {
|
||||||
|
when(streamingHelper.streamCall(any(), any(), anyString(), anyString()))
|
||||||
|
.thenThrow(new CancellationException("Stream stopped by user"));
|
||||||
|
|
||||||
|
Map<String, Object> output = createNode().apply(buildStaleState());
|
||||||
|
|
||||||
|
assertControlFlagsCleared(output, "cancellation");
|
||||||
|
assertLlmCallCountWritten(output, "cancellation");
|
||||||
|
assertEquals("stopped", output.get(FINISH_REASON));
|
||||||
|
assertEquals("", output.get(FINAL_ANSWER));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Stopped with partial content =====
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("stopped-with-partial 路径:output 包含 needsToolCall=false + shouldSummarize=false + STOPPED")
|
||||||
|
void stoppedWithPartial_clearsControlFlags() throws Exception {
|
||||||
|
NodeStreamingChatHelper.StreamResult result = new NodeStreamingChatHelper.StreamResult(
|
||||||
|
"部分内容", "部分思考", new AssistantMessage("部分内容"),
|
||||||
|
List.of(), false, 100, 30, true, null,
|
||||||
|
NodeStreamingChatHelper.ErrorType.NONE, true);
|
||||||
|
when(streamingHelper.streamCall(any(), any(), anyString(), anyString())).thenReturn(result);
|
||||||
|
|
||||||
|
Map<String, Object> output = createNode().apply(buildStaleState());
|
||||||
|
|
||||||
|
assertControlFlagsCleared(output, "stoppedWithPartial");
|
||||||
|
assertLlmCallCountWritten(output, "stoppedWithPartial");
|
||||||
|
assertEquals("stopped", output.get(FINISH_REASON));
|
||||||
|
assertEquals("部分内容", output.get(FINAL_ANSWER));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user