From e38f5a4db7d41eb2a5cb9e62912b07b4da7c0419 Mon Sep 17 00:00:00 2001 From: Jason Carreira Date: Wed, 11 Mar 2026 18:18:02 -0400 Subject: [PATCH] fix(session): auto-clear stuck conversation on invalid tool call ID mismatch (#555) Co-authored-by: Claude Sonnet 4.6 --- src/core/errors.ts | 10 ++++++++++ src/core/session-manager.ts | 23 ++++++++++++++++++++++- src/tools/letta-api.ts | 3 ++- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/core/errors.ts b/src/core/errors.ts index 1cfa88a..477a90a 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -59,6 +59,16 @@ export function isAgentMissingFromInitError(error: unknown): boolean { return agentMissingPatterns.some((pattern) => pattern.test(msg)); } +/** + * Detect if a recovery details string indicates mismatched tool call IDs. + * When this happens, the conversation is permanently stuck -- the pending + * approval can never be resolved because the server expects different IDs. + * The conversation must be cleared and recreated. + */ +export function isInvalidToolCallIdsError(details: string): boolean { + return details.toLowerCase().includes('invalid tool call id'); +} + /** * Map a structured API error into a clear, user-facing message. * The `error` object comes from the SDK's new SDKErrorMessage type. diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index 70a3792..cd038a6 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -8,7 +8,7 @@ import { createAgent, createSession, resumeSession, type Session, type SendMessage, type CanUseToolCallback } from '@letta-ai/letta-code-sdk'; import type { BotConfig, StreamMsg } from './types.js'; -import { isApprovalConflictError, isConversationMissingError, isAgentMissingFromInitError } from './errors.js'; +import { isApprovalConflictError, isConversationMissingError, isAgentMissingFromInitError, isInvalidToolCallIdsError } from './errors.js'; import { Store } from './store.js'; import { updateAgentName, recoverOrphanedConversationApproval, isRecoverableConversationId, recoverPendingApprovalsForAgent } from '../tools/letta-api.js'; import { installSkillsToAgent, prependSkillDirsToPath } from '../skills/loader.js'; @@ -399,6 +399,16 @@ export class SessionManager { } else { log.warn(`Proactive approval recovery did not find resolvable approvals: ${result.details}`); } + // Even on partial recovery, if any denial failed with mismatched IDs the + // conversation may still be stuck. Clear it so the retry creates a fresh one. + if (isInvalidToolCallIdsError(result.details)) { + log.warn(`Clearing stuck conversation (key=${key}) due to invalid tool call IDs mismatch`); + if (key !== 'shared') { + this.store.clearConversation(key); + } else { + this.store.conversationId = null; + } + } return this._createSessionForKey(key, true, generation); } } @@ -585,6 +595,17 @@ export class SessionManager { const result = isRecoverableConversationId(convId) ? await recoverOrphanedConversationApproval(this.store.agentId, convId) : await recoverPendingApprovalsForAgent(this.store.agentId); + // Even on partial recovery, if any denial failed with mismatched IDs the + // conversation may still be stuck. Clear it so the retry creates a fresh one. + if (isInvalidToolCallIdsError(result.details)) { + log.warn(`Clearing stuck conversation (key=${convKey}) due to invalid tool call IDs mismatch, retrying with fresh conversation`); + if (convKey !== 'shared') { + this.store.clearConversation(convKey); + } else { + this.store.conversationId = null; + } + return this.runSession(message, { retried: true, canUseTool, convKey }); + } if (result.recovered) { log.info(`Recovery succeeded (${result.details}), retrying...`); return this.runSession(message, { retried: true, canUseTool, convKey }); diff --git a/src/tools/letta-api.ts b/src/tools/letta-api.ts index 5e393cc..8a872af 100644 --- a/src/tools/letta-api.ts +++ b/src/tools/letta-api.ts @@ -895,8 +895,9 @@ export async function recoverOrphanedConversationApproval( streaming: false, }); } catch (batchError) { + const batchErrMsg = batchError instanceof Error ? batchError.message : String(batchError); log.warn(`Failed to submit approval denial batch for run ${runId} (${approvals.length} tool call(s)):`, batchError); - details.push(`Failed to deny ${approvals.length} approval(s) from run ${runId}`); + details.push(`Failed to deny ${approvals.length} approval(s) from run ${runId}: ${batchErrMsg}`); continue; }