import { createHash } from "node:crypto"; import type { ToolLoopDetectionConfig } from "../config/types.tools.js"; import type { SessionState } from "../logging/diagnostic-session-state.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { isPlainObject } from "../utils.js";
function asPositiveInt(value: number | undefined, fallback: number): number { if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) { return fallback;
} return value;
}
function resolveLoopDetectionConfig(config?: ToolLoopDetectionConfig): ResolvedLoopDetectionConfig {
let warningThreshold = asPositiveInt(
config?.warningThreshold,
DEFAULT_LOOP_DETECTION_CONFIG.warningThreshold,
);
let criticalThreshold = asPositiveInt(
config?.criticalThreshold,
DEFAULT_LOOP_DETECTION_CONFIG.criticalThreshold,
);
let globalCircuitBreakerThreshold = asPositiveInt(
config?.globalCircuitBreakerThreshold,
DEFAULT_LOOP_DETECTION_CONFIG.globalCircuitBreakerThreshold,
);
const tailStart = Math.max(0, history.length - alternatingTailCount);
let firstHashA: string | undefined;
let firstHashB: string | undefined;
let noProgressEvidence = true; for (let i = tailStart; i < history.length; i += 1) { const call = history[i]; if (!call) { continue;
} if (!call.resultHash) {
noProgressEvidence = false; break;
} if (call.argsHash === last.argsHash) { if (!firstHashA) {
firstHashA = call.resultHash;
} elseif (firstHashA !== call.resultHash) {
noProgressEvidence = false; break;
} continue;
} if (call.argsHash === otherSignature) { if (!firstHashB) {
firstHashB = call.resultHash;
} elseif (firstHashB !== call.resultHash) {
noProgressEvidence = false; break;
} continue;
}
noProgressEvidence = false; break;
}
// Need repeated stable outcomes on both sides before treating ping-pong as no-progress. if (!firstHashA || !firstHashB) {
noProgressEvidence = false;
}
/** * Detect if an agent is stuck in a repetitive tool call loop. * Checks if the same tool+params combination has been called excessively.
*/
export function detectToolCallLoop(
state: SessionState,
toolName: string,
params: unknown,
config?: ToolLoopDetectionConfig,
): LoopDetectionResult { const resolvedConfig = resolveLoopDetectionConfig(config); if (!resolvedConfig.enabled) { return { stuck: false };
} const history = state.toolCallHistory ?? []; const currentHash = hashToolCall(toolName, params); const unknownToolStreak = getUnknownToolRepeatStreak(history, toolName); const noProgress = getNoProgressStreak(history, toolName, currentHash); const noProgressStreak = noProgress.count; const knownPollTool = isKnownPollToolCall(toolName, params); const pingPong = getPingPongStreak(history, currentHash);
if (unknownToolStreak.count >= resolvedConfig.unknownToolThreshold) { return {
stuck: true,
level: "critical",
detector: "unknown_tool_repeat",
count: unknownToolStreak.count,
message: `CRITICAL: attempted unavailable tool ${unknownToolStreak.unknownToolName ?? toolName} ${unknownToolStreak.count} times. Stop retrying that missing tool and answer without it.`,
warningKey: `unknown-tool:${toolName}:${unknownToolStreak.unknownToolName ?? "unknown"}`,
};
}
if (noProgressStreak >= resolvedConfig.globalCircuitBreakerThreshold) {
log.error(
`Global circuit breaker triggered: ${toolName} repeated ${noProgressStreak} times with no progress`,
); return {
stuck: true,
level: "critical",
detector: "global_circuit_breaker",
count: noProgressStreak,
message: `CRITICAL: ${toolName} has repeated identical no-progress outcomes ${noProgressStreak} times. Session execution blocked by global circuit breaker to prevent runaway loops.`,
warningKey: `global:${toolName}:${currentHash}:${noProgress.latestResultHash ?? "none"}`,
};
}
if (
knownPollTool &&
resolvedConfig.detectors.knownPollNoProgress &&
noProgressStreak >= resolvedConfig.criticalThreshold
) {
log.error(`Critical polling loop detected: ${toolName} repeated ${noProgressStreak} times`); return {
stuck: true,
level: "critical",
detector: "known_poll_no_progress",
count: noProgressStreak,
message: `CRITICAL: Called ${toolName} with identical arguments and no progress ${noProgressStreak} times. This appears to be a stuck polling loop. Session execution blocked to prevent resource waste.`,
warningKey: `poll:${toolName}:${currentHash}:${noProgress.latestResultHash ?? "none"}`,
};
}
if (
knownPollTool &&
resolvedConfig.detectors.knownPollNoProgress &&
noProgressStreak >= resolvedConfig.warningThreshold
) {
log.warn(`Polling loop warning: ${toolName} repeated ${noProgressStreak} times`); return {
stuck: true,
level: "warning",
detector: "known_poll_no_progress",
count: noProgressStreak,
message: `WARNING: You have called ${toolName} ${noProgressStreak} times with identical arguments and no progress. Stop polling and either (1) increase wait time between checks, or (2) report the task as failed if the process is stuck.`,
warningKey: `poll:${toolName}:${currentHash}:${noProgress.latestResultHash ?? "none"}`,
};
}
if (
!knownPollTool &&
resolvedConfig.detectors.genericRepeat &&
recentCount >= resolvedConfig.warningThreshold
) {
log.warn(`Loop warning: ${toolName} called ${recentCount} times with identical arguments`); return {
stuck: true,
level: "warning",
detector: "generic_repeat",
count: recentCount,
message: `WARNING: You have called ${toolName} ${recentCount} times with identical arguments. Ifthis is not making progress, stop retrying and report the task as failed.`,
warningKey: `generic:${toolName}:${currentHash}`,
};
}
return { stuck: false };
}
/** * Record a tool call in the session's history for loop detection. * Maintains sliding window of last N calls.
*/
export function recordToolCall(
state: SessionState,
toolName: string,
params: unknown,
toolCallId?: string,
config?: ToolLoopDetectionConfig,
): void { const resolvedConfig = resolveLoopDetectionConfig(config); if (!state.toolCallHistory) {
state.toolCallHistory = [];
}
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.