fix: invalid tool call ID recovery and system-reminder tag centralization (#627)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -6,6 +6,11 @@ import { APPROVAL_RECOVERY_PROMPT } from "./promptAssets";
|
||||
const APPROVAL_RECOVERY_DETAIL_FRAGMENT =
|
||||
"no tool call is currently awaiting approval";
|
||||
|
||||
// Error when approval tool call IDs don't match what server expects
|
||||
// Format: "Invalid tool call IDs: Expected [...], got [...]"
|
||||
// This is a specific subtype of desync - server HAS approvals but with different IDs
|
||||
const INVALID_TOOL_CALL_IDS_FRAGMENT = "invalid tool call ids";
|
||||
|
||||
// Error when trying to SEND message but server has pending approval waiting
|
||||
// This is the CONFLICT error - opposite of desync
|
||||
const APPROVAL_PENDING_DETAIL_FRAGMENT = "cannot send a new message";
|
||||
@@ -27,7 +32,27 @@ type RunErrorMetadata =
|
||||
|
||||
export function isApprovalStateDesyncError(detail: unknown): boolean {
|
||||
if (typeof detail !== "string") return false;
|
||||
return detail.toLowerCase().includes(APPROVAL_RECOVERY_DETAIL_FRAGMENT);
|
||||
const lower = detail.toLowerCase();
|
||||
return (
|
||||
lower.includes(APPROVAL_RECOVERY_DETAIL_FRAGMENT) ||
|
||||
lower.includes(INVALID_TOOL_CALL_IDS_FRAGMENT)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error specifically indicates tool call ID mismatch.
|
||||
* This is a subtype of desync where the server HAS pending approvals,
|
||||
* but they have different IDs than what the client sent.
|
||||
*
|
||||
* Unlike "no tool call is currently awaiting approval" (server has nothing),
|
||||
* this error means we need to FETCH the actual pending approvals to resync.
|
||||
*
|
||||
* Error format:
|
||||
* { detail: "Invalid tool call IDs: Expected ['tc_abc'], got ['tc_xyz']" }
|
||||
*/
|
||||
export function isInvalidToolCallIdsError(detail: unknown): boolean {
|
||||
if (typeof detail !== "string") return false;
|
||||
return detail.toLowerCase().includes(INVALID_TOOL_CALL_IDS_FRAGMENT);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
addToolCall,
|
||||
updateSubagent,
|
||||
} from "../../cli/helpers/subagentState.js";
|
||||
import { INTERRUPTED_BY_USER } from "../../constants";
|
||||
import {
|
||||
INTERRUPTED_BY_USER,
|
||||
SYSTEM_REMINDER_CLOSE,
|
||||
SYSTEM_REMINDER_OPEN,
|
||||
} from "../../constants";
|
||||
import { cliPermissions } from "../../permissions/cli";
|
||||
import { permissionMode } from "../../permissions/mode";
|
||||
import { sessionPermissions } from "../../permissions/session";
|
||||
@@ -643,11 +647,11 @@ function buildDeploySystemReminder(
|
||||
? "read-only tools (Read, Glob, Grep)"
|
||||
: "local tools (Bash, Read, Write, Edit, etc.)";
|
||||
|
||||
return `<system-reminder>
|
||||
return `${SYSTEM_REMINDER_OPEN}
|
||||
This task is from "${senderAgentName}" (agent ID: ${senderAgentId}), which deployed you as a subagent inside the Letta Code CLI (docs.letta.com/letta-code).
|
||||
You have access to ${toolDescription} in their codebase.
|
||||
Your final message will be returned to the caller.
|
||||
</system-reminder>
|
||||
${SYSTEM_REMINDER_CLOSE}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
229
src/cli/App.tsx
229
src/cli/App.tsx
@@ -34,6 +34,7 @@ import {
|
||||
isApprovalPendingError,
|
||||
isApprovalStateDesyncError,
|
||||
isConversationBusyError,
|
||||
isInvalidToolCallIdsError,
|
||||
} from "../agent/approval-recovery";
|
||||
import { prefetchAvailableModelHandles } from "../agent/available-models";
|
||||
import { getResumeData } from "../agent/check-approval";
|
||||
@@ -44,7 +45,11 @@ import { ISOLATED_BLOCK_LABELS } from "../agent/memory";
|
||||
import { sendMessageStream } from "../agent/message";
|
||||
import { getModelDisplayName, getModelInfo } from "../agent/model";
|
||||
import { SessionStats } from "../agent/stats";
|
||||
import { INTERRUPTED_BY_USER } from "../constants";
|
||||
import {
|
||||
INTERRUPTED_BY_USER,
|
||||
SYSTEM_REMINDER_CLOSE,
|
||||
SYSTEM_REMINDER_OPEN,
|
||||
} from "../constants";
|
||||
import {
|
||||
runNotificationHooks,
|
||||
runPreCompactHooks,
|
||||
@@ -321,7 +326,7 @@ function getPlanModeReminder(): string {
|
||||
const planFilePath = permissionMode.getPlanFilePath();
|
||||
|
||||
// Generate dynamic reminder with plan file path
|
||||
return `<system-reminder>
|
||||
return `${SYSTEM_REMINDER_OPEN}
|
||||
Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received.
|
||||
|
||||
## Plan File Info:
|
||||
@@ -366,7 +371,7 @@ At the very end of your turn, once you have asked the user questions and are hap
|
||||
This is critical - your turn should only end with either asking the user a question or calling ExitPlanMode. Do not stop unless it's for these 2 reasons.
|
||||
|
||||
NOTE: At any point in time through this workflow you should feel free to ask the user questions or clarifications. Don't make large assumptions about user intent. The goal is to present a well researched plan to the user, and tie any loose ends before implementation begins.
|
||||
</system-reminder>
|
||||
${SYSTEM_REMINDER_CLOSE}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -454,7 +459,7 @@ function buildRalphFirstTurnReminder(state: RalphState): string {
|
||||
? `${state.currentIteration}/${state.maxIterations}`
|
||||
: `${state.currentIteration}`;
|
||||
|
||||
let reminder = `<system-reminder>
|
||||
let reminder = `${SYSTEM_REMINDER_OPEN}
|
||||
🔄 Ralph Wiggum mode activated (iteration ${iterInfo})
|
||||
`;
|
||||
|
||||
@@ -489,7 +494,7 @@ No completion promise set - loop runs until --max-iterations or ESC/Shift+Tab to
|
||||
`;
|
||||
}
|
||||
|
||||
reminder += `</system-reminder>`;
|
||||
reminder += SYSTEM_REMINDER_CLOSE;
|
||||
return reminder;
|
||||
}
|
||||
|
||||
@@ -502,16 +507,28 @@ function buildRalphContinuationReminder(state: RalphState): string {
|
||||
: `${state.currentIteration}`;
|
||||
|
||||
if (state.completionPromise) {
|
||||
return `<system-reminder>
|
||||
return `${SYSTEM_REMINDER_OPEN}
|
||||
🔄 Ralph iteration ${iterInfo} | To stop: output <promise>${state.completionPromise}</promise> (ONLY when statement is TRUE - do not lie to exit!)
|
||||
</system-reminder>`;
|
||||
${SYSTEM_REMINDER_CLOSE}`;
|
||||
} else {
|
||||
return `<system-reminder>
|
||||
return `${SYSTEM_REMINDER_OPEN}
|
||||
🔄 Ralph iteration ${iterInfo} | No completion promise set - loop runs infinitely
|
||||
</system-reminder>`;
|
||||
${SYSTEM_REMINDER_CLOSE}`;
|
||||
}
|
||||
}
|
||||
|
||||
function stripSystemReminders(text: string): string {
|
||||
return text
|
||||
.replace(
|
||||
new RegExp(
|
||||
`${SYSTEM_REMINDER_OPEN}[\\s\\S]*?${SYSTEM_REMINDER_CLOSE}`,
|
||||
"g",
|
||||
),
|
||||
"",
|
||||
)
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Items that have finished rendering and no longer change
|
||||
type StaticItem =
|
||||
| {
|
||||
@@ -1974,7 +1991,95 @@ export default function App({
|
||||
);
|
||||
|
||||
if (hasApprovalInPayload) {
|
||||
// If desync detected and retries available, recover with keep-alive prompt
|
||||
// "Invalid tool call IDs" means server HAS pending approvals but with different IDs.
|
||||
// We need to fetch the actual pending approvals and show them to the user.
|
||||
if (isInvalidToolCallIdsError(errorDetail)) {
|
||||
try {
|
||||
const client = await getClient();
|
||||
const agent = await client.agents.retrieve(
|
||||
agentIdRef.current,
|
||||
);
|
||||
const { pendingApprovals: serverApprovals } =
|
||||
await getResumeData(
|
||||
client,
|
||||
agent,
|
||||
conversationIdRef.current,
|
||||
);
|
||||
|
||||
if (serverApprovals && serverApprovals.length > 0) {
|
||||
// Preserve user message from current input (if any)
|
||||
// Filter out system reminders to avoid re-injecting them
|
||||
const userMessage = currentInput.find(
|
||||
(item) => item?.type === "message",
|
||||
);
|
||||
if (userMessage && "content" in userMessage) {
|
||||
const content = userMessage.content;
|
||||
let textToRestore = "";
|
||||
if (typeof content === "string") {
|
||||
textToRestore = stripSystemReminders(content);
|
||||
} else if (Array.isArray(content)) {
|
||||
// Extract text parts, filtering out system reminders
|
||||
textToRestore = content
|
||||
.filter(
|
||||
(c): c is { type: "text"; text: string } =>
|
||||
typeof c === "object" &&
|
||||
c !== null &&
|
||||
"type" in c &&
|
||||
c.type === "text" &&
|
||||
"text" in c &&
|
||||
typeof c.text === "string" &&
|
||||
!c.text.includes(SYSTEM_REMINDER_OPEN),
|
||||
)
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
}
|
||||
if (textToRestore.trim()) {
|
||||
setRestoredInput(textToRestore);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all stale approval state before setting new approvals
|
||||
setApprovalResults([]);
|
||||
setAutoHandledResults([]);
|
||||
setAutoDeniedApprovals([]);
|
||||
setApprovalContexts([]);
|
||||
queueApprovalResults(null);
|
||||
|
||||
// Set up approval UI with fetched approvals
|
||||
setPendingApprovals(serverApprovals);
|
||||
|
||||
// Analyze approval contexts (same logic as /resume)
|
||||
try {
|
||||
const contexts = await Promise.all(
|
||||
serverApprovals.map(async (approval) => {
|
||||
const parsedArgs = safeJsonParseOr<
|
||||
Record<string, unknown>
|
||||
>(approval.toolArgs, {});
|
||||
return await analyzeToolApproval(
|
||||
approval.toolName,
|
||||
parsedArgs,
|
||||
);
|
||||
}),
|
||||
);
|
||||
setApprovalContexts(contexts);
|
||||
} catch {
|
||||
// If analysis fails, contexts remain empty (will show basic options)
|
||||
}
|
||||
|
||||
// Stop streaming and exit - user needs to approve/deny
|
||||
// (finally block will decrement processingConversationRef)
|
||||
setStreaming(false);
|
||||
sendDesktopNotification("Approval needed");
|
||||
return;
|
||||
}
|
||||
// No approvals found - fall through to general desync recovery
|
||||
} catch {
|
||||
// Fetch failed - fall through to general desync recovery
|
||||
}
|
||||
}
|
||||
|
||||
// General desync: "no tool call awaiting" or fetch failed above
|
||||
// Recover with keep-alive prompt or strip stale approvals
|
||||
if (
|
||||
isApprovalStateDesyncError(errorDetail) &&
|
||||
llmApiErrorRetriesRef.current < LLM_API_ERROR_MAX_RETRIES
|
||||
@@ -2755,6 +2860,94 @@ export default function App({
|
||||
// Track last failure info so we can emit it if retries stop
|
||||
const lastFailureMessage = latestErrorText || detailFromRun || null;
|
||||
|
||||
// "Invalid tool call IDs" means server HAS pending approvals but with different IDs.
|
||||
// Fetch the actual pending approvals and show them to the user.
|
||||
const invalidIdsDetected =
|
||||
isInvalidToolCallIdsError(detailFromRun) ||
|
||||
isInvalidToolCallIdsError(latestErrorText);
|
||||
|
||||
if (hasApprovalInPayload && invalidIdsDetected) {
|
||||
try {
|
||||
const client = await getClient();
|
||||
const agent = await client.agents.retrieve(agentIdRef.current);
|
||||
const { pendingApprovals: serverApprovals } = await getResumeData(
|
||||
client,
|
||||
agent,
|
||||
conversationIdRef.current,
|
||||
);
|
||||
|
||||
if (serverApprovals && serverApprovals.length > 0) {
|
||||
// Preserve user message from current input (if any)
|
||||
// Filter out system reminders to avoid re-injecting them
|
||||
const userMessage = currentInput.find(
|
||||
(item) => item?.type === "message",
|
||||
);
|
||||
if (userMessage && "content" in userMessage) {
|
||||
const content = userMessage.content;
|
||||
let textToRestore = "";
|
||||
if (typeof content === "string") {
|
||||
textToRestore = stripSystemReminders(content);
|
||||
} else if (Array.isArray(content)) {
|
||||
// Extract text parts, filtering out system reminders
|
||||
textToRestore = content
|
||||
.filter(
|
||||
(c): c is { type: "text"; text: string } =>
|
||||
typeof c === "object" &&
|
||||
c !== null &&
|
||||
"type" in c &&
|
||||
c.type === "text" &&
|
||||
"text" in c &&
|
||||
typeof c.text === "string" &&
|
||||
!c.text.includes(SYSTEM_REMINDER_OPEN),
|
||||
)
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
}
|
||||
if (textToRestore.trim()) {
|
||||
setRestoredInput(textToRestore);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all stale approval state before setting new approvals
|
||||
setApprovalResults([]);
|
||||
setAutoHandledResults([]);
|
||||
setAutoDeniedApprovals([]);
|
||||
setApprovalContexts([]);
|
||||
queueApprovalResults(null);
|
||||
|
||||
// Set up approval UI with fetched approvals
|
||||
setPendingApprovals(serverApprovals);
|
||||
|
||||
// Analyze approval contexts
|
||||
try {
|
||||
const contexts = await Promise.all(
|
||||
serverApprovals.map(async (approval) => {
|
||||
const parsedArgs = safeJsonParseOr<
|
||||
Record<string, unknown>
|
||||
>(approval.toolArgs, {});
|
||||
return await analyzeToolApproval(
|
||||
approval.toolName,
|
||||
parsedArgs,
|
||||
);
|
||||
}),
|
||||
);
|
||||
setApprovalContexts(contexts);
|
||||
} catch {
|
||||
// If analysis fails, contexts remain empty (will show basic options)
|
||||
}
|
||||
|
||||
// Stop streaming and exit - user needs to approve/deny
|
||||
// (finally block will decrement processingConversationRef)
|
||||
setStreaming(false);
|
||||
sendDesktopNotification("Approval needed");
|
||||
return;
|
||||
}
|
||||
// No approvals found - fall through to general desync recovery
|
||||
} catch {
|
||||
// Fetch failed - fall through to general desync recovery
|
||||
}
|
||||
}
|
||||
|
||||
// Check for approval desync errors even if stop_reason isn't llm_api_error.
|
||||
// Handle both approval-only payloads and mixed [approval, message] payloads.
|
||||
if (hasApprovalInPayload && desyncDetected) {
|
||||
@@ -5474,7 +5667,7 @@ export default function App({
|
||||
? `\n\nUser-provided skill description:\n${description}`
|
||||
: "\n\nThe user did not provide a description with /skill. Ask what kind of skill they want to create before proceeding.";
|
||||
|
||||
const skillMessage = `<system-reminder>\n${SKILL_CREATOR_PROMPT}${userDescriptionLine}\n</system-reminder>`;
|
||||
const skillMessage = `${SYSTEM_REMINDER_OPEN}\n${SKILL_CREATOR_PROMPT}${userDescriptionLine}\n${SYSTEM_REMINDER_CLOSE}`;
|
||||
|
||||
// Mark command as finished before sending message
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
@@ -5552,8 +5745,8 @@ export default function App({
|
||||
|
||||
// Build system-reminder content for memory request
|
||||
const rememberMessage = userText
|
||||
? `<system-reminder>\n${REMEMBER_PROMPT}\n</system-reminder>${userText}`
|
||||
: `<system-reminder>\n${REMEMBER_PROMPT}\n\nThe user did not specify what to remember. Look at the recent conversation context to identify what they likely want you to remember, or ask them to clarify.\n</system-reminder>`;
|
||||
? `${SYSTEM_REMINDER_OPEN}\n${REMEMBER_PROMPT}\n${SYSTEM_REMINDER_CLOSE}${userText}`
|
||||
: `${SYSTEM_REMINDER_OPEN}\n${REMEMBER_PROMPT}\n\nThe user did not specify what to remember. Look at the recent conversation context to identify what they likely want you to remember, or ask them to clarify.\n${SYSTEM_REMINDER_CLOSE}`;
|
||||
|
||||
// Mark command as finished before sending message
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
@@ -5708,7 +5901,7 @@ ${recentCommits}
|
||||
refreshDerived();
|
||||
|
||||
// Send trigger message instructing agent to load the initializing-memory skill
|
||||
const initMessage = `<system-reminder>
|
||||
const initMessage = `${SYSTEM_REMINDER_OPEN}
|
||||
The user has requested memory initialization via /init.
|
||||
|
||||
## 1. Load the initializing-memory skill
|
||||
@@ -5727,7 +5920,7 @@ If the skill fails to load, proceed with your best judgment based on these guide
|
||||
|
||||
Once loaded, follow the instructions in the \`initializing-memory\` skill to complete the initialization.
|
||||
${gitContext}
|
||||
</system-reminder>`;
|
||||
${SYSTEM_REMINDER_CLOSE}`;
|
||||
|
||||
// Process conversation with the init prompt
|
||||
await processConversation([
|
||||
@@ -5817,7 +6010,7 @@ ${gitContext}
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: `<system-reminder>\n${prompt}\n</system-reminder>`,
|
||||
content: `${SYSTEM_REMINDER_OPEN}\n${prompt}\n${SYSTEM_REMINDER_CLOSE}`,
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
@@ -5935,10 +6128,10 @@ ${gitContext}
|
||||
// Build bash command prefix if there are cached commands
|
||||
let bashCommandPrefix = "";
|
||||
if (bashCommandCacheRef.current.length > 0) {
|
||||
bashCommandPrefix = `<system-reminder>
|
||||
bashCommandPrefix = `${SYSTEM_REMINDER_OPEN}
|
||||
The messages below were generated by the user while running local commands using "bash mode" in the Letta Code CLI tool.
|
||||
DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.
|
||||
</system-reminder>
|
||||
${SYSTEM_REMINDER_CLOSE}
|
||||
`;
|
||||
for (const cmd of bashCommandCacheRef.current) {
|
||||
bashCommandPrefix += `<bash-input>${cmd.input}</bash-input>\n<bash-output>${cmd.output}</bash-output>\n`;
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Conversation } from "@letta-ai/letta-client/resources/conversation
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getClient } from "../../agent/client";
|
||||
import { SYSTEM_REMINDER_OPEN } from "../../constants";
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { colors } from "./colors";
|
||||
import { MarkdownDisplay } from "./MarkdownDisplay";
|
||||
@@ -85,7 +86,7 @@ function extractUserMessagePreview(message: Message): string | null {
|
||||
const part = content[i];
|
||||
if (part?.type === "text" && part.text) {
|
||||
// Skip system-reminder blocks
|
||||
if (part.text.startsWith("<system-reminder>")) continue;
|
||||
if (part.text.startsWith(SYSTEM_REMINDER_OPEN)) continue;
|
||||
textToShow = part.text;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { platform } from "node:os";
|
||||
import { LETTA_CLOUD_API_URL } from "../../auth/oauth";
|
||||
import { SYSTEM_REMINDER_CLOSE, SYSTEM_REMINDER_OPEN } from "../../constants";
|
||||
import { settingsManager } from "../../settings-manager";
|
||||
import { getVersion } from "../../version";
|
||||
|
||||
@@ -194,7 +195,7 @@ export function buildSessionContext(options: SessionContextOptions): string {
|
||||
}
|
||||
|
||||
// Build the context
|
||||
let context = `<system-reminder>
|
||||
let context = `${SYSTEM_REMINDER_OPEN}
|
||||
This is an automated message providing context about the user's environment.
|
||||
The user has just initiated a new connection via the [Letta Code CLI client](https://docs.letta.com/letta-code/index.md).
|
||||
|
||||
@@ -242,7 +243,7 @@ ${gitInfo.status}
|
||||
- **Agent description**: ${agentInfo.description || "(no description)"} (the user can change this with /description)
|
||||
- **Last message**: ${lastRunInfo}
|
||||
- **Server location**: ${actualServerUrl}
|
||||
</system-reminder>`;
|
||||
${SYSTEM_REMINDER_CLOSE}`;
|
||||
|
||||
return context;
|
||||
} catch {
|
||||
|
||||
@@ -17,6 +17,13 @@ export const DEFAULT_AGENT_NAME = "Nameless Agent";
|
||||
*/
|
||||
export const INTERRUPTED_BY_USER = "Interrupted by user";
|
||||
|
||||
/**
|
||||
* XML tag used to wrap system reminder content injected into messages
|
||||
*/
|
||||
export const SYSTEM_REMINDER_TAG = "system-reminder";
|
||||
export const SYSTEM_REMINDER_OPEN = `<${SYSTEM_REMINDER_TAG}>`;
|
||||
export const SYSTEM_REMINDER_CLOSE = `</${SYSTEM_REMINDER_TAG}>`;
|
||||
|
||||
/**
|
||||
* Status bar thresholds - only show indicators when values exceed these
|
||||
*/
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
isApprovalPendingError,
|
||||
isApprovalStateDesyncError,
|
||||
isConversationBusyError,
|
||||
isInvalidToolCallIdsError,
|
||||
} from "./agent/approval-recovery";
|
||||
import { getClient } from "./agent/client";
|
||||
import {
|
||||
@@ -1411,6 +1412,39 @@ export async function handleHeadlessCommand(
|
||||
// Fallback: if we were sending only approvals and hit an internal error that
|
||||
// says there is no pending approval, resend using the keep-alive recovery prompt.
|
||||
if (approvalDesynced) {
|
||||
// "Invalid tool call IDs" means server HAS pending approvals but with different IDs.
|
||||
// Fetch the actual pending approvals and process them before retrying.
|
||||
if (
|
||||
isInvalidToolCallIdsError(detailFromRun) ||
|
||||
isInvalidToolCallIdsError(latestErrorText)
|
||||
) {
|
||||
if (outputFormat === "stream-json") {
|
||||
const recoveryMsg: RecoveryMessage = {
|
||||
type: "recovery",
|
||||
recovery_type: "invalid_tool_call_ids",
|
||||
message:
|
||||
"Tool call ID mismatch; fetching actual pending approvals and resyncing",
|
||||
run_id: lastRunId ?? undefined,
|
||||
session_id: sessionId,
|
||||
uuid: `recovery-${lastRunId || crypto.randomUUID()}`,
|
||||
};
|
||||
console.log(JSON.stringify(recoveryMsg));
|
||||
} else {
|
||||
console.error(
|
||||
"Tool call ID mismatch; fetching actual pending approvals...",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch and process actual pending approvals from server
|
||||
await resolveAllPendingApprovals();
|
||||
// After processing, continue to next iteration (fresh state)
|
||||
continue;
|
||||
} catch {
|
||||
// If fetch fails, fall through to general desync recovery
|
||||
}
|
||||
}
|
||||
|
||||
if (llmApiErrorRetries < LLM_API_ERROR_MAX_RETRIES) {
|
||||
llmApiErrorRetries += 1;
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
SYSTEM_REMINDER_CLOSE,
|
||||
SYSTEM_REMINDER_OPEN,
|
||||
} from "../../../../constants";
|
||||
|
||||
// Use createRequire for @letta-ai/letta-client so NODE_PATH is respected
|
||||
// (ES module imports don't respect NODE_PATH, but require does)
|
||||
@@ -92,11 +96,11 @@ function buildSystemReminder(
|
||||
senderAgentName: string,
|
||||
senderAgentId: string,
|
||||
): string {
|
||||
return `<system-reminder>
|
||||
return `${SYSTEM_REMINDER_OPEN}
|
||||
This message is from "${senderAgentName}" (agent ID: ${senderAgentId}), an agent currently running inside the Letta Code CLI (docs.letta.com/letta-code).
|
||||
The sender will only see the final message you generate (not tool calls or reasoning).
|
||||
If you need to share detailed information, include it in your response text.
|
||||
</system-reminder>
|
||||
${SYSTEM_REMINDER_CLOSE}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
SYSTEM_REMINDER_CLOSE,
|
||||
SYSTEM_REMINDER_OPEN,
|
||||
} from "../../../../constants";
|
||||
|
||||
// Use createRequire for @letta-ai/letta-client so NODE_PATH is respected
|
||||
// (ES module imports don't respect NODE_PATH, but require does)
|
||||
@@ -92,11 +96,11 @@ function buildSystemReminder(
|
||||
senderAgentName: string,
|
||||
senderAgentId: string,
|
||||
): string {
|
||||
return `<system-reminder>
|
||||
return `${SYSTEM_REMINDER_OPEN}
|
||||
This message is from "${senderAgentName}" (agent ID: ${senderAgentId}), an agent currently running inside the Letta Code CLI (docs.letta.com/letta-code).
|
||||
The sender will only see the final message you generate (not tool calls or reasoning).
|
||||
If you need to share detailed information, include it in your response text.
|
||||
</system-reminder>
|
||||
${SYSTEM_REMINDER_CLOSE}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Message } from "@letta-ai/letta-client/resources/agents/messages";
|
||||
import {
|
||||
isApprovalPendingError,
|
||||
isApprovalStateDesyncError,
|
||||
isInvalidToolCallIdsError,
|
||||
} from "../agent/approval-recovery";
|
||||
import { extractApprovals } from "../agent/check-approval";
|
||||
|
||||
@@ -31,6 +32,21 @@ describe("isApprovalStateDesyncError", () => {
|
||||
expect(isApprovalStateDesyncError(detail)).toBe(true);
|
||||
});
|
||||
|
||||
test("detects invalid tool call IDs error", () => {
|
||||
const detail =
|
||||
"Invalid tool call IDs: Expected ['tc_abc123'], got ['tc_xyz789']";
|
||||
expect(isApprovalStateDesyncError(detail)).toBe(true);
|
||||
});
|
||||
|
||||
test("detects invalid tool call IDs error case-insensitively", () => {
|
||||
expect(
|
||||
isApprovalStateDesyncError("INVALID TOOL CALL IDS: Expected X, got Y"),
|
||||
).toBe(true);
|
||||
expect(isApprovalStateDesyncError("invalid tool call ids: mismatch")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("returns false for unrelated errors", () => {
|
||||
expect(isApprovalStateDesyncError("Connection timeout")).toBe(false);
|
||||
expect(isApprovalStateDesyncError("Internal server error")).toBe(false);
|
||||
@@ -44,6 +60,43 @@ describe("isApprovalStateDesyncError", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isInvalidToolCallIdsError", () => {
|
||||
test("detects invalid tool call IDs error", () => {
|
||||
const detail =
|
||||
"Invalid tool call IDs: Expected ['tc_abc123'], got ['tc_xyz789']";
|
||||
expect(isInvalidToolCallIdsError(detail)).toBe(true);
|
||||
});
|
||||
|
||||
test("detects invalid tool call IDs error case-insensitively", () => {
|
||||
expect(
|
||||
isInvalidToolCallIdsError("INVALID TOOL CALL IDS: Expected X, got Y"),
|
||||
).toBe(true);
|
||||
expect(isInvalidToolCallIdsError("invalid tool call ids: mismatch")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("returns false for 'no tool call awaiting' error", () => {
|
||||
// This is a different desync type - server has NO pending approvals
|
||||
expect(
|
||||
isInvalidToolCallIdsError("No tool call is currently awaiting approval"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for unrelated errors", () => {
|
||||
expect(isInvalidToolCallIdsError("Connection timeout")).toBe(false);
|
||||
expect(isInvalidToolCallIdsError("Internal server error")).toBe(false);
|
||||
expect(isInvalidToolCallIdsError("Rate limit exceeded")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for non-string input", () => {
|
||||
expect(isInvalidToolCallIdsError(null)).toBe(false);
|
||||
expect(isInvalidToolCallIdsError(undefined)).toBe(false);
|
||||
expect(isInvalidToolCallIdsError(123)).toBe(false);
|
||||
expect(isInvalidToolCallIdsError({ error: "test" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isApprovalPendingError", () => {
|
||||
// This is the actual error format from the Letta backend (screenshot from LET-7101)
|
||||
const REAL_ERROR_DETAIL =
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
import type Letta from "@letta-ai/letta-client";
|
||||
import { SYSTEM_REMINDER_OPEN } from "../../constants";
|
||||
import { continueConversation } from "../../skills/builtin/messaging-agents/scripts/continue-conversation";
|
||||
import { startConversation } from "../../skills/builtin/messaging-agents/scripts/start-conversation";
|
||||
|
||||
@@ -98,7 +99,7 @@ describe("start-conversation", () => {
|
||||
|
||||
// Check message was sent with system reminder
|
||||
expect(mockMessageCreate).toHaveBeenCalledWith(mockConversation.id, {
|
||||
input: expect.stringContaining("<system-reminder>"),
|
||||
input: expect.stringContaining(SYSTEM_REMINDER_OPEN),
|
||||
});
|
||||
expect(mockMessageCreate).toHaveBeenCalledWith(mockConversation.id, {
|
||||
input: expect.stringContaining("Hello!"),
|
||||
@@ -217,7 +218,7 @@ describe("continue-conversation", () => {
|
||||
|
||||
// Check message was sent with system reminder
|
||||
expect(mockMessageCreate).toHaveBeenCalledWith(mockConversation.id, {
|
||||
input: expect.stringContaining("<system-reminder>"),
|
||||
input: expect.stringContaining(SYSTEM_REMINDER_OPEN),
|
||||
});
|
||||
expect(mockMessageCreate).toHaveBeenCalledWith(mockConversation.id, {
|
||||
input: expect.stringContaining("Follow-up question"),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { SYSTEM_REMINDER_OPEN } from "../../constants";
|
||||
import { read } from "../../tools/impl/Read";
|
||||
import { TestDirectory } from "../helpers/testFs";
|
||||
|
||||
@@ -115,7 +116,7 @@ export default box;
|
||||
|
||||
const result = await read({ file_path: file });
|
||||
|
||||
expect(result.content).toContain("<system-reminder>");
|
||||
expect(result.content).toContain(SYSTEM_REMINDER_OPEN);
|
||||
expect(result.content).toContain("empty contents");
|
||||
});
|
||||
|
||||
@@ -125,7 +126,7 @@ export default box;
|
||||
|
||||
const result = await read({ file_path: file });
|
||||
|
||||
expect(result.content).toContain("<system-reminder>");
|
||||
expect(result.content).toContain(SYSTEM_REMINDER_OPEN);
|
||||
expect(result.content).toContain("empty contents");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
} from "@letta-ai/letta-client/resources/agents/messages";
|
||||
import { LETTA_CLOUD_API_URL } from "../../auth/oauth.js";
|
||||
import { resizeImageIfNeeded } from "../../cli/helpers/imageResize.js";
|
||||
import { SYSTEM_REMINDER_CLOSE, SYSTEM_REMINDER_OPEN } from "../../constants";
|
||||
import { settingsManager } from "../../settings-manager.js";
|
||||
import { OVERFLOW_CONFIG, writeOverflowFile } from "./overflow.js";
|
||||
import { LIMITS } from "./truncation.js";
|
||||
@@ -249,7 +250,7 @@ export async function read(args: ReadArgs): Promise<ReadResult> {
|
||||
const content = await fs.readFile(resolvedPath, "utf-8");
|
||||
if (content.trim() === "") {
|
||||
return {
|
||||
content: `<system-reminder>\nThe file ${resolvedPath} exists but has empty contents.\n</system-reminder>`,
|
||||
content: `${SYSTEM_REMINDER_OPEN}\nThe file ${resolvedPath} exists but has empty contents.\n${SYSTEM_REMINDER_CLOSE}`,
|
||||
};
|
||||
}
|
||||
const formattedContent = formatWithLineNumbers(
|
||||
|
||||
@@ -205,7 +205,10 @@ export interface RetryMessage extends MessageEnvelope {
|
||||
export interface RecoveryMessage extends MessageEnvelope {
|
||||
type: "recovery";
|
||||
/** Type of recovery performed */
|
||||
recovery_type: "approval_pending" | "approval_desync";
|
||||
recovery_type:
|
||||
| "approval_pending"
|
||||
| "approval_desync"
|
||||
| "invalid_tool_call_ids";
|
||||
/** Human-readable description of what happened */
|
||||
message: string;
|
||||
run_id?: string;
|
||||
|
||||
Reference in New Issue
Block a user