Agents: add tool guardrails

This commit is contained in:
Shrey Gupta 2026-01-26 17:46:28 -05:00
parent 20f6a5546f
commit 5fd04c58a1
13 changed files with 616 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string>;
lastToolError?: ToolErrorSummary;
// Tool guardrail state
consecutiveToolError?: ConsecutiveToolError;
toolCallCount: number;
toolCallBudgetExceeded: boolean;
blockReplyBreak: "text_end" | "message_end";
reasoningMode: ReasoningLevel;
includeReasoning: boolean;

View File

@ -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<typeof subscribeEmbeddedPiSession>[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<typeof subscribeEmbeddedPiSession>[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");
});
});

View File

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

View File

@ -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<void>;
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void | Promise<void>;
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";

View File

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

View File

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

View File

@ -267,6 +267,13 @@ const FIELD_LABELS: Record<string, string> = {
"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<string, string> = {
"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":

View File

@ -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";

View File

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