fix: invalid tool call ID recovery and system-reminder tag centralization (#627)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-01-21 20:43:35 -08:00
committed by GitHub
parent 35b0d658f3
commit 2e7fe42658
14 changed files with 367 additions and 35 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
*/

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"),

View File

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

View File

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

View File

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