fix(session): auto-clear stuck conversation on invalid tool call ID mismatch (#555)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Carreira
2026-03-11 18:18:02 -04:00
committed by GitHub
parent 535e5680c3
commit e38f5a4db7
3 changed files with 34 additions and 2 deletions

View File

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

View File

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

View File

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