fix: handle possible case of desync on pending approvals (#53)

This commit is contained in:
Charles Packer
2025-11-02 22:12:12 -08:00
committed by GitHub
parent c18de86346
commit c5b9f50767
4 changed files with 146 additions and 29 deletions

View File

@@ -2,6 +2,7 @@
// Check for pending approvals and retrieve recent message history when resuming an agent
import type Letta from "@letta-ai/letta-client";
import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents";
import type { LettaMessageUnion } from "@letta-ai/letta-client/resources/agents/messages";
import type { ApprovalRequest } from "../cli/helpers/stream";
@@ -18,34 +19,98 @@ export interface ResumeData {
* Checks for pending approvals and retrieves recent message history for backfill.
*
* @param client - The Letta client
* @param agentId - The agent ID
* @param agent - The agent state (includes in-context messages)
* @returns Pending approval (if any) and recent message history
*/
export async function getResumeData(
client: Letta,
agentId: string,
agent: AgentState,
): Promise<ResumeData> {
try {
const messagesPage = await client.agents.messages.list(agentId);
const messagesPage = await client.agents.messages.list(agent.id);
const messages = messagesPage.items;
if (!messages || messages.length === 0) {
return { pendingApproval: null, messageHistory: [] };
}
// Check for pending approval (last message)
// Compare cursor last message with in-context last message ID
// The backend uses in-context messages for CONFLICT validation, so if they're
// desynced, we need to check the in-context message for pending approvals
const cursorLastMessage = messages[messages.length - 1];
if (!cursorLastMessage) {
return { pendingApproval: null, messageHistory: [] };
}
const inContextLastMessageId =
agent.message_ids && agent.message_ids.length > 0
? agent.message_ids[agent.message_ids.length - 1]
: null;
let messageToCheck = cursorLastMessage;
// If there's a desync, find the in-context message in the cursor fetch
if (
inContextLastMessageId &&
cursorLastMessage.id !== inContextLastMessageId
) {
console.warn(
`[check-approval] Desync detected - cursor last: ${cursorLastMessage.id}, in-context last: ${inContextLastMessageId}`,
);
// Search for the in-context message in the fetched messages
// NOTE: There might be multiple messages with the same ID (duplicates)
// We want the one with role === "approval" if it exists
const matchingMessages = messages.filter(
(msg) => msg.id === inContextLastMessageId,
);
if (matchingMessages.length > 0) {
// Prefer the approval request message if it exists (duplicates can have different types)
const approvalMessage = matchingMessages.find(
(msg) => msg.message_type === "approval_request_message",
);
const inContextMessage =
approvalMessage || matchingMessages[matchingMessages.length - 1]!;
messageToCheck = inContextMessage;
} else {
console.warn(
`[check-approval] In-context message ${inContextLastMessageId} not found in cursor fetch.\n` +
` This likely means the in-context message is older than the cursor window.\n` +
` Falling back to cursor message - approval state may be incorrect.`,
);
// Fall back to cursor message if we can't find the in-context one
}
}
// Check for pending approval using SDK types
let pendingApproval: ApprovalRequest | null = null;
const lastMessage = messages[messages.length - 1];
if (lastMessage?.message_type === "approval_request_message") {
if (messageToCheck.message_type === "approval_request_message") {
// Cast to access tool_calls with proper typing
const approvalMsg = messageToCheck as LettaMessageUnion & {
tool_calls?: Array<{
tool_call_id?: string;
name?: string;
arguments?: string;
}>;
tool_call?: {
tool_call_id?: string;
name?: string;
arguments?: string;
};
};
// Use tool_calls array (new) or fallback to tool_call (deprecated)
const toolCalls = Array.isArray(lastMessage.tool_calls)
? lastMessage.tool_calls
: lastMessage.tool_call
? [lastMessage.tool_call]
const toolCalls = Array.isArray(approvalMsg.tool_calls)
? approvalMsg.tool_calls
: approvalMsg.tool_call
? [approvalMsg.tool_call]
: [];
if (toolCalls.length > 0) {
const toolCall = toolCalls[0];
// Ensure all required fields are present (type guard for ToolCall vs ToolCallDelta)
// Ensure all required fields are present
if (toolCall?.tool_call_id && toolCall.name && toolCall.arguments) {
pendingApproval = {
toolCallId: toolCall.tool_call_id,
@@ -56,7 +121,7 @@ export async function getResumeData(
}
}
// Get last N messages for backfill
// Get last N messages for backfill (always use cursor messages for history)
const historyCount = Math.min(MESSAGE_HISTORY_LIMIT, messages.length);
let messageHistory = messages.slice(-historyCount);