From 5fd04c58a1b45eec56851f43bae33edf08339b37 Mon Sep 17 00:00:00 2001 From: Shrey Gupta Date: Mon, 26 Jan 2026 17:46:28 -0500 Subject: [PATCH] Agents: add tool guardrails --- CHANGELOG.md | 1 + docs/gateway/configuration.md | 34 +++ src/agents/pi-embedded-runner/run/attempt.ts | 13 + .../pi-embedded-subscribe.handlers.tools.ts | 43 ++++ .../pi-embedded-subscribe.handlers.types.ts | 6 + ...embedded-subscribe.tool-guardrails.test.ts | 103 ++++++++ src/agents/pi-embedded-subscribe.ts | 9 + src/agents/pi-embedded-subscribe.types.ts | 5 + .../pi-embedded-tool-guardrails.test.ts | 223 ++++++++++++++++++ src/agents/pi-embedded-tool-guardrails.ts | 109 +++++++++ src/config/schema.ts | 21 ++ src/config/types.agent-defaults.ts | 28 +++ src/config/zod-schema.agent-defaults.ts | 21 ++ 13 files changed, 616 insertions(+) create mode 100644 src/agents/pi-embedded-subscribe.tool-guardrails.test.ts create mode 100644 src/agents/pi-embedded-tool-guardrails.test.ts create mode 100644 src/agents/pi-embedded-tool-guardrails.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f1330931..6713e02a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Status: unreleased. - **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). ### Fixes +- Agents: add tool guardrails to stop consecutive tool-error loops and per-turn tool call storms. (#806) - Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj. - Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos. - Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index eaba866b1..ede9154ed 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1860,6 +1860,40 @@ Example (tuned): } ``` +#### `agents.defaults.toolGuardrails` (tool call guardrails) + +Tool guardrails prevent runaway tool-call loops by capping: +- consecutive identical tool errors (same tool + normalized error message) +- total tool calls per assistant turn (one assistant message) + +Defaults: +- `maxConsecutiveToolErrors`: `3` +- `maxToolCallsPerTurn`: `50` +- `toolErrorAction`: `"abort"` + +Actions: +- `abort`: stop the run +- `warn`: log a warning and continue +- `escalate`: currently behaves like `warn` (reserved for future escalation hooks) + +Example: +```json5 +{ + agents: { + defaults: { + toolGuardrails: { + maxConsecutiveToolErrors: 3, + maxToolCallsPerTurn: 50, + toolErrorAction: "abort" + } + } + } +} +``` + +Note: The top-level keys `agents.defaults.maxConsecutiveToolErrors`, `agents.defaults.maxToolCallsPerTurn`, +and `agents.defaults.toolErrorAction` are accepted for compatibility, but `toolGuardrails` takes precedence. + Block streaming: - `agents.defaults.blockStreamingDefault`: `"on"`/`"off"` (default off). - Channel overrides: `*.blockStreaming` (and per-account variants) to force block streaming on/off. diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index f1c487470..9a1b79ed0 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -34,6 +34,7 @@ import { validateGeminiTurns, } from "../../pi-embedded-helpers.js"; import { subscribeEmbeddedPiSession } from "../../pi-embedded-subscribe.js"; +import { resolveToolGuardrails } from "../../pi-embedded-tool-guardrails.js"; import { ensurePiCompactionReserveTokens, resolveCompactionReserveTokensFloor, @@ -590,6 +591,8 @@ export async function runEmbeddedAttempt( }); }; + const toolGuardrails = resolveToolGuardrails(params.config); + const subscription = subscribeEmbeddedPiSession({ session: activeSession, runId: params.runId, @@ -608,6 +611,16 @@ export async function runEmbeddedAttempt( onAssistantMessageStart: params.onAssistantMessageStart, onAgentEvent: params.onAgentEvent, enforceFinalTag: params.enforceFinalTag, + toolGuardrails, + onToolGuardrailTriggered: (event) => { + log.warn( + `Tool guardrail triggered: type=${event.type} tool=${event.toolName ?? "N/A"} count=${event.count} limit=${event.limit} action=${event.action}`, + ); + if (event.action === "abort") { + abortRun(false, new Error(`Tool guardrail: ${event.type}`)); + } + // "warn" action just logs; "escalate" could notify owner (future enhancement) + }, }); const { diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.ts b/src/agents/pi-embedded-subscribe.handlers.tools.ts index 129042273..a2de99ce1 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.ts @@ -11,6 +11,7 @@ import { isToolResultError, sanitizeToolResult, } from "./pi-embedded-subscribe.tools.js"; +import { checkConsecutiveToolError } from "./pi-embedded-tool-guardrails.js"; import { inferToolMetaFromArgs } from "./pi-embedded-utils.js"; import { normalizeToolName } from "./tool-policy.js"; @@ -155,6 +156,10 @@ export function handleToolExecutionEnd( ctx.state.toolMetas.push({ toolName, meta }); ctx.state.toolMetaById.delete(toolCallId); ctx.state.toolSummaryById.delete(toolCallId); + + // Increment tool call count for guardrail budget tracking + ctx.state.toolCallCount += 1; + if (isToolError) { const errorMessage = extractToolErrorMessage(sanitizedResult); ctx.state.lastToolError = { @@ -162,6 +167,44 @@ export function handleToolExecutionEnd( meta, error: errorMessage, }; + + // Track consecutive identical tool errors + ctx.state.consecutiveToolError = checkConsecutiveToolError( + { toolName, error: errorMessage }, + ctx.state.consecutiveToolError, + ); + + // Check if consecutive error limit exceeded + const guardrails = ctx.params.toolGuardrails; + if (guardrails && ctx.state.consecutiveToolError.count >= guardrails.maxConsecutiveToolErrors) { + ctx.params.onToolGuardrailTriggered?.({ + type: "consecutive_error_limit", + toolName, + errorMessage: ctx.state.consecutiveToolError.errorMessage, + count: ctx.state.consecutiveToolError.count, + limit: guardrails.maxConsecutiveToolErrors, + action: guardrails.toolErrorAction, + }); + } + } else { + // Reset consecutive error counter on successful tool call + ctx.state.consecutiveToolError = undefined; + } + + // Check tool call budget + const guardrails = ctx.params.toolGuardrails; + if ( + guardrails && + ctx.state.toolCallCount >= guardrails.maxToolCallsPerTurn && + !ctx.state.toolCallBudgetExceeded + ) { + ctx.state.toolCallBudgetExceeded = true; + ctx.params.onToolGuardrailTriggered?.({ + type: "tool_call_budget_exceeded", + count: ctx.state.toolCallCount, + limit: guardrails.maxToolCallsPerTurn, + action: guardrails.toolErrorAction, + }); } // Commit messaging tool text on success, discard on error. diff --git a/src/agents/pi-embedded-subscribe.handlers.types.ts b/src/agents/pi-embedded-subscribe.handlers.types.ts index 4a464c5e2..6dab8842a 100644 --- a/src/agents/pi-embedded-subscribe.handlers.types.ts +++ b/src/agents/pi-embedded-subscribe.handlers.types.ts @@ -5,6 +5,7 @@ import type { ReplyDirectiveParseResult } from "../auto-reply/reply/reply-direct import type { InlineCodeState } from "../markdown/code-spans.js"; import type { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js"; import type { MessagingToolSend } from "./pi-embedded-messaging.js"; +import type { ConsecutiveToolError } from "./pi-embedded-tool-guardrails.js"; import type { BlockReplyChunking, SubscribeEmbeddedPiSessionParams, @@ -28,6 +29,11 @@ export type EmbeddedPiSubscribeState = { toolSummaryById: Set; lastToolError?: ToolErrorSummary; + // Tool guardrail state + consecutiveToolError?: ConsecutiveToolError; + toolCallCount: number; + toolCallBudgetExceeded: boolean; + blockReplyBreak: "text_end" | "message_end"; reasoningMode: ReasoningLevel; includeReasoning: boolean; diff --git a/src/agents/pi-embedded-subscribe.tool-guardrails.test.ts b/src/agents/pi-embedded-subscribe.tool-guardrails.test.ts new file mode 100644 index 000000000..0ff2f1243 --- /dev/null +++ b/src/agents/pi-embedded-subscribe.tool-guardrails.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from "vitest"; + +import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; + +type StubSession = { + subscribe: (fn: (evt: unknown) => void) => () => void; +}; + +describe("subscribeEmbeddedPiSession tool guardrails", () => { + it("resets tool call budget per assistant message", () => { + let handler: ((evt: unknown) => void) | undefined; + const session: StubSession = { + subscribe: (fn) => { + handler = fn; + return () => {}; + }, + }; + + const onToolGuardrailTriggered = vi.fn(); + + subscribeEmbeddedPiSession({ + session: session as unknown as Parameters[0]["session"], + runId: "run-tool-budget", + toolGuardrails: { + maxConsecutiveToolErrors: 3, + maxToolCallsPerTurn: 2, + toolErrorAction: "abort", + }, + onToolGuardrailTriggered, + }); + + handler?.({ type: "message_start", message: { role: "assistant" } }); + handler?.({ + type: "tool_execution_end", + toolName: "read", + toolCallId: "tool-1", + isError: false, + result: "ok", + }); + handler?.({ + type: "tool_execution_end", + toolName: "read", + toolCallId: "tool-2", + isError: false, + result: "ok", + }); + + expect(onToolGuardrailTriggered).toHaveBeenCalledTimes(1); + expect(onToolGuardrailTriggered.mock.calls[0]?.[0]?.type).toBe("tool_call_budget_exceeded"); + + handler?.({ type: "message_start", message: { role: "assistant" } }); + handler?.({ + type: "tool_execution_end", + toolName: "read", + toolCallId: "tool-3", + isError: false, + result: "ok", + }); + + expect(onToolGuardrailTriggered).toHaveBeenCalledTimes(1); + }); + + it("emits consecutive error guardrail events", () => { + let handler: ((evt: unknown) => void) | undefined; + const session: StubSession = { + subscribe: (fn) => { + handler = fn; + return () => {}; + }, + }; + + const onToolGuardrailTriggered = vi.fn(); + + subscribeEmbeddedPiSession({ + session: session as unknown as Parameters[0]["session"], + runId: "run-tool-errors", + toolGuardrails: { + maxConsecutiveToolErrors: 2, + maxToolCallsPerTurn: 50, + toolErrorAction: "warn", + }, + onToolGuardrailTriggered, + }); + + handler?.({ + type: "tool_execution_end", + toolName: "bash", + toolCallId: "tool-err-1", + isError: true, + result: { error: "Boom" }, + }); + handler?.({ + type: "tool_execution_end", + toolName: "bash", + toolCallId: "tool-err-2", + isError: true, + result: { error: "Boom" }, + }); + + expect(onToolGuardrailTriggered).toHaveBeenCalledTimes(1); + expect(onToolGuardrailTriggered.mock.calls[0]?.[0]?.type).toBe("consecutive_error_limit"); + }); +}); diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index a4a4b906a..b667cb54e 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -37,6 +37,9 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar toolMetaById: new Map(), toolSummaryById: new Set(), lastToolError: undefined, + consecutiveToolError: undefined, + toolCallCount: 0, + toolCallBudgetExceeded: false, blockReplyBreak: params.blockReplyBreak ?? "text_end", reasoningMode, includeReasoning: reasoningMode === "on", @@ -96,6 +99,9 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar state.lastAssistantTextNormalized = undefined; state.lastAssistantTextTrimmed = undefined; state.assistantTextBaseline = nextAssistantTextBaseline; + // Tool call budget is per assistant message (turn). + state.toolCallCount = 0; + state.toolCallBudgetExceeded = false; }; const rememberAssistantText = (text: string) => { @@ -432,6 +438,9 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar toolMetaById.clear(); toolSummaryById.clear(); state.lastToolError = undefined; + state.consecutiveToolError = undefined; + state.toolCallCount = 0; + state.toolCallBudgetExceeded = false; messagingToolSentTexts.length = 0; messagingToolSentTextsNormalized.length = 0; messagingToolSentTargets.length = 0; diff --git a/src/agents/pi-embedded-subscribe.types.ts b/src/agents/pi-embedded-subscribe.types.ts index 766ff7f18..d9cc45e16 100644 --- a/src/agents/pi-embedded-subscribe.types.ts +++ b/src/agents/pi-embedded-subscribe.types.ts @@ -2,6 +2,7 @@ import type { AgentSession } from "@mariozechner/pi-coding-agent"; import type { ReasoningLevel, VerboseLevel } from "../auto-reply/thinking.js"; import type { BlockReplyChunking } from "./pi-embedded-block-chunker.js"; +import type { ToolGuardrailsResolved, ToolGuardrailEvent } from "./pi-embedded-tool-guardrails.js"; export type ToolResultFormat = "markdown" | "plain"; @@ -31,6 +32,10 @@ export type SubscribeEmbeddedPiSessionParams = { onAssistantMessageStart?: () => void | Promise; onAgentEvent?: (evt: { stream: string; data: Record }) => void | Promise; enforceFinalTag?: boolean; + /** Tool guardrails configuration (resolved). */ + toolGuardrails?: ToolGuardrailsResolved; + /** Callback when a tool guardrail is triggered. */ + onToolGuardrailTriggered?: (event: ToolGuardrailEvent) => void; }; export type { BlockReplyChunking } from "./pi-embedded-block-chunker.js"; diff --git a/src/agents/pi-embedded-tool-guardrails.test.ts b/src/agents/pi-embedded-tool-guardrails.test.ts new file mode 100644 index 000000000..a75b91768 --- /dev/null +++ b/src/agents/pi-embedded-tool-guardrails.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from "vitest"; +import { + checkConsecutiveToolError, + normalizeErrorKey, + resolveToolGuardrails, +} from "./pi-embedded-tool-guardrails.js"; +import type { ClawdbotConfig } from "../config/config.js"; + +describe("pi-embedded-tool-guardrails", () => { + describe("resolveToolGuardrails", () => { + it("returns defaults when no config provided", () => { + const result = resolveToolGuardrails(undefined); + expect(result.maxConsecutiveToolErrors).toBe(3); + expect(result.maxToolCallsPerTurn).toBe(50); + expect(result.toolErrorAction).toBe("abort"); + }); + + it("returns defaults when config has no toolGuardrails", () => { + const cfg = { agents: { defaults: {} } } as ClawdbotConfig; + const result = resolveToolGuardrails(cfg); + expect(result.maxConsecutiveToolErrors).toBe(3); + expect(result.maxToolCallsPerTurn).toBe(50); + expect(result.toolErrorAction).toBe("abort"); + }); + + it("uses top-level guardrails when toolGuardrails is not set", () => { + const cfg = { + agents: { + defaults: { + maxConsecutiveToolErrors: 4, + maxToolCallsPerTurn: 12, + toolErrorAction: "escalate" as const, + }, + }, + } as ClawdbotConfig; + const result = resolveToolGuardrails(cfg); + expect(result.maxConsecutiveToolErrors).toBe(4); + expect(result.maxToolCallsPerTurn).toBe(12); + expect(result.toolErrorAction).toBe("escalate"); + }); + + it("uses configured values when provided", () => { + const cfg = { + agents: { + defaults: { + toolGuardrails: { + maxConsecutiveToolErrors: 5, + maxToolCallsPerTurn: 20, + toolErrorAction: "warn" as const, + }, + }, + }, + } as ClawdbotConfig; + const result = resolveToolGuardrails(cfg); + expect(result.maxConsecutiveToolErrors).toBe(5); + expect(result.maxToolCallsPerTurn).toBe(20); + expect(result.toolErrorAction).toBe("warn"); + }); + + it("prefers toolGuardrails when both top-level and nested values are set", () => { + const cfg = { + agents: { + defaults: { + maxConsecutiveToolErrors: 10, + maxToolCallsPerTurn: 99, + toolErrorAction: "warn" as const, + toolGuardrails: { + maxConsecutiveToolErrors: 2, + maxToolCallsPerTurn: 7, + toolErrorAction: "abort" as const, + }, + }, + }, + } as ClawdbotConfig; + const result = resolveToolGuardrails(cfg); + expect(result.maxConsecutiveToolErrors).toBe(2); + expect(result.maxToolCallsPerTurn).toBe(7); + expect(result.toolErrorAction).toBe("abort"); + }); + + it("clamps maxConsecutiveToolErrors to valid range", () => { + // Below minimum + const cfgLow = { + agents: { defaults: { toolGuardrails: { maxConsecutiveToolErrors: 0 } } }, + } as ClawdbotConfig; + expect(resolveToolGuardrails(cfgLow).maxConsecutiveToolErrors).toBe(1); + + // Above maximum + const cfgHigh = { + agents: { defaults: { toolGuardrails: { maxConsecutiveToolErrors: 100 } } }, + } as ClawdbotConfig; + expect(resolveToolGuardrails(cfgHigh).maxConsecutiveToolErrors).toBe(25); + }); + + it("clamps maxToolCallsPerTurn to valid range", () => { + // Below minimum + const cfgLow = { + agents: { defaults: { toolGuardrails: { maxToolCallsPerTurn: 0 } } }, + } as ClawdbotConfig; + expect(resolveToolGuardrails(cfgLow).maxToolCallsPerTurn).toBe(1); + + // Above maximum + const cfgHigh = { + agents: { defaults: { toolGuardrails: { maxToolCallsPerTurn: 500 } } }, + } as ClawdbotConfig; + expect(resolveToolGuardrails(cfgHigh).maxToolCallsPerTurn).toBe(200); + }); + + it("falls back to default for invalid toolErrorAction", () => { + const cfg = { + agents: { defaults: { toolGuardrails: { toolErrorAction: "invalid" } } }, + } as unknown as ClawdbotConfig; + expect(resolveToolGuardrails(cfg).toolErrorAction).toBe("abort"); + }); + + it("handles non-finite numeric values", () => { + const cfg = { + agents: { + defaults: { + toolGuardrails: { + maxConsecutiveToolErrors: Number.NaN, + maxToolCallsPerTurn: Number.POSITIVE_INFINITY, + }, + }, + }, + } as ClawdbotConfig; + const result = resolveToolGuardrails(cfg); + expect(result.maxConsecutiveToolErrors).toBe(3); + expect(result.maxToolCallsPerTurn).toBe(50); + }); + + it("floors fractional values", () => { + const cfg = { + agents: { + defaults: { + toolGuardrails: { maxConsecutiveToolErrors: 5.9, maxToolCallsPerTurn: 30.1 }, + }, + }, + } as ClawdbotConfig; + const result = resolveToolGuardrails(cfg); + expect(result.maxConsecutiveToolErrors).toBe(5); + expect(result.maxToolCallsPerTurn).toBe(30); + }); + }); + + describe("normalizeErrorKey", () => { + it("returns empty string for undefined", () => { + expect(normalizeErrorKey(undefined)).toBe(""); + }); + + it("returns empty string for empty/whitespace string", () => { + expect(normalizeErrorKey("")).toBe(""); + expect(normalizeErrorKey(" ")).toBe(""); + }); + + it("trims whitespace and lowercases", () => { + expect(normalizeErrorKey(" Error MESSAGE ")).toBe("error message"); + }); + + it("truncates long error messages", () => { + const longError = "x".repeat(600); + const result = normalizeErrorKey(longError); + expect(result.length).toBe(500); + }); + + it("handles typical error messages", () => { + expect(normalizeErrorKey("to required")).toBe("to required"); + expect(normalizeErrorKey("Permission denied")).toBe("permission denied"); + }); + }); + + describe("checkConsecutiveToolError", () => { + it("returns count 1 when no previous error", () => { + const result = checkConsecutiveToolError( + { toolName: "exec", error: "permission denied" }, + undefined, + ); + expect(result.toolName).toBe("exec"); + expect(result.errorMessage).toBe("permission denied"); + expect(result.count).toBe(1); + }); + + it("increments count for same tool and error", () => { + const prev = { toolName: "exec", errorMessage: "permission denied", count: 2 }; + const result = checkConsecutiveToolError( + { toolName: "exec", error: "Permission denied" }, + prev, + ); + expect(result.count).toBe(3); + expect(result.toolName).toBe("exec"); + expect(result.errorMessage).toBe("permission denied"); + }); + + it("resets count for different tool name", () => { + const prev = { toolName: "exec", errorMessage: "permission denied", count: 5 }; + const result = checkConsecutiveToolError( + { toolName: "read", error: "permission denied" }, + prev, + ); + expect(result.count).toBe(1); + expect(result.toolName).toBe("read"); + }); + + it("resets count for different error message", () => { + const prev = { toolName: "exec", errorMessage: "permission denied", count: 5 }; + const result = checkConsecutiveToolError({ toolName: "exec", error: "file not found" }, prev); + expect(result.count).toBe(1); + expect(result.errorMessage).toBe("file not found"); + }); + + it("handles undefined error gracefully", () => { + const result = checkConsecutiveToolError({ toolName: "exec", error: undefined }, undefined); + expect(result.errorMessage).toBe(""); + expect(result.count).toBe(1); + }); + + it("matches error after normalization (case-insensitive)", () => { + const prev = { toolName: "message", errorMessage: "to required", count: 1 }; + const result = checkConsecutiveToolError({ toolName: "message", error: "TO REQUIRED" }, prev); + expect(result.count).toBe(2); + }); + }); +}); diff --git a/src/agents/pi-embedded-tool-guardrails.ts b/src/agents/pi-embedded-tool-guardrails.ts new file mode 100644 index 000000000..7f848d413 --- /dev/null +++ b/src/agents/pi-embedded-tool-guardrails.ts @@ -0,0 +1,109 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import type { ToolErrorAction } from "../config/types.agent-defaults.js"; + +const DEFAULT_MAX_CONSECUTIVE_TOOL_ERRORS = 3; +const DEFAULT_MAX_TOOL_CALLS_PER_TURN = 50; +const DEFAULT_TOOL_ERROR_ACTION: ToolErrorAction = "abort"; + +const MAX_CONSECUTIVE_TOOL_ERRORS_LIMIT = 25; +const MAX_TOOL_CALLS_PER_TURN_LIMIT = 200; + +/** Error key truncation limit for normalization. */ +const ERROR_KEY_MAX_CHARS = 500; + +export type ToolGuardrailsResolved = { + maxConsecutiveToolErrors: number; + maxToolCallsPerTurn: number; + toolErrorAction: ToolErrorAction; +}; + +export type ConsecutiveToolError = { + toolName: string; + errorMessage: string; + count: number; +}; + +export type ToolGuardrailEvent = { + type: "consecutive_error_limit" | "tool_call_budget_exceeded"; + toolName?: string; + errorMessage?: string; + count: number; + limit: number; + action: ToolErrorAction; +}; + +/** + * Resolve tool guardrails configuration from agent defaults. + * Follows the `resolvePingPongTurns` pattern from sessions-send-helpers.ts. + */ +export function resolveToolGuardrails(cfg?: ClawdbotConfig): ToolGuardrailsResolved { + const defaults = cfg?.agents?.defaults; + const guardrails = defaults?.toolGuardrails; + + const rawConsecutive = guardrails?.maxConsecutiveToolErrors ?? defaults?.maxConsecutiveToolErrors; + const maxConsecutiveToolErrors = + typeof rawConsecutive === "number" && Number.isFinite(rawConsecutive) + ? Math.max(1, Math.min(MAX_CONSECUTIVE_TOOL_ERRORS_LIMIT, Math.floor(rawConsecutive))) + : DEFAULT_MAX_CONSECUTIVE_TOOL_ERRORS; + + const rawBudget = guardrails?.maxToolCallsPerTurn ?? defaults?.maxToolCallsPerTurn; + const maxToolCallsPerTurn = + typeof rawBudget === "number" && Number.isFinite(rawBudget) + ? Math.max(1, Math.min(MAX_TOOL_CALLS_PER_TURN_LIMIT, Math.floor(rawBudget))) + : DEFAULT_MAX_TOOL_CALLS_PER_TURN; + + const rawAction = guardrails?.toolErrorAction ?? defaults?.toolErrorAction; + const toolErrorAction: ToolErrorAction = + rawAction === "warn" || rawAction === "escalate" || rawAction === "abort" + ? rawAction + : DEFAULT_TOOL_ERROR_ACTION; + + return { maxConsecutiveToolErrors, maxToolCallsPerTurn, toolErrorAction }; +} + +/** + * Normalize an error message for comparison. + * - Trims whitespace + * - Converts to lowercase + * - Truncates to ERROR_KEY_MAX_CHARS + */ +export function normalizeErrorKey(error: string | undefined): string { + if (!error) return ""; + const trimmed = error.trim(); + if (trimmed.length === 0) return ""; + const truncated = + trimmed.length > ERROR_KEY_MAX_CHARS ? trimmed.slice(0, ERROR_KEY_MAX_CHARS) : trimmed; + return truncated.toLowerCase(); +} + +/** + * Check if the current tool error is consecutive with the previous one. + * Returns the updated consecutive error state. + * + * - If toolName and normalized error match previous, increment count + * - Otherwise, reset to count 1 + */ +export function checkConsecutiveToolError( + currentError: { toolName: string; error?: string }, + previousError: ConsecutiveToolError | undefined, +): ConsecutiveToolError { + const normalizedError = normalizeErrorKey(currentError.error); + + if ( + previousError && + previousError.toolName === currentError.toolName && + previousError.errorMessage === normalizedError + ) { + return { + toolName: currentError.toolName, + errorMessage: normalizedError, + count: previousError.count + 1, + }; + } + + return { + toolName: currentError.toolName, + errorMessage: normalizedError, + count: 1, + }; +} diff --git a/src/config/schema.ts b/src/config/schema.ts index 9627d64f3..571409440 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -267,6 +267,13 @@ const FIELD_LABELS: Record = { "agents.defaults.humanDelay.minMs": "Human Delay Min (ms)", "agents.defaults.humanDelay.maxMs": "Human Delay Max (ms)", "agents.defaults.cliBackends": "CLI Backends", + "agents.defaults.toolGuardrails": "Tool Guardrails", + "agents.defaults.toolGuardrails.maxConsecutiveToolErrors": "Max Consecutive Tool Errors", + "agents.defaults.toolGuardrails.maxToolCallsPerTurn": "Max Tool Calls Per Turn", + "agents.defaults.toolGuardrails.toolErrorAction": "Tool Guardrail Action", + "agents.defaults.maxConsecutiveToolErrors": "Max Consecutive Tool Errors (Deprecated)", + "agents.defaults.maxToolCallsPerTurn": "Max Tool Calls Per Turn (Deprecated)", + "agents.defaults.toolErrorAction": "Tool Guardrail Action (Deprecated)", "commands.native": "Native Commands", "commands.nativeSkills": "Native Skill Commands", "commands.text": "Text Commands", @@ -576,6 +583,20 @@ const FIELD_HELP: Record = { "agents.defaults.humanDelay.mode": 'Delay style for block replies ("off", "natural", "custom").', "agents.defaults.humanDelay.minMs": "Minimum delay in ms for custom humanDelay (default: 800).", "agents.defaults.humanDelay.maxMs": "Maximum delay in ms for custom humanDelay (default: 2500).", + "agents.defaults.toolGuardrails": + "Guardrails to prevent tool-call loops (identical errors or too many tool calls per assistant turn).", + "agents.defaults.toolGuardrails.maxConsecutiveToolErrors": + "Max consecutive identical tool errors before action (default: 3).", + "agents.defaults.toolGuardrails.maxToolCallsPerTurn": + "Max tool calls per assistant turn before action (default: 50).", + "agents.defaults.toolGuardrails.toolErrorAction": + 'Action when guardrails are reached ("abort", "warn", or "escalate"; default: "abort").', + "agents.defaults.maxConsecutiveToolErrors": + "Deprecated. Use agents.defaults.toolGuardrails.maxConsecutiveToolErrors instead.", + "agents.defaults.maxToolCallsPerTurn": + "Deprecated. Use agents.defaults.toolGuardrails.maxToolCallsPerTurn instead.", + "agents.defaults.toolErrorAction": + "Deprecated. Use agents.defaults.toolGuardrails.toolErrorAction instead.", "commands.native": "Register native commands with channels that support it (Discord/Slack/Telegram).", "commands.nativeSkills": diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 2a42d3623..1f0c6987f 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -235,6 +235,34 @@ export type AgentDefaultsConfig = { /** Auto-prune sandbox containers. */ prune?: SandboxPruneSettings; }; + /** + * @deprecated Use toolGuardrails.maxConsecutiveToolErrors instead. + * Max consecutive identical tool errors before action (default: 3). + */ + maxConsecutiveToolErrors?: number; + /** + * @deprecated Use toolGuardrails.maxToolCallsPerTurn instead. + * Max tool calls per agent turn before action (default: 50). + */ + maxToolCallsPerTurn?: number; + /** + * @deprecated Use toolGuardrails.toolErrorAction instead. + * Action when limits are reached (default: "abort"). + */ + toolErrorAction?: ToolErrorAction; + /** Tool call guardrails to prevent infinite loops from unreliable models. */ + toolGuardrails?: ToolGuardrailsConfig; +}; + +export type ToolErrorAction = "abort" | "warn" | "escalate"; + +export type ToolGuardrailsConfig = { + /** Max consecutive identical tool errors before action (default: 3). */ + maxConsecutiveToolErrors?: number; + /** Max tool calls per agent turn before action (default: 50). */ + maxToolCallsPerTurn?: number; + /** Action when limits are reached (default: "abort"). */ + toolErrorAction?: ToolErrorAction; }; export type AgentCompactionMode = "default" | "safeguard"; diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index c4b8a8f2c..f3f77f7d2 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -166,6 +166,27 @@ export const AgentDefaultsSchema = z }) .strict() .optional(), + /** @deprecated Use toolGuardrails.maxConsecutiveToolErrors instead. */ + maxConsecutiveToolErrors: z.number().int().min(1).max(25).optional(), + /** @deprecated Use toolGuardrails.maxToolCallsPerTurn instead. */ + maxToolCallsPerTurn: z.number().int().min(1).max(200).optional(), + /** @deprecated Use toolGuardrails.toolErrorAction instead. */ + toolErrorAction: z + .union([z.literal("abort"), z.literal("warn"), z.literal("escalate")]) + .optional(), + toolGuardrails: z + .object({ + /** Max consecutive identical tool errors before action (default: 3). */ + maxConsecutiveToolErrors: z.number().int().min(1).max(25).optional(), + /** Max tool calls per agent turn before action (default: 50). */ + maxToolCallsPerTurn: z.number().int().min(1).max(200).optional(), + /** Action when limits are reached (default: "abort"). */ + toolErrorAction: z + .union([z.literal("abort"), z.literal("warn"), z.literal("escalate")]) + .optional(), + }) + .strict() + .optional(), }) .strict() .optional();