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:
Vincent Koc
2026-04-05 09:53:52 +01:00
committed by GitHub
parent a9c52dd935
commit 4613f121ad
17 changed files with 233 additions and 15 deletions

View File

@@ -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)
- 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/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.
- 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.

View File

@@ -70,6 +70,7 @@ describe("anthropic-vertex provider plugin", () => {
sanitizeMode: "full",
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
preserveNativeAnthropicToolUseIds: true,
preserveSignatures: true,
repairToolUseResultPairing: true,
validateAnthropicTurns: true,

View File

@@ -1,5 +1,5 @@
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 {
mergeImplicitAnthropicVertexProvider,
resolveAnthropicVertexConfigApiKey,
@@ -7,9 +7,6 @@ import {
} from "./api.js";
const PROVIDER_ID = "anthropic-vertex";
const ANTHROPIC_BY_MODEL_REPLAY_HOOKS = buildProviderReplayFamilyHooks({
family: "anthropic-by-model",
});
export default definePluginEntry({
id: PROVIDER_ID,
@@ -39,7 +36,7 @@ export default definePluginEntry({
},
},
resolveConfigApiKey: ({ env }) => resolveAnthropicVertexConfigApiKey(env),
...ANTHROPIC_BY_MODEL_REPLAY_HOOKS,
buildReplayPolicy: ({ modelId }) => buildNativeAnthropicReplayPolicyForModel(modelId),
});
},
});

View File

@@ -28,6 +28,7 @@ describe("anthropic provider replay hooks", () => {
sanitizeMode: "full",
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
preserveNativeAnthropicToolUseIds: true,
preserveSignatures: true,
repairToolUseResultPairing: true,
validateAnthropicTurns: true,

View File

@@ -2,11 +2,11 @@ import type {
ProviderReplayPolicy,
ProviderReplayPolicyContext,
} 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.
*/
export function buildAnthropicReplayPolicy(ctx: ProviderReplayPolicyContext): ProviderReplayPolicy {
return buildAnthropicReplayPolicyForModel(ctx.modelId);
return buildNativeAnthropicReplayPolicyForModel(ctx.modelId);
}

View File

@@ -43,6 +43,7 @@ export async function sanitizeSessionMessagesImages(
options?: {
sanitizeMode?: "full" | "images-only";
sanitizeToolCallIds?: boolean;
preserveNativeAnthropicToolUseIds?: boolean;
/**
* Mode for tool call ID sanitization:
* - "strict" (alphanumeric only)
@@ -66,7 +67,9 @@ export async function sanitizeSessionMessagesImages(
// We sanitize historical session messages because Anthropic can reject a request
// if the transcript contains oversized base64 images (default max side 1200px).
const sanitizedIds = shouldSanitizeToolCallIds
? sanitizeToolCallIdsForCloudCodeAssist(messages, options.toolCallIdMode)
? sanitizeToolCallIdsForCloudCodeAssist(messages, options.toolCallIdMode, {
preserveNativeAnthropicToolUseIds: options?.preserveNativeAnthropicToolUseIds,
})
: messages;
const out: AgentMessage[] = [];
for (const msg of sanitizedIds) {

View File

@@ -16,6 +16,7 @@ import {
TEST_SESSION_ID,
} from "./pi-embedded-runner.sanitize-session-history.test-harness.js";
import { castAgentMessage, castAgentMessages } from "./test-helpers/agent-message-fixtures.js";
import type { TranscriptPolicy } from "./transcript-policy.js";
import { makeZeroUsageSnapshot } from "./usage.js";
vi.mock("./pi-embedded-helpers.js", async () => ({
@@ -121,6 +122,7 @@ describe("sanitizeSessionHistory", () => {
provider?: string;
modelApi?: string;
modelId?: string;
policy?: TranscriptPolicy;
}) =>
sanitizeSessionHistory({
messages: params.messages,
@@ -129,6 +131,7 @@ describe("sanitizeSessionHistory", () => {
modelId: params.modelId ?? "claude-opus-4-6",
sessionManager: makeMockSessionManager(),
sessionId: TEST_SESSION_ID,
policy: params.policy,
});
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 () => {
setNonGoogleModelApi();

View File

@@ -657,6 +657,7 @@ export async function sanitizeSessionHistory(params: {
sanitizeMode: policy.sanitizeMode,
sanitizeToolCallIds: policy.sanitizeToolCallIds,
toolCallIdMode: policy.toolCallIdMode,
preserveNativeAnthropicToolUseIds: policy.preserveNativeAnthropicToolUseIds,
preserveSignatures: policy.preserveSignatures,
sanitizeThoughtSignatures: policy.sanitizeThoughtSignatures,
...resolveImageSanitizationLimits(params.config),

View File

@@ -470,6 +470,7 @@ export async function sanitizeSessionHistory(params: {
sanitizeMode: policy.sanitizeMode,
sanitizeToolCallIds: policy.sanitizeToolCallIds,
toolCallIdMode: policy.toolCallIdMode,
preserveNativeAnthropicToolUseIds: policy.preserveNativeAnthropicToolUseIds,
preserveSignatures: policy.preserveSignatures,
sanitizeThoughtSignatures: policy.sanitizeThoughtSignatures,
...resolveImageSanitizationLimits(params.config),

View File

@@ -1039,7 +1039,13 @@ export async function runEmbeddedAttempt(
if (!Array.isArray(messages)) {
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) {
return inner(model, context, options);
}

View File

@@ -239,6 +239,60 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
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", () => {
const input = buildDuplicateIdCollisionInput();

View File

@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
import type { AgentMessage } from "@mariozechner/pi-agent-core";
export type ToolCallIdMode = "strict" | "strict9";
const NATIVE_ANTHROPIC_TOOL_USE_ID_RE = /^toolu_[A-Za-z0-9_]+$/;
const STRICT9_LEN = 9;
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);
}
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 {
if (params.mode === "strict9") {
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}`;
}
function createOccurrenceAwareResolver(mode: ToolCallIdMode): {
function createOccurrenceAwareResolver(
mode: ToolCallIdMode,
options?: { preserveNativeAnthropicToolUseIds?: boolean },
): {
resolveAssistantId: (id: string) => string;
resolveToolResultId: (id: string) => string;
} {
@@ -152,6 +160,7 @@ function createOccurrenceAwareResolver(mode: ToolCallIdMode): {
const assistantOccurrences = new Map<string, number>();
const orphanToolResultOccurrences = new Map<string, number>();
const pendingByRawId = new Map<string, string[]>();
const preserveNativeAnthropicToolUseIds = options?.preserveNativeAnthropicToolUseIds === true;
const allocate = (seed: string): string => {
const next = makeUniqueToolId({ id: seed, used, mode });
@@ -159,10 +168,23 @@ function createOccurrenceAwareResolver(mode: ToolCallIdMode): {
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 occurrence = (assistantOccurrences.get(id) ?? 0) + 1;
assistantOccurrences.set(id, occurrence);
const next = allocate(occurrence === 1 ? id : `${id}:${occurrence}`);
const next = allocatePreservingNativeAnthropicId(id, occurrence);
const pending = pendingByRawId.get(id);
if (pending) {
pending.push(next);
@@ -184,6 +206,15 @@ function createOccurrenceAwareResolver(mode: ToolCallIdMode): {
const occurrence = (orphanToolResultOccurrences.get(id) ?? 0) + 1;
orphanToolResultOccurrences.set(id, occurrence);
if (
preserveNativeAnthropicToolUseIds &&
isNativeAnthropicToolUseId(id) &&
occurrence === 1 &&
!used.has(id)
) {
used.add(id);
return id;
}
return allocate(`${id}:tool_result:${occurrence}`);
};
@@ -267,6 +298,7 @@ function rewriteToolResultIds(params: {
export function sanitizeToolCallIdsForCloudCodeAssist(
messages: AgentMessage[],
mode: ToolCallIdMode = "strict",
options?: { preserveNativeAnthropicToolUseIds?: boolean },
): AgentMessage[] {
// Strict mode: only [a-zA-Z0-9]
// 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
// raw IDs receive distinct rewritten IDs, while matching tool results consume
// the same rewritten IDs in encounter order.
const { resolveAssistantId, resolveToolResultId } = createOccurrenceAwareResolver(mode);
const { resolveAssistantId, resolveToolResultId } = createOccurrenceAwareResolver(mode, options);
let changed = false;
const out = messages.map((msg) => {

View File

@@ -11,6 +11,7 @@ export type TranscriptPolicy = {
sanitizeMode: TranscriptSanitizeMode;
sanitizeToolCallIds: boolean;
toolCallIdMode?: ToolCallIdMode;
preserveNativeAnthropicToolUseIds: boolean;
repairToolUseResultPairing: boolean;
preserveSignatures: boolean;
sanitizeThoughtSignatures?: {
@@ -29,6 +30,7 @@ const DEFAULT_TRANSCRIPT_POLICY: TranscriptPolicy = {
sanitizeMode: "images-only",
sanitizeToolCallIds: false,
toolCallIdMode: undefined,
preserveNativeAnthropicToolUseIds: false,
repairToolUseResultPairing: true,
preserveSignatures: false,
sanitizeThoughtSignatures: undefined,
@@ -114,6 +116,9 @@ function mergeTranscriptPolicy(
? { sanitizeToolCallIds: policy.sanitizeToolCallIds }
: {}),
...(policy.toolCallIdMode ? { toolCallIdMode: policy.toolCallIdMode as ToolCallIdMode } : {}),
...(typeof policy.preserveNativeAnthropicToolUseIds === "boolean"
? { preserveNativeAnthropicToolUseIds: policy.preserveNativeAnthropicToolUseIds }
: {}),
...(typeof policy.repairToolUseResultPairing === "boolean"
? { repairToolUseResultPairing: policy.repairToolUseResultPairing }
: {}),

View File

@@ -8,6 +8,7 @@ import {
buildAnthropicReplayPolicyForModel,
buildGoogleGeminiReplayPolicy,
buildHybridAnthropicOrOpenAIReplayPolicy,
buildNativeAnthropicReplayPolicyForModel,
buildOpenAICompatibleReplayPolicy,
buildPassthroughGeminiSanitizingReplayPolicy,
buildStrictAnthropicReplayPolicy,
@@ -49,6 +50,7 @@ export {
buildAnthropicReplayPolicyForModel,
buildGoogleGeminiReplayPolicy,
buildHybridAnthropicOrOpenAIReplayPolicy,
buildNativeAnthropicReplayPolicyForModel,
buildOpenAICompatibleReplayPolicy,
buildPassthroughGeminiSanitizingReplayPolicy,
resolveTaggedReasoningOutputMode,

View File

@@ -3,6 +3,7 @@ import {
buildAnthropicReplayPolicyForModel,
buildGoogleGeminiReplayPolicy,
buildHybridAnthropicOrOpenAIReplayPolicy,
buildNativeAnthropicReplayPolicyForModel,
buildOpenAICompatibleReplayPolicy,
buildPassthroughGeminiSanitizingReplayPolicy,
resolveTaggedReasoningOutputMode,
@@ -33,6 +34,8 @@ describe("provider replay helpers", () => {
it("derives claude-only anthropic replay policy from the model id", () => {
expect(buildAnthropicReplayPolicyForModel("claude-sonnet-4-6")).toMatchObject({
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
dropThinkingBlocks: 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", () => {
expect(
buildHybridAnthropicOrOpenAIReplayPolicy(

View File

@@ -37,12 +37,24 @@ export function buildOpenAICompatibleReplayPolicy(
}
export function buildStrictAnthropicReplayPolicy(
options: { dropThinkingBlocks?: boolean } = {},
options: {
dropThinkingBlocks?: boolean;
sanitizeToolCallIds?: boolean;
preserveNativeAnthropicToolUseIds?: boolean;
} = {},
): ProviderReplayPolicy {
const sanitizeToolCallIds = options.sanitizeToolCallIds ?? true;
return {
sanitizeMode: "full",
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
...(sanitizeToolCallIds
? {
sanitizeToolCallIds: true,
toolCallIdMode: "strict" as const,
...(options.preserveNativeAnthropicToolUseIds
? { preserveNativeAnthropicToolUseIds: true }
: {}),
}
: {}),
preserveSignatures: true,
repairToolUseResultPairing: 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(
ctx: ProviderReplayPolicyContext,
options: { anthropicModelDropThinkingBlocks?: boolean } = {},

View File

@@ -583,6 +583,7 @@ export type ProviderReplayPolicy = {
sanitizeMode?: ProviderReplaySanitizeMode;
sanitizeToolCallIds?: boolean;
toolCallIdMode?: ProviderReplayToolCallIdMode;
preserveNativeAnthropicToolUseIds?: boolean;
preserveSignatures?: boolean;
sanitizeThoughtSignatures?: {
allowBase64Only?: boolean;