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 <noreply@anthropic.com>
This commit is contained in:
Gabriele Sarti
2026-02-06 13:43:37 -05:00
committed by GitHub
parent 0d32e05906
commit 8cd48d9f54
5 changed files with 19 additions and 2 deletions

View File

@@ -165,6 +165,9 @@ export function configToEnv(config: LettaBotConfig): Record<string, string> {
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) {

View File

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

View File

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

View File

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

View File

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