From 8cd48d9f544f6ddc8e5fa621a46ea5de52286f4c Mon Sep 17 00:00:00 2001 From: Gabriele Sarti Date: Fri, 6 Feb 2026 13:43:37 -0500 Subject: [PATCH] fix: abort agent when stuck in tool-call loop (#185) Add a configurable maxToolCalls safeguard (default: 100) that aborts the session when the agent enters an infinite tool-calling loop. The stream watchdog didn't catch this because the stream was active (sending tool_call events), just not productive. Configurable via lettabot.yaml: features: maxToolCalls: 100 Co-authored-by: Claude Opus 4.6 --- src/config/io.ts | 3 +++ src/config/types.ts | 1 + src/core/bot.ts | 9 +++++++++ src/core/types.ts | 7 +++++-- src/main.ts | 1 + 5 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/config/io.ts b/src/config/io.ts index 0047901..9c8eb3e 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -165,6 +165,9 @@ export function configToEnv(config: LettaBotConfig): Record { if (config.features?.heartbeat?.enabled) { env.HEARTBEAT_INTERVAL_MIN = String(config.features.heartbeat.intervalMin || 30); } + if (config.features?.maxToolCalls !== undefined) { + env.MAX_TOOL_CALLS = String(config.features.maxToolCalls); + } // Integrations - Google (Gmail polling) if (config.integrations?.google?.enabled && config.integrations.google.account) { diff --git a/src/config/types.ts b/src/config/types.ts index 7d4e12b..c00fc12 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -43,6 +43,7 @@ export interface LettaBotConfig { enabled: boolean; intervalMin?: number; }; + maxToolCalls?: number; // Abort if agent calls this many tools in one turn (default: 100) }; // Integrations (Google Workspace, etc.) diff --git a/src/core/bot.ts b/src/core/bot.ts index c36b209..7f914fb 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -485,6 +485,15 @@ export class LettaBot { await finalizeMessage(); } + // Detect tool-call loops: abort if agent calls too many tools without producing a result + const maxToolCalls = this.config.maxToolCalls ?? 100; + if (streamMsg.type === 'tool_call' && (msgTypeCounts['tool_call'] || 0) >= maxToolCalls) { + console.error(`[Bot] Agent stuck in tool loop (${msgTypeCounts['tool_call']} tool calls, limit=${maxToolCalls}), aborting`); + session.abort().catch(() => {}); + response = '(Agent got stuck in a tool loop and was stopped. Try sending your message again.)'; + break; + } + // Log meaningful events (always, not just on type change for tools) if (streamMsg.type === 'tool_call') { const toolName = (streamMsg as any).toolName || 'unknown'; diff --git a/src/core/types.ts b/src/core/types.ts index 2bc329c..c0733e8 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -121,10 +121,13 @@ export interface BotConfig { model?: string; // e.g., 'anthropic/claude-sonnet-4-5-20250929' agentName?: string; // Name for the agent (set via API after creation) allowedTools: string[]; - + // Skills skills?: SkillsConfig; - + + // Safety + maxToolCalls?: number; // Abort if agent calls this many tools in one turn (default: 100) + // Security allowedUsers?: string[]; // Empty = allow all } diff --git a/src/main.ts b/src/main.ts index 0723be7..d34f9cf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -329,6 +329,7 @@ async function main() { model: config.model, agentName: process.env.AGENT_NAME || 'LettaBot', allowedTools: config.allowedTools, + maxToolCalls: process.env.MAX_TOOL_CALLS ? Number(process.env.MAX_TOOL_CALLS) : undefined, skills: { cronEnabled: config.cronEnabled, googleEnabled: config.polling.gmail.enabled,