feat(agent): tool-call loop guard, post-mutation verify reminder, warning chips

This commit is contained in:
matevip 2026-07-08 18:18:34 +08:00
parent 5b948f7852
commit cc444f4c06
9 changed files with 847 additions and 3 deletions

View File

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

View File

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

View File

@ -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.
* <p>
* Three independent detectors, each keyed on a tool-call signature
* ({@code toolName + ":" + sha256(canonicalized args JSON)}):
* <ol>
* <li><b>Identical-argument repeated failure</b> the model retries the
* exact same failing call without reading the error. Warn early, halt
* when clearly stuck.</li>
* <li><b>Per-tool repeated failure</b> (arguments ignored) the model
* keeps guessing slightly different arguments against the same broken
* tool ("path-guessing" loops).</li>
* <li><b>Idempotent no-progress</b> 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.</li>
* </ol>
* 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.
* <p>
* 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<String> 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": <non-empty>} / {@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<String, Object> stats, List<String> 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<String, Object> previousStats,
List<AssistantMessage.ToolCall> toolCalls,
List<ToolResponseMessage.ToolResponse> toolResults) {
Map<String, Object> stats = new HashMap<>(previousStats == null ? Map.of() : previousStats);
List<String> 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<AssistantMessage.ToolCall> 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<String, Object> stats, String key) {
int next = ((Number) stats.getOrDefault(key, 0)).intValue() + 1;
stats.put(key, next);
return next;
}
private static int resetNoProgress(Map<String, Object> 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());
}
}
}

View File

@ -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<String> 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<AssistantMessage.ToolCall> toolCalls =
state.<List<AssistantMessage.ToolCall>>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<GraphEventPublisher.GraphEvent> 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();

View File

@ -253,6 +253,21 @@ public final class MateClawStateAccessor {
return state.<Set<String>>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<String, Object> toolLoopStats() {
return state.<java.util.Map<String, Object>>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<String, Object> stats) {
return put(TOOL_LOOP_STATS, stats);
}
public OutputBuilder mutationReminderInjected(boolean injected) {
return put(MUTATION_REMINDER_INJECTED, injected);
}
// ---- Token Usage ----
/** 将本次 LLM 调用的 usage 累加到 state 已有值上 */

View File

@ -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<String, Object>}
* 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.
* <p>
* 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";
}

View File

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

View File

@ -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<String, Object> loopStats, Boolean reminderInjected,
List<AssistantMessage.ToolCall> calls,
List<ToolResponseMessage.ToolResponse> results) {
Map<String, Object> m = new HashMap<>();
m.put(CURRENT_ITERATION, 1);
m.put(MAX_ITERATIONS, 25);
m.put(OBSERVATION_HISTORY, new ArrayList<String>());
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<String, Object> out) {
List<String> history = (List<String>) out.get(OBSERVATION_HISTORY);
return history.get(history.size() - 1);
}
@Test
@DisplayName("同参二次失败:警告注入观察文本,计数器写回状态")
void warnInjectedIntoObservation() throws Exception {
List<AssistantMessage.ToolCall> calls = List.of(call("c1", "web_search", "{\"q\":\"x\"}"));
List<ToolResponseMessage.ToolResponse> results =
List.of(result("c1", "web_search", "Error: rate limited"));
Map<String, Object> round1 = node().apply(state(null, null, calls, results));
assertFalse(lastObservation(round1).contains("循环警告"), "1st failure: no warning yet");
Map<String, Object> stats = (Map<String, Object>) round1.get(TOOL_LOOP_STATS);
assertNotNull(stats, "counters must be written back to state");
Map<String, Object> 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<AssistantMessage.ToolCall> calls = List.of(call("c1", "web_search", "{\"q\":\"x\"}"));
List<ToolResponseMessage.ToolResponse> results =
List.of(result("c1", "web_search", "Error: rate limited"));
Map<String, Object> stats = null;
Map<String, Object> out = null;
for (int i = 0; i < 5; i++) {
out = node().apply(state(stats, null, calls, results));
stats = (Map<String, Object>) 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<AssistantMessage.ToolCall> calls =
List.of(call("c1", "write_file", "{\"filePath\":\"a.txt\",\"content\":\"x\"}"));
List<ToolResponseMessage.ToolResponse> results =
List.of(result("c1", "write_file", "{\"filePath\":\"a.txt\",\"bytesWritten\":1}"));
Map<String, Object> 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<String, Object> round2 = node().apply(state(
(Map<String, Object>) 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<AssistantMessage.ToolCall> calls =
List.of(call("c1", "write_file", "{\"filePath\":\"a.txt\",\"content\":\"x\"}"));
List<ToolResponseMessage.ToolResponse> results =
List.of(result("c1", "write_file", "Tool execution failed: disk full"));
Map<String, Object> 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<AssistantMessage.ToolCall> calls = List.of(call("c1", "read_file", "{\"path\":\"a\"}"));
List<ToolResponseMessage.ToolResponse> results =
List.of(result("c1", "read_file", "file content"));
Map<String, Object> 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<vip.mate.agent.GraphEventPublisher.GraphEvent> warningEvents(Map<String, Object> out) {
var events = (List<vip.mate.agent.GraphEventPublisher.GraphEvent>) 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<AssistantMessage.ToolCall> calls = List.of(call("c1", "web_search", "{\"q\":\"x\"}"));
List<ToolResponseMessage.ToolResponse> results =
List.of(result("c1", "web_search", "Error: rate limited"));
Map<String, Object> round1 = node().apply(state(null, null, calls, results));
assertTrue(warningEvents(round1).isEmpty(), "1st failure: no warning event");
Map<String, Object> round2 = node().apply(state(
(Map<String, Object>) 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<AssistantMessage.ToolCall> calls = List.of(call("c1", "web_search", "{\"q\":\"x\"}"));
List<ToolResponseMessage.ToolResponse> results =
List.of(result("c1", "web_search", "Error: rate limited"));
Map<String, Object> stats = null;
Map<String, Object> out = null;
for (int i = 0; i < 5; i++) {
out = node().apply(state(stats, null, calls, results));
stats = (Map<String, Object>) 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<AssistantMessage.ToolCall> calls = List.of(call("c1", "read_file", "{\"path\":\"a\"}"));
List<ToolResponseMessage.ToolResponse> results =
List.of(result("c1", "read_file", "file content"));
Map<String, Object> out = node().apply(state(null, null, calls, results));
assertTrue(warningEvents(out).isEmpty());
}
}

View File

@ -256,6 +256,20 @@
<p class="evidence-card__description">{{ $t('chat.evidenceDescription') }}</p>
</div>
<!--
Runtime warning chips (metadata.warnings): loop-guard interventions
and other backend runtime notices. Populated live via the 'warning'
SSE event (useChat merges it into metadata.warnings) and persisted by
the stream accumulator, so streaming and history reload render the
same chips. Message text arrives pre-localized from the backend.
-->
<div v-if="runtimeWarnings.length" class="runtime-warnings">
<div v-for="(warning, wIdx) in runtimeWarnings" :key="wIdx" class="runtime-warning-chip">
<el-icon class="runtime-warning-chip__icon"><WarningFilled /></el-icon>
<span class="runtime-warning-chip__text">{{ warning }}</span>
</div>
</div>
<!--
feedback_event card: recovery affordances for turns that ended
in a non-transient error. Backend's NodeStreamingChatHelper
@ -1238,6 +1252,20 @@ const isEvidenceInsufficient = computed<boolean>(() => {
return parsedMetadata.value?.finishReason === 'evidence_insufficient'
})
/**
* Backend runtime warnings for this turn (metadata.warnings) e.g. the
* tool-call loop guard flagging repeated identical failures or a forced
* wrap-up. Strings arrive pre-localized from the backend; live streaming
* pushes them via the 'warning' SSE event and history reload reads the
* persisted metadata, so both paths converge here.
*/
const runtimeWarnings = computed<string[]>(() => {
if (props.message.role !== 'assistant') return []
const raw = parsedMetadata.value?.warnings
if (!Array.isArray(raw)) return []
return raw.filter((w: unknown): w is string => typeof w === 'string' && w.trim().length > 0)
})
/**
* Recovery-affordance payload from the graph's feedback_event. Populated
* for assistant turns that ended in a non-transient error (after the
@ -2075,6 +2103,39 @@ watch(isGenerating, (generating) => {
border-color: color-mix(in srgb, var(--mc-danger) 50%, transparent);
}
/* ==================== 运行时警示条(循环守卫等 metadata.warnings ==================== */
.runtime-warnings {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 8px;
}
.runtime-warning-chip {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 6px 10px;
border-radius: 6px;
background: color-mix(in srgb, var(--mc-warning, #d97706) 8%, var(--mc-bg-elevated));
border: 1px solid color-mix(in srgb, var(--mc-warning, #d97706) 25%, transparent);
font-size: 12px;
line-height: 1.5;
max-width: 560px;
color: var(--mc-text-secondary);
}
.runtime-warning-chip__icon {
flex-shrink: 0;
margin-top: 2px;
font-size: 13px;
color: var(--mc-warning, #d97706);
}
.runtime-warning-chip__text {
word-break: break-word;
}
/* ==================== INCOMPLETE 截断卡片(重复检测 / thinking-only 软上限) ==================== */
.incomplete-card {
margin-top: 8px;