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:
@@ -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.
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user