mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 08:31:00 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7debb69be8 | ||
|
|
4bc977b95d | ||
|
|
9bbf62e6e6 | ||
|
|
8a8da57a49 | ||
|
|
fb03abea64 |
No files matched your search
@@ -2,9 +2,11 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
createCattyAnthropicSignatureRetryError,
|
||||
createCattyRequestTooLargeRetryError,
|
||||
hadToolProgressBeforeRequestTooLarge,
|
||||
} from "./cattyRequestTooLargeRetry.ts";
|
||||
import { isInvalidAnthropicThinkingSignatureError } from "./errorClassifier.ts";
|
||||
|
||||
test("createCattyRequestTooLargeRetryError marks 413 retry errors after tool progress", () => {
|
||||
const source = Object.assign(new Error("HTTP 413 Request Entity Too Large"), {
|
||||
@@ -27,3 +29,71 @@ test("hadToolProgressBeforeRequestTooLarge is false when no tool progress was re
|
||||
assert.equal(hadToolProgressBeforeRequestTooLarge(retryError), false);
|
||||
assert.equal(hadToolProgressBeforeRequestTooLarge(new Error("HTTP 413")), false);
|
||||
});
|
||||
|
||||
test("createCattyAnthropicSignatureRetryError preserves classifier fields and tool progress", () => {
|
||||
const source = Object.assign(
|
||||
new Error("Invalid signature in thinking block. The block is bound to a different conversation."),
|
||||
{
|
||||
statusCode: 400,
|
||||
responseBody: JSON.stringify({
|
||||
error: {
|
||||
message: "Invalid signature in thinking block. The block is bound to a different conversation.",
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const retryError = createCattyAnthropicSignatureRetryError(source, true);
|
||||
|
||||
assert.equal(retryError.statusCode, 400);
|
||||
assert.equal(retryError.responseBody, source.responseBody);
|
||||
assert.equal(retryError.cause, source);
|
||||
assert.equal(hadToolProgressBeforeRequestTooLarge(retryError), true);
|
||||
assert.equal(isInvalidAnthropicThinkingSignatureError(retryError), true);
|
||||
});
|
||||
|
||||
test("createCattyAnthropicSignatureRetryError defaults status 400 and no progress", () => {
|
||||
const retryError = createCattyAnthropicSignatureRetryError(
|
||||
"Invalid signature in thinking block. The block is bound to a different conversation.",
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(retryError.statusCode, 400);
|
||||
assert.equal(hadToolProgressBeforeRequestTooLarge(retryError), false);
|
||||
assert.equal(isInvalidAnthropicThinkingSignatureError(retryError), true);
|
||||
});
|
||||
|
||||
test("createCattyAnthropicSignatureRetryError preserves plain-object SSE signature message", () => {
|
||||
const source = {
|
||||
type: "invalid_request_error",
|
||||
message: "Invalid signature in thinking block",
|
||||
status: 400,
|
||||
};
|
||||
|
||||
const retryError = createCattyAnthropicSignatureRetryError(source, false);
|
||||
|
||||
assert.equal(retryError.message, "Invalid signature in thinking block");
|
||||
assert.notEqual(retryError.message, "[object Object]");
|
||||
assert.equal(retryError.statusCode, 400);
|
||||
assert.equal(retryError.status, 400);
|
||||
assert.equal(retryError.cause, source);
|
||||
assert.equal(isInvalidAnthropicThinkingSignatureError(retryError), true);
|
||||
});
|
||||
|
||||
test("createCattyAnthropicSignatureRetryError preserves plain-object message with statusCode", () => {
|
||||
const source = {
|
||||
type: "invalid_request_error",
|
||||
message: "Invalid signature in thinking block. The block is bound to a different conversation.",
|
||||
statusCode: 400,
|
||||
};
|
||||
|
||||
const retryError = createCattyAnthropicSignatureRetryError(source, true);
|
||||
|
||||
assert.equal(
|
||||
retryError.message,
|
||||
"Invalid signature in thinking block. The block is bound to a different conversation.",
|
||||
);
|
||||
assert.equal(retryError.statusCode, 400);
|
||||
assert.equal(hadToolProgressBeforeRequestTooLarge(retryError), true);
|
||||
assert.equal(isInvalidAnthropicThinkingSignatureError(retryError), true);
|
||||
});
|
||||
@@ -5,13 +5,32 @@ export type CattyRequestTooLargeRetryError = Error & {
|
||||
responseBody?: string;
|
||||
};
|
||||
|
||||
export type CattyAnthropicSignatureRetryError = Error & {
|
||||
cattyHadToolProgress?: boolean;
|
||||
statusCode?: number;
|
||||
status?: number;
|
||||
responseBody?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mirror errorClassifier.extractMessage so plain SDK / SSE error objects
|
||||
* with a string `.message` keep that text (not `[object Object]`).
|
||||
*/
|
||||
function extractRetryMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof Error) return error.message || fallback;
|
||||
if (typeof error === 'string') return error;
|
||||
if (error && typeof error === 'object' && 'message' in error) {
|
||||
const m = (error as { message: unknown }).message;
|
||||
if (typeof m === 'string' && m.length > 0) return m;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function createCattyRequestTooLargeRetryError(
|
||||
error: unknown,
|
||||
hadToolProgress: boolean,
|
||||
): CattyRequestTooLargeRetryError {
|
||||
const message = error instanceof Error
|
||||
? error.message
|
||||
: String(error ?? 'Request too large');
|
||||
const message = extractRetryMessage(error, 'Request too large');
|
||||
const retryError = new Error(message) as CattyRequestTooLargeRetryError;
|
||||
retryError.name = 'CattyRequestTooLargeRetryError';
|
||||
retryError.cause = error;
|
||||
@@ -25,6 +44,33 @@ export function createCattyRequestTooLargeRetryError(
|
||||
return retryError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an Anthropic thinking-signature failure so the turn-driver retry path
|
||||
* can see prior tool progress, while keeping message/body/status intact for
|
||||
* `isInvalidAnthropicThinkingSignatureError`.
|
||||
*/
|
||||
export function createCattyAnthropicSignatureRetryError(
|
||||
error: unknown,
|
||||
hadToolProgress: boolean,
|
||||
): CattyAnthropicSignatureRetryError {
|
||||
const message = extractRetryMessage(error, 'Invalid signature in thinking block');
|
||||
const retryError = new Error(message) as CattyAnthropicSignatureRetryError;
|
||||
retryError.name = 'CattyAnthropicSignatureRetryError';
|
||||
retryError.cause = error;
|
||||
retryError.cattyHadToolProgress = hadToolProgress;
|
||||
if (error && typeof error === 'object') {
|
||||
const source = error as Record<string, unknown>;
|
||||
if (typeof source.statusCode === 'number') retryError.statusCode = source.statusCode;
|
||||
else if (typeof source.status === 'number') retryError.statusCode = source.status;
|
||||
else retryError.statusCode = 400;
|
||||
if (typeof source.status === 'number') retryError.status = source.status;
|
||||
if (typeof source.responseBody === 'string') retryError.responseBody = source.responseBody;
|
||||
} else {
|
||||
retryError.statusCode = 400;
|
||||
}
|
||||
return retryError;
|
||||
}
|
||||
|
||||
export function hadToolProgressBeforeRequestTooLarge(error: unknown): boolean {
|
||||
return !!(
|
||||
error &&
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { classifyError, isRequestTooLargeError, sanitizeErrorMessage } from "./errorClassifier.ts";
|
||||
import { classifyError, isInvalidAnthropicThinkingSignatureError, isRequestTooLargeError, sanitizeErrorMessage } from "./errorClassifier.ts";
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// sanitizeErrorMessage — regression guard for pre-existing behavior
|
||||
@@ -74,6 +74,61 @@ test("isRequestTooLargeError does not treat timing text as HTTP 413", () => {
|
||||
assert.equal(isRequestTooLargeError("413 ms"), false);
|
||||
});
|
||||
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// isInvalidAnthropicThinkingSignatureError — #4073 prefix binding
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
test("isInvalidAnthropicThinkingSignatureError matches Anthropic 400 signature text", () => {
|
||||
const err = Object.assign(
|
||||
new Error("Invalid signature in thinking block. The block is bound to a different conversation."),
|
||||
{ statusCode: 400 },
|
||||
);
|
||||
assert.equal(isInvalidAnthropicThinkingSignatureError(err), true);
|
||||
});
|
||||
|
||||
test("isInvalidAnthropicThinkingSignatureError matches responseBody-only signature errors", () => {
|
||||
const err = Object.assign(new Error("AI_APICallError"), {
|
||||
statusCode: 400,
|
||||
responseBody: JSON.stringify({
|
||||
type: "error",
|
||||
error: {
|
||||
type: "invalid_request_error",
|
||||
message: "Invalid signature in thinking block. The block is bound to a different conversation.",
|
||||
},
|
||||
}),
|
||||
});
|
||||
assert.equal(isInvalidAnthropicThinkingSignatureError(err), true);
|
||||
});
|
||||
|
||||
test("isInvalidAnthropicThinkingSignatureError matches text without statusCode", () => {
|
||||
assert.equal(
|
||||
isInvalidAnthropicThinkingSignatureError(
|
||||
new Error("bound to a different conversation"),
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("isInvalidAnthropicThinkingSignatureError rejects unrelated 400s and non-400 signature-ish text", () => {
|
||||
assert.equal(
|
||||
isInvalidAnthropicThinkingSignatureError(
|
||||
Object.assign(new Error("Invalid request: missing field"), { statusCode: 400 }),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isInvalidAnthropicThinkingSignatureError(
|
||||
Object.assign(
|
||||
new Error("Invalid signature in thinking block. The block is bound to a different conversation."),
|
||||
{ statusCode: 500 },
|
||||
),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(isInvalidAnthropicThinkingSignatureError(new Error("plain failure")), false);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// classifyError — 502 / 503 / 504 upstream gateway
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
@@ -72,6 +72,25 @@ export function isRequestTooLargeError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anthropic preserved-thinking prefix-binding failure (#4073):
|
||||
* "Invalid signature in thinking block. The block is bound to a different conversation."
|
||||
* Triggered when client history mutates an earlier tool_result / drops early turns /
|
||||
* injects a durable summary while still replaying later signed thinking blocks.
|
||||
*/
|
||||
export function isInvalidAnthropicThinkingSignatureError(error: unknown): boolean {
|
||||
const message = extractMessage(error).trim();
|
||||
const responseBody = extractResponseBody(error) ?? "";
|
||||
const combined = `${message}\n${responseBody}`;
|
||||
const statusCode = extractStatusCode(error, combined);
|
||||
const looksLikeSignature = /Invalid[\s\S]*signature[\s\S]*thinking|bound to a different conversation/i.test(combined);
|
||||
if (!looksLikeSignature) return false;
|
||||
// Prefer structured 400 when present; still match when only the text is available
|
||||
// (some SDK shims omit statusCode and put the body in the message).
|
||||
if (statusCode !== undefined && statusCode !== 400) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function looksLikeHtml(text: string): boolean {
|
||||
if (!text) return false;
|
||||
const lower = text.toLowerCase();
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { ModelMessage } from 'ai';
|
||||
|
||||
import {
|
||||
hasAnthropicThinkingSignature,
|
||||
providerAllowsAnthropicSignedThinkingRemoval,
|
||||
stripAnthropicSignedReasoningParts,
|
||||
} from './anthropicThinkingBinding.ts';
|
||||
|
||||
function assistantWithParts(parts: unknown[]): ModelMessage {
|
||||
return { role: 'assistant', content: parts } as ModelMessage;
|
||||
}
|
||||
|
||||
test('stripAnthropicSignedReasoningParts drops only signed anthropic reasoning', () => {
|
||||
const input: ModelMessage[] = [
|
||||
{ role: 'user', content: 'hi' },
|
||||
assistantWithParts([
|
||||
{ type: 'reasoning', text: 'signed', providerOptions: { anthropic: { signature: 'sig-1' } } },
|
||||
{ type: 'reasoning', text: 'unsigned' },
|
||||
{ type: 'text', text: 'hello' },
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'c1',
|
||||
toolName: 'terminal_execute',
|
||||
input: { command: 'ls' },
|
||||
},
|
||||
]),
|
||||
assistantWithParts([
|
||||
{ type: 'reasoning', text: 'also signed', providerOptions: { anthropic: { signature: 'sig-2' } } },
|
||||
{ type: 'text', text: 'done' },
|
||||
]),
|
||||
];
|
||||
|
||||
const { messages, didStrip } = stripAnthropicSignedReasoningParts(input);
|
||||
|
||||
assert.equal(didStrip, true);
|
||||
assert.equal(messages[0], input[0], 'unchanged messages stay same reference');
|
||||
assert.notEqual(messages[1], input[1], 'changed assistant gets new object');
|
||||
assert.deepEqual(
|
||||
(messages[1].content as { type: string; text?: string }[]).map((p) => [p.type, p.text]),
|
||||
[
|
||||
['reasoning', 'unsigned'],
|
||||
['text', 'hello'],
|
||||
['tool-call', undefined],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
(messages[2].content as { type: string; text?: string }[]).map((p) => [p.type, p.text]),
|
||||
[['text', 'done']],
|
||||
);
|
||||
// Input not mutated
|
||||
assert.equal((input[1].content as unknown[]).length, 4);
|
||||
});
|
||||
|
||||
test('stripAnthropicSignedReasoningParts leaves unsigned / empty-signature alone', () => {
|
||||
const input: ModelMessage[] = [
|
||||
assistantWithParts([
|
||||
{ type: 'reasoning', text: 'plain' },
|
||||
{ type: 'reasoning', text: 'empty sig', providerOptions: { anthropic: { signature: '' } } },
|
||||
{ type: 'reasoning', text: 'other provider', providerOptions: { openai: { itemId: 'rs_1' } } },
|
||||
{ type: 'text', text: 'ok' },
|
||||
]),
|
||||
];
|
||||
const { messages, didStrip } = stripAnthropicSignedReasoningParts(input);
|
||||
assert.equal(didStrip, false);
|
||||
assert.equal(messages, input);
|
||||
});
|
||||
|
||||
test('stripAnthropicSignedReasoningParts ignores non-array assistant content', () => {
|
||||
const input: ModelMessage[] = [
|
||||
{ role: 'assistant', content: 'plain string' },
|
||||
{ role: 'tool', content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 't', output: { type: 'text', value: 'x' } }] } as ModelMessage,
|
||||
];
|
||||
const { messages, didStrip } = stripAnthropicSignedReasoningParts(input);
|
||||
assert.equal(didStrip, false);
|
||||
assert.equal(messages, input);
|
||||
});
|
||||
|
||||
test('hasAnthropicThinkingSignature matches non-empty anthropic.signature', () => {
|
||||
assert.equal(hasAnthropicThinkingSignature({
|
||||
providerOptions: { anthropic: { signature: 'abc' } },
|
||||
}), true);
|
||||
assert.equal(hasAnthropicThinkingSignature({
|
||||
providerOptions: { anthropic: { signature: '' } },
|
||||
}), false);
|
||||
assert.equal(hasAnthropicThinkingSignature({ providerOptions: {} }), false);
|
||||
assert.equal(hasAnthropicThinkingSignature({}), false);
|
||||
});
|
||||
|
||||
test('prepareStep strip condition: signed after long tool_result + didAdjust → no signatures remain', () => {
|
||||
// Mirrors the long-chat path: prepareStepContext mutates an earlier
|
||||
// tool_result (didAdjust=true) while a later assistant still carries a
|
||||
// signed thinking block. After the strip helper runs, no anthropic.signature
|
||||
// may remain on the retained tail.
|
||||
const longToolResult = 'x'.repeat(5000);
|
||||
const messages: ModelMessage[] = [
|
||||
assistantWithParts([
|
||||
{ type: 'reasoning', text: 'plan', providerOptions: { anthropic: { signature: 'bound-to-prefix' } } },
|
||||
{ type: 'text', text: 'running' },
|
||||
{ type: 'tool-call', toolCallId: 'c1', toolName: 'terminal_execute', input: { command: 'cat big' } },
|
||||
]),
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'c1',
|
||||
toolName: 'terminal_execute',
|
||||
output: { type: 'text', value: longToolResult },
|
||||
}],
|
||||
} as ModelMessage,
|
||||
assistantWithParts([
|
||||
{ type: 'reasoning', text: 'after tool', providerOptions: { anthropic: { signature: 'later-sig' } } },
|
||||
{ type: 'text', text: 'done' },
|
||||
]),
|
||||
];
|
||||
|
||||
// Simulate the turn-driver wiring: when didAdjust and provider allows
|
||||
// removal, strip before returning (official Anthropic).
|
||||
const didAdjust = true;
|
||||
const preserveAnthropicThinkingReplay = true;
|
||||
const allowStripSignedThinking = true;
|
||||
let stepMessages = messages;
|
||||
if (preserveAnthropicThinkingReplay && didAdjust && allowStripSignedThinking) {
|
||||
stepMessages = stripAnthropicSignedReasoningParts(stepMessages).messages;
|
||||
}
|
||||
|
||||
for (const message of stepMessages) {
|
||||
if (message.role !== 'assistant' || !Array.isArray(message.content)) continue;
|
||||
for (const part of message.content as { type?: string; providerOptions?: { anthropic?: { signature?: string } } }[]) {
|
||||
if (part.type === 'reasoning') {
|
||||
assert.equal(
|
||||
part.providerOptions?.anthropic?.signature,
|
||||
undefined,
|
||||
'no anthropic.signature may remain after strip-on-didAdjust',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Unsigned-looking text / tool-call parts still present
|
||||
const first = stepMessages[0].content as { type: string }[];
|
||||
assert.deepEqual(first.map((p) => p.type), ['text', 'tool-call']);
|
||||
});
|
||||
|
||||
test('providerAllowsAnthropicSignedThinkingRemoval is false for DeepSeek identities', () => {
|
||||
assert.equal(providerAllowsAnthropicSignedThinkingRemoval({ providerId: 'deepseek' }), false);
|
||||
assert.equal(providerAllowsAnthropicSignedThinkingRemoval({
|
||||
providerId: 'custom',
|
||||
baseURL: 'https://api.deepseek.com/anthropic',
|
||||
}), false);
|
||||
assert.equal(providerAllowsAnthropicSignedThinkingRemoval(
|
||||
{ providerId: 'anthropic' },
|
||||
'deepseek-v4-flash',
|
||||
), false);
|
||||
assert.equal(providerAllowsAnthropicSignedThinkingRemoval({
|
||||
providerId: 'custom',
|
||||
defaultModel: 'DeepSeek-V3',
|
||||
}), false);
|
||||
assert.equal(providerAllowsAnthropicSignedThinkingRemoval({
|
||||
providerId: 'anthropic',
|
||||
defaultModel: 'claude-opus-4-5',
|
||||
}), true);
|
||||
assert.equal(providerAllowsAnthropicSignedThinkingRemoval({
|
||||
providerId: 'anthropic',
|
||||
baseURL: 'https://api.anthropic.com',
|
||||
}, 'claude-sonnet-4'), true);
|
||||
});
|
||||
|
||||
test('prepareStep strip is skipped when provider forbids thinking removal (DeepSeek)', () => {
|
||||
const messages: ModelMessage[] = [
|
||||
assistantWithParts([
|
||||
{ type: 'reasoning', text: 'plan', providerOptions: { anthropic: { signature: 'keep-me' } } },
|
||||
{ type: 'text', text: 'running' },
|
||||
{ type: 'tool-call', toolCallId: 'c1', toolName: 'terminal_execute', input: { command: 'ls' } },
|
||||
]),
|
||||
];
|
||||
const didAdjust = true;
|
||||
const preserveAnthropicThinkingReplay = true;
|
||||
const allowStripSignedThinking = providerAllowsAnthropicSignedThinkingRemoval(
|
||||
{ providerId: 'deepseek' },
|
||||
'deepseek-v4-flash',
|
||||
);
|
||||
let stepMessages = messages;
|
||||
if (preserveAnthropicThinkingReplay && didAdjust && allowStripSignedThinking) {
|
||||
stepMessages = stripAnthropicSignedReasoningParts(stepMessages).messages;
|
||||
}
|
||||
assert.equal(allowStripSignedThinking, false);
|
||||
assert.equal(stepMessages, messages);
|
||||
const first = stepMessages[0].content as { type: string; providerOptions?: { anthropic?: { signature?: string } } }[];
|
||||
assert.equal(first[0].providerOptions?.anthropic?.signature, 'keep-me');
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { ModelMessage } from 'ai';
|
||||
|
||||
/**
|
||||
* Anthropic preserved-thinking signatures are cryptographically bound to the
|
||||
* exact conversation prefix (system + tools + every earlier messages[] entry).
|
||||
* Client-side edits that shorten tool_results, drop early turns, or inject a
|
||||
* durable summary invalidate later signatures with:
|
||||
* "Invalid signature in thinking block. The block is bound to a different conversation."
|
||||
*
|
||||
* Anthropic allows removing thinking blocks wholesale; it forbids keeping a
|
||||
* later signed block after mutating its prefix. Strip signed reasoning parts
|
||||
* when we know the prefix mutated (#4073).
|
||||
*/
|
||||
|
||||
type ContentPart = Extract<ModelMessage['content'], readonly unknown[]>[number];
|
||||
|
||||
function anthropicSignatureOf(part: ContentPart): string | undefined {
|
||||
if (!part || typeof part !== 'object') return undefined;
|
||||
if (!('type' in part) || (part as { type?: unknown }).type !== 'reasoning') return undefined;
|
||||
const providerOptions = (part as { providerOptions?: unknown }).providerOptions;
|
||||
if (!providerOptions || typeof providerOptions !== 'object') return undefined;
|
||||
const anthropic = (providerOptions as { anthropic?: unknown }).anthropic;
|
||||
if (!anthropic || typeof anthropic !== 'object') return undefined;
|
||||
const signature = (anthropic as { signature?: unknown }).signature;
|
||||
return typeof signature === 'string' && signature.length > 0 ? signature : undefined;
|
||||
}
|
||||
|
||||
function isSignedAnthropicReasoningPart(part: ContentPart): boolean {
|
||||
return anthropicSignatureOf(part) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop assistant `reasoning` parts that carry a non-empty
|
||||
* `providerOptions.anthropic.signature`. Unsigned reasoning / text / tool-call
|
||||
* parts are left alone. Does not mutate input arrays in place — returns new
|
||||
* message objects only when a message actually changes.
|
||||
*/
|
||||
export function stripAnthropicSignedReasoningParts(
|
||||
messages: ModelMessage[],
|
||||
): { messages: ModelMessage[]; didStrip: boolean } {
|
||||
let didStrip = false;
|
||||
const next = messages.map((message) => {
|
||||
if (message.role !== 'assistant' || !Array.isArray(message.content)) {
|
||||
return message;
|
||||
}
|
||||
const content = message.content as ContentPart[];
|
||||
const filtered = content.filter((part) => !isSignedAnthropicReasoningPart(part));
|
||||
if (filtered.length === content.length) {
|
||||
return message;
|
||||
}
|
||||
didStrip = true;
|
||||
return { ...message, content: filtered } as ModelMessage;
|
||||
});
|
||||
return { messages: didStrip ? next : messages, didStrip };
|
||||
}
|
||||
|
||||
/** True when a reasoning-like part carries a non-empty Anthropic signature. */
|
||||
export function hasAnthropicThinkingSignature(part: {
|
||||
providerOptions?: { anthropic?: { signature?: unknown } } | Record<string, unknown> | undefined;
|
||||
}): boolean {
|
||||
const providerOptions = part.providerOptions;
|
||||
if (!providerOptions || typeof providerOptions !== 'object') return false;
|
||||
const anthropic = (providerOptions as { anthropic?: unknown }).anthropic;
|
||||
if (!anthropic || typeof anthropic !== 'object') return false;
|
||||
const signature = (anthropic as { signature?: unknown }).signature;
|
||||
return typeof signature === 'string' && signature.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Official Anthropic allows removing thinking blocks wholesale after a prefix
|
||||
* mutate (#4073). DeepSeek's Anthropic-compatible endpoint does NOT — it
|
||||
* requires thinking blocks to accompany replayed tool calls. Return false when
|
||||
* the active provider/model looks like DeepSeek so callers keep signed thinking.
|
||||
*/
|
||||
export function providerAllowsAnthropicSignedThinkingRemoval(
|
||||
provider: { providerId?: string; baseURL?: string; defaultModel?: string } | null | undefined,
|
||||
modelId?: string | null,
|
||||
): boolean {
|
||||
const providerId = String(provider?.providerId ?? '').toLowerCase();
|
||||
if (providerId === 'deepseek') return false;
|
||||
const baseURL = String(provider?.baseURL ?? '').toLowerCase();
|
||||
if (baseURL.includes('deepseek.com')) return false;
|
||||
const models = [modelId, provider?.defaultModel]
|
||||
.map((value) => String(value ?? '').toLowerCase())
|
||||
.filter(Boolean);
|
||||
if (models.some((value) => value.includes('deepseek'))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -665,3 +665,157 @@ test('Anthropic-format signed thinking survives build and stream preparation', (
|
||||
assert.equal(plainThinking?.text, 'final thinking');
|
||||
assert.deepEqual(plainThinking?.providerOptions?.anthropic, { signature: 'sig-xyz' });
|
||||
});
|
||||
|
||||
test('durable summary skips Anthropic signed thinking on the retained tail (#4073)', () => {
|
||||
// Durable compaction injects a synthetic summary and keeps the recent tail.
|
||||
// Replaying signed thinking on that tail would 400 (prefix binding). Unsigned
|
||||
// reasoning may still be replayed.
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: 'assistant-old',
|
||||
role: 'assistant',
|
||||
content: 'Earlier.',
|
||||
timestamp: 1,
|
||||
providerContinuation: {
|
||||
source: { providerConfigId: 'anthropic-1', providerType: 'anthropic', modelId: 'claude-opus-4-5' },
|
||||
reasoningParts: [{
|
||||
text: 'compacted thinking',
|
||||
providerOptions: { anthropic: { signature: 'sig-old' } },
|
||||
}],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'assistant-tail',
|
||||
role: 'assistant',
|
||||
content: 'Recent reply.',
|
||||
timestamp: 2,
|
||||
providerContinuation: {
|
||||
source: { providerConfigId: 'anthropic-1', providerType: 'anthropic', modelId: 'claude-opus-4-5' },
|
||||
reasoningParts: [
|
||||
{
|
||||
text: 'signed recent',
|
||||
providerOptions: { anthropic: { signature: 'sig-tail' } },
|
||||
},
|
||||
{ text: 'unsigned recent' },
|
||||
],
|
||||
},
|
||||
toolCalls: [{ id: 'call-1', name: 'terminal_execute', arguments: { command: 'ls' } }],
|
||||
},
|
||||
{
|
||||
id: 'tool-1',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: 3,
|
||||
toolResults: [{ toolCallId: 'call-1', content: 'ok' }],
|
||||
},
|
||||
];
|
||||
|
||||
const built = buildCattySdkMessages({
|
||||
allMessages: messages,
|
||||
contextCompaction: {
|
||||
summary: 'Earlier work finished.',
|
||||
compactedMessageCount: 1,
|
||||
},
|
||||
includeCurrentUserMessage: false,
|
||||
trimmed: '',
|
||||
continuationContext: createContinuationContext('anthropic-1', 'anthropic', 'claude-opus-4-5'),
|
||||
chatSessionId: 'chat-1',
|
||||
toolOutputStore: new ToolOutputStore(),
|
||||
fieldsByMessage: new Map(),
|
||||
});
|
||||
|
||||
assert.match(String(built[0].content), /Earlier work finished/);
|
||||
const tailAssistant = built.find((m) => m.role === 'assistant' && Array.isArray(m.content)
|
||||
&& (m.content as { type?: string }[]).some((p) => p.type === 'tool-call'));
|
||||
assert.ok(tailAssistant);
|
||||
const parts = tailAssistant!.content as {
|
||||
type: string;
|
||||
text?: string;
|
||||
providerOptions?: { anthropic?: { signature?: string } };
|
||||
}[];
|
||||
const reasoning = parts.filter((p) => p.type === 'reasoning');
|
||||
assert.equal(reasoning.length, 1, 'only unsigned reasoning should remain');
|
||||
assert.equal(reasoning[0].text, 'unsigned recent');
|
||||
assert.equal(reasoning[0].providerOptions?.anthropic?.signature, undefined);
|
||||
for (const part of parts) {
|
||||
if (part.type === 'reasoning') {
|
||||
assert.equal(part.providerOptions?.anthropic?.signature, undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('durable summary keeps Anthropic signed thinking for DeepSeek (#4074 P2)', () => {
|
||||
// DeepSeek Anthropic-compat requires thinking blocks with replayed tool calls.
|
||||
// Even with a durable summary (prefix mutate), do not strip signed thinking.
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: 'assistant-old',
|
||||
role: 'assistant',
|
||||
content: 'Earlier.',
|
||||
timestamp: 1,
|
||||
providerContinuation: {
|
||||
source: { providerConfigId: 'anthropic-1', providerType: 'anthropic', modelId: 'deepseek-v4-flash' },
|
||||
reasoningParts: [{
|
||||
text: 'compacted thinking',
|
||||
providerOptions: { anthropic: { signature: 'sig-old' } },
|
||||
}],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'assistant-tail',
|
||||
role: 'assistant',
|
||||
content: 'Recent reply.',
|
||||
timestamp: 2,
|
||||
providerContinuation: {
|
||||
source: { providerConfigId: 'anthropic-1', providerType: 'anthropic', modelId: 'deepseek-v4-flash' },
|
||||
reasoningParts: [
|
||||
{
|
||||
text: 'signed recent',
|
||||
providerOptions: { anthropic: { signature: 'sig-tail' } },
|
||||
},
|
||||
{ text: 'unsigned recent' },
|
||||
],
|
||||
},
|
||||
toolCalls: [{ id: 'call-1', name: 'terminal_execute', arguments: { command: 'ls' } }],
|
||||
},
|
||||
{
|
||||
id: 'tool-1',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: 3,
|
||||
toolResults: [{ toolCallId: 'call-1', content: 'ok' }],
|
||||
},
|
||||
];
|
||||
|
||||
const built = buildCattySdkMessages({
|
||||
allMessages: messages,
|
||||
contextCompaction: {
|
||||
summary: 'Earlier work finished.',
|
||||
compactedMessageCount: 1,
|
||||
},
|
||||
includeCurrentUserMessage: false,
|
||||
trimmed: '',
|
||||
continuationContext: createContinuationContext('anthropic-1', 'anthropic', 'deepseek-v4-flash'),
|
||||
chatSessionId: 'chat-1',
|
||||
toolOutputStore: new ToolOutputStore(),
|
||||
fieldsByMessage: new Map(),
|
||||
allowStripAnthropicSignedThinking: false,
|
||||
});
|
||||
|
||||
assert.match(String(built[0].content), /Earlier work finished/);
|
||||
const tailAssistant = built.find((m) => m.role === 'assistant' && Array.isArray(m.content)
|
||||
&& (m.content as { type?: string }[]).some((p) => p.type === 'tool-call'));
|
||||
assert.ok(tailAssistant);
|
||||
const parts = tailAssistant!.content as {
|
||||
type: string;
|
||||
text?: string;
|
||||
providerOptions?: { anthropic?: { signature?: string } };
|
||||
}[];
|
||||
const reasoning = parts.filter((p) => p.type === 'reasoning');
|
||||
assert.equal(reasoning.length, 2, 'signed + unsigned reasoning must both remain for DeepSeek');
|
||||
const signed = reasoning.find((p) => p.providerOptions?.anthropic?.signature === 'sig-tail');
|
||||
assert.ok(signed, 'signed thinking must survive when allowStrip is false');
|
||||
assert.equal(signed!.text, 'signed recent');
|
||||
assert.ok(reasoning.some((p) => p.text === 'unsigned recent'));
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { redactSecretsForModel, redactSecretsInValueForModel } from '../modelSecretRedaction';
|
||||
import { fitLargeUserInputForModel } from '../largeUserInput';
|
||||
import type { ToolOutputStore } from '../toolOutputStore';
|
||||
import { hasAnthropicThinkingSignature } from '../anthropicThinkingBinding';
|
||||
|
||||
const OPENAI_CHAT_ASSISTANT_FIELDS = Symbol('netcatty.openAIChatAssistantFields');
|
||||
|
||||
@@ -201,6 +202,12 @@ export interface BuildCattySdkMessagesInput {
|
||||
chatSessionId: string;
|
||||
toolOutputStore: ToolOutputStore;
|
||||
fieldsByMessage: Map<ModelMessage, OpenAIChatAssistantFields | undefined>;
|
||||
/**
|
||||
* When true (default), durable-summary builds skip Anthropic signed thinking
|
||||
* on the retained tail (#4073). DeepSeek Anthropic-compat must pass false —
|
||||
* it requires thinking blocks with replayed tool calls.
|
||||
*/
|
||||
allowStripAnthropicSignedThinking?: boolean;
|
||||
}
|
||||
|
||||
export function buildCattySdkMessages(input: BuildCattySdkMessagesInput): ModelMessage[] {
|
||||
@@ -214,6 +221,7 @@ export function buildCattySdkMessages(input: BuildCattySdkMessagesInput): ModelM
|
||||
chatSessionId,
|
||||
toolOutputStore,
|
||||
fieldsByMessage,
|
||||
allowStripAnthropicSignedThinking = true,
|
||||
} = input;
|
||||
|
||||
const { resolvedToolCallsByAssistant, toolCallByToolResult } = buildHistoricalToolReplayMaps(allMessages);
|
||||
@@ -231,6 +239,14 @@ export function buildCattySdkMessages(input: BuildCattySdkMessagesInput): ModelM
|
||||
// The boundary can become zero when storage trims messages that were all
|
||||
// covered by the durable summary. Keep injecting that summary even though
|
||||
// no remaining persisted message needs to be skipped.
|
||||
// Durable summary replaces the compacted prefix. Replaying Anthropic signed
|
||||
// thinking on the retained tail would 400 on official Anthropic (prefix
|
||||
// binding / #4073) — skip those signatures while leaving unsigned reasoning
|
||||
// intact. DeepSeek Anthropic-compat forbids wholesale removal, so callers
|
||||
// pass allowStripAnthropicSignedThinking=false for that provider.
|
||||
const skipSignedAnthropicReasoning = Boolean(contextCompaction?.summary)
|
||||
&& allowStripAnthropicSignedThinking;
|
||||
|
||||
if (contextCompaction?.summary) {
|
||||
sdkMessages.push({
|
||||
role: 'user',
|
||||
@@ -322,6 +338,7 @@ export function buildCattySdkMessages(input: BuildCattySdkMessagesInput): ModelM
|
||||
if (replayedCalls.length > 0) {
|
||||
for (const part of collectReplayableReasoningParts(replayContinuation)) {
|
||||
if (!part.text && !part.providerOptions) continue;
|
||||
if (skipSignedAnthropicReasoning && hasAnthropicThinkingSignature(part)) continue;
|
||||
contentParts.push({
|
||||
type: 'reasoning' as const,
|
||||
text: part.text,
|
||||
@@ -357,6 +374,7 @@ export function buildCattySdkMessages(input: BuildCattySdkMessagesInput): ModelM
|
||||
const contentParts: AssistantContentPart[] = [];
|
||||
for (const part of collectReplayableReasoningParts(replayContinuation)) {
|
||||
if (!part.text && !part.providerOptions) continue;
|
||||
if (skipSignedAnthropicReasoning && hasAnthropicThinkingSignature(part)) continue;
|
||||
contentParts.push({
|
||||
type: 'reasoning' as const,
|
||||
text: part.text,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { streamText, isStepCount, type ModelMessage } from 'ai';
|
||||
import type { LanguageModelV4 } from '@ai-sdk/provider';
|
||||
import { classifyError } from '../../errorClassifier';
|
||||
import { isRequestTooLargeError } from '../../errorClassifier';
|
||||
import { classifyError, isInvalidAnthropicThinkingSignatureError, isRequestTooLargeError } from '../../errorClassifier';
|
||||
import { isSdkStreamStateError } from '../../shared/streamStateErrors';
|
||||
import {
|
||||
createCattyAnthropicSignatureRetryError,
|
||||
createCattyRequestTooLargeRetryError,
|
||||
hadToolProgressBeforeRequestTooLarge,
|
||||
} from '../../cattyRequestTooLargeRetry';
|
||||
@@ -368,6 +368,9 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
|
||||
if (isRequestTooLargeError(readErr)) {
|
||||
throw createCattyRequestTooLargeRetryError(readErr, hadToolProgress);
|
||||
}
|
||||
if (isInvalidAnthropicThinkingSignatureError(readErr)) {
|
||||
throw createCattyAnthropicSignatureRetryError(readErr, hadToolProgress);
|
||||
}
|
||||
throw readErr;
|
||||
}
|
||||
const { done, value } = readResult;
|
||||
@@ -535,6 +538,16 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
|
||||
hadToolProgress,
|
||||
);
|
||||
}
|
||||
if (isInvalidAnthropicThinkingSignatureError(typedChunk.error)) {
|
||||
// Propagate to turn-driver strip-and-retry (#4074 P1); do not consume
|
||||
// as a terminal error message the way other stream errors are.
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
throw createCattyAnthropicSignatureRetryError(
|
||||
typedChunk.error,
|
||||
hadToolProgress,
|
||||
);
|
||||
}
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
ui.updateMessageById(streamSessionId, activeMsgId, msg => ({
|
||||
|
||||
@@ -31,7 +31,11 @@ import {
|
||||
} from '../cattyRuntime';
|
||||
import { computeTotalInputTokens, DEFAULT_MAX_OUTPUT_TOKENS } from '../contextBudget';
|
||||
import { clearChatSessionCancelled } from '../agentStop';
|
||||
import { isRequestTooLargeError } from '../../errorClassifier';
|
||||
import { isInvalidAnthropicThinkingSignatureError, isRequestTooLargeError } from '../../errorClassifier';
|
||||
import {
|
||||
providerAllowsAnthropicSignedThinkingRemoval,
|
||||
stripAnthropicSignedReasoningParts,
|
||||
} from '../anthropicThinkingBinding';
|
||||
import { getNetcattyBridge, generateId, resolveUserSkillsContext } from '../../aiChatStreamingSupport';
|
||||
import {
|
||||
buildCattySdkMessages,
|
||||
@@ -217,6 +221,10 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
|
||||
|
||||
try {
|
||||
const openAIChatAssistantFieldsByMessage = new Map<ModelMessage, OpenAIChatAssistantFields | undefined>();
|
||||
const allowStripSignedThinking = providerAllowsAnthropicSignedThinkingRemoval(
|
||||
context.activeProvider,
|
||||
activeModelId,
|
||||
);
|
||||
|
||||
const buildSdkMessages = (
|
||||
allMessages: import('../../types').ChatMessage[],
|
||||
@@ -232,6 +240,7 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
|
||||
chatSessionId: sessionId,
|
||||
toolOutputStore: ctx.toolOutputStore,
|
||||
fieldsByMessage: openAIChatAssistantFieldsByMessage,
|
||||
allowStripAnthropicSignedThinking: allowStripSignedThinking,
|
||||
});
|
||||
|
||||
const responseIdleTimeoutSeconds = normalizeResponseIdleTimeoutSeconds(
|
||||
@@ -298,6 +307,7 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
|
||||
// Keep the reasoning parts buildCattySdkMessages replayed instead of
|
||||
// pruning them like generic Chat Completions histories.
|
||||
const preserveAnthropicThinkingReplay = resolveProviderStyle(context.activeProvider) === 'anthropic';
|
||||
// allowStripSignedThinking computed once above (DeepSeek must keep signed thinking).
|
||||
const preserveReasoningForStream = preserveStatelessResponsesReasoning || preserveAnthropicThinkingReplay;
|
||||
const reasoningReserveTokens = estimateReasoningOutputReserve(reasoningProviderOptions);
|
||||
// Fold thinking budget into compaction maxOutput only. reservedTokens is
|
||||
@@ -497,9 +507,20 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
|
||||
runtimeContext: stepRuntimeContext,
|
||||
onEvent: (event) => ctx.emit(event),
|
||||
});
|
||||
emitContextSnapshot(prepared.messages);
|
||||
// Prefix-mutating step prep (tool_result compress/prune/budget head-prune)
|
||||
// invalidates Anthropic preserved-thinking signatures on the retained
|
||||
// tail (#4073). Strip signed reasoning when we know the prefix changed.
|
||||
let stepMessages = prepared.messages;
|
||||
if (preserveAnthropicThinkingReplay && prepared.didAdjust && allowStripSignedThinking) {
|
||||
const stripped = stripAnthropicSignedReasoningParts(stepMessages);
|
||||
if (stripped.didStrip) {
|
||||
console.warn('[Catty] Stripped Anthropic signed thinking after step context mutate (#4073)');
|
||||
stepMessages = stripped.messages;
|
||||
}
|
||||
}
|
||||
emitContextSnapshot(stepMessages);
|
||||
return {
|
||||
messages: prepared.messages,
|
||||
messages: stepMessages,
|
||||
runtimeContext: prepared.runtimeContext,
|
||||
};
|
||||
},
|
||||
@@ -513,55 +534,116 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
|
||||
try {
|
||||
await runStream(messagesForStream, assistantMsgId);
|
||||
} catch (streamErr) {
|
||||
if (signal.aborted || !isRequestTooLargeError(streamErr)) {
|
||||
if (signal.aborted) {
|
||||
throw streamErr;
|
||||
}
|
||||
|
||||
if (context.delegated) console.warn('[Plugin Agent] Context compaction requested');
|
||||
else console.warn('[Catty] Request hit HTTP 413; forcing context compaction and retrying once.', streamErr);
|
||||
const hadToolProgress = hadToolProgressBeforeRequestTooLarge(streamErr);
|
||||
let retryBaseMessages = messagesForStream;
|
||||
let retryAssistantMsgId = assistantMsgId;
|
||||
let preservedWriteFingerprints: string[] = [];
|
||||
if (hadToolProgress) {
|
||||
const latestSession = ui.getLatestSession?.(sessionId);
|
||||
if (latestSession) {
|
||||
preservedWriteFingerprints = collectPreservedTerminalWriteFingerprints(
|
||||
latestSession.messages,
|
||||
assistantMsgId,
|
||||
sessionId,
|
||||
);
|
||||
retryBaseMessages = buildSdkMessages(latestSession.messages, false, latestSession.contextCompaction);
|
||||
if (isRequestTooLargeError(streamErr)) {
|
||||
if (context.delegated) console.warn('[Plugin Agent] Context compaction requested');
|
||||
else console.warn('[Catty] Request hit HTTP 413; forcing context compaction and retrying once.', streamErr);
|
||||
const hadToolProgress = hadToolProgressBeforeRequestTooLarge(streamErr);
|
||||
let retryBaseMessages = messagesForStream;
|
||||
let retryAssistantMsgId = assistantMsgId;
|
||||
let preservedWriteFingerprints: string[] = [];
|
||||
if (hadToolProgress) {
|
||||
const latestSession = ui.getLatestSession?.(sessionId);
|
||||
if (latestSession) {
|
||||
preservedWriteFingerprints = collectPreservedTerminalWriteFingerprints(
|
||||
latestSession.messages,
|
||||
assistantMsgId,
|
||||
sessionId,
|
||||
);
|
||||
retryBaseMessages = buildSdkMessages(latestSession.messages, false, latestSession.contextCompaction);
|
||||
}
|
||||
retryAssistantMsgId = generateId();
|
||||
ui.addMessageToSession(sessionId, {
|
||||
id: retryAssistantMsgId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
model: activeModelId || context.activeProvider?.defaultModel || '',
|
||||
providerId: context.activeProvider?.providerId,
|
||||
});
|
||||
} else {
|
||||
ui.updateMessageById(sessionId, assistantMsgId, msg => ({
|
||||
...msg,
|
||||
content: '',
|
||||
thinking: undefined,
|
||||
thinkingDurationMs: undefined,
|
||||
providerContinuation: undefined,
|
||||
toolCalls: undefined,
|
||||
errorInfo: undefined,
|
||||
executionStatus: undefined,
|
||||
pendingApproval: undefined,
|
||||
}));
|
||||
}
|
||||
retryAssistantMsgId = generateId();
|
||||
ui.addMessageToSession(sessionId, {
|
||||
id: retryAssistantMsgId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
model: activeModelId || context.activeProvider?.defaultModel || '',
|
||||
providerId: context.activeProvider?.providerId,
|
||||
});
|
||||
ctx.toolResultDedup.enableWriteReplay(preservedWriteFingerprints);
|
||||
const retryMessages = prepareMessagesForStream((await compactMessages(retryBaseMessages, {
|
||||
force: true,
|
||||
compressForRequestTooLargeRetry: true,
|
||||
})).messages);
|
||||
emitContextSnapshot(retryMessages);
|
||||
await runStream(retryMessages, retryAssistantMsgId);
|
||||
} else if (
|
||||
preserveAnthropicThinkingReplay
|
||||
&& isInvalidAnthropicThinkingSignatureError(streamErr)
|
||||
) {
|
||||
// Once per turn: strip signed thinking and retry. Nested failure throws
|
||||
// to the outer catch (no loop). Mirror the 413 progress-aware path so
|
||||
// completed tool calls/results are retained and write replay is enabled
|
||||
// (#4074 P1) instead of clearing the initial assistant and restarting.
|
||||
console.warn(
|
||||
'[Catty] Anthropic thinking signature invalid after prefix mutate; stripping signed reasoning and retrying once (#4073).',
|
||||
streamErr,
|
||||
);
|
||||
const hadToolProgress = hadToolProgressBeforeRequestTooLarge(streamErr);
|
||||
let retryBaseMessages = messagesForStream;
|
||||
let retryAssistantMsgId = assistantMsgId;
|
||||
let preservedWriteFingerprints: string[] = [];
|
||||
if (hadToolProgress) {
|
||||
const latestSession = ui.getLatestSession?.(sessionId);
|
||||
if (latestSession) {
|
||||
preservedWriteFingerprints = collectPreservedTerminalWriteFingerprints(
|
||||
latestSession.messages,
|
||||
assistantMsgId,
|
||||
sessionId,
|
||||
);
|
||||
retryBaseMessages = buildSdkMessages(latestSession.messages, false, latestSession.contextCompaction);
|
||||
retryBaseMessages = prepareMessagesForStream(retryBaseMessages);
|
||||
}
|
||||
retryAssistantMsgId = generateId();
|
||||
ui.addMessageToSession(sessionId, {
|
||||
id: retryAssistantMsgId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
model: activeModelId || context.activeProvider?.defaultModel || '',
|
||||
providerId: context.activeProvider?.providerId,
|
||||
});
|
||||
} else {
|
||||
ui.updateMessageById(sessionId, assistantMsgId, msg => ({
|
||||
...msg,
|
||||
content: '',
|
||||
thinking: undefined,
|
||||
thinkingDurationMs: undefined,
|
||||
providerContinuation: undefined,
|
||||
toolCalls: undefined,
|
||||
errorInfo: undefined,
|
||||
executionStatus: undefined,
|
||||
pendingApproval: undefined,
|
||||
}));
|
||||
}
|
||||
const stripped = stripAnthropicSignedReasoningParts(retryBaseMessages);
|
||||
if (!stripped.didStrip) {
|
||||
throw streamErr;
|
||||
}
|
||||
ctx.toolResultDedup.enableWriteReplay(preservedWriteFingerprints);
|
||||
const retryMessages = stripped.messages;
|
||||
emitContextSnapshot(retryMessages);
|
||||
await runStream(retryMessages, retryAssistantMsgId);
|
||||
} else {
|
||||
ui.updateMessageById(sessionId, assistantMsgId, msg => ({
|
||||
...msg,
|
||||
content: '',
|
||||
thinking: undefined,
|
||||
thinkingDurationMs: undefined,
|
||||
providerContinuation: undefined,
|
||||
toolCalls: undefined,
|
||||
errorInfo: undefined,
|
||||
executionStatus: undefined,
|
||||
pendingApproval: undefined,
|
||||
}));
|
||||
throw streamErr;
|
||||
}
|
||||
ctx.toolResultDedup.enableWriteReplay(preservedWriteFingerprints);
|
||||
const retryMessages = prepareMessagesForStream((await compactMessages(retryBaseMessages, {
|
||||
force: true,
|
||||
compressForRequestTooLargeRetry: true,
|
||||
})).messages);
|
||||
emitContextSnapshot(retryMessages);
|
||||
await runStream(retryMessages, retryAssistantMsgId);
|
||||
}
|
||||
} catch (err) {
|
||||
if (context.delegated && signal.aborted) return;
|
||||
|
||||
Reference in new issue
Block a user