feat: retry messages on pre-mature interrupt (#593)

Co-authored-by: Caren Thomas <carenthomas@gmail.com>
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Kian Jones
2026-01-23 17:19:58 -08:00
committed by GitHub
parent 3e71a08156
commit 7af73fe53e
3 changed files with 44 additions and 1 deletions

View File

@@ -5,6 +5,7 @@ import anthropicPrompt from "./prompts/claude.md";
import codexPrompt from "./prompts/codex.md";
import geminiPrompt from "./prompts/gemini.md";
import humanPrompt from "./prompts/human.mdx";
import interruptRecoveryAlert from "./prompts/interrupt_recovery_alert.txt";
// init_memory.md is now a bundled skill at src/skills/builtin/init/SKILL.md
import lettaAnthropicPrompt from "./prompts/letta_claude.md";
import lettaCodexPrompt from "./prompts/letta_codex.md";
@@ -31,6 +32,7 @@ export const SKILL_CREATOR_PROMPT = skillCreatorModePrompt;
export const REMEMBER_PROMPT = rememberPrompt;
export const MEMORY_CHECK_REMINDER = memoryCheckReminder;
export const APPROVAL_RECOVERY_PROMPT = approvalRecoveryAlert;
export const INTERRUPT_RECOVERY_ALERT = interruptRecoveryAlert;
export const MEMORY_PROMPTS: Record<string, string> = {
"persona.mdx": personaPrompt,

View File

@@ -0,0 +1 @@
<system-alert>The user interrupted the active stream.</system-alert>

View File

@@ -44,6 +44,7 @@ import { type AgentProvenance, createAgent } from "../agent/create";
import { ISOLATED_BLOCK_LABELS } from "../agent/memory";
import { sendMessageStream } from "../agent/message";
import { getModelInfo, getModelShortName } from "../agent/model";
import { INTERRUPT_RECOVERY_ALERT } from "../agent/promptAssets";
import { SessionStats } from "../agent/stats";
import {
INTERRUPTED_BY_USER,
@@ -1173,6 +1174,11 @@ export default function App({
const queueAppendTimeoutRef = useRef<NodeJS.Timeout | null>(null); // 15s append mode timeout
// Cache last sent input - cleared on successful completion, remains if interrupted
const lastSentInputRef = useRef<Array<MessageCreate | ApprovalCreate> | null>(
null,
);
// Epoch counter to force dequeue effect re-run when refs change but state doesn't
// Incremented when userCancelledRef is reset while messages are queued
const [dequeueEpoch, setDequeueEpoch] = useState(0);
@@ -1904,7 +1910,7 @@ export default function App({
};
// Copy so we can safely mutate for retry recovery flows
const currentInput = [...initialInput];
let currentInput = [...initialInput];
const allowReentry = options?.allowReentry ?? false;
// Use provided generation (from onSubmit) or capture current
@@ -1951,6 +1957,38 @@ export default function App({
setStreaming(true);
abortControllerRef.current = new AbortController();
// Recover interrupted message: if cache contains ONLY user messages, prepend them
// Note: type="message" is a local discriminator (not in SDK types) to distinguish from approvals
const originalInput = currentInput;
const cacheIsAllUserMsgs = lastSentInputRef.current?.every(
(m) => m.type === "message" && m.role === "user",
);
if (cacheIsAllUserMsgs && lastSentInputRef.current) {
currentInput = [
...lastSentInputRef.current,
...currentInput.map((m) =>
m.type === "message" && m.role === "user"
? {
...m,
content: [
{ type: "text" as const, text: INTERRUPT_RECOVERY_ALERT },
...(typeof m.content === "string"
? [{ type: "text" as const, text: m.content }]
: m.content),
],
}
: m,
),
];
// Cache old + new for chained recovery
lastSentInputRef.current = [
...lastSentInputRef.current,
...originalInput,
];
} else {
lastSentInputRef.current = originalInput;
}
// Clear any stale pending tool calls from previous turns
// If we're sending a new message, old pending state is no longer relevant
// Pass false to avoid setting interrupted=true, which causes race conditions
@@ -2358,6 +2396,7 @@ export default function App({
llmApiErrorRetriesRef.current = 0; // Reset retry counter on success
conversationBusyRetriesRef.current = 0;
lastDequeuedMessageRef.current = null; // Clear - message was processed successfully
lastSentInputRef.current = null; // Clear - no recovery needed
// Run Stop hooks - if blocked/errored, continue the conversation with feedback
const stopHookResult = await runStopHooks(
@@ -2498,6 +2537,7 @@ export default function App({
// Clear stale state immediately to prevent ID mismatch bugs
setAutoHandledResults([]);
setAutoDeniedApprovals([]);
lastSentInputRef.current = null; // Clear - message was received by server
// Use new approvals array, fallback to legacy approval for backward compat
const approvalsToProcess =