Agents: add tool guardrails
This commit is contained in:
parent
20f6a5546f
commit
5fd04c58a1
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
|
||||
103
src/agents/pi-embedded-subscribe.tool-guardrails.test.ts
Normal file
103
src/agents/pi-embedded-subscribe.tool-guardrails.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
@ -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";
|
||||
|
||||
223
src/agents/pi-embedded-tool-guardrails.test.ts
Normal file
223
src/agents/pi-embedded-tool-guardrails.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
109
src/agents/pi-embedded-tool-guardrails.ts
Normal file
109
src/agents/pi-embedded-tool-guardrails.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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":
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user