mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-12 11:06:02 +00:00
fix: repair rejected Anthropic thinking replay (#92286)
* fix: repair rejected Anthropic thinking replay * fix: narrow recovered retry result * test: satisfy thinking recovery lint * test: prove thinking retry preserves fresh reasoning * test: type narrow thinking retry proof
This commit is contained in:
@@ -538,6 +538,193 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps newly generated thinking after repairing rejected Anthropic replay", async () => {
|
||||
const { SessionManager: ActualSessionManager } =
|
||||
await vi.importActual<typeof import("../../sessions/index.js")>("../../sessions/index.js");
|
||||
const staleAssistant = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "historical stale thinking",
|
||||
thinkingSignature: "stale-signature",
|
||||
},
|
||||
{ type: "text", text: "historical answer" },
|
||||
],
|
||||
stopReason: "stop",
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
timestamp: 2,
|
||||
} as AgentMessage;
|
||||
const sessionMessages = [
|
||||
{ role: "user", content: "historical question", timestamp: 1 } as AgentMessage,
|
||||
staleAssistant,
|
||||
];
|
||||
const sessionManager = ActualSessionManager.inMemory();
|
||||
const appendSessionMessage = (message: AgentMessage) =>
|
||||
sessionManager.appendMessage(message as Parameters<typeof sessionManager.appendMessage>[0]);
|
||||
for (const message of sessionMessages) {
|
||||
appendSessionMessage(message);
|
||||
}
|
||||
const retryAssistant = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "fresh valid retry thinking",
|
||||
thinkingSignature: "fresh-valid-signature",
|
||||
},
|
||||
{ type: "text", text: "retry answer" },
|
||||
],
|
||||
stopReason: "stop",
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
timestamp: 4,
|
||||
} as AgentMessage;
|
||||
const providerContexts: AgentMessage[][] = [];
|
||||
const afterTurn = vi.fn(async (_params: { messages: AgentMessage[] }) => {});
|
||||
|
||||
hoisted.sessionManagerOpenMock.mockReturnValue(sessionManager);
|
||||
|
||||
await createContextEngineAttemptRunner({
|
||||
contextEngine: {
|
||||
...createContextEngineBootstrapAndAssemble(),
|
||||
afterTurn,
|
||||
},
|
||||
sessionKey,
|
||||
tempPaths,
|
||||
sessionMessages,
|
||||
attemptOverrides: {
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
model: {
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
id: "claude-sonnet-4-6",
|
||||
contextWindow: 128_000,
|
||||
input: ["text"],
|
||||
} as never,
|
||||
runtimePlan: {
|
||||
prompt: {
|
||||
resolveSystemPromptContribution: () => undefined,
|
||||
},
|
||||
transcript: {
|
||||
resolvePolicy: () => ({
|
||||
sanitizeMode: "full",
|
||||
sanitizeToolCallIds: true,
|
||||
preserveNativeAnthropicToolUseIds: false,
|
||||
repairToolUseResultPairing: true,
|
||||
preserveSignatures: true,
|
||||
sanitizeThinkingSignatures: false,
|
||||
dropThinkingBlocks: false,
|
||||
dropReasoningFromHistory: false,
|
||||
applyGoogleTurnOrdering: false,
|
||||
validateGeminiTurns: false,
|
||||
validateAnthropicTurns: false,
|
||||
allowSyntheticToolResults: false,
|
||||
}),
|
||||
},
|
||||
transport: {
|
||||
extraParams: {},
|
||||
resolveExtraParams: () => ({}),
|
||||
},
|
||||
tools: {
|
||||
normalize: (tools: unknown[]) => tools,
|
||||
logDiagnostics: () => {},
|
||||
},
|
||||
auth: {
|
||||
providerForAuth: "anthropic",
|
||||
authProfileProviderForAuth: "",
|
||||
forwardedAuthProfileId: undefined,
|
||||
},
|
||||
delivery: {
|
||||
isSilentPayload: () => false,
|
||||
resolveFollowupRoute: () => undefined,
|
||||
},
|
||||
outcome: {
|
||||
classifyRunResult: () => undefined,
|
||||
},
|
||||
observability: {
|
||||
resolvedRef: "anthropic/claude-sonnet-4-6",
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
modelApi: "anthropic-messages",
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
createSession: () => {
|
||||
const session = createDefaultEmbeddedSession({ initialMessages: sessionMessages });
|
||||
let streamCalls = 0;
|
||||
session.agent.streamFn = async (_model, context) => {
|
||||
streamCalls += 1;
|
||||
providerContexts.push([
|
||||
...((context as { messages?: AgentMessage[] } | undefined)?.messages ?? []),
|
||||
]);
|
||||
if (streamCalls === 1) {
|
||||
throw new Error("invalid signature in thinking block");
|
||||
}
|
||||
return {
|
||||
async result() {
|
||||
return retryAssistant;
|
||||
},
|
||||
[Symbol.asyncIterator]() {
|
||||
return (async function* () {})();
|
||||
},
|
||||
};
|
||||
};
|
||||
session.prompt = async (prompt, options) => {
|
||||
options?.preflightResult?.(true);
|
||||
const userMessage = {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: prompt }],
|
||||
timestamp: 3,
|
||||
} as AgentMessage;
|
||||
session.messages = [...session.messages, userMessage];
|
||||
appendSessionMessage(userMessage);
|
||||
const stream = await session.agent.streamFn?.(
|
||||
{} as never,
|
||||
{ messages: session.messages } as never,
|
||||
{},
|
||||
);
|
||||
const assistantMessage = await (
|
||||
stream as { result: () => Promise<AgentMessage> }
|
||||
).result();
|
||||
session.messages = [...session.messages, assistantMessage];
|
||||
appendSessionMessage(assistantMessage);
|
||||
};
|
||||
return session;
|
||||
},
|
||||
});
|
||||
|
||||
const firstProviderContext = providerContexts[0] ?? [];
|
||||
const retryProviderContext = providerContexts[1] ?? [];
|
||||
expect(JSON.stringify(firstProviderContext)).toContain("stale-signature");
|
||||
expect(JSON.stringify(retryProviderContext)).not.toContain("stale-signature");
|
||||
|
||||
const finalMessages = sessionManager.buildSessionContext().messages;
|
||||
expect(JSON.stringify(finalMessages[1])).not.toContain("historical stale thinking");
|
||||
expect(JSON.stringify(finalMessages.at(-1))).toContain("fresh valid retry thinking");
|
||||
expect(JSON.stringify(finalMessages.at(-1))).toContain("fresh-valid-signature");
|
||||
expect(afterTurn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messages: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: "assistant",
|
||||
content: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "thinking",
|
||||
thinking: "fresh valid retry thinking",
|
||||
thinkingSignature: "fresh-valid-signature",
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends transcriptPrompt visibly and keeps runtime context out of transcript messages", async () => {
|
||||
const seen: { prompt?: string; messages?: unknown[]; systemPrompt?: string } = {};
|
||||
|
||||
|
||||
@@ -793,10 +793,14 @@ vi.mock("../sandbox-info.js", () => ({
|
||||
resolveEmbeddedSandboxInfoExecPolicy: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("../thinking.js", () => ({
|
||||
dropReasoningFromHistory: <T>(messages: T) => messages,
|
||||
dropThinkingBlocks: <T>(messages: T) => messages,
|
||||
}));
|
||||
vi.mock("../thinking.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../thinking.js")>();
|
||||
return {
|
||||
...actual,
|
||||
dropReasoningFromHistory: <T>(messages: T) => messages,
|
||||
dropThinkingBlocks: <T>(messages: T) => messages,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../tool-name-allowlist.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../tool-name-allowlist.js")>();
|
||||
|
||||
@@ -277,6 +277,7 @@ import {
|
||||
resolveEmbeddedAgentStreamFn,
|
||||
} from "../stream-resolution.js";
|
||||
import { applySystemPromptToSession } from "../system-prompt.js";
|
||||
import { repairRejectedThinkingReplayInSessionManager } from "../thinking-replay-repair.js";
|
||||
import {
|
||||
dropReasoningFromHistory,
|
||||
dropThinkingBlocks,
|
||||
@@ -1990,6 +1991,7 @@ export async function runEmbeddedAttempt(
|
||||
let trajectoryEndRecorded = false;
|
||||
let buildAbortSettlePromise: () => Promise<void> | null = () => null;
|
||||
let cleanupYieldAborted = false;
|
||||
let repairedRejectedThinkingReplay = false;
|
||||
try {
|
||||
await repairSessionFileIfNeeded({
|
||||
sessionFile: params.sessionFile,
|
||||
@@ -2744,6 +2746,30 @@ export async function runEmbeddedAttempt(
|
||||
activeSession.agent.streamFn,
|
||||
{
|
||||
id: activeSession.sessionId,
|
||||
onRecoveredAnthropicThinking: () => {
|
||||
if (!sessionManager) {
|
||||
log.warn(
|
||||
`[session-recovery] unable to repair rejected thinking replay: session manager unavailable sessionId=${activeSession.sessionId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const repair = repairRejectedThinkingReplayInSessionManager({
|
||||
sessionManager,
|
||||
sessionFile: params.sessionFile,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: sessionAgentId,
|
||||
});
|
||||
if (repair.repaired) {
|
||||
repairedRejectedThinkingReplay = true;
|
||||
sessionLockController.refreshAfterOwnedSessionWrite();
|
||||
return;
|
||||
}
|
||||
log.warn(
|
||||
`[session-recovery] rejected thinking replay retry succeeded but transcript repair made no changes: ` +
|
||||
`sessionId=${activeSession.sessionId} reason=${repair.reason ?? "unknown"}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -4538,6 +4564,9 @@ export async function runEmbeddedAttempt(
|
||||
|
||||
await sessionLockController.waitForSessionEvents(activeSession);
|
||||
await waitForPendingEvents();
|
||||
if (repairedRejectedThinkingReplay) {
|
||||
activeSession.agent.state.messages = activeSessionManager.buildSessionContext().messages;
|
||||
}
|
||||
await sessionLockController.releaseForPrompt();
|
||||
|
||||
if (
|
||||
|
||||
139
src/agents/embedded-agent-runner/thinking-replay-repair.test.ts
Normal file
139
src/agents/embedded-agent-runner/thinking-replay-repair.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
// Provider thinking replay repair tests cover durable transcript cleanup after
|
||||
// Anthropic/Bedrock proves a signed thinking block invalid.
|
||||
import type { AgentMessage } from "openclaw/plugin-sdk/agent-core";
|
||||
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { repairRejectedThinkingReplayInSessionManager } from "./thinking-replay-repair.js";
|
||||
|
||||
type AppendMessage = Parameters<SessionManager["appendMessage"]>[0];
|
||||
|
||||
function asAppendMessage(message: unknown): AppendMessage {
|
||||
return message as AppendMessage;
|
||||
}
|
||||
|
||||
function branchMessages(sessionManager: SessionManager): AgentMessage[] {
|
||||
return sessionManager
|
||||
.getBranch()
|
||||
.filter((entry) => entry.type === "message")
|
||||
.map((entry) => entry.message);
|
||||
}
|
||||
|
||||
function branchAssistantContents(sessionManager: SessionManager): unknown[] {
|
||||
return branchMessages(sessionManager)
|
||||
.filter((message): message is Extract<AgentMessage, { role: "assistant" }> => {
|
||||
return message.role === "assistant";
|
||||
})
|
||||
.map((message) => message.content);
|
||||
}
|
||||
|
||||
describe("repairRejectedThinkingReplayInSessionManager", () => {
|
||||
it("strips thinking blocks from active-branch assistant messages and preserves visible content", () => {
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
sessionManager.appendMessage(asAppendMessage({ role: "user", content: "first", timestamp: 1 }));
|
||||
sessionManager.appendMessage(
|
||||
asAppendMessage({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "private", thinkingSignature: "sig_bad" },
|
||||
{ type: "text", text: "visible answer" },
|
||||
],
|
||||
timestamp: 2,
|
||||
}),
|
||||
);
|
||||
sessionManager.appendMessage(
|
||||
asAppendMessage({ role: "user", content: "second", timestamp: 3 }),
|
||||
);
|
||||
|
||||
const result = repairRejectedThinkingReplayInSessionManager({ sessionManager });
|
||||
|
||||
expect(result).toMatchObject({ repaired: true, repairedCount: 1 });
|
||||
expect(branchMessages(sessionManager).map((message) => message.role)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
"user",
|
||||
]);
|
||||
expect(branchAssistantContents(sessionManager)).toEqual([
|
||||
[{ type: "text", text: "visible answer" }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps thinking-only assistant turns as omitted-reasoning placeholders", () => {
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
sessionManager.appendMessage(asAppendMessage({ role: "user", content: "first", timestamp: 1 }));
|
||||
sessionManager.appendMessage(
|
||||
asAppendMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "private", thinkingSignature: "sig_bad" }],
|
||||
timestamp: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = repairRejectedThinkingReplayInSessionManager({ sessionManager });
|
||||
|
||||
expect(result).toMatchObject({ repaired: true, repairedCount: 1 });
|
||||
expect(branchAssistantContents(sessionManager)).toEqual([
|
||||
[{ type: "text", text: "[assistant reasoning omitted]" }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves downstream branch suffix entries after rewriting the first repaired assistant", () => {
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
sessionManager.appendMessage(asAppendMessage({ role: "user", content: "first", timestamp: 1 }));
|
||||
sessionManager.appendMessage(
|
||||
asAppendMessage({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "private", thinkingSignature: "sig_bad" },
|
||||
{ type: "text", text: "first answer" },
|
||||
],
|
||||
timestamp: 2,
|
||||
}),
|
||||
);
|
||||
sessionManager.appendMessage(
|
||||
asAppendMessage({ role: "user", content: "follow-up", timestamp: 3 }),
|
||||
);
|
||||
sessionManager.appendMessage(
|
||||
asAppendMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "follow-up answer" }],
|
||||
timestamp: 4,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = repairRejectedThinkingReplayInSessionManager({ sessionManager });
|
||||
|
||||
expect(result).toMatchObject({ repaired: true, repairedCount: 1 });
|
||||
expect(branchMessages(sessionManager).map((message) => message.role)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
"user",
|
||||
"assistant",
|
||||
]);
|
||||
expect(branchAssistantContents(sessionManager)).toEqual([
|
||||
[{ type: "text", text: "first answer" }],
|
||||
[{ type: "text", text: "follow-up answer" }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not rewrite sessions without active-branch thinking blocks", () => {
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
sessionManager.appendMessage(asAppendMessage({ role: "user", content: "first", timestamp: 1 }));
|
||||
sessionManager.appendMessage(
|
||||
asAppendMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "visible answer" }],
|
||||
timestamp: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
const beforeLeafId = sessionManager.getLeafId();
|
||||
const result = repairRejectedThinkingReplayInSessionManager({ sessionManager });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
repaired: false,
|
||||
repairedCount: 0,
|
||||
reason: "no thinking blocks on active branch",
|
||||
});
|
||||
expect(sessionManager.getLeafId()).toBe(beforeLeafId);
|
||||
});
|
||||
});
|
||||
70
src/agents/embedded-agent-runner/thinking-replay-repair.ts
Normal file
70
src/agents/embedded-agent-runner/thinking-replay-repair.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Repairs persisted signed-thinking replay state after provider-confirmed rejection.
|
||||
*/
|
||||
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
|
||||
import type { AgentMessage } from "../runtime/index.js";
|
||||
import { log } from "./logger.js";
|
||||
import { stripThinkingBlocksFromMessage } from "./thinking.js";
|
||||
import { rewriteTranscriptEntriesInSessionManager } from "./transcript-rewrite.js";
|
||||
|
||||
type RewritableSessionManager = Parameters<
|
||||
typeof rewriteTranscriptEntriesInSessionManager
|
||||
>[0]["sessionManager"];
|
||||
|
||||
export function repairRejectedThinkingReplayInSessionManager(params: {
|
||||
sessionManager: RewritableSessionManager;
|
||||
sessionFile?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
}): { repaired: boolean; repairedCount: number; reason?: string } {
|
||||
const replacements: Array<{ entryId: string; message: AgentMessage }> = [];
|
||||
for (const entry of params.sessionManager.getBranch()) {
|
||||
if (entry.type !== "message") {
|
||||
continue;
|
||||
}
|
||||
const replacement = stripThinkingBlocksFromMessage(entry.message);
|
||||
if (replacement === entry.message) {
|
||||
continue;
|
||||
}
|
||||
replacements.push({ entryId: entry.id, message: replacement });
|
||||
}
|
||||
|
||||
if (replacements.length === 0) {
|
||||
return {
|
||||
repaired: false,
|
||||
repairedCount: 0,
|
||||
reason: "no thinking blocks on active branch",
|
||||
};
|
||||
}
|
||||
|
||||
const rewriteResult = rewriteTranscriptEntriesInSessionManager({
|
||||
sessionManager: params.sessionManager,
|
||||
replacements,
|
||||
});
|
||||
if (!rewriteResult.changed) {
|
||||
return {
|
||||
repaired: false,
|
||||
repairedCount: 0,
|
||||
reason: rewriteResult.reason,
|
||||
};
|
||||
}
|
||||
|
||||
if (params.sessionFile) {
|
||||
emitSessionTranscriptUpdate({
|
||||
sessionFile: params.sessionFile,
|
||||
sessionKey: params.sessionKey,
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
});
|
||||
}
|
||||
log.warn(
|
||||
`[session-recovery] stripped thinking blocks after provider rejected replay: ` +
|
||||
`repaired=${rewriteResult.rewrittenEntries} sessionKey=${params.sessionKey ?? params.sessionId ?? "unknown"}`,
|
||||
);
|
||||
|
||||
return {
|
||||
repaired: true,
|
||||
repairedCount: rewriteResult.rewrittenEntries,
|
||||
reason: rewriteResult.reason,
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// recovery behavior for provider transcripts and active assistant turns.
|
||||
import type { AgentMessage } from "openclaw/plugin-sdk/agent-core";
|
||||
import { createAssistantMessageEventStream } from "openclaw/plugin-sdk/llm";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { castAgentMessage, castAgentMessages } from "../test-helpers/agent-message-fixtures.js";
|
||||
import {
|
||||
OMITTED_ASSISTANT_REASONING_TEXT,
|
||||
@@ -594,6 +594,139 @@ describe("wrapAnthropicStreamWithRecovery", () => {
|
||||
expect(retryMessage.content).toEqual([{ type: "text", text: "visible answer" }]);
|
||||
});
|
||||
|
||||
it("notifies recovery only after a rejected request retry succeeds", async () => {
|
||||
let callCount = 0;
|
||||
const recovered = vi.fn();
|
||||
const finalMessage = createTestAssistantMessage({
|
||||
content: [{ type: "text", text: "recovered" }],
|
||||
stopReason: "stop",
|
||||
});
|
||||
const originalMessages = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "secret", thinkingSignature: "sig" },
|
||||
{ type: "text", text: "visible answer" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const wrapped = wrapAnthropicStreamWithRecovery(
|
||||
(() => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
return Promise.reject(anthropicThinkingError);
|
||||
}
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "done", reason: "stop", message: finalMessage });
|
||||
stream.end();
|
||||
});
|
||||
return stream;
|
||||
}) as Parameters<typeof wrapAnthropicStreamWithRecovery>[0],
|
||||
{ id: "test-session", onRecoveredAnthropicThinking: recovered },
|
||||
);
|
||||
|
||||
const response = (await wrapped(
|
||||
{} as never,
|
||||
{
|
||||
messages: originalMessages,
|
||||
} as never,
|
||||
{} as never,
|
||||
)) as { result: () => Promise<unknown> } & AsyncIterable<unknown>;
|
||||
for await (const event of response) {
|
||||
void event;
|
||||
// Drain the retry stream before reading result().
|
||||
}
|
||||
|
||||
await expect(response.result()).resolves.toEqual(finalMessage);
|
||||
expect(callCount).toBe(2);
|
||||
expect(recovered).toHaveBeenCalledTimes(1);
|
||||
expect(recovered).toHaveBeenCalledWith({
|
||||
originalMessages,
|
||||
cleanedMessages: [
|
||||
{
|
||||
...originalMessages[0],
|
||||
content: [{ type: "text", text: "visible answer" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not notify recovery when the stripped-thinking retry also fails", async () => {
|
||||
const recovered = vi.fn();
|
||||
let callCount = 0;
|
||||
const retryError = new Error("retry failed");
|
||||
const wrapped = wrapAnthropicStreamWithRecovery(
|
||||
(() => {
|
||||
callCount += 1;
|
||||
return Promise.reject(callCount === 1 ? anthropicThinkingError : retryError);
|
||||
}) as Parameters<typeof wrapAnthropicStreamWithRecovery>[0],
|
||||
{ id: "test-session", onRecoveredAnthropicThinking: recovered },
|
||||
);
|
||||
|
||||
await expect(
|
||||
wrapped(
|
||||
{} as never,
|
||||
{
|
||||
messages: castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "secret", thinkingSignature: "sig" }],
|
||||
},
|
||||
]),
|
||||
} as never,
|
||||
{} as never,
|
||||
),
|
||||
).rejects.toBe(retryError);
|
||||
expect(recovered).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not notify recovery when the stripped-thinking retry resolves to a stream error", async () => {
|
||||
const recovered = vi.fn();
|
||||
let callCount = 0;
|
||||
const errorMessage = createTestStreamErrorMessage("retry stream failed");
|
||||
const wrapped = wrapAnthropicStreamWithRecovery(
|
||||
(() => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
return Promise.reject(anthropicThinkingError);
|
||||
}
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({
|
||||
type: "error",
|
||||
reason: "error",
|
||||
error: errorMessage,
|
||||
});
|
||||
stream.end();
|
||||
});
|
||||
return stream;
|
||||
}) as Parameters<typeof wrapAnthropicStreamWithRecovery>[0],
|
||||
{ id: "test-session", onRecoveredAnthropicThinking: recovered },
|
||||
);
|
||||
|
||||
const response = (await wrapped(
|
||||
{} as never,
|
||||
{
|
||||
messages: castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "secret", thinkingSignature: "sig" }],
|
||||
},
|
||||
]),
|
||||
} as never,
|
||||
{} as never,
|
||||
)) as { result: () => Promise<unknown> } & AsyncIterable<unknown>;
|
||||
for await (const event of response) {
|
||||
void event;
|
||||
// Drain the retry stream before reading result().
|
||||
}
|
||||
|
||||
await expect(response.result()).resolves.toEqual(errorMessage);
|
||||
expect(callCount).toBe(2);
|
||||
expect(recovered).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries Bedrock-style invalid thinking signature errors", async () => {
|
||||
let callCount = 0;
|
||||
const bedrockThinkingError = new Error(
|
||||
|
||||
@@ -10,7 +10,15 @@ import { log } from "./logger.js";
|
||||
type AssistantContentBlock = Extract<AgentMessage, { role: "assistant" }>["content"][number];
|
||||
type AssistantMessage = Extract<AgentMessage, { role: "assistant" }>;
|
||||
type RecoveryAssessment = "valid" | "incomplete-thinking" | "incomplete-text";
|
||||
type RecoverySessionMeta = { id: string; recoveredAnthropicThinking?: boolean };
|
||||
export type AnthropicThinkingRecovery = {
|
||||
originalMessages: AgentMessage[];
|
||||
cleanedMessages: AgentMessage[];
|
||||
};
|
||||
type RecoverySessionMeta = {
|
||||
id: string;
|
||||
recoveredAnthropicThinking?: boolean;
|
||||
onRecoveredAnthropicThinking?: (recovery: AnthropicThinkingRecovery) => void | Promise<void>;
|
||||
};
|
||||
|
||||
const THINKING_BLOCK_ERROR_PATTERN =
|
||||
/(?:thinking|redacted_thinking).*?(?:cannot be modified|signature|invalid|missing|empty|blank)|(?:signature|invalid|missing|empty|blank).*?(?:thinking|redacted_thinking)/i;
|
||||
@@ -417,26 +425,31 @@ export function shouldPreserveLatestAssistantThinking(messages: AgentMessage[]):
|
||||
return shouldPreserveCurrentToolTurnReasoning(messages, latestAssistantIndex, latestUserIndex);
|
||||
}
|
||||
|
||||
export function stripThinkingBlocksFromMessage(message: AgentMessage): AgentMessage {
|
||||
if (!isAssistantMessageWithContent(message)) {
|
||||
return message;
|
||||
}
|
||||
const nextContent = message.content.filter((block) => !isThinkingBlock(block));
|
||||
if (nextContent.length === message.content.length) {
|
||||
return message;
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
content: nextContent.length > 0 ? nextContent : buildOmittedAssistantReasoningContent(),
|
||||
};
|
||||
}
|
||||
|
||||
function stripAllThinkingBlocks(messages: AgentMessage[]): AgentMessage[] {
|
||||
let touched = false;
|
||||
const out: AgentMessage[] = [];
|
||||
for (const message of messages) {
|
||||
if (!isAssistantMessageWithContent(message)) {
|
||||
out.push(message);
|
||||
const stripped = stripThinkingBlocksFromMessage(message);
|
||||
if (stripped === message) {
|
||||
out.push(stripped);
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextContent = message.content.filter((block) => !isThinkingBlock(block));
|
||||
if (nextContent.length === message.content.length) {
|
||||
out.push(message);
|
||||
continue;
|
||||
}
|
||||
|
||||
touched = true;
|
||||
out.push({
|
||||
...message,
|
||||
content: nextContent.length > 0 ? nextContent : buildOmittedAssistantReasoningContent(),
|
||||
});
|
||||
out.push(stripped);
|
||||
}
|
||||
return touched ? out : messages;
|
||||
}
|
||||
@@ -592,9 +605,58 @@ function getAssistantMessageErrorText(
|
||||
return typeof errorMessage === "string" ? errorMessage : "";
|
||||
}
|
||||
|
||||
async function notifyRecoveredAnthropicThinking(
|
||||
sessionMeta: RecoverySessionMeta,
|
||||
recovery: AnthropicThinkingRecovery,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await sessionMeta.onRecoveredAnthropicThinking?.(recovery);
|
||||
} catch (error: unknown) {
|
||||
log.warn(
|
||||
`[session-recovery] Anthropic thinking transcript repair hook failed: sessionId=${sessionMeta.id} error=${formatErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isSuccessfulRecoveryRetryResult(message: AssistantMessage | undefined): boolean {
|
||||
if (!message) {
|
||||
return false;
|
||||
}
|
||||
return message.stopReason !== "error" && message.stopReason !== "aborted";
|
||||
}
|
||||
|
||||
function wrapRetryStreamWithRecoveryNotification(
|
||||
retryStream: ReturnType<StreamFn>,
|
||||
notify: () => Promise<void>,
|
||||
): ReturnType<StreamFn> {
|
||||
if (retryStream instanceof Promise) {
|
||||
return retryStream.then((resolved) =>
|
||||
wrapRetryStreamWithRecoveryNotification(resolved as ReturnType<StreamFn>, notify),
|
||||
) as ReturnType<StreamFn>;
|
||||
}
|
||||
const streamWithResult = retryStream as unknown as {
|
||||
result?: () => Promise<AssistantMessage>;
|
||||
};
|
||||
if (typeof streamWithResult.result !== "function") {
|
||||
return retryStream;
|
||||
}
|
||||
const result = streamWithResult.result.bind(streamWithResult);
|
||||
let notified = false;
|
||||
streamWithResult.result = async () => {
|
||||
const message = await result();
|
||||
if (!notified && isSuccessfulRecoveryRetryResult(message)) {
|
||||
notified = true;
|
||||
await notify();
|
||||
}
|
||||
return message;
|
||||
};
|
||||
return retryStream;
|
||||
}
|
||||
|
||||
async function retryStreamWithoutThinking(
|
||||
outer: ReturnType<typeof createAssistantMessageEventStream>,
|
||||
retry: () => ReturnType<StreamFn>,
|
||||
notify: () => Promise<void>,
|
||||
): Promise<AssistantMessage> {
|
||||
const retryStream = retry();
|
||||
const resolvedRetry = retryStream instanceof Promise ? await retryStream : retryStream;
|
||||
@@ -602,6 +664,9 @@ async function retryStreamWithoutThinking(
|
||||
outer.push(chunk as Parameters<typeof outer.push>[0]);
|
||||
}
|
||||
const result = await (resolvedRetry as { result?: () => Promise<AssistantMessage> }).result?.();
|
||||
if (isSuccessfulRecoveryRetryResult(result)) {
|
||||
await notify();
|
||||
}
|
||||
return result as AssistantMessage;
|
||||
}
|
||||
|
||||
@@ -610,6 +675,7 @@ async function pumpStreamWithRecovery(
|
||||
stream: ReturnType<StreamFn>,
|
||||
sessionMeta: RecoverySessionMeta,
|
||||
retry: () => ReturnType<StreamFn>,
|
||||
notify: () => Promise<void>,
|
||||
): Promise<AssistantMessage> {
|
||||
let yieldedOutput = false;
|
||||
try {
|
||||
@@ -631,7 +697,7 @@ async function pumpStreamWithRecovery(
|
||||
log.warn(
|
||||
`[session-recovery] Anthropic thinking stream error; retrying once without thinking blocks: sessionId=${sessionMeta.id}`,
|
||||
);
|
||||
return retryStreamWithoutThinking(outer, retry);
|
||||
return retryStreamWithoutThinking(outer, retry, notify);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -655,7 +721,7 @@ async function pumpStreamWithRecovery(
|
||||
log.warn(
|
||||
`[session-recovery] Anthropic thinking error during stream; retrying once without thinking blocks: sessionId=${sessionMeta.id}`,
|
||||
);
|
||||
return retryStreamWithoutThinking(outer, retry);
|
||||
return retryStreamWithoutThinking(outer, retry, notify);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -664,7 +730,10 @@ export function wrapAnthropicStreamWithRecovery(
|
||||
sessionMeta: RecoverySessionMeta,
|
||||
): StreamFn {
|
||||
return (model, context, options) => {
|
||||
const requestMeta: RecoverySessionMeta = { id: sessionMeta.id };
|
||||
const requestMeta: RecoverySessionMeta = {
|
||||
id: sessionMeta.id,
|
||||
onRecoveredAnthropicThinking: sessionMeta.onRecoveredAnthropicThinking,
|
||||
};
|
||||
const contextRecord = context as unknown as { messages?: unknown };
|
||||
const originalMessages = Array.isArray(contextRecord.messages)
|
||||
? (contextRecord.messages as AgentMessage[])
|
||||
@@ -677,6 +746,11 @@ export function wrapAnthropicStreamWithRecovery(
|
||||
} as typeof context;
|
||||
return innerStreamFn(model, nextContext, options);
|
||||
};
|
||||
const notify = () =>
|
||||
notifyRecoveredAnthropicThinking(requestMeta, {
|
||||
originalMessages,
|
||||
cleanedMessages: stripAllThinkingBlocks(originalMessages),
|
||||
});
|
||||
|
||||
const stream = innerStreamFn(model, context, options);
|
||||
if (stream instanceof Promise) {
|
||||
@@ -688,15 +762,19 @@ export function wrapAnthropicStreamWithRecovery(
|
||||
log.warn(
|
||||
`[session-recovery] Anthropic thinking request rejected; retrying once without thinking blocks: sessionId=${requestMeta.id}`,
|
||||
);
|
||||
return retry();
|
||||
return wrapRetryStreamWithRecoveryNotification(retry(), notify);
|
||||
}) as ReturnType<StreamFn>;
|
||||
}
|
||||
const outer = createAssistantMessageEventStream();
|
||||
const finalResultPromise = pumpStreamWithRecovery(outer, stream, requestMeta, retry).finally(
|
||||
() => {
|
||||
outer.end();
|
||||
},
|
||||
);
|
||||
const finalResultPromise = pumpStreamWithRecovery(
|
||||
outer,
|
||||
stream,
|
||||
requestMeta,
|
||||
retry,
|
||||
notify,
|
||||
).finally(() => {
|
||||
outer.end();
|
||||
});
|
||||
outer.result = () => finalResultPromise;
|
||||
return outer as unknown as ReturnType<StreamFn>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user