diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
index ba8d194a..227e60d3 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
@@ -741,6 +741,12 @@ public class AgentGraphBuilder {
// Tool progressive disclosure — extensions enabled this run.
// Registered in BOTH graphs for the same merge-safety reason.
.addStrategy(MateClawStateKeys.ENABLED_EXTENSION_TOOLS, KeyStrategy.REPLACE)
+ // Tool-call loop guard counters + one-shot post-mutation
+ // verification reminder flag. Read-merge-write by
+ // ObservationNode; registered in BOTH graphs so the
+ // counters survive multi-node merges.
+ .addStrategy(MateClawStateKeys.TOOL_LOOP_STATS, KeyStrategy.REPLACE)
+ .addStrategy(MateClawStateKeys.MUTATION_REMINDER_INJECTED, KeyStrategy.REPLACE)
.build();
// Graph 拓扑:
@@ -1067,6 +1073,12 @@ public class AgentGraphBuilder {
// Tool progressive disclosure — extensions enabled this run.
// Registered in BOTH graphs for the same merge-safety reason.
.addStrategy(MateClawStateKeys.ENABLED_EXTENSION_TOOLS, KeyStrategy.REPLACE)
+ // Tool-call loop guard counters + one-shot post-mutation
+ // verification reminder flag. Read-merge-write by
+ // ObservationNode; registered in BOTH graphs so the
+ // counters survive multi-node merges.
+ .addStrategy(MateClawStateKeys.TOOL_LOOP_STATS, KeyStrategy.REPLACE)
+ .addStrategy(MateClawStateKeys.MUTATION_REMINDER_INJECTED, KeyStrategy.REPLACE)
.build();
GoalEvaluationNode goalEvalNode = new GoalEvaluationNode(
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/GraphEventPublisher.java b/mateclaw-server/src/main/java/vip/mate/agent/GraphEventPublisher.java
index 1cc8b5f1..1a0bd842 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/GraphEventPublisher.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/GraphEventPublisher.java
@@ -366,4 +366,25 @@ public final class GraphEventPublisher {
data.put("timestamp", ts);
return new GraphEvent(EVENT_ITERATION_END, Map.copyOf(data), ts);
}
+
+ // ===== Warning events =====
+
+ public static final String EVENT_WARNING = "warning";
+
+ /**
+ * A user-visible runtime warning. The stream accumulator folds
+ * {@code message} into the assistant message's {@code metadata.warnings}
+ * (persisted) and rebroadcasts the event live on SSE, so the chat UI can
+ * render a warning chip both during streaming and on history reload.
+ * {@code source} lets consumers group or filter warnings by origin
+ * (e.g. {@code "loop_guard"}).
+ */
+ public static GraphEvent warning(String message, String source) {
+ long ts = System.currentTimeMillis();
+ return new GraphEvent(EVENT_WARNING, Map.of(
+ "message", message != null ? message : "",
+ "source", source != null ? source : "",
+ "timestamp", ts
+ ), ts);
+ }
}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/guard/ToolLoopGuard.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/guard/ToolLoopGuard.java
new file mode 100644
index 00000000..33d714cf
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/guard/ToolLoopGuard.java
@@ -0,0 +1,252 @@
+package vip.mate.agent.graph.guard;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import org.springframework.ai.chat.messages.AssistantMessage;
+import org.springframework.ai.chat.messages.ToolResponseMessage;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+/**
+ * Tool-call loop guard — pure, side-effect-free decision logic that detects
+ * a ReAct loop stuck on repetitive tool calls.
+ *
+ * Three independent detectors, each keyed on a tool-call signature
+ * ({@code toolName + ":" + sha256(canonicalized args JSON)}):
+ *
+ * - Identical-argument repeated failure — the model retries the
+ * exact same failing call without reading the error. Warn early, halt
+ * when clearly stuck.
+ * - Per-tool repeated failure (arguments ignored) — the model
+ * keeps guessing slightly different arguments against the same broken
+ * tool ("path-guessing" loops).
+ * - Idempotent no-progress — a read-only tool keeps returning the
+ * byte-identical result; the model should use what it already has.
+ * Restricted to a known read-only tool set so legitimate repeated
+ * writes are never flagged.
+ *
+ * Warnings are meant to be appended to the observation text so the model can
+ * self-correct on the next reasoning turn; a halt is meant to be routed to
+ * the graceful wrap-up node. Executing those side effects is the caller's
+ * (ObservationNode's) job — this class only counts and decides, which keeps
+ * it trivially unit-testable.
+ *
+ * Counters live in graph state under
+ * {@link vip.mate.agent.graph.state.MateClawStateKeys#TOOL_LOOP_STATS} and are
+ * scoped to a single graph run.
+ *
+ * @author MateClaw Team
+ */
+public final class ToolLoopGuard {
+
+ /** Identical tool + identical args failing: warn from the 2nd failure, halt at the 5th. */
+ static final int EXACT_FAILURE_WARN_AFTER = 2;
+ static final int EXACT_FAILURE_HALT_AFTER = 5;
+
+ /** Same tool failing regardless of args: warn from the 3rd failure, halt at the 8th. */
+ static final int SAME_TOOL_FAILURE_WARN_AFTER = 3;
+ static final int SAME_TOOL_FAILURE_HALT_AFTER = 8;
+
+ /** Idempotent tool returning the identical result: warn from the 2nd repeat, halt at the 5th. */
+ static final int NO_PROGRESS_WARN_AFTER = 2;
+ static final int NO_PROGRESS_HALT_AFTER = 5;
+
+ /**
+ * Read-only tools eligible for no-progress detection. Mutating tools are
+ * deliberately excluded — calling {@code write_file} twice with the same
+ * content is legitimate (e.g. after an external revert).
+ */
+ static final Set IDEMPOTENT_TOOLS = Set.of(
+ "read_file", "web_search", "extract_document_text", "extract_pdf_text");
+
+ /** Counter-key prefixes inside the stats map. */
+ private static final String KEY_EXACT_FAILURE = "ef:";
+ private static final String KEY_TOOL_FAILURE = "tf:";
+ private static final String KEY_NO_PROGRESS_HASH = "nph:";
+ private static final String KEY_NO_PROGRESS_COUNT = "npc:";
+
+ /**
+ * Failure heuristic, aligned with how tool errors actually surface:
+ * the executor's exception path ({@code "Tool execution failed: …"}), the
+ * guard-block path ({@code "[安全拦截] …"}), tools' own error prefixes, and
+ * structured JSON errors ({@code "error": } / {@code "success": false}).
+ */
+ // The possessive \s*+ prevents backtracking from letting the lookahead
+ // land on a whitespace char and misclassify {"error": null} as a failure.
+ private static final Pattern JSON_ERROR_PATTERN = Pattern.compile(
+ "\"error\"\\s*:\\s*+(?!null|\"\")|\"success\"\\s*:\\s*false");
+
+ private static final ObjectMapper CANONICAL_MAPPER = new ObjectMapper()
+ .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
+
+ private ToolLoopGuard() {
+ }
+
+ /**
+ * Outcome of one observation round.
+ *
+ * @param stats updated counter map to write back to graph state
+ * @param warnings guidance lines to append to the observation text (may be empty)
+ * @param haltReason non-null when a detector crossed its halt threshold;
+ * the text is suitable for the graph ERROR slot
+ */
+ public record Evaluation(Map stats, List warnings, String haltReason) {
+ public boolean shouldHalt() {
+ return haltReason != null;
+ }
+ }
+
+ /**
+ * Evaluate one tool batch. Pairs calls with results by tool-call id
+ * (falling back to list order), updates the counters, and returns the
+ * warnings / halt decision for this round.
+ *
+ * @param previousStats counter map from the previous round (never mutated)
+ * @param toolCalls the batch the model requested this round
+ * @param toolResults the corresponding execution results
+ */
+ public static Evaluation evaluate(Map previousStats,
+ List toolCalls,
+ List toolResults) {
+ Map stats = new HashMap<>(previousStats == null ? Map.of() : previousStats);
+ List warnings = new ArrayList<>();
+ String haltReason = null;
+
+ if (toolResults == null || toolResults.isEmpty()) {
+ return new Evaluation(stats, warnings, null);
+ }
+
+ for (ToolResponseMessage.ToolResponse result : toolResults) {
+ String toolName = result.name();
+ if (toolName == null || toolName.isBlank()) {
+ continue;
+ }
+ String arguments = findArguments(toolCalls, result);
+ String signature = toolName + ":" + hash(canonicalizeArguments(arguments));
+ boolean failed = isFailure(result.responseData());
+
+ if (failed) {
+ int exactCount = increment(stats, KEY_EXACT_FAILURE + signature);
+ int toolCount = increment(stats, KEY_TOOL_FAILURE + toolName);
+
+ if (exactCount >= EXACT_FAILURE_HALT_AFTER) {
+ haltReason = "工具调用陷入循环:" + toolName + " 已连续 " + exactCount
+ + " 次以相同参数失败,已强制收尾";
+ } else if (toolCount >= SAME_TOOL_FAILURE_HALT_AFTER) {
+ haltReason = "工具调用陷入循环:" + toolName + " 本次运行累计失败 " + toolCount
+ + " 次,已强制收尾";
+ } else if (exactCount >= EXACT_FAILURE_WARN_AFTER) {
+ warnings.add("[🔁 循环警告] 工具 " + toolName + " 已连续 " + exactCount
+ + " 次以相同参数失败。请勿原样重试:分析上面的错误信息并改变策略"
+ + "(调整参数或改用其他工具),或向用户说明具体阻塞点。");
+ } else if (toolCount >= SAME_TOOL_FAILURE_WARN_AFTER) {
+ warnings.add("[🔁 循环警告] 工具 " + toolName + " 本次运行已失败 " + toolCount
+ + " 次。请先诊断根因(检查路径、参数、前置条件)再继续,不要盲目换参数重试。");
+ }
+ } else {
+ // A success clears the failure streaks for this call shape / tool.
+ stats.remove(KEY_EXACT_FAILURE + signature);
+ stats.remove(KEY_TOOL_FAILURE + toolName);
+
+ if (IDEMPOTENT_TOOLS.contains(toolName)) {
+ String resultHash = hash(result.responseData());
+ String lastHash = (String) stats.get(KEY_NO_PROGRESS_HASH + signature);
+ int repeatCount = resultHash.equals(lastHash)
+ ? increment(stats, KEY_NO_PROGRESS_COUNT + signature)
+ : resetNoProgress(stats, signature);
+ stats.put(KEY_NO_PROGRESS_HASH + signature, resultHash);
+
+ if (repeatCount >= NO_PROGRESS_HALT_AFTER) {
+ haltReason = "工具调用陷入循环:" + toolName + " 已连续 " + repeatCount
+ + " 次返回完全相同的结果,已强制收尾";
+ } else if (repeatCount >= NO_PROGRESS_WARN_AFTER) {
+ warnings.add("[🔁 循环提示] 工具 " + toolName + " 已连续 " + repeatCount
+ + " 次返回完全相同的结果。请直接使用已获得的结果继续任务,不要重复调用。");
+ }
+ }
+ }
+ }
+ return new Evaluation(Map.copyOf(stats), List.copyOf(warnings), haltReason);
+ }
+
+ /** Heuristic: does this tool response text represent a failure? */
+ public static boolean isFailure(String responseData) {
+ if (responseData == null || responseData.isBlank()) {
+ return false;
+ }
+ String head = responseData.substring(0, Math.min(responseData.length(), 300)).strip();
+ String lower = head.toLowerCase(Locale.ROOT);
+ if (lower.startsWith("tool execution failed")
+ || head.startsWith("[安全拦截]")
+ || lower.startsWith("error:")
+ || head.startsWith("错误:")
+ || head.startsWith("错误:")) {
+ return true;
+ }
+ return head.startsWith("{") && JSON_ERROR_PATTERN.matcher(head).find();
+ }
+
+ /**
+ * Canonicalize an arguments JSON string so key order and whitespace do not
+ * change the signature. Falls back to the trimmed raw string when the
+ * arguments are not parseable JSON.
+ */
+ static String canonicalizeArguments(String argumentsJson) {
+ if (argumentsJson == null || argumentsJson.isBlank()) {
+ return "";
+ }
+ try {
+ Object parsed = CANONICAL_MAPPER.readValue(argumentsJson, Object.class);
+ return CANONICAL_MAPPER.writeValueAsString(parsed);
+ } catch (Exception e) {
+ return argumentsJson.trim();
+ }
+ }
+
+ private static String findArguments(List toolCalls,
+ ToolResponseMessage.ToolResponse result) {
+ if (toolCalls == null) {
+ return "";
+ }
+ for (AssistantMessage.ToolCall call : toolCalls) {
+ if (call != null && call.id() != null && call.id().equals(result.id())) {
+ return call.arguments();
+ }
+ }
+ return "";
+ }
+
+ private static int increment(Map stats, String key) {
+ int next = ((Number) stats.getOrDefault(key, 0)).intValue() + 1;
+ stats.put(key, next);
+ return next;
+ }
+
+ private static int resetNoProgress(Map stats, String signature) {
+ stats.put(KEY_NO_PROGRESS_COUNT + signature, 1);
+ return 1;
+ }
+
+ private static String hash(String text) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] bytes = digest.digest((text == null ? "" : text).getBytes(StandardCharsets.UTF_8));
+ StringBuilder sb = new StringBuilder(24);
+ for (int i = 0; i < 12; i++) {
+ sb.append(String.format("%02x", bytes[i]));
+ }
+ return sb.toString();
+ } catch (Exception e) {
+ // SHA-256 is mandatory on every JVM; fall back to hashCode just in case.
+ return Integer.toHexString(text == null ? 0 : text.hashCode());
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ObservationNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ObservationNode.java
index 708670a2..a1e89d4d 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ObservationNode.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ObservationNode.java
@@ -3,8 +3,10 @@ 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 org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import vip.mate.agent.GraphEventPublisher;
+import vip.mate.agent.graph.guard.ToolLoopGuard;
import vip.mate.agent.graph.observation.ObservationProcessor;
import vip.mate.agent.graph.state.MateClawStateAccessor;
@@ -44,6 +46,21 @@ public class ObservationNode implements NodeAction {
/** Per-run cap on iteration refunds — keeps a load-skill-only model from looping forever. */
private static final int MAX_ITERATION_REFUNDS_PER_RUN = 3;
+ /**
+ * File-mutation tools whose first successful call this run triggers the
+ * one-shot verification reminder — nudging the model to verify the change
+ * (run tests / re-read the file) before declaring the task complete.
+ * Shell/code/SQL tools are excluded: their mutating nature can't be
+ * determined statically, and a false reminder is worse than none.
+ */
+ private static final java.util.Set FILE_MUTATION_TOOLS =
+ java.util.Set.of("write_file", "edit_file");
+
+ private static final String VERIFICATION_REMINDER =
+ "\n\n[✅ 验证提醒] 本轮修改了文件。在给出最终回答前,请先验证改动是否生效" +
+ "(运行相关测试 / 构建命令,或重读文件确认关键内容);" +
+ "若无法验证,请在回答中明确说明「未经验证」及原因,不要声称已确认。";
+
public ObservationNode(ObservationProcessor observationProcessor) {
this(observationProcessor, null);
}
@@ -99,6 +116,30 @@ public class ObservationNode implements NodeAction {
// 合并为单条观察记录
String combinedObservation = String.join("\n---\n", processedObservations);
+ // Tool-call loop guard: signature-level repetition detection across the
+ // run (identical-arg failures / per-tool failures / idempotent
+ // no-progress). Warnings are appended so the model can self-correct on
+ // the next reasoning turn; crossing a halt threshold routes to the
+ // graceful wrap-up via the existing ERROR path.
+ List toolCalls =
+ state.>value(TOOL_CALLS).orElse(List.of());
+ ToolLoopGuard.Evaluation loopGuard = ToolLoopGuard.evaluate(
+ accessor.toolLoopStats(), toolCalls, toolResults);
+ for (String warning : loopGuard.warnings()) {
+ combinedObservation += "\n\n" + warning;
+ log.info("[ObservationNode] Loop-guard warning injected: {}", warning);
+ }
+
+ // One-shot post-mutation verification reminder: the first successful
+ // file mutation this run asks the model to verify before wrapping up.
+ boolean injectVerificationReminder = !accessor.mutationReminderInjected()
+ && toolResults.stream().anyMatch(tr -> FILE_MUTATION_TOOLS.contains(tr.name())
+ && !ToolLoopGuard.isFailure(tr.responseData()));
+ if (injectVerificationReminder) {
+ combinedObservation += VERIFICATION_REMINDER;
+ log.info("[ObservationNode] Post-mutation verification reminder injected");
+ }
+
// Budget Pressure Warning:接近上限时注入警告到工具结果中
// LLM 下一轮 reasoning 时能看到,从而主动收束,而非被硬性截断
if (maxIterations > 0) {
@@ -146,26 +187,48 @@ public class ObservationNode implements NodeAction {
.iterationCount(nextIteration)
.put(OBSERVATION_HISTORY, updatedHistory)
.shouldSummarize(shouldSummarize)
- .toolCallCount(newToolCallCount);
+ .toolCallCount(newToolCallCount)
+ .toolLoopStats(loopGuard.stats());
if (refundIteration) {
builder.iterationRefundCount(refundCount + 1);
}
+ if (injectVerificationReminder) {
+ builder.mutationReminderInjected(true);
+ }
+
// Close out the iteration we just observed. We use currentIteration
// (not nextIteration) so the index pairs with whatever
// iteration_start the ReasoningNode emitted at the top of this turn.
// Char totals are best-effort: ObservationNode doesn't see the LLM
// delta stream directly, so 0/0 is acceptable for now — consumers
// that care fall back to summing the deltas themselves.
+ List events = new ArrayList<>();
if (streamTracker == null || streamTracker.isIterationEventsEnabled()) {
- builder.events(List.of(
- GraphEventPublisher.iterationEnd(currentIteration, "parent", null, 0, 0)));
+ events.add(GraphEventPublisher.iterationEnd(currentIteration, "parent", null, 0, 0));
+ }
+ // Surface loop-guard interventions to the user: the observation-text
+ // injection above is LLM-only, so mirror each warning (and a halt) as
+ // a "warning" graph event — the accumulator persists it under
+ // metadata.warnings and rebroadcasts it live on SSE.
+ for (String warning : loopGuard.warnings()) {
+ events.add(GraphEventPublisher.warning(warning, "loop_guard"));
+ }
+ if (loopGuard.shouldHalt() && !duplicateObservation) {
+ events.add(GraphEventPublisher.warning(
+ "[⛔ 循环熔断] " + loopGuard.haltReason() + ",已提前收尾。", "loop_guard"));
+ }
+ if (!events.isEmpty()) {
+ builder.events(events);
}
// 重复观察时标记错误,让 ObservationDispatcher 路由到 limitExceededNode
if (duplicateObservation) {
builder.put(ERROR, "连续 3 次工具调用返回相同结果,已强制终止循环");
+ } else if (loopGuard.shouldHalt()) {
+ log.warn("[ObservationNode] Loop-guard halt: {}", loopGuard.haltReason());
+ builder.put(ERROR, loopGuard.haltReason());
}
return builder.build();
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java
index 5529f440..e1fa44a7 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java
@@ -253,6 +253,21 @@ public final class MateClawStateAccessor {
return state.>value(ENABLED_EXTENSION_TOOLS).orElse(Set.of());
}
+ // ===== Tool-call loop guard =====
+
+ /**
+ * Loop-guard counters accumulated so far this run. Empty at run start.
+ */
+ @SuppressWarnings("unchecked")
+ public java.util.Map toolLoopStats() {
+ return state.>value(TOOL_LOOP_STATS).orElse(java.util.Map.of());
+ }
+
+ /** Whether the one-shot post-mutation verification reminder was already injected this run. */
+ public boolean mutationReminderInjected() {
+ return state.value(MUTATION_REMINDER_INJECTED, false);
+ }
+
// ===== Token Usage =====
public int promptTokens() {
@@ -528,6 +543,15 @@ public final class MateClawStateAccessor {
return put(ENABLED_EXTENSION_TOOLS, names);
}
+ // ---- Tool-call loop guard ----
+ public OutputBuilder toolLoopStats(java.util.Map stats) {
+ return put(TOOL_LOOP_STATS, stats);
+ }
+
+ public OutputBuilder mutationReminderInjected(boolean injected) {
+ return put(MUTATION_REMINDER_INJECTED, injected);
+ }
+
// ---- Token Usage ----
/** 将本次 LLM 调用的 usage 累加到 state 已有值上 */
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java
index 2bddc46c..db5d33f2 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java
@@ -302,4 +302,29 @@ public final class MateClawStateKeys {
* {@link #LOADED_SKILLS}).
*/
public static final String ENABLED_EXTENSION_TOOLS = "enabled_extension_tools";
+
+ // ===== Tool-call loop guard (REPLACE strategy) =====
+
+ /**
+ * Per-run counters for the tool-call loop guard: repeated identical-argument
+ * failures, per-tool failure totals, and consecutive no-progress results
+ * from idempotent read-only tools. Stored as a {@code Map}
+ * keyed by detector-prefixed signatures; ObservationNode reads the prior
+ * map and writes back the updated one each observation round
+ * (read-merge-write under REPLACE). Implicitly empty at run start, so the
+ * counters reset naturally between graph runs.
+ *
+ * MUST be registered in both KeyStrategyFactory blocks (see
+ * {@link #LOADED_SKILLS}).
+ */
+ public static final String TOOL_LOOP_STATS = "tool_loop_stats";
+
+ /**
+ * True once the one-shot post-mutation verification reminder has been
+ * injected into an observation this run. The reminder asks the model to
+ * verify a successful file mutation (run tests / re-read the file) before
+ * declaring the task complete; injecting it at most once per run keeps
+ * multi-file tasks from being spammed. REPLACE strategy.
+ */
+ public static final String MUTATION_REMINDER_INJECTED = "mutation_reminder_injected";
}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/guard/ToolLoopGuardTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/guard/ToolLoopGuardTest.java
new file mode 100644
index 00000000..66c498a5
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/guard/ToolLoopGuardTest.java
@@ -0,0 +1,186 @@
+package vip.mate.agent.graph.guard;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.ai.chat.messages.AssistantMessage;
+import org.springframework.ai.chat.messages.ToolResponseMessage;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Pure-logic tests for the tool-call loop guard: signature canonicalization,
+ * the failure heuristic, and the three detectors' threshold boundaries.
+ */
+class ToolLoopGuardTest {
+
+ private static AssistantMessage.ToolCall call(String id, String name, String args) {
+ return new AssistantMessage.ToolCall(id, "function", name, args);
+ }
+
+ private static ToolResponseMessage.ToolResponse result(String id, String name, String data) {
+ return new ToolResponseMessage.ToolResponse(id, name, data);
+ }
+
+ /** Run N consecutive rounds of the same single call/result pair, chaining stats. */
+ private static ToolLoopGuard.Evaluation runRounds(int rounds, String name, String args, String data) {
+ Map stats = Map.of();
+ ToolLoopGuard.Evaluation eval = null;
+ for (int i = 0; i < rounds; i++) {
+ eval = ToolLoopGuard.evaluate(stats,
+ List.of(call("c1", name, args)),
+ List.of(result("c1", name, data)));
+ stats = eval.stats();
+ }
+ return eval;
+ }
+
+ // ==================== failure heuristic ====================
+
+ @Test
+ @DisplayName("失败判定:执行器异常前缀 / 安全拦截 / 工具自身错误前缀 / JSON error 字段")
+ void failureHeuristic() {
+ assertTrue(ToolLoopGuard.isFailure("Tool execution failed: boom"));
+ assertTrue(ToolLoopGuard.isFailure("[安全拦截] rm -rf 被拒绝。请使用更安全的替代方案。"));
+ assertTrue(ToolLoopGuard.isFailure("Error: file not found"));
+ assertTrue(ToolLoopGuard.isFailure("错误:路径不存在"));
+ assertTrue(ToolLoopGuard.isFailure("{\"error\":\"path outside workspace\"}"));
+ assertTrue(ToolLoopGuard.isFailure("{\"success\": false, \"message\":\"denied\"}"));
+
+ assertFalse(ToolLoopGuard.isFailure("{\"filePath\":\"a.txt\",\"bytesWritten\":42}"));
+ assertFalse(ToolLoopGuard.isFailure("{\"error\": null, \"rows\": 3}"));
+ assertFalse(ToolLoopGuard.isFailure("{\"error\": \"\", \"rows\": 3}"));
+ assertFalse(ToolLoopGuard.isFailure("plain successful output"));
+ assertFalse(ToolLoopGuard.isFailure(null));
+ assertFalse(ToolLoopGuard.isFailure(" "));
+ }
+
+ // ==================== signature canonicalization ====================
+
+ @Test
+ @DisplayName("参数规范化:键序与空白差异命中同一签名")
+ void canonicalization_keyOrderAndWhitespace() {
+ String a = ToolLoopGuard.canonicalizeArguments("{\"b\":1,\"a\":2}");
+ String b = ToolLoopGuard.canonicalizeArguments("{ \"a\" : 2, \"b\" : 1 }");
+ assertEquals(a, b);
+
+ // Non-JSON falls back to the trimmed raw string.
+ assertEquals("not-json", ToolLoopGuard.canonicalizeArguments(" not-json "));
+ assertEquals("", ToolLoopGuard.canonicalizeArguments(null));
+ }
+
+ @Test
+ @DisplayName("同参失败:不同键序也累计到同一计数器")
+ void exactFailure_keyOrderInsensitive() {
+ Map stats = Map.of();
+ ToolLoopGuard.Evaluation e1 = ToolLoopGuard.evaluate(stats,
+ List.of(call("c1", "read_file", "{\"b\":1,\"a\":2}")),
+ List.of(result("c1", "read_file", "Error: nope")));
+ ToolLoopGuard.Evaluation e2 = ToolLoopGuard.evaluate(e1.stats(),
+ List.of(call("c1", "read_file", "{\"a\":2,\"b\":1}")),
+ List.of(result("c1", "read_file", "Error: nope")));
+ // 2nd identical-arg failure crosses the warn threshold.
+ assertEquals(1, e2.warnings().size());
+ assertTrue(e2.warnings().get(0).contains("相同参数"));
+ }
+
+ // ==================== detector 1: exact failure ====================
+
+ @Test
+ @DisplayName("同参失败:1 次不警告,2 次警告,5 次熔断")
+ void exactFailure_thresholds() {
+ assertTrue(runRounds(1, "web_search", "{\"q\":\"x\"}", "Error: rate limited").warnings().isEmpty());
+
+ ToolLoopGuard.Evaluation warn = runRounds(2, "web_search", "{\"q\":\"x\"}", "Error: rate limited");
+ assertEquals(1, warn.warnings().size());
+ assertFalse(warn.shouldHalt());
+
+ ToolLoopGuard.Evaluation halt = runRounds(5, "web_search", "{\"q\":\"x\"}", "Error: rate limited");
+ assertTrue(halt.shouldHalt());
+ assertTrue(halt.haltReason().contains("web_search"));
+ }
+
+ @Test
+ @DisplayName("同参失败:中途成功清零计数")
+ void exactFailure_successResets() {
+ Map stats = runRounds(4, "web_search", "{\"q\":\"x\"}", "Error: rate limited").stats();
+ // One success on the same signature clears the streak.
+ ToolLoopGuard.Evaluation ok = ToolLoopGuard.evaluate(stats,
+ List.of(call("c1", "web_search", "{\"q\":\"x\"}")),
+ List.of(result("c1", "web_search", "10 results found")));
+ assertFalse(ok.shouldHalt());
+ // Next failure starts from 1 again — no warning.
+ ToolLoopGuard.Evaluation after = ToolLoopGuard.evaluate(ok.stats(),
+ List.of(call("c1", "web_search", "{\"q\":\"x\"}")),
+ List.of(result("c1", "web_search", "Error: rate limited")));
+ assertTrue(after.warnings().isEmpty());
+ }
+
+ // ==================== detector 2: per-tool failure ====================
+
+ @Test
+ @DisplayName("同工具换参失败:3 次警告,8 次熔断")
+ void sameToolFailure_thresholds() {
+ Map stats = Map.of();
+ ToolLoopGuard.Evaluation eval = null;
+ for (int i = 0; i < 8; i++) {
+ eval = ToolLoopGuard.evaluate(stats,
+ List.of(call("c1", "read_file", "{\"path\":\"/guess/" + i + "\"}")),
+ List.of(result("c1", "read_file", "Error: no such file")));
+ stats = eval.stats();
+ if (i == 1) {
+ assertTrue(eval.warnings().isEmpty(), "2 failures with different args: below warn threshold");
+ }
+ if (i == 2) {
+ assertEquals(1, eval.warnings().size(), "3rd failure warns");
+ assertTrue(eval.warnings().get(0).contains("已失败 3 次"));
+ }
+ }
+ assertTrue(eval.shouldHalt(), "8th failure halts");
+ }
+
+ // ==================== detector 3: idempotent no-progress ====================
+
+ @Test
+ @DisplayName("只读工具无进展:第 2 次相同结果警告,第 5 次熔断,结果变化清零")
+ void noProgress_thresholds() {
+ assertTrue(runRounds(1, "read_file", "{\"path\":\"a\"}", "same content").warnings().isEmpty());
+
+ ToolLoopGuard.Evaluation warn = runRounds(2, "read_file", "{\"path\":\"a\"}", "same content");
+ assertEquals(1, warn.warnings().size());
+ assertTrue(warn.warnings().get(0).contains("完全相同的结果"));
+
+ ToolLoopGuard.Evaluation halt = runRounds(5, "read_file", "{\"path\":\"a\"}", "same content");
+ assertTrue(halt.shouldHalt());
+
+ // A changed result resets the streak.
+ Map stats = runRounds(4, "read_file", "{\"path\":\"a\"}", "same content").stats();
+ ToolLoopGuard.Evaluation changed = ToolLoopGuard.evaluate(stats,
+ List.of(call("c1", "read_file", "{\"path\":\"a\"}")),
+ List.of(result("c1", "read_file", "different content")));
+ assertFalse(changed.shouldHalt());
+ assertTrue(changed.warnings().isEmpty());
+ }
+
+ @Test
+ @DisplayName("变更类工具不参与无进展检测")
+ void noProgress_mutatingToolsExempt() {
+ // Same successful write repeated 6 times — legitimate, never flagged.
+ ToolLoopGuard.Evaluation eval = runRounds(6, "write_file",
+ "{\"filePath\":\"a.txt\",\"content\":\"x\"}",
+ "{\"filePath\":\"a.txt\",\"bytesWritten\":1}");
+ assertTrue(eval.warnings().isEmpty());
+ assertFalse(eval.shouldHalt());
+ }
+
+ @Test
+ @DisplayName("空批次与空历史安全返回")
+ void emptyInputsAreSafe() {
+ ToolLoopGuard.Evaluation eval = ToolLoopGuard.evaluate(null, List.of(), List.of());
+ assertTrue(eval.warnings().isEmpty());
+ assertFalse(eval.shouldHalt());
+ assertTrue(eval.stats().isEmpty());
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ObservationNodeLoopGuardTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ObservationNodeLoopGuardTest.java
new file mode 100644
index 00000000..f23e32a8
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ObservationNodeLoopGuardTest.java
@@ -0,0 +1,200 @@
+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 org.springframework.ai.chat.messages.AssistantMessage;
+import org.springframework.ai.chat.messages.ToolResponseMessage;
+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 wiring for the tool-call loop guard and the one-shot
+ * post-mutation verification reminder: warnings land in the observation text,
+ * a halt lands in the ERROR slot (routing to graceful wrap-up), and the
+ * reminder fires exactly once per run.
+ */
+class ObservationNodeLoopGuardTest {
+
+ private ObservationNode node() {
+ return new ObservationNode(new ObservationProcessor(new GraphObservationProperties()));
+ }
+
+ private static AssistantMessage.ToolCall call(String id, String name, String args) {
+ return new AssistantMessage.ToolCall(id, "function", name, args);
+ }
+
+ private static ToolResponseMessage.ToolResponse result(String id, String name, String data) {
+ return new ToolResponseMessage.ToolResponse(id, name, data);
+ }
+
+ private OverAllState state(Map loopStats, Boolean reminderInjected,
+ List calls,
+ List results) {
+ Map m = new HashMap<>();
+ m.put(CURRENT_ITERATION, 1);
+ m.put(MAX_ITERATIONS, 25);
+ m.put(OBSERVATION_HISTORY, new ArrayList());
+ m.put(TOOL_CALLS, calls);
+ m.put(TOOL_RESULTS, results);
+ m.put(TOOL_CALL_COUNT, 0);
+ if (loopStats != null) {
+ m.put(TOOL_LOOP_STATS, loopStats);
+ }
+ if (reminderInjected != null) {
+ m.put(MUTATION_REMINDER_INJECTED, reminderInjected);
+ }
+ return new OverAllState(m);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static String lastObservation(Map out) {
+ List history = (List) out.get(OBSERVATION_HISTORY);
+ return history.get(history.size() - 1);
+ }
+
+ @Test
+ @DisplayName("同参二次失败:警告注入观察文本,计数器写回状态")
+ void warnInjectedIntoObservation() throws Exception {
+ List calls = List.of(call("c1", "web_search", "{\"q\":\"x\"}"));
+ List results =
+ List.of(result("c1", "web_search", "Error: rate limited"));
+
+ Map round1 = node().apply(state(null, null, calls, results));
+ assertFalse(lastObservation(round1).contains("循环警告"), "1st failure: no warning yet");
+ Map stats = (Map) round1.get(TOOL_LOOP_STATS);
+ assertNotNull(stats, "counters must be written back to state");
+
+ Map round2 = node().apply(state(stats, null, calls, results));
+ assertTrue(lastObservation(round2).contains("循环警告"), "2nd identical failure warns");
+ assertNull(round2.get(ERROR), "warning must not set the error slot");
+ }
+
+ @Test
+ @DisplayName("同参五次失败:置 ERROR 走优雅收尾路由")
+ void haltSetsError() throws Exception {
+ List calls = List.of(call("c1", "web_search", "{\"q\":\"x\"}"));
+ List results =
+ List.of(result("c1", "web_search", "Error: rate limited"));
+
+ Map stats = null;
+ Map out = null;
+ for (int i = 0; i < 5; i++) {
+ out = node().apply(state(stats, null, calls, results));
+ stats = (Map) out.get(TOOL_LOOP_STATS);
+ }
+ assertNotNull(out.get(ERROR), "5th identical failure must halt via the ERROR slot");
+ assertTrue(((String) out.get(ERROR)).contains("web_search"));
+ }
+
+ @Test
+ @DisplayName("成功写文件:验证提醒注入一次,后续轮不重复")
+ void verificationReminderFiresOnce() throws Exception {
+ List calls =
+ List.of(call("c1", "write_file", "{\"filePath\":\"a.txt\",\"content\":\"x\"}"));
+ List results =
+ List.of(result("c1", "write_file", "{\"filePath\":\"a.txt\",\"bytesWritten\":1}"));
+
+ Map round1 = node().apply(state(null, null, calls, results));
+ assertTrue(lastObservation(round1).contains("验证提醒"), "first successful mutation reminds");
+ assertEquals(Boolean.TRUE, round1.get(MUTATION_REMINDER_INJECTED));
+
+ Map round2 = node().apply(state(
+ (Map) round1.get(TOOL_LOOP_STATS), true, calls, results));
+ assertFalse(lastObservation(round2).contains("验证提醒"), "reminder is one-shot per run");
+ }
+
+ @Test
+ @DisplayName("写文件失败不触发验证提醒")
+ void failedMutationDoesNotRemind() throws Exception {
+ List calls =
+ List.of(call("c1", "write_file", "{\"filePath\":\"a.txt\",\"content\":\"x\"}"));
+ List results =
+ List.of(result("c1", "write_file", "Tool execution failed: disk full"));
+
+ Map out = node().apply(state(null, null, calls, results));
+ assertFalse(lastObservation(out).contains("验证提醒"));
+ assertNull(out.get(MUTATION_REMINDER_INJECTED));
+ }
+
+ @Test
+ @DisplayName("只读工具正常成功:无警告、无提醒、无 ERROR")
+ void healthyRoundIsUntouched() throws Exception {
+ List calls = List.of(call("c1", "read_file", "{\"path\":\"a\"}"));
+ List results =
+ List.of(result("c1", "read_file", "file content"));
+
+ Map out = node().apply(state(null, null, calls, results));
+ String obs = lastObservation(out);
+ assertFalse(obs.contains("循环"));
+ assertFalse(obs.contains("验证提醒"));
+ assertNull(out.get(ERROR));
+ }
+
+ // ==================== warning events (UI visibility) ====================
+
+ @SuppressWarnings("unchecked")
+ private static List warningEvents(Map out) {
+ var events = (List) out.get(PENDING_EVENTS);
+ if (events == null) return List.of();
+ return events.stream()
+ .filter(e -> vip.mate.agent.GraphEventPublisher.EVENT_WARNING.equals(e.type()))
+ .toList();
+ }
+
+ @Test
+ @DisplayName("循环警告轮:PENDING_EVENTS 携带 warning 事件(source=loop_guard)")
+ void warningRoundEmitsWarningEvent() throws Exception {
+ List calls = List.of(call("c1", "web_search", "{\"q\":\"x\"}"));
+ List results =
+ List.of(result("c1", "web_search", "Error: rate limited"));
+
+ Map round1 = node().apply(state(null, null, calls, results));
+ assertTrue(warningEvents(round1).isEmpty(), "1st failure: no warning event");
+
+ Map round2 = node().apply(state(
+ (Map) round1.get(TOOL_LOOP_STATS), null, calls, results));
+ var events = warningEvents(round2);
+ assertEquals(1, events.size(), "2nd identical failure emits one warning event");
+ assertEquals("loop_guard", events.get(0).data().get("source"));
+ assertTrue(String.valueOf(events.get(0).data().get("message")).contains("循环警告"));
+ }
+
+ @Test
+ @DisplayName("熔断轮:额外携带循环熔断 warning 事件")
+ void haltRoundEmitsHaltWarningEvent() throws Exception {
+ List calls = List.of(call("c1", "web_search", "{\"q\":\"x\"}"));
+ List results =
+ List.of(result("c1", "web_search", "Error: rate limited"));
+
+ Map stats = null;
+ Map out = null;
+ for (int i = 0; i < 5; i++) {
+ out = node().apply(state(stats, null, calls, results));
+ stats = (Map) out.get(TOOL_LOOP_STATS);
+ }
+ var events = warningEvents(out);
+ assertFalse(events.isEmpty());
+ assertTrue(events.stream().anyMatch(e ->
+ String.valueOf(e.data().get("message")).contains("循环熔断")));
+ }
+
+ @Test
+ @DisplayName("健康轮:PENDING_EVENTS 无 warning 事件")
+ void healthyRoundEmitsNoWarningEvent() throws Exception {
+ List calls = List.of(call("c1", "read_file", "{\"path\":\"a\"}"));
+ List results =
+ List.of(result("c1", "read_file", "file content"));
+
+ Map out = node().apply(state(null, null, calls, results));
+ assertTrue(warningEvents(out).isEmpty());
+ }
+}
diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue
index 31732931..e56b1258 100644
--- a/mateclaw-ui/src/components/chat/MessageBubble.vue
+++ b/mateclaw-ui/src/components/chat/MessageBubble.vue
@@ -256,6 +256,20 @@
{{ $t('chat.evidenceDescription') }}
+
+
+