Files
openclaw/src/agents/pi-tools.before-tool-call.ts
Sk Akram e5eb5b3e43 feat: add stuck loop detection and exponential backoff infrastructure for agent polling (#17118)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: eebabf679b983e5a660fb3cef371e1303f11f615
Co-authored-by: akramcodez <179671552+akramcodez@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-16 15:16:35 -05:00

249 lines
7.6 KiB
TypeScript

import type { SessionState } from "../logging/diagnostic-session-state.js";
import type { AnyAgentTool } from "./tools/common.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import { isPlainObject } from "../utils.js";
import { normalizeToolName } from "./tool-policy.js";
type HookContext = {
agentId?: string;
sessionKey?: string;
};
type HookOutcome = { blocked: true; reason: string } | { blocked: false; params: unknown };
const log = createSubsystemLogger("agents/tools");
const BEFORE_TOOL_CALL_WRAPPED = Symbol("beforeToolCallWrapped");
const adjustedParamsByToolCallId = new Map<string, unknown>();
const MAX_TRACKED_ADJUSTED_PARAMS = 1024;
const LOOP_WARNING_BUCKET_SIZE = 10;
const MAX_LOOP_WARNING_KEYS = 256;
function shouldEmitLoopWarning(state: SessionState, warningKey: string, count: number): boolean {
if (!state.toolLoopWarningBuckets) {
state.toolLoopWarningBuckets = new Map();
}
const bucket = Math.floor(count / LOOP_WARNING_BUCKET_SIZE);
const lastBucket = state.toolLoopWarningBuckets.get(warningKey) ?? 0;
if (bucket <= lastBucket) {
return false;
}
state.toolLoopWarningBuckets.set(warningKey, bucket);
if (state.toolLoopWarningBuckets.size > MAX_LOOP_WARNING_KEYS) {
const oldest = state.toolLoopWarningBuckets.keys().next().value;
if (oldest) {
state.toolLoopWarningBuckets.delete(oldest);
}
}
return true;
}
async function recordLoopOutcome(args: {
ctx?: HookContext;
toolName: string;
toolParams: unknown;
toolCallId?: string;
result?: unknown;
error?: unknown;
}): Promise<void> {
if (!args.ctx?.sessionKey) {
return;
}
try {
const { getDiagnosticSessionState } = await import("../logging/diagnostic-session-state.js");
const { recordToolCallOutcome } = await import("./tool-loop-detection.js");
const sessionState = getDiagnosticSessionState({
sessionKey: args.ctx.sessionKey,
sessionId: args.ctx?.agentId,
});
recordToolCallOutcome(sessionState, {
toolName: args.toolName,
toolParams: args.toolParams,
toolCallId: args.toolCallId,
result: args.result,
error: args.error,
});
} catch (err) {
log.warn(`tool loop outcome tracking failed: tool=${args.toolName} error=${String(err)}`);
}
}
export async function runBeforeToolCallHook(args: {
toolName: string;
params: unknown;
toolCallId?: string;
ctx?: HookContext;
}): Promise<HookOutcome> {
const toolName = normalizeToolName(args.toolName || "tool");
const params = args.params;
if (args.ctx?.sessionKey) {
const { getDiagnosticSessionState } = await import("../logging/diagnostic-session-state.js");
const { logToolLoopAction } = await import("../logging/diagnostic.js");
const { detectToolCallLoop, recordToolCall } = await import("./tool-loop-detection.js");
const sessionState = getDiagnosticSessionState({
sessionKey: args.ctx.sessionKey,
sessionId: args.ctx?.agentId,
});
const loopResult = detectToolCallLoop(sessionState, toolName, params);
if (loopResult.stuck) {
if (loopResult.level === "critical") {
log.error(`Blocking ${toolName} due to critical loop: ${loopResult.message}`);
logToolLoopAction({
sessionKey: args.ctx.sessionKey,
sessionId: args.ctx?.agentId,
toolName,
level: "critical",
action: "block",
detector: loopResult.detector,
count: loopResult.count,
message: loopResult.message,
pairedToolName: loopResult.pairedToolName,
});
return {
blocked: true,
reason: loopResult.message,
};
} else {
const warningKey = loopResult.warningKey ?? `${loopResult.detector}:${toolName}`;
if (shouldEmitLoopWarning(sessionState, warningKey, loopResult.count)) {
log.warn(`Loop warning for ${toolName}: ${loopResult.message}`);
logToolLoopAction({
sessionKey: args.ctx.sessionKey,
sessionId: args.ctx?.agentId,
toolName,
level: "warning",
action: "warn",
detector: loopResult.detector,
count: loopResult.count,
message: loopResult.message,
pairedToolName: loopResult.pairedToolName,
});
}
}
}
recordToolCall(sessionState, toolName, params, args.toolCallId);
}
const hookRunner = getGlobalHookRunner();
if (!hookRunner?.hasHooks("before_tool_call")) {
return { blocked: false, params: args.params };
}
try {
const normalizedParams = isPlainObject(params) ? params : {};
const hookResult = await hookRunner.runBeforeToolCall(
{
toolName,
params: normalizedParams,
},
{
toolName,
agentId: args.ctx?.agentId,
sessionKey: args.ctx?.sessionKey,
},
);
if (hookResult?.block) {
return {
blocked: true,
reason: hookResult.blockReason || "Tool call blocked by plugin hook",
};
}
if (hookResult?.params && isPlainObject(hookResult.params)) {
if (isPlainObject(params)) {
return { blocked: false, params: { ...params, ...hookResult.params } };
}
return { blocked: false, params: hookResult.params };
}
} catch (err) {
const toolCallId = args.toolCallId ? ` toolCallId=${args.toolCallId}` : "";
log.warn(`before_tool_call hook failed: tool=${toolName}${toolCallId} error=${String(err)}`);
}
return { blocked: false, params };
}
export function wrapToolWithBeforeToolCallHook(
tool: AnyAgentTool,
ctx?: HookContext,
): AnyAgentTool {
const execute = tool.execute;
if (!execute) {
return tool;
}
const toolName = tool.name || "tool";
const wrappedTool: AnyAgentTool = {
...tool,
execute: async (toolCallId, params, signal, onUpdate) => {
const outcome = await runBeforeToolCallHook({
toolName,
params,
toolCallId,
ctx,
});
if (outcome.blocked) {
throw new Error(outcome.reason);
}
if (toolCallId) {
adjustedParamsByToolCallId.set(toolCallId, outcome.params);
if (adjustedParamsByToolCallId.size > MAX_TRACKED_ADJUSTED_PARAMS) {
const oldest = adjustedParamsByToolCallId.keys().next().value;
if (oldest) {
adjustedParamsByToolCallId.delete(oldest);
}
}
}
const normalizedToolName = normalizeToolName(toolName || "tool");
try {
const result = await execute(toolCallId, outcome.params, signal, onUpdate);
await recordLoopOutcome({
ctx,
toolName: normalizedToolName,
toolParams: outcome.params,
toolCallId,
result,
});
return result;
} catch (err) {
await recordLoopOutcome({
ctx,
toolName: normalizedToolName,
toolParams: outcome.params,
toolCallId,
error: err,
});
throw err;
}
},
};
Object.defineProperty(wrappedTool, BEFORE_TOOL_CALL_WRAPPED, {
value: true,
enumerable: false,
});
return wrappedTool;
}
export function isToolWrappedWithBeforeToolCallHook(tool: AnyAgentTool): boolean {
const taggedTool = tool as unknown as Record<symbol, unknown>;
return taggedTool[BEFORE_TOOL_CALL_WRAPPED] === true;
}
export function consumeAdjustedParamsForToolCall(toolCallId: string): unknown {
const params = adjustedParamsByToolCallId.get(toolCallId);
adjustedParamsByToolCallId.delete(toolCallId);
return params;
}
export const __testing = {
BEFORE_TOOL_CALL_WRAPPED,
adjustedParamsByToolCallId,
runBeforeToolCallHook,
isPlainObject,
};