mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
feat(agent): tool-call loop guard, post-mutation verify reminder, warning chips
This commit is contained in:
parent
5b948f7852
commit
cc444f4c06
@ -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(
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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 已有值上 */
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user