mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:20:43 +00:00
fix(agents): preserve native Anthropic replay tool ids (#61254)
* fix(agents): preserve native Anthropic replay tool ids * docs(changelog): note native Anthropic replay ids * fix(agents): preserve native Anthropic replay ids selectively
This commit is contained in:
@@ -64,6 +64,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- MS Teams: download inline DM images via Graph API and preserve channel reply threading in proactive fallback. (#52212, #55198)
|
- MS Teams: download inline DM images via Graph API and preserve channel reply threading in proactive fallback. (#52212, #55198)
|
||||||
- Agents/Claude CLI: persist explicit `openclaw agent --session-id` runs under a stable session key so follow-ups can reuse the stored CLI binding and resume the same underlying Claude session.
|
- Agents/Claude CLI: persist explicit `openclaw agent --session-id` runs under a stable session key so follow-ups can reuse the stored CLI binding and resume the same underlying Claude session.
|
||||||
- Agents/CLI backends: invalidate stored CLI session reuse when local CLI login state or the selected auth profile credential changes, so relogin and token rotation stop resuming stale sessions.
|
- Agents/CLI backends: invalidate stored CLI session reuse when local CLI login state or the selected auth profile credential changes, so relogin and token rotation stop resuming stale sessions.
|
||||||
|
- Agents/Anthropic: preserve native `toolu_*` replay ids on direct Anthropic and Anthropic Vertex paths so cache-sensitive history stops rewriting known-valid Anthropic tool-use ids. (#52612)
|
||||||
- Gateway/macOS: recover installed-but-unloaded LaunchAgents during `openclaw gateway start` and `restart`, while still preferring live unmanaged gateways during restart recovery. (#43766) Thanks @HenryC-3.
|
- Gateway/macOS: recover installed-but-unloaded LaunchAgents during `openclaw gateway start` and `restart`, while still preferring live unmanaged gateways during restart recovery. (#43766) Thanks @HenryC-3.
|
||||||
- Auth/failover: persist selected fallback overrides before retrying, shorten `auth_permanent` lockouts, and refresh websocket/shared-auth sessions only when real auth changes occur so retries and secret rotations behave predictably. (#60404, #60323, #60387)
|
- Auth/failover: persist selected fallback overrides before retrying, shorten `auth_permanent` lockouts, and refresh websocket/shared-auth sessions only when real auth changes occur so retries and secret rotations behave predictably. (#60404, #60323, #60387)
|
||||||
- Cron: replay interrupted recurring jobs on the first gateway restart instead of waiting for a second restart. (#60583) Thanks @joelnishanth.
|
- Cron: replay interrupted recurring jobs on the first gateway restart instead of waiting for a second restart. (#60583) Thanks @joelnishanth.
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ describe("anthropic-vertex provider plugin", () => {
|
|||||||
sanitizeMode: "full",
|
sanitizeMode: "full",
|
||||||
sanitizeToolCallIds: true,
|
sanitizeToolCallIds: true,
|
||||||
toolCallIdMode: "strict",
|
toolCallIdMode: "strict",
|
||||||
|
preserveNativeAnthropicToolUseIds: true,
|
||||||
preserveSignatures: true,
|
preserveSignatures: true,
|
||||||
repairToolUseResultPairing: true,
|
repairToolUseResultPairing: true,
|
||||||
validateAnthropicTurns: true,
|
validateAnthropicTurns: true,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||||
import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared";
|
import { buildNativeAnthropicReplayPolicyForModel } from "openclaw/plugin-sdk/provider-model-shared";
|
||||||
import {
|
import {
|
||||||
mergeImplicitAnthropicVertexProvider,
|
mergeImplicitAnthropicVertexProvider,
|
||||||
resolveAnthropicVertexConfigApiKey,
|
resolveAnthropicVertexConfigApiKey,
|
||||||
@@ -7,9 +7,6 @@ import {
|
|||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
|
|
||||||
const PROVIDER_ID = "anthropic-vertex";
|
const PROVIDER_ID = "anthropic-vertex";
|
||||||
const ANTHROPIC_BY_MODEL_REPLAY_HOOKS = buildProviderReplayFamilyHooks({
|
|
||||||
family: "anthropic-by-model",
|
|
||||||
});
|
|
||||||
|
|
||||||
export default definePluginEntry({
|
export default definePluginEntry({
|
||||||
id: PROVIDER_ID,
|
id: PROVIDER_ID,
|
||||||
@@ -39,7 +36,7 @@ export default definePluginEntry({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
resolveConfigApiKey: ({ env }) => resolveAnthropicVertexConfigApiKey(env),
|
resolveConfigApiKey: ({ env }) => resolveAnthropicVertexConfigApiKey(env),
|
||||||
...ANTHROPIC_BY_MODEL_REPLAY_HOOKS,
|
buildReplayPolicy: ({ modelId }) => buildNativeAnthropicReplayPolicyForModel(modelId),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ describe("anthropic provider replay hooks", () => {
|
|||||||
sanitizeMode: "full",
|
sanitizeMode: "full",
|
||||||
sanitizeToolCallIds: true,
|
sanitizeToolCallIds: true,
|
||||||
toolCallIdMode: "strict",
|
toolCallIdMode: "strict",
|
||||||
|
preserveNativeAnthropicToolUseIds: true,
|
||||||
preserveSignatures: true,
|
preserveSignatures: true,
|
||||||
repairToolUseResultPairing: true,
|
repairToolUseResultPairing: true,
|
||||||
validateAnthropicTurns: true,
|
validateAnthropicTurns: true,
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import type {
|
|||||||
ProviderReplayPolicy,
|
ProviderReplayPolicy,
|
||||||
ProviderReplayPolicyContext,
|
ProviderReplayPolicyContext,
|
||||||
} from "openclaw/plugin-sdk/plugin-entry";
|
} from "openclaw/plugin-sdk/plugin-entry";
|
||||||
import { buildAnthropicReplayPolicyForModel } from "openclaw/plugin-sdk/provider-model-shared";
|
import { buildNativeAnthropicReplayPolicyForModel } from "openclaw/plugin-sdk/provider-model-shared";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the provider-owned replay policy for Anthropic transports.
|
* Returns the provider-owned replay policy for Anthropic transports.
|
||||||
*/
|
*/
|
||||||
export function buildAnthropicReplayPolicy(ctx: ProviderReplayPolicyContext): ProviderReplayPolicy {
|
export function buildAnthropicReplayPolicy(ctx: ProviderReplayPolicyContext): ProviderReplayPolicy {
|
||||||
return buildAnthropicReplayPolicyForModel(ctx.modelId);
|
return buildNativeAnthropicReplayPolicyForModel(ctx.modelId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export async function sanitizeSessionMessagesImages(
|
|||||||
options?: {
|
options?: {
|
||||||
sanitizeMode?: "full" | "images-only";
|
sanitizeMode?: "full" | "images-only";
|
||||||
sanitizeToolCallIds?: boolean;
|
sanitizeToolCallIds?: boolean;
|
||||||
|
preserveNativeAnthropicToolUseIds?: boolean;
|
||||||
/**
|
/**
|
||||||
* Mode for tool call ID sanitization:
|
* Mode for tool call ID sanitization:
|
||||||
* - "strict" (alphanumeric only)
|
* - "strict" (alphanumeric only)
|
||||||
@@ -66,7 +67,9 @@ export async function sanitizeSessionMessagesImages(
|
|||||||
// We sanitize historical session messages because Anthropic can reject a request
|
// We sanitize historical session messages because Anthropic can reject a request
|
||||||
// if the transcript contains oversized base64 images (default max side 1200px).
|
// if the transcript contains oversized base64 images (default max side 1200px).
|
||||||
const sanitizedIds = shouldSanitizeToolCallIds
|
const sanitizedIds = shouldSanitizeToolCallIds
|
||||||
? sanitizeToolCallIdsForCloudCodeAssist(messages, options.toolCallIdMode)
|
? sanitizeToolCallIdsForCloudCodeAssist(messages, options.toolCallIdMode, {
|
||||||
|
preserveNativeAnthropicToolUseIds: options?.preserveNativeAnthropicToolUseIds,
|
||||||
|
})
|
||||||
: messages;
|
: messages;
|
||||||
const out: AgentMessage[] = [];
|
const out: AgentMessage[] = [];
|
||||||
for (const msg of sanitizedIds) {
|
for (const msg of sanitizedIds) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
TEST_SESSION_ID,
|
TEST_SESSION_ID,
|
||||||
} from "./pi-embedded-runner.sanitize-session-history.test-harness.js";
|
} from "./pi-embedded-runner.sanitize-session-history.test-harness.js";
|
||||||
import { castAgentMessage, castAgentMessages } from "./test-helpers/agent-message-fixtures.js";
|
import { castAgentMessage, castAgentMessages } from "./test-helpers/agent-message-fixtures.js";
|
||||||
|
import type { TranscriptPolicy } from "./transcript-policy.js";
|
||||||
import { makeZeroUsageSnapshot } from "./usage.js";
|
import { makeZeroUsageSnapshot } from "./usage.js";
|
||||||
|
|
||||||
vi.mock("./pi-embedded-helpers.js", async () => ({
|
vi.mock("./pi-embedded-helpers.js", async () => ({
|
||||||
@@ -121,6 +122,7 @@ describe("sanitizeSessionHistory", () => {
|
|||||||
provider?: string;
|
provider?: string;
|
||||||
modelApi?: string;
|
modelApi?: string;
|
||||||
modelId?: string;
|
modelId?: string;
|
||||||
|
policy?: TranscriptPolicy;
|
||||||
}) =>
|
}) =>
|
||||||
sanitizeSessionHistory({
|
sanitizeSessionHistory({
|
||||||
messages: params.messages,
|
messages: params.messages,
|
||||||
@@ -129,6 +131,7 @@ describe("sanitizeSessionHistory", () => {
|
|||||||
modelId: params.modelId ?? "claude-opus-4-6",
|
modelId: params.modelId ?? "claude-opus-4-6",
|
||||||
sessionManager: makeMockSessionManager(),
|
sessionManager: makeMockSessionManager(),
|
||||||
sessionId: TEST_SESSION_ID,
|
sessionId: TEST_SESSION_ID,
|
||||||
|
policy: params.policy,
|
||||||
});
|
});
|
||||||
|
|
||||||
const getAssistantMessage = (messages: AgentMessage[]) => {
|
const getAssistantMessage = (messages: AgentMessage[]) => {
|
||||||
@@ -930,6 +933,79 @@ describe("sanitizeSessionHistory", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the earlier anthropic replay prefix stable after a later subagent turn", async () => {
|
||||||
|
setNonGoogleModelApi();
|
||||||
|
|
||||||
|
const priorToolId = "toolu_01ABCDEF1234567890";
|
||||||
|
const laterToolId = "toolu_01ZZZZZZ9999999999";
|
||||||
|
const nativeAnthropicPolicy: TranscriptPolicy = {
|
||||||
|
sanitizeMode: "full",
|
||||||
|
sanitizeToolCallIds: true,
|
||||||
|
toolCallIdMode: "strict",
|
||||||
|
preserveNativeAnthropicToolUseIds: true,
|
||||||
|
repairToolUseResultPairing: true,
|
||||||
|
preserveSignatures: true,
|
||||||
|
sanitizeThoughtSignatures: undefined,
|
||||||
|
sanitizeThinkingSignatures: false,
|
||||||
|
dropThinkingBlocks: true,
|
||||||
|
applyGoogleTurnOrdering: false,
|
||||||
|
validateGeminiTurns: false,
|
||||||
|
validateAnthropicTurns: true,
|
||||||
|
allowSyntheticToolResults: true,
|
||||||
|
};
|
||||||
|
const baseMessages = castAgentMessages([
|
||||||
|
makeUserMessage("Read IDENTITY.md"),
|
||||||
|
makeAssistantMessage(
|
||||||
|
[{ type: "toolUse", id: priorToolId, name: "read", input: { path: "IDENTITY.md" } }],
|
||||||
|
{ stopReason: "toolUse" },
|
||||||
|
),
|
||||||
|
{
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: priorToolId,
|
||||||
|
toolUseId: priorToolId,
|
||||||
|
toolName: "read",
|
||||||
|
content: [{ type: "text", text: "ok" }],
|
||||||
|
isError: false,
|
||||||
|
},
|
||||||
|
makeAssistantMessage([{ type: "text", text: "done" }]),
|
||||||
|
]);
|
||||||
|
const withSubagentMessages = castAgentMessages([
|
||||||
|
...baseMessages,
|
||||||
|
makeUserMessage("Ask a subagent for an emoji"),
|
||||||
|
makeAssistantMessage(
|
||||||
|
[{ type: "toolUse", id: laterToolId, name: "subagent", input: { prompt: "emoji" } }],
|
||||||
|
{ stopReason: "toolUse" },
|
||||||
|
),
|
||||||
|
{
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: laterToolId,
|
||||||
|
toolUseId: laterToolId,
|
||||||
|
toolName: "subagent",
|
||||||
|
content: [{ type: "text", text: "😀" }],
|
||||||
|
isError: false,
|
||||||
|
},
|
||||||
|
makeAssistantMessage([{ type: "text", text: "it was 😀" }]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sanitizedBase = await sanitizeAnthropicHistory({
|
||||||
|
messages: baseMessages,
|
||||||
|
policy: nativeAnthropicPolicy,
|
||||||
|
});
|
||||||
|
const sanitizedWithSubagent = await sanitizeAnthropicHistory({
|
||||||
|
messages: withSubagentMessages,
|
||||||
|
policy: nativeAnthropicPolicy,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sanitizedWithSubagent.slice(0, sanitizedBase.length)).toEqual(sanitizedBase);
|
||||||
|
expect((sanitizedBase[1] as Extract<AgentMessage, { role: "assistant" }>).content).toEqual([
|
||||||
|
{ type: "toolUse", id: priorToolId, name: "read", input: { path: "IDENTITY.md" } },
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
(sanitizedBase[2] as Extract<AgentMessage, { role: "toolResult" }> & { toolUseId?: string })
|
||||||
|
.toolCallId,
|
||||||
|
).toBe(priorToolId);
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves latest assistant thinking blocks for amazon-bedrock replay", async () => {
|
it("preserves latest assistant thinking blocks for amazon-bedrock replay", async () => {
|
||||||
setNonGoogleModelApi();
|
setNonGoogleModelApi();
|
||||||
|
|
||||||
|
|||||||
@@ -657,6 +657,7 @@ export async function sanitizeSessionHistory(params: {
|
|||||||
sanitizeMode: policy.sanitizeMode,
|
sanitizeMode: policy.sanitizeMode,
|
||||||
sanitizeToolCallIds: policy.sanitizeToolCallIds,
|
sanitizeToolCallIds: policy.sanitizeToolCallIds,
|
||||||
toolCallIdMode: policy.toolCallIdMode,
|
toolCallIdMode: policy.toolCallIdMode,
|
||||||
|
preserveNativeAnthropicToolUseIds: policy.preserveNativeAnthropicToolUseIds,
|
||||||
preserveSignatures: policy.preserveSignatures,
|
preserveSignatures: policy.preserveSignatures,
|
||||||
sanitizeThoughtSignatures: policy.sanitizeThoughtSignatures,
|
sanitizeThoughtSignatures: policy.sanitizeThoughtSignatures,
|
||||||
...resolveImageSanitizationLimits(params.config),
|
...resolveImageSanitizationLimits(params.config),
|
||||||
|
|||||||
@@ -470,6 +470,7 @@ export async function sanitizeSessionHistory(params: {
|
|||||||
sanitizeMode: policy.sanitizeMode,
|
sanitizeMode: policy.sanitizeMode,
|
||||||
sanitizeToolCallIds: policy.sanitizeToolCallIds,
|
sanitizeToolCallIds: policy.sanitizeToolCallIds,
|
||||||
toolCallIdMode: policy.toolCallIdMode,
|
toolCallIdMode: policy.toolCallIdMode,
|
||||||
|
preserveNativeAnthropicToolUseIds: policy.preserveNativeAnthropicToolUseIds,
|
||||||
preserveSignatures: policy.preserveSignatures,
|
preserveSignatures: policy.preserveSignatures,
|
||||||
sanitizeThoughtSignatures: policy.sanitizeThoughtSignatures,
|
sanitizeThoughtSignatures: policy.sanitizeThoughtSignatures,
|
||||||
...resolveImageSanitizationLimits(params.config),
|
...resolveImageSanitizationLimits(params.config),
|
||||||
|
|||||||
@@ -1039,7 +1039,13 @@ export async function runEmbeddedAttempt(
|
|||||||
if (!Array.isArray(messages)) {
|
if (!Array.isArray(messages)) {
|
||||||
return inner(model, context, options);
|
return inner(model, context, options);
|
||||||
}
|
}
|
||||||
const sanitized = sanitizeToolCallIdsForCloudCodeAssist(messages as AgentMessage[], mode);
|
const sanitized = sanitizeToolCallIdsForCloudCodeAssist(
|
||||||
|
messages as AgentMessage[],
|
||||||
|
mode,
|
||||||
|
{
|
||||||
|
preserveNativeAnthropicToolUseIds: transcriptPolicy.preserveNativeAnthropicToolUseIds,
|
||||||
|
},
|
||||||
|
);
|
||||||
if (sanitized === messages) {
|
if (sanitized === messages) {
|
||||||
return inner(model, context, options);
|
return inner(model, context, options);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,6 +239,60 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
|
|||||||
expectSingleToolCallRewrite(out, "whatsapplogin17687998415271", "strict");
|
expectSingleToolCallRewrite(out, "whatsapplogin17687998415271", "strict");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves native anthropic ids while sanitizing mixed-provider ids when requested", () => {
|
||||||
|
const nativeId = "toolu_01ABCDEF1234567890";
|
||||||
|
const nonNativeId = "call_123|fc_123";
|
||||||
|
const input = castAgentMessages([
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "toolUse", id: nativeId, name: "read", input: { path: "IDENTITY.md" } },
|
||||||
|
{ type: "toolUse", id: nonNativeId, name: "read", input: { path: "README.md" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: nativeId,
|
||||||
|
toolUseId: nativeId,
|
||||||
|
toolName: "read",
|
||||||
|
content: [{ type: "text", text: "identity" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: nonNativeId,
|
||||||
|
toolUseId: nonNativeId,
|
||||||
|
toolName: "read",
|
||||||
|
content: [{ type: "text", text: "readme" }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict", {
|
||||||
|
preserveNativeAnthropicToolUseIds: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(out).not.toBe(input);
|
||||||
|
expect((out[0] as Extract<AgentMessage, { role: "assistant" }>).content).toEqual([
|
||||||
|
{ type: "toolUse", id: nativeId, name: "read", input: { path: "IDENTITY.md" } },
|
||||||
|
{ type: "toolUse", id: "call123fc123", name: "read", input: { path: "README.md" } },
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
(out[1] as Extract<AgentMessage, { role: "toolResult" }> & { toolUseId?: string })
|
||||||
|
.toolCallId,
|
||||||
|
).toBe(nativeId);
|
||||||
|
expect(
|
||||||
|
(out[1] as Extract<AgentMessage, { role: "toolResult" }> & { toolUseId?: string })
|
||||||
|
.toolUseId,
|
||||||
|
).toBe(nativeId);
|
||||||
|
expect(
|
||||||
|
(out[2] as Extract<AgentMessage, { role: "toolResult" }> & { toolUseId?: string })
|
||||||
|
.toolCallId,
|
||||||
|
).toBe("call123fc123");
|
||||||
|
expect(
|
||||||
|
(out[2] as Extract<AgentMessage, { role: "toolResult" }> & { toolUseId?: string })
|
||||||
|
.toolUseId,
|
||||||
|
).toBe("call123fc123");
|
||||||
|
});
|
||||||
|
|
||||||
it("avoids collisions with alphanumeric-only suffixes", () => {
|
it("avoids collisions with alphanumeric-only suffixes", () => {
|
||||||
const input = buildDuplicateIdCollisionInput();
|
const input = buildDuplicateIdCollisionInput();
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|||||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||||
|
|
||||||
export type ToolCallIdMode = "strict" | "strict9";
|
export type ToolCallIdMode = "strict" | "strict9";
|
||||||
|
const NATIVE_ANTHROPIC_TOOL_USE_ID_RE = /^toolu_[A-Za-z0-9_]+$/;
|
||||||
|
|
||||||
const STRICT9_LEN = 9;
|
const STRICT9_LEN = 9;
|
||||||
const TOOL_CALL_TYPES = new Set(["toolCall", "toolUse", "functionCall"]);
|
const TOOL_CALL_TYPES = new Set(["toolCall", "toolUse", "functionCall"]);
|
||||||
@@ -97,6 +98,10 @@ function shortHash(text: string, length = 8): string {
|
|||||||
return createHash("sha256").update(text).digest("hex").slice(0, length);
|
return createHash("sha256").update(text).digest("hex").slice(0, length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isNativeAnthropicToolUseId(id: string): boolean {
|
||||||
|
return NATIVE_ANTHROPIC_TOOL_USE_ID_RE.test(id);
|
||||||
|
}
|
||||||
|
|
||||||
function makeUniqueToolId(params: { id: string; used: Set<string>; mode: ToolCallIdMode }): string {
|
function makeUniqueToolId(params: { id: string; used: Set<string>; mode: ToolCallIdMode }): string {
|
||||||
if (params.mode === "strict9") {
|
if (params.mode === "strict9") {
|
||||||
const base = sanitizeToolCallId(params.id, params.mode);
|
const base = sanitizeToolCallId(params.id, params.mode);
|
||||||
@@ -144,7 +149,10 @@ function makeUniqueToolId(params: { id: string; used: Set<string>; mode: ToolCal
|
|||||||
return `${candidate.slice(0, MAX_LEN - ts.length)}${ts}`;
|
return `${candidate.slice(0, MAX_LEN - ts.length)}${ts}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createOccurrenceAwareResolver(mode: ToolCallIdMode): {
|
function createOccurrenceAwareResolver(
|
||||||
|
mode: ToolCallIdMode,
|
||||||
|
options?: { preserveNativeAnthropicToolUseIds?: boolean },
|
||||||
|
): {
|
||||||
resolveAssistantId: (id: string) => string;
|
resolveAssistantId: (id: string) => string;
|
||||||
resolveToolResultId: (id: string) => string;
|
resolveToolResultId: (id: string) => string;
|
||||||
} {
|
} {
|
||||||
@@ -152,6 +160,7 @@ function createOccurrenceAwareResolver(mode: ToolCallIdMode): {
|
|||||||
const assistantOccurrences = new Map<string, number>();
|
const assistantOccurrences = new Map<string, number>();
|
||||||
const orphanToolResultOccurrences = new Map<string, number>();
|
const orphanToolResultOccurrences = new Map<string, number>();
|
||||||
const pendingByRawId = new Map<string, string[]>();
|
const pendingByRawId = new Map<string, string[]>();
|
||||||
|
const preserveNativeAnthropicToolUseIds = options?.preserveNativeAnthropicToolUseIds === true;
|
||||||
|
|
||||||
const allocate = (seed: string): string => {
|
const allocate = (seed: string): string => {
|
||||||
const next = makeUniqueToolId({ id: seed, used, mode });
|
const next = makeUniqueToolId({ id: seed, used, mode });
|
||||||
@@ -159,10 +168,23 @@ function createOccurrenceAwareResolver(mode: ToolCallIdMode): {
|
|||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const allocatePreservingNativeAnthropicId = (id: string, occurrence: number): string => {
|
||||||
|
if (
|
||||||
|
preserveNativeAnthropicToolUseIds &&
|
||||||
|
isNativeAnthropicToolUseId(id) &&
|
||||||
|
occurrence === 1 &&
|
||||||
|
!used.has(id)
|
||||||
|
) {
|
||||||
|
used.add(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
return allocate(occurrence === 1 ? id : `${id}:${occurrence}`);
|
||||||
|
};
|
||||||
|
|
||||||
const resolveAssistantId = (id: string): string => {
|
const resolveAssistantId = (id: string): string => {
|
||||||
const occurrence = (assistantOccurrences.get(id) ?? 0) + 1;
|
const occurrence = (assistantOccurrences.get(id) ?? 0) + 1;
|
||||||
assistantOccurrences.set(id, occurrence);
|
assistantOccurrences.set(id, occurrence);
|
||||||
const next = allocate(occurrence === 1 ? id : `${id}:${occurrence}`);
|
const next = allocatePreservingNativeAnthropicId(id, occurrence);
|
||||||
const pending = pendingByRawId.get(id);
|
const pending = pendingByRawId.get(id);
|
||||||
if (pending) {
|
if (pending) {
|
||||||
pending.push(next);
|
pending.push(next);
|
||||||
@@ -184,6 +206,15 @@ function createOccurrenceAwareResolver(mode: ToolCallIdMode): {
|
|||||||
|
|
||||||
const occurrence = (orphanToolResultOccurrences.get(id) ?? 0) + 1;
|
const occurrence = (orphanToolResultOccurrences.get(id) ?? 0) + 1;
|
||||||
orphanToolResultOccurrences.set(id, occurrence);
|
orphanToolResultOccurrences.set(id, occurrence);
|
||||||
|
if (
|
||||||
|
preserveNativeAnthropicToolUseIds &&
|
||||||
|
isNativeAnthropicToolUseId(id) &&
|
||||||
|
occurrence === 1 &&
|
||||||
|
!used.has(id)
|
||||||
|
) {
|
||||||
|
used.add(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
return allocate(`${id}:tool_result:${occurrence}`);
|
return allocate(`${id}:tool_result:${occurrence}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -267,6 +298,7 @@ function rewriteToolResultIds(params: {
|
|||||||
export function sanitizeToolCallIdsForCloudCodeAssist(
|
export function sanitizeToolCallIdsForCloudCodeAssist(
|
||||||
messages: AgentMessage[],
|
messages: AgentMessage[],
|
||||||
mode: ToolCallIdMode = "strict",
|
mode: ToolCallIdMode = "strict",
|
||||||
|
options?: { preserveNativeAnthropicToolUseIds?: boolean },
|
||||||
): AgentMessage[] {
|
): AgentMessage[] {
|
||||||
// Strict mode: only [a-zA-Z0-9]
|
// Strict mode: only [a-zA-Z0-9]
|
||||||
// Strict9 mode: only [a-zA-Z0-9], length 9 (Mistral tool call requirement)
|
// Strict9 mode: only [a-zA-Z0-9], length 9 (Mistral tool call requirement)
|
||||||
@@ -274,7 +306,7 @@ export function sanitizeToolCallIdsForCloudCodeAssist(
|
|||||||
// duplicate tool-call IDs. Track assistant occurrences in-order so repeated
|
// duplicate tool-call IDs. Track assistant occurrences in-order so repeated
|
||||||
// raw IDs receive distinct rewritten IDs, while matching tool results consume
|
// raw IDs receive distinct rewritten IDs, while matching tool results consume
|
||||||
// the same rewritten IDs in encounter order.
|
// the same rewritten IDs in encounter order.
|
||||||
const { resolveAssistantId, resolveToolResultId } = createOccurrenceAwareResolver(mode);
|
const { resolveAssistantId, resolveToolResultId } = createOccurrenceAwareResolver(mode, options);
|
||||||
|
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const out = messages.map((msg) => {
|
const out = messages.map((msg) => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type TranscriptPolicy = {
|
|||||||
sanitizeMode: TranscriptSanitizeMode;
|
sanitizeMode: TranscriptSanitizeMode;
|
||||||
sanitizeToolCallIds: boolean;
|
sanitizeToolCallIds: boolean;
|
||||||
toolCallIdMode?: ToolCallIdMode;
|
toolCallIdMode?: ToolCallIdMode;
|
||||||
|
preserveNativeAnthropicToolUseIds: boolean;
|
||||||
repairToolUseResultPairing: boolean;
|
repairToolUseResultPairing: boolean;
|
||||||
preserveSignatures: boolean;
|
preserveSignatures: boolean;
|
||||||
sanitizeThoughtSignatures?: {
|
sanitizeThoughtSignatures?: {
|
||||||
@@ -29,6 +30,7 @@ const DEFAULT_TRANSCRIPT_POLICY: TranscriptPolicy = {
|
|||||||
sanitizeMode: "images-only",
|
sanitizeMode: "images-only",
|
||||||
sanitizeToolCallIds: false,
|
sanitizeToolCallIds: false,
|
||||||
toolCallIdMode: undefined,
|
toolCallIdMode: undefined,
|
||||||
|
preserveNativeAnthropicToolUseIds: false,
|
||||||
repairToolUseResultPairing: true,
|
repairToolUseResultPairing: true,
|
||||||
preserveSignatures: false,
|
preserveSignatures: false,
|
||||||
sanitizeThoughtSignatures: undefined,
|
sanitizeThoughtSignatures: undefined,
|
||||||
@@ -114,6 +116,9 @@ function mergeTranscriptPolicy(
|
|||||||
? { sanitizeToolCallIds: policy.sanitizeToolCallIds }
|
? { sanitizeToolCallIds: policy.sanitizeToolCallIds }
|
||||||
: {}),
|
: {}),
|
||||||
...(policy.toolCallIdMode ? { toolCallIdMode: policy.toolCallIdMode as ToolCallIdMode } : {}),
|
...(policy.toolCallIdMode ? { toolCallIdMode: policy.toolCallIdMode as ToolCallIdMode } : {}),
|
||||||
|
...(typeof policy.preserveNativeAnthropicToolUseIds === "boolean"
|
||||||
|
? { preserveNativeAnthropicToolUseIds: policy.preserveNativeAnthropicToolUseIds }
|
||||||
|
: {}),
|
||||||
...(typeof policy.repairToolUseResultPairing === "boolean"
|
...(typeof policy.repairToolUseResultPairing === "boolean"
|
||||||
? { repairToolUseResultPairing: policy.repairToolUseResultPairing }
|
? { repairToolUseResultPairing: policy.repairToolUseResultPairing }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
buildAnthropicReplayPolicyForModel,
|
buildAnthropicReplayPolicyForModel,
|
||||||
buildGoogleGeminiReplayPolicy,
|
buildGoogleGeminiReplayPolicy,
|
||||||
buildHybridAnthropicOrOpenAIReplayPolicy,
|
buildHybridAnthropicOrOpenAIReplayPolicy,
|
||||||
|
buildNativeAnthropicReplayPolicyForModel,
|
||||||
buildOpenAICompatibleReplayPolicy,
|
buildOpenAICompatibleReplayPolicy,
|
||||||
buildPassthroughGeminiSanitizingReplayPolicy,
|
buildPassthroughGeminiSanitizingReplayPolicy,
|
||||||
buildStrictAnthropicReplayPolicy,
|
buildStrictAnthropicReplayPolicy,
|
||||||
@@ -49,6 +50,7 @@ export {
|
|||||||
buildAnthropicReplayPolicyForModel,
|
buildAnthropicReplayPolicyForModel,
|
||||||
buildGoogleGeminiReplayPolicy,
|
buildGoogleGeminiReplayPolicy,
|
||||||
buildHybridAnthropicOrOpenAIReplayPolicy,
|
buildHybridAnthropicOrOpenAIReplayPolicy,
|
||||||
|
buildNativeAnthropicReplayPolicyForModel,
|
||||||
buildOpenAICompatibleReplayPolicy,
|
buildOpenAICompatibleReplayPolicy,
|
||||||
buildPassthroughGeminiSanitizingReplayPolicy,
|
buildPassthroughGeminiSanitizingReplayPolicy,
|
||||||
resolveTaggedReasoningOutputMode,
|
resolveTaggedReasoningOutputMode,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
buildAnthropicReplayPolicyForModel,
|
buildAnthropicReplayPolicyForModel,
|
||||||
buildGoogleGeminiReplayPolicy,
|
buildGoogleGeminiReplayPolicy,
|
||||||
buildHybridAnthropicOrOpenAIReplayPolicy,
|
buildHybridAnthropicOrOpenAIReplayPolicy,
|
||||||
|
buildNativeAnthropicReplayPolicyForModel,
|
||||||
buildOpenAICompatibleReplayPolicy,
|
buildOpenAICompatibleReplayPolicy,
|
||||||
buildPassthroughGeminiSanitizingReplayPolicy,
|
buildPassthroughGeminiSanitizingReplayPolicy,
|
||||||
resolveTaggedReasoningOutputMode,
|
resolveTaggedReasoningOutputMode,
|
||||||
@@ -33,6 +34,8 @@ describe("provider replay helpers", () => {
|
|||||||
|
|
||||||
it("derives claude-only anthropic replay policy from the model id", () => {
|
it("derives claude-only anthropic replay policy from the model id", () => {
|
||||||
expect(buildAnthropicReplayPolicyForModel("claude-sonnet-4-6")).toMatchObject({
|
expect(buildAnthropicReplayPolicyForModel("claude-sonnet-4-6")).toMatchObject({
|
||||||
|
sanitizeToolCallIds: true,
|
||||||
|
toolCallIdMode: "strict",
|
||||||
dropThinkingBlocks: true,
|
dropThinkingBlocks: true,
|
||||||
validateAnthropicTurns: true,
|
validateAnthropicTurns: true,
|
||||||
});
|
});
|
||||||
@@ -41,6 +44,20 @@ describe("provider replay helpers", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds native Anthropic replay policy with selective tool-call id preservation", () => {
|
||||||
|
expect(buildNativeAnthropicReplayPolicyForModel("claude-sonnet-4-6")).toMatchObject({
|
||||||
|
sanitizeMode: "full",
|
||||||
|
sanitizeToolCallIds: true,
|
||||||
|
toolCallIdMode: "strict",
|
||||||
|
preserveNativeAnthropicToolUseIds: true,
|
||||||
|
preserveSignatures: true,
|
||||||
|
repairToolUseResultPairing: true,
|
||||||
|
validateAnthropicTurns: true,
|
||||||
|
allowSyntheticToolResults: true,
|
||||||
|
dropThinkingBlocks: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("builds hybrid anthropic or openai replay policy", () => {
|
it("builds hybrid anthropic or openai replay policy", () => {
|
||||||
expect(
|
expect(
|
||||||
buildHybridAnthropicOrOpenAIReplayPolicy(
|
buildHybridAnthropicOrOpenAIReplayPolicy(
|
||||||
|
|||||||
@@ -37,12 +37,24 @@ export function buildOpenAICompatibleReplayPolicy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildStrictAnthropicReplayPolicy(
|
export function buildStrictAnthropicReplayPolicy(
|
||||||
options: { dropThinkingBlocks?: boolean } = {},
|
options: {
|
||||||
|
dropThinkingBlocks?: boolean;
|
||||||
|
sanitizeToolCallIds?: boolean;
|
||||||
|
preserveNativeAnthropicToolUseIds?: boolean;
|
||||||
|
} = {},
|
||||||
): ProviderReplayPolicy {
|
): ProviderReplayPolicy {
|
||||||
|
const sanitizeToolCallIds = options.sanitizeToolCallIds ?? true;
|
||||||
return {
|
return {
|
||||||
sanitizeMode: "full",
|
sanitizeMode: "full",
|
||||||
sanitizeToolCallIds: true,
|
...(sanitizeToolCallIds
|
||||||
toolCallIdMode: "strict",
|
? {
|
||||||
|
sanitizeToolCallIds: true,
|
||||||
|
toolCallIdMode: "strict" as const,
|
||||||
|
...(options.preserveNativeAnthropicToolUseIds
|
||||||
|
? { preserveNativeAnthropicToolUseIds: true }
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
preserveSignatures: true,
|
preserveSignatures: true,
|
||||||
repairToolUseResultPairing: true,
|
repairToolUseResultPairing: true,
|
||||||
validateAnthropicTurns: true,
|
validateAnthropicTurns: true,
|
||||||
@@ -57,6 +69,14 @@ export function buildAnthropicReplayPolicyForModel(modelId?: string): ProviderRe
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildNativeAnthropicReplayPolicyForModel(modelId?: string): ProviderReplayPolicy {
|
||||||
|
return buildStrictAnthropicReplayPolicy({
|
||||||
|
dropThinkingBlocks: (modelId?.toLowerCase() ?? "").includes("claude"),
|
||||||
|
sanitizeToolCallIds: true,
|
||||||
|
preserveNativeAnthropicToolUseIds: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function buildHybridAnthropicOrOpenAIReplayPolicy(
|
export function buildHybridAnthropicOrOpenAIReplayPolicy(
|
||||||
ctx: ProviderReplayPolicyContext,
|
ctx: ProviderReplayPolicyContext,
|
||||||
options: { anthropicModelDropThinkingBlocks?: boolean } = {},
|
options: { anthropicModelDropThinkingBlocks?: boolean } = {},
|
||||||
|
|||||||
@@ -583,6 +583,7 @@ export type ProviderReplayPolicy = {
|
|||||||
sanitizeMode?: ProviderReplaySanitizeMode;
|
sanitizeMode?: ProviderReplaySanitizeMode;
|
||||||
sanitizeToolCallIds?: boolean;
|
sanitizeToolCallIds?: boolean;
|
||||||
toolCallIdMode?: ProviderReplayToolCallIdMode;
|
toolCallIdMode?: ProviderReplayToolCallIdMode;
|
||||||
|
preserveNativeAnthropicToolUseIds?: boolean;
|
||||||
preserveSignatures?: boolean;
|
preserveSignatures?: boolean;
|
||||||
sanitizeThoughtSignatures?: {
|
sanitizeThoughtSignatures?: {
|
||||||
allowBase64Only?: boolean;
|
allowBase64Only?: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user