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).
|
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
|
||||||
|
|
||||||
### Fixes
|
### 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.
|
- Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj.
|
||||||
- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.
|
- 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.
|
- 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:
|
Block streaming:
|
||||||
- `agents.defaults.blockStreamingDefault`: `"on"`/`"off"` (default off).
|
- `agents.defaults.blockStreamingDefault`: `"on"`/`"off"` (default off).
|
||||||
- Channel overrides: `*.blockStreaming` (and per-account variants) to force block streaming on/off.
|
- Channel overrides: `*.blockStreaming` (and per-account variants) to force block streaming on/off.
|
||||||
|
|||||||
@ -34,6 +34,7 @@ import {
|
|||||||
validateGeminiTurns,
|
validateGeminiTurns,
|
||||||
} from "../../pi-embedded-helpers.js";
|
} from "../../pi-embedded-helpers.js";
|
||||||
import { subscribeEmbeddedPiSession } from "../../pi-embedded-subscribe.js";
|
import { subscribeEmbeddedPiSession } from "../../pi-embedded-subscribe.js";
|
||||||
|
import { resolveToolGuardrails } from "../../pi-embedded-tool-guardrails.js";
|
||||||
import {
|
import {
|
||||||
ensurePiCompactionReserveTokens,
|
ensurePiCompactionReserveTokens,
|
||||||
resolveCompactionReserveTokensFloor,
|
resolveCompactionReserveTokensFloor,
|
||||||
@ -590,6 +591,8 @@ export async function runEmbeddedAttempt(
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toolGuardrails = resolveToolGuardrails(params.config);
|
||||||
|
|
||||||
const subscription = subscribeEmbeddedPiSession({
|
const subscription = subscribeEmbeddedPiSession({
|
||||||
session: activeSession,
|
session: activeSession,
|
||||||
runId: params.runId,
|
runId: params.runId,
|
||||||
@ -608,6 +611,16 @@ export async function runEmbeddedAttempt(
|
|||||||
onAssistantMessageStart: params.onAssistantMessageStart,
|
onAssistantMessageStart: params.onAssistantMessageStart,
|
||||||
onAgentEvent: params.onAgentEvent,
|
onAgentEvent: params.onAgentEvent,
|
||||||
enforceFinalTag: params.enforceFinalTag,
|
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 {
|
const {
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
isToolResultError,
|
isToolResultError,
|
||||||
sanitizeToolResult,
|
sanitizeToolResult,
|
||||||
} from "./pi-embedded-subscribe.tools.js";
|
} from "./pi-embedded-subscribe.tools.js";
|
||||||
|
import { checkConsecutiveToolError } from "./pi-embedded-tool-guardrails.js";
|
||||||
import { inferToolMetaFromArgs } from "./pi-embedded-utils.js";
|
import { inferToolMetaFromArgs } from "./pi-embedded-utils.js";
|
||||||
import { normalizeToolName } from "./tool-policy.js";
|
import { normalizeToolName } from "./tool-policy.js";
|
||||||
|
|
||||||
@ -155,6 +156,10 @@ export function handleToolExecutionEnd(
|
|||||||
ctx.state.toolMetas.push({ toolName, meta });
|
ctx.state.toolMetas.push({ toolName, meta });
|
||||||
ctx.state.toolMetaById.delete(toolCallId);
|
ctx.state.toolMetaById.delete(toolCallId);
|
||||||
ctx.state.toolSummaryById.delete(toolCallId);
|
ctx.state.toolSummaryById.delete(toolCallId);
|
||||||
|
|
||||||
|
// Increment tool call count for guardrail budget tracking
|
||||||
|
ctx.state.toolCallCount += 1;
|
||||||
|
|
||||||
if (isToolError) {
|
if (isToolError) {
|
||||||
const errorMessage = extractToolErrorMessage(sanitizedResult);
|
const errorMessage = extractToolErrorMessage(sanitizedResult);
|
||||||
ctx.state.lastToolError = {
|
ctx.state.lastToolError = {
|
||||||
@ -162,6 +167,44 @@ export function handleToolExecutionEnd(
|
|||||||
meta,
|
meta,
|
||||||
error: errorMessage,
|
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.
|
// 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 { InlineCodeState } from "../markdown/code-spans.js";
|
||||||
import type { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js";
|
import type { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js";
|
||||||
import type { MessagingToolSend } from "./pi-embedded-messaging.js";
|
import type { MessagingToolSend } from "./pi-embedded-messaging.js";
|
||||||
|
import type { ConsecutiveToolError } from "./pi-embedded-tool-guardrails.js";
|
||||||
import type {
|
import type {
|
||||||
BlockReplyChunking,
|
BlockReplyChunking,
|
||||||
SubscribeEmbeddedPiSessionParams,
|
SubscribeEmbeddedPiSessionParams,
|
||||||
@ -28,6 +29,11 @@ export type EmbeddedPiSubscribeState = {
|
|||||||
toolSummaryById: Set<string>;
|
toolSummaryById: Set<string>;
|
||||||
lastToolError?: ToolErrorSummary;
|
lastToolError?: ToolErrorSummary;
|
||||||
|
|
||||||
|
// Tool guardrail state
|
||||||
|
consecutiveToolError?: ConsecutiveToolError;
|
||||||
|
toolCallCount: number;
|
||||||
|
toolCallBudgetExceeded: boolean;
|
||||||
|
|
||||||
blockReplyBreak: "text_end" | "message_end";
|
blockReplyBreak: "text_end" | "message_end";
|
||||||
reasoningMode: ReasoningLevel;
|
reasoningMode: ReasoningLevel;
|
||||||
includeReasoning: boolean;
|
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(),
|
toolMetaById: new Map(),
|
||||||
toolSummaryById: new Set(),
|
toolSummaryById: new Set(),
|
||||||
lastToolError: undefined,
|
lastToolError: undefined,
|
||||||
|
consecutiveToolError: undefined,
|
||||||
|
toolCallCount: 0,
|
||||||
|
toolCallBudgetExceeded: false,
|
||||||
blockReplyBreak: params.blockReplyBreak ?? "text_end",
|
blockReplyBreak: params.blockReplyBreak ?? "text_end",
|
||||||
reasoningMode,
|
reasoningMode,
|
||||||
includeReasoning: reasoningMode === "on",
|
includeReasoning: reasoningMode === "on",
|
||||||
@ -96,6 +99,9 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
|||||||
state.lastAssistantTextNormalized = undefined;
|
state.lastAssistantTextNormalized = undefined;
|
||||||
state.lastAssistantTextTrimmed = undefined;
|
state.lastAssistantTextTrimmed = undefined;
|
||||||
state.assistantTextBaseline = nextAssistantTextBaseline;
|
state.assistantTextBaseline = nextAssistantTextBaseline;
|
||||||
|
// Tool call budget is per assistant message (turn).
|
||||||
|
state.toolCallCount = 0;
|
||||||
|
state.toolCallBudgetExceeded = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const rememberAssistantText = (text: string) => {
|
const rememberAssistantText = (text: string) => {
|
||||||
@ -432,6 +438,9 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
|||||||
toolMetaById.clear();
|
toolMetaById.clear();
|
||||||
toolSummaryById.clear();
|
toolSummaryById.clear();
|
||||||
state.lastToolError = undefined;
|
state.lastToolError = undefined;
|
||||||
|
state.consecutiveToolError = undefined;
|
||||||
|
state.toolCallCount = 0;
|
||||||
|
state.toolCallBudgetExceeded = false;
|
||||||
messagingToolSentTexts.length = 0;
|
messagingToolSentTexts.length = 0;
|
||||||
messagingToolSentTextsNormalized.length = 0;
|
messagingToolSentTextsNormalized.length = 0;
|
||||||
messagingToolSentTargets.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 { ReasoningLevel, VerboseLevel } from "../auto-reply/thinking.js";
|
||||||
import type { BlockReplyChunking } from "./pi-embedded-block-chunker.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";
|
export type ToolResultFormat = "markdown" | "plain";
|
||||||
|
|
||||||
@ -31,6 +32,10 @@ export type SubscribeEmbeddedPiSessionParams = {
|
|||||||
onAssistantMessageStart?: () => void | Promise<void>;
|
onAssistantMessageStart?: () => void | Promise<void>;
|
||||||
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void | Promise<void>;
|
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void | Promise<void>;
|
||||||
enforceFinalTag?: boolean;
|
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";
|
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.minMs": "Human Delay Min (ms)",
|
||||||
"agents.defaults.humanDelay.maxMs": "Human Delay Max (ms)",
|
"agents.defaults.humanDelay.maxMs": "Human Delay Max (ms)",
|
||||||
"agents.defaults.cliBackends": "CLI Backends",
|
"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.native": "Native Commands",
|
||||||
"commands.nativeSkills": "Native Skill Commands",
|
"commands.nativeSkills": "Native Skill Commands",
|
||||||
"commands.text": "Text 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.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.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.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":
|
"commands.native":
|
||||||
"Register native commands with channels that support it (Discord/Slack/Telegram).",
|
"Register native commands with channels that support it (Discord/Slack/Telegram).",
|
||||||
"commands.nativeSkills":
|
"commands.nativeSkills":
|
||||||
|
|||||||
@ -235,6 +235,34 @@ export type AgentDefaultsConfig = {
|
|||||||
/** Auto-prune sandbox containers. */
|
/** Auto-prune sandbox containers. */
|
||||||
prune?: SandboxPruneSettings;
|
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";
|
export type AgentCompactionMode = "default" | "safeguard";
|
||||||
|
|||||||
@ -166,6 +166,27 @@ export const AgentDefaultsSchema = z
|
|||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.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()
|
.strict()
|
||||||
.optional();
|
.optional();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user