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)); 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. * Map a structured API error into a clear, user-facing message.
* The `error` object comes from the SDK's new SDKErrorMessage type. * 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 { createAgent, createSession, resumeSession, type Session, type SendMessage, type CanUseToolCallback } from '@letta-ai/letta-code-sdk';
import type { BotConfig, StreamMsg } from './types.js'; 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 { Store } from './store.js';
import { updateAgentName, recoverOrphanedConversationApproval, isRecoverableConversationId, recoverPendingApprovalsForAgent } from '../tools/letta-api.js'; import { updateAgentName, recoverOrphanedConversationApproval, isRecoverableConversationId, recoverPendingApprovalsForAgent } from '../tools/letta-api.js';
import { installSkillsToAgent, prependSkillDirsToPath } from '../skills/loader.js'; import { installSkillsToAgent, prependSkillDirsToPath } from '../skills/loader.js';
@@ -399,6 +399,16 @@ export class SessionManager {
} else { } else {
log.warn(`Proactive approval recovery did not find resolvable approvals: ${result.details}`); 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); return this._createSessionForKey(key, true, generation);
} }
} }
@@ -585,6 +595,17 @@ export class SessionManager {
const result = isRecoverableConversationId(convId) const result = isRecoverableConversationId(convId)
? await recoverOrphanedConversationApproval(this.store.agentId, convId) ? await recoverOrphanedConversationApproval(this.store.agentId, convId)
: await recoverPendingApprovalsForAgent(this.store.agentId); : 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) { if (result.recovered) {
log.info(`Recovery succeeded (${result.details}), retrying...`); log.info(`Recovery succeeded (${result.details}), retrying...`);
return this.runSession(message, { retried: true, canUseTool, convKey }); return this.runSession(message, { retried: true, canUseTool, convKey });

View File

@@ -895,8 +895,9 @@ export async function recoverOrphanedConversationApproval(
streaming: false, streaming: false,
}); });
} catch (batchError) { } 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); 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; continue;
} }