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)}): + *

    + *
  1. Identical-argument repeated failure — the model retries the + * exact same failing call without reading the error. Warn early, halt + * when clearly stuck.
  2. + *
  3. Per-tool repeated failure (arguments ignored) — the model + * keeps guessing slightly different arguments against the same broken + * tool ("path-guessing" loops).
  4. + *
  5. 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.
  6. + *
+ * 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') }}

+ +
+
+ + {{ warning }} +
+
+