fix: gate transcript sanitization by provider

This commit is contained in:
Peter Steinberger
2026-01-23 00:28:41 +00:00
parent fac21e6eb4
commit db0235a26a
15 changed files with 307 additions and 212 deletions

View File

@@ -28,7 +28,7 @@ Docs: https://docs.clawd.bot
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
- Agents: surface concrete API error details instead of generic AI service errors.
- Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.
- Agents: avoid sanitizing tool call IDs for OpenAI responses to preserve Pi pairing.
- Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status.

View File

@@ -43,49 +43,6 @@ describe("sanitizeSessionMessagesImages", () => {
expect(toolResult.toolCallId).toBe("call_123|fc_456");
});
it("sanitizes tool call + tool result IDs in standard mode (preserves underscores)", async () => {
const input = [
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call_123|fc_456",
name: "read",
arguments: { path: "package.json" },
},
],
},
{
role: "toolResult",
toolCallId: "call_123|fc_456",
toolName: "read",
content: [{ type: "text", text: "ok" }],
isError: false,
},
] satisfies AgentMessage[];
const out = await sanitizeSessionMessagesImages(input, "test", {
sanitizeToolCallIds: true,
});
const assistant = out[0] as unknown as { role?: string; content?: unknown };
expect(assistant.role).toBe("assistant");
expect(Array.isArray(assistant.content)).toBe(true);
const toolCall = (assistant.content as Array<{ type?: string; id?: string }>).find(
(b) => b.type === "toolCall",
);
// Standard mode preserves underscores for readability, replaces invalid chars
expect(toolCall?.id).toBe("call_123_fc_456");
const toolResult = out[1] as unknown as {
role?: string;
toolCallId?: string;
};
expect(toolResult.role).toBe("toolResult");
expect(toolResult.toolCallId).toBe("call_123_fc_456");
});
it("sanitizes tool call + tool result IDs in strict mode (alphanumeric only)", async () => {
const input = [
{

View File

@@ -23,40 +23,6 @@ describe("sanitizeSessionMessagesImages", () => {
expect((content as Array<{ type?: string }>)[0]?.type).toBe("toolCall");
});
it("sanitizes tool ids in standard mode (preserves underscores)", async () => {
const input = [
{
role: "assistant",
content: [
{ type: "toolUse", id: "call_abc|item:123", name: "test", input: {} },
{
type: "toolCall",
id: "call_abc|item:456",
name: "exec",
arguments: {},
},
],
},
{
role: "toolResult",
toolUseId: "call_abc|item:123",
content: [{ type: "text", text: "ok" }],
},
] satisfies AgentMessage[];
const out = await sanitizeSessionMessagesImages(input, "test", {
sanitizeToolCallIds: true,
});
// Standard mode preserves underscores for readability
const assistant = out[0] as { content?: Array<{ id?: string }> };
expect(assistant.content?.[0]?.id).toBe("call_abc_item_123");
expect(assistant.content?.[1]?.id).toBe("call_abc_item_456");
const toolResult = out[1] as { toolUseId?: string };
expect(toolResult.toolUseId).toBe("call_abc_item_123");
});
it("sanitizes tool ids in strict mode (alphanumeric only)", async () => {
const input = [
{

View File

@@ -2,19 +2,19 @@ import { describe, expect, it } from "vitest";
import { sanitizeToolCallId } from "./pi-embedded-helpers.js";
describe("sanitizeToolCallId", () => {
describe("standard mode (default)", () => {
describe("strict mode (default)", () => {
it("keeps valid alphanumeric tool call IDs", () => {
expect(sanitizeToolCallId("callabc123")).toBe("callabc123");
});
it("keeps underscores and hyphens for readability", () => {
expect(sanitizeToolCallId("call_abc-123")).toBe("call_abc-123");
expect(sanitizeToolCallId("call_abc_def")).toBe("call_abc_def");
it("strips underscores and hyphens", () => {
expect(sanitizeToolCallId("call_abc-123")).toBe("callabc123");
expect(sanitizeToolCallId("call_abc_def")).toBe("callabcdef");
});
it("replaces invalid characters with underscores", () => {
expect(sanitizeToolCallId("call_abc|item:456")).toBe("call_abc_item_456");
it("strips invalid characters", () => {
expect(sanitizeToolCallId("call_abc|item:456")).toBe("callabcitem456");
});
it("returns default for empty IDs", () => {
expect(sanitizeToolCallId("")).toBe("default_tool_id");
expect(sanitizeToolCallId("")).toBe("defaulttoolid");
});
});

View File

@@ -32,10 +32,10 @@ export async function sanitizeSessionMessagesImages(
messages: AgentMessage[],
label: string,
options?: {
sanitizeMode?: "full" | "images-only";
sanitizeToolCallIds?: boolean;
/**
* Mode for tool call ID sanitization:
* - "standard" (default, preserves _-)
* - "strict" (alphanumeric only)
* - "strict9" (alphanumeric only, length 9)
*/
@@ -48,11 +48,14 @@ export async function sanitizeSessionMessagesImages(
};
},
): Promise<AgentMessage[]> {
const sanitizeMode = options?.sanitizeMode ?? "full";
const allowNonImageSanitization = sanitizeMode === "full";
// We sanitize historical session messages because Anthropic can reject a request
// if the transcript contains oversized base64 images (see MAX_IMAGE_DIMENSION_PX).
const sanitizedIds = options?.sanitizeToolCallIds
? sanitizeToolCallIdsForCloudCodeAssist(messages, options.toolCallIdMode)
: messages;
const sanitizedIds =
allowNonImageSanitization && options?.sanitizeToolCallIds
? sanitizeToolCallIdsForCloudCodeAssist(messages, options.toolCallIdMode)
: messages;
const out: AgentMessage[] = [];
for (const msg of sanitizedIds) {
if (!msg || typeof msg !== "object") {
@@ -87,11 +90,19 @@ export async function sanitizeSessionMessagesImages(
if (role === "assistant") {
const assistantMsg = msg as Extract<AgentMessage, { role: "assistant" }>;
if (isEmptyAssistantErrorMessage(assistantMsg)) {
if (allowNonImageSanitization && isEmptyAssistantErrorMessage(assistantMsg)) {
continue;
}
const content = assistantMsg.content;
if (Array.isArray(content)) {
if (!allowNonImageSanitization) {
const nextContent = (await sanitizeContentBlocksImages(
content as unknown as ContentBlock[],
label,
)) as unknown as typeof assistantMsg.content;
out.push({ ...assistantMsg, content: nextContent });
continue;
}
const strippedContent = options?.preserveSignatures
? content // Keep signatures for Antigravity Claude
: stripThoughtSignatures(content, options?.sanitizeThoughtSignatures); // Strip for Gemini

View File

@@ -40,17 +40,16 @@ describe("sanitizeSessionHistory", () => {
await sanitizeSessionHistory({
messages: mockMessages,
modelApi: "google-gemini",
modelApi: "google-generative-ai",
provider: "google-vertex",
sessionManager: mockSessionManager,
sessionId: "test-session",
});
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("google-gemini");
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
mockMessages,
"session:history",
expect.objectContaining({ sanitizeToolCallIds: true }),
expect.objectContaining({ sanitizeMode: "full", sanitizeToolCallIds: true }),
);
});
@@ -69,7 +68,11 @@ describe("sanitizeSessionHistory", () => {
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
mockMessages,
"session:history",
expect.objectContaining({ sanitizeToolCallIds: true, toolCallIdMode: "strict9" }),
expect.objectContaining({
sanitizeMode: "full",
sanitizeToolCallIds: true,
toolCallIdMode: "strict9",
}),
);
});
@@ -84,11 +87,10 @@ describe("sanitizeSessionHistory", () => {
sessionId: "test-session",
});
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("anthropic-messages");
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
mockMessages,
"session:history",
expect.objectContaining({ sanitizeToolCallIds: false }),
expect.objectContaining({ sanitizeMode: "full", sanitizeToolCallIds: false }),
);
});
@@ -103,11 +105,10 @@ describe("sanitizeSessionHistory", () => {
sessionId: "test-session",
});
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("openai-responses");
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
mockMessages,
"session:history",
expect.objectContaining({ sanitizeToolCallIds: false }),
expect.objectContaining({ sanitizeMode: "images-only", sanitizeToolCallIds: false }),
);
});
@@ -137,8 +138,27 @@ describe("sanitizeSessionHistory", () => {
sessionId: "test-session",
});
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("openai-responses");
expect(result).toHaveLength(2);
expect(result[1]?.role).toBe("assistant");
});
it("does not synthesize tool results for openai-responses", async () => {
const messages: AgentMessage[] = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
},
];
const result = await sanitizeSessionHistory({
messages,
modelApi: "openai-responses",
provider: "openai",
sessionManager: mockSessionManager,
sessionId: "test-session",
});
expect(result).toHaveLength(1);
expect(result[0]?.role).toBe("assistant");
});
});

View File

@@ -40,6 +40,7 @@ import {
import { createClawdbotCodingTools } from "../pi-tools.js";
import { resolveSandboxContext } from "../sandbox.js";
import { guardSessionManager } from "../session-tool-result-guard-wrapper.js";
import { resolveTranscriptPolicy } from "../transcript-policy.js";
import { acquireSessionWriteLock } from "../session-write-lock.js";
import {
applySkillEnvOverrides,
@@ -315,9 +316,16 @@ export async function compactEmbeddedPiSession(params: {
});
try {
await prewarmSessionFile(params.sessionFile);
const transcriptPolicy = resolveTranscriptPolicy({
modelApi: model.api,
provider,
modelId,
});
const sessionManager = guardSessionManager(SessionManager.open(params.sessionFile), {
agentId: sessionAgentId,
sessionKey: params.sessionKey,
allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults,
stripFinalTags: transcriptPolicy.stripFinalTags,
});
trackSessionManagerAccess(params.sessionFile);
const settingsManager = SettingsManager.create(effectiveWorkspace, agentDir);
@@ -364,9 +372,14 @@ export async function compactEmbeddedPiSession(params: {
provider,
sessionManager,
sessionId: params.sessionId,
policy: transcriptPolicy,
});
const validatedGemini = validateGeminiTurns(prior);
const validated = validateAnthropicTurns(validatedGemini);
const validatedGemini = transcriptPolicy.validateGeminiTurns
? validateGeminiTurns(prior)
: prior;
const validated = transcriptPolicy.validateAnthropicTurns
? validateAnthropicTurns(validatedGemini)
: validatedGemini;
const limited = limitHistoryTurns(
validated,
getDmHistoryLimitFromSessionKey(params.sessionKey, params.config),

View File

@@ -12,10 +12,9 @@ import {
import { sanitizeToolUseResultPairing } from "../session-transcript-repair.js";
import { log } from "./logger.js";
import { describeUnknownError } from "./utils.js";
import { isAntigravityClaude } from "../pi-embedded-helpers/google.js";
import { cleanToolSchemaForGemini } from "../pi-tools.schema.js";
import { normalizeProviderId } from "../model-selection.js";
import type { ToolCallIdMode } from "../tool-call-id.js";
import type { TranscriptPolicy } from "../transcript-policy.js";
import { resolveTranscriptPolicy } from "../transcript-policy.js";
const GOOGLE_TURN_ORDERING_CUSTOM_TYPE = "google-turn-ordering-bootstrap";
const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
@@ -40,15 +39,6 @@ const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
"minProperties",
"maxProperties",
]);
const MISTRAL_MODEL_HINTS = [
"mistral",
"mixtral",
"codestral",
"pixtral",
"devstral",
"ministral",
"mistralai",
];
const ANTIGRAVITY_SIGNATURE_RE = /^[A-Za-z0-9+/]+={0,2}$/;
function isValidAntigravitySignature(value: unknown): value is string {
@@ -59,19 +49,6 @@ function isValidAntigravitySignature(value: unknown): value is string {
return ANTIGRAVITY_SIGNATURE_RE.test(trimmed);
}
function shouldSanitizeToolCallIds(modelApi?: string | null): boolean {
if (!modelApi) return false;
return isGoogleModelApi(modelApi);
}
function isMistralModel(params: { provider?: string | null; modelId?: string | null }): boolean {
const provider = normalizeProviderId(params.provider ?? "");
if (provider === "mistral") return true;
const modelId = (params.modelId ?? "").toLowerCase();
if (!modelId) return false;
return MISTRAL_MODEL_HINTS.some((hint) => modelId.includes(hint));
}
function sanitizeAntigravityThinkingBlocks(messages: AgentMessage[]): AgentMessage[] {
let touched = false;
const out: AgentMessage[] = [];
@@ -271,32 +248,33 @@ export async function sanitizeSessionHistory(params: {
provider?: string;
sessionManager: SessionManager;
sessionId: string;
policy?: TranscriptPolicy;
}): Promise<AgentMessage[]> {
const isAntigravityClaudeModel = isAntigravityClaude({
api: params.modelApi,
provider: params.provider,
modelId: params.modelId,
});
const provider = normalizeProviderId(params.provider ?? "");
const modelId = (params.modelId ?? "").toLowerCase();
const isOpenRouterGemini =
(provider === "openrouter" || provider === "opencode") && modelId.includes("gemini");
const isMistral = isMistralModel({ provider, modelId });
const toolCallIdMode: ToolCallIdMode | undefined = isMistral ? "strict9" : undefined;
const sanitizeToolCallIds = shouldSanitizeToolCallIds(params.modelApi) || isMistral;
const policy =
params.policy ??
resolveTranscriptPolicy({
modelApi: params.modelApi,
provider: params.provider,
modelId: params.modelId,
});
const sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", {
sanitizeToolCallIds,
toolCallIdMode,
enforceToolCallLast: params.modelApi === "anthropic-messages",
preserveSignatures: isAntigravityClaudeModel,
sanitizeThoughtSignatures: isOpenRouterGemini
? { allowBase64Only: true, includeCamelCase: true }
: undefined,
sanitizeMode: policy.sanitizeMode,
sanitizeToolCallIds: policy.sanitizeToolCallIds,
toolCallIdMode: policy.toolCallIdMode,
enforceToolCallLast: policy.enforceToolCallLast,
preserveSignatures: policy.preserveSignatures,
sanitizeThoughtSignatures: policy.sanitizeThoughtSignatures,
});
const sanitizedThinking = isAntigravityClaudeModel
const sanitizedThinking = policy.normalizeAntigravityThinkingBlocks
? sanitizeAntigravityThinkingBlocks(sanitizedImages)
: sanitizedImages;
const repairedTools = sanitizeToolUseResultPairing(sanitizedThinking);
const repairedTools = policy.repairToolUseResultPairing
? sanitizeToolUseResultPairing(sanitizedThinking)
: sanitizedThinking;
if (!policy.applyGoogleTurnOrdering) {
return repairedTools;
}
return applyGoogleTurnOrderingFix({
messages: repairedTools,

View File

@@ -39,6 +39,7 @@ import {
import { createClawdbotCodingTools } from "../../pi-tools.js";
import { resolveSandboxContext } from "../../sandbox.js";
import { guardSessionManager } from "../../session-tool-result-guard-wrapper.js";
import { resolveTranscriptPolicy } from "../../transcript-policy.js";
import { acquireSessionWriteLock } from "../../session-write-lock.js";
import {
applySkillEnvOverrides,
@@ -369,10 +370,18 @@ export async function runEmbeddedAttempt(
.then(() => true)
.catch(() => false);
const transcriptPolicy = resolveTranscriptPolicy({
modelApi: params.model?.api,
provider: params.provider,
modelId: params.modelId,
});
await prewarmSessionFile(params.sessionFile);
sessionManager = guardSessionManager(SessionManager.open(params.sessionFile), {
agentId: sessionAgentId,
sessionKey: params.sessionKey,
allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults,
stripFinalTags: transcriptPolicy.stripFinalTags,
});
trackSessionManagerAccess(params.sessionFile);
@@ -473,10 +482,15 @@ export async function runEmbeddedAttempt(
provider: params.provider,
sessionManager,
sessionId: params.sessionId,
policy: transcriptPolicy,
});
cacheTrace?.recordStage("session:sanitized", { messages: prior });
const validatedGemini = validateGeminiTurns(prior);
const validated = validateAnthropicTurns(validatedGemini);
const validatedGemini = transcriptPolicy.validateGeminiTurns
? validateGeminiTurns(prior)
: prior;
const validated = transcriptPolicy.validateAnthropicTurns
? validateAnthropicTurns(validatedGemini)
: validatedGemini;
const limited = limitHistoryTurns(
validated,
getDmHistoryLimitFromSessionKey(params.sessionKey, params.config),

View File

@@ -9,19 +9,30 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ContextEvent, ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { isGoogleModelApi } from "../pi-embedded-helpers.js";
import { repairToolUseResultPairing } from "../session-transcript-repair.js";
import { sanitizeToolCallIdsForCloudCodeAssist } from "../tool-call-id.js";
import { resolveTranscriptPolicy } from "../transcript-policy.js";
export default function transcriptSanitizeExtension(api: ExtensionAPI): void {
api.on("context", (event: ContextEvent, ctx: ExtensionContext) => {
let next = event.messages as AgentMessage[];
const repaired = repairToolUseResultPairing(next);
if (repaired.messages !== next) next = repaired.messages;
const policy = resolveTranscriptPolicy({
modelApi: ctx.model?.api,
provider: ctx.model?.provider,
modelId: ctx.model?.id,
});
if (isGoogleModelApi(ctx.model?.api)) {
const repairedIds = sanitizeToolCallIdsForCloudCodeAssist(next);
if (policy.repairToolUseResultPairing) {
const repaired = repairToolUseResultPairing(next);
if (repaired.messages !== next) next = repaired.messages;
}
if (policy.sanitizeToolCallIds) {
const repairedIds = sanitizeToolCallIdsForCloudCodeAssist(
next,
policy.toolCallIdMode ?? "strict",
);
if (repairedIds !== next) next = repairedIds;
}

View File

@@ -14,7 +14,12 @@ export type GuardedSessionManager = SessionManager & {
*/
export function guardSessionManager(
sessionManager: SessionManager,
opts?: { agentId?: string; sessionKey?: string },
opts?: {
agentId?: string;
sessionKey?: string;
allowSyntheticToolResults?: boolean;
stripFinalTags?: boolean;
},
): GuardedSessionManager {
if (typeof (sessionManager as GuardedSessionManager).flushPendingToolResults === "function") {
return sessionManager as GuardedSessionManager;
@@ -43,6 +48,8 @@ export function guardSessionManager(
const guard = installSessionToolResultGuard(sessionManager, {
transformToolResultForPersistence: transform,
allowSyntheticToolResults: opts?.allowSyntheticToolResults,
stripFinalTags: opts?.stripFinalTags,
});
(sessionManager as GuardedSessionManager).flushPendingToolResults = guard.flushPendingToolResults;
return sessionManager as GuardedSessionManager;

View File

@@ -79,6 +79,16 @@ export function installSessionToolResultGuard(
message: AgentMessage,
meta: { toolCallId?: string; toolName?: string; isSynthetic?: boolean },
) => AgentMessage;
/**
* Whether to strip <final> tags from assistant text before persistence.
* Defaults to true.
*/
stripFinalTags?: boolean;
/**
* Whether to synthesize missing tool results to satisfy strict providers.
* Defaults to true.
*/
allowSyntheticToolResults?: boolean;
},
): {
flushPendingToolResults: () => void;
@@ -95,17 +105,22 @@ export function installSessionToolResultGuard(
return transformer ? transformer(message, meta) : message;
};
const allowSyntheticToolResults = opts?.allowSyntheticToolResults ?? true;
const stripFinalTags = opts?.stripFinalTags ?? true;
const flushPendingToolResults = () => {
if (pending.size === 0) return;
for (const [id, name] of pending.entries()) {
const synthetic = makeMissingToolResult({ toolCallId: id, toolName: name });
originalAppend(
persistToolResult(synthetic, {
toolCallId: id,
toolName: name,
isSynthetic: true,
}) as never,
);
if (allowSyntheticToolResults) {
for (const [id, name] of pending.entries()) {
const synthetic = makeMissingToolResult({ toolCallId: id, toolName: name });
originalAppend(
persistToolResult(synthetic, {
toolCallId: id,
toolName: name,
isSynthetic: true,
}) as never,
);
}
}
pending.clear();
};
@@ -127,7 +142,7 @@ export function installSessionToolResultGuard(
}
const sanitized =
role === "assistant"
role === "assistant" && stripFinalTags
? stripFinalTagsFromAssistant(message as Extract<AgentMessage, { role: "assistant" }>)
: message;
const toolCalls =
@@ -135,13 +150,15 @@ export function installSessionToolResultGuard(
? extractAssistantToolCalls(sanitized as Extract<AgentMessage, { role: "assistant" }>)
: [];
// If previous tool calls are still pending, flush before non-tool results.
if (pending.size > 0 && (toolCalls.length === 0 || role !== "assistant")) {
flushPendingToolResults();
}
// If new tool calls arrive while older ones are pending, flush the old ones first.
if (pending.size > 0 && toolCalls.length > 0) {
flushPendingToolResults();
if (allowSyntheticToolResults) {
// If previous tool calls are still pending, flush before non-tool results.
if (pending.size > 0 && (toolCalls.length === 0 || role !== "assistant")) {
flushPendingToolResults();
}
// If new tool calls arrive while older ones are pending, flush the old ones first.
if (pending.size > 0 && toolCalls.length > 0) {
flushPendingToolResults();
}
}
const result = originalAppend(sanitized as never);

View File

@@ -7,16 +7,16 @@ import {
} from "./tool-call-id.js";
describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
describe("standard mode (default)", () => {
describe("strict mode (default)", () => {
it("is a no-op for already-valid non-colliding IDs", () => {
const input = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
content: [{ type: "toolCall", id: "call1", name: "read", arguments: {} }],
},
{
role: "toolResult",
toolCallId: "call_1",
toolCallId: "call1",
toolName: "read",
content: [{ type: "text", text: "ok" }],
},
@@ -26,7 +26,7 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
expect(out).toBe(input);
});
it("replaces invalid characters with underscores (preserves readability)", () => {
it("strips non-alphanumeric characters from tool call IDs", () => {
const input = [
{
role: "assistant",
@@ -45,9 +45,9 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
const toolCall = assistant.content?.[0] as { id?: string };
// Standard mode preserves underscores for readability
expect(toolCall.id).toBe("call_item_123");
expect(isValidCloudCodeAssistToolId(toolCall.id as string)).toBe(true);
// Strict mode strips all non-alphanumeric characters
expect(toolCall.id).toBe("callitem123");
expect(isValidCloudCodeAssistToolId(toolCall.id as string, "strict")).toBe(true);
const result = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
expect(result.toolCallId).toBe(toolCall.id);
@@ -85,8 +85,8 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
expect(typeof a.id).toBe("string");
expect(typeof b.id).toBe("string");
expect(a.id).not.toBe(b.id);
expect(isValidCloudCodeAssistToolId(a.id as string)).toBe(true);
expect(isValidCloudCodeAssistToolId(b.id as string)).toBe(true);
expect(isValidCloudCodeAssistToolId(a.id as string, "strict")).toBe(true);
expect(isValidCloudCodeAssistToolId(b.id as string, "strict")).toBe(true);
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
const r2 = out[2] as Extract<AgentMessage, { role: "toolResult" }>;
@@ -129,8 +129,8 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
expect(a.id).not.toBe(b.id);
expect(a.id?.length).toBeLessThanOrEqual(40);
expect(b.id?.length).toBeLessThanOrEqual(40);
expect(isValidCloudCodeAssistToolId(a.id as string)).toBe(true);
expect(isValidCloudCodeAssistToolId(b.id as string)).toBe(true);
expect(isValidCloudCodeAssistToolId(a.id as string, "strict")).toBe(true);
expect(isValidCloudCodeAssistToolId(b.id as string, "strict")).toBe(true);
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
const r2 = out[2] as Extract<AgentMessage, { role: "toolResult" }>;

View File

@@ -2,27 +2,20 @@ import { createHash } from "node:crypto";
import type { AgentMessage } from "@mariozechner/pi-agent-core";
export type ToolCallIdMode = "standard" | "strict" | "strict9";
export type ToolCallIdMode = "strict" | "strict9";
const STRICT9_LEN = 9;
/**
* Sanitize a tool call ID to be compatible with various providers.
*
* - "standard" mode: allows [a-zA-Z0-9_-], better readability (default)
* - "strict" mode: only [a-zA-Z0-9]
* - "strict9" mode: only [a-zA-Z0-9], length 9 (Mistral tool call requirement)
*/
export function sanitizeToolCallId(id: string, mode: ToolCallIdMode = "standard"): string {
export function sanitizeToolCallId(id: string, mode: ToolCallIdMode = "strict"): string {
if (!id || typeof id !== "string") {
if (mode === "strict9") return "defaultid";
return mode === "strict" ? "defaulttoolid" : "default_tool_id";
}
if (mode === "strict") {
// Some providers require strictly alphanumeric tool call IDs.
const alphanumericOnly = id.replace(/[^a-zA-Z0-9]/g, "");
return alphanumericOnly.length > 0 ? alphanumericOnly : "sanitizedtoolid";
return "defaulttoolid";
}
if (mode === "strict9") {
@@ -32,26 +25,18 @@ export function sanitizeToolCallId(id: string, mode: ToolCallIdMode = "standard"
return shortHash("sanitized", STRICT9_LEN);
}
// Standard mode: allow underscores and hyphens for better readability in logs
const sanitized = id.replace(/[^a-zA-Z0-9_-]/g, "_");
const trimmed = sanitized.replace(/^[^a-zA-Z0-9_-]+/, "");
return trimmed.length > 0 ? trimmed : "sanitized_tool_id";
// Some providers require strictly alphanumeric tool call IDs.
const alphanumericOnly = id.replace(/[^a-zA-Z0-9]/g, "");
return alphanumericOnly.length > 0 ? alphanumericOnly : "sanitizedtoolid";
}
export function isValidCloudCodeAssistToolId(
id: string,
mode: ToolCallIdMode = "standard",
): boolean {
export function isValidCloudCodeAssistToolId(id: string, mode: ToolCallIdMode = "strict"): boolean {
if (!id || typeof id !== "string") return false;
if (mode === "strict") {
// Strictly alphanumeric for providers with tighter tool ID constraints
return /^[a-zA-Z0-9]+$/.test(id);
}
if (mode === "strict9") {
return /^[a-zA-Z0-9]{9}$/.test(id);
}
// Standard mode allows underscores and hyphens
return /^[a-zA-Z0-9_-]+$/.test(id);
// Strictly alphanumeric for providers with tighter tool ID constraints
return /^[a-zA-Z0-9]+$/.test(id);
}
function shortHash(text: string, length = 8): string {
@@ -78,7 +63,7 @@ function makeUniqueToolId(params: { id: string; used: Set<string>; mode: ToolCal
if (!params.used.has(base)) return base;
const hash = shortHash(params.id);
// Use separator based on mode: underscore for standard (readable), none for strict
// Use separator based on mode: none for strict, underscore for non-strict variants
const separator = params.mode === "strict" ? "" : "_";
const maxBaseLen = MAX_LEN - separator.length - hash.length;
const clippedBase = base.length > maxBaseLen ? base.slice(0, maxBaseLen) : base;
@@ -154,16 +139,15 @@ function rewriteToolResultIds(params: {
* Sanitize tool call IDs for provider compatibility.
*
* @param messages - The messages to sanitize
* @param mode - "standard" (default, allows _-), "strict" (alphanumeric only), or "strict9" (alphanumeric length 9)
* @param mode - "strict" (alphanumeric only) or "strict9" (alphanumeric length 9)
*/
export function sanitizeToolCallIdsForCloudCodeAssist(
messages: AgentMessage[],
mode: ToolCallIdMode = "standard",
mode: ToolCallIdMode = "strict",
): AgentMessage[] {
// Standard mode: allows [a-zA-Z0-9_-] for better readability in session logs
// Strict mode: only [a-zA-Z0-9]
// Strict9 mode: only [a-zA-Z0-9], length 9 (Mistral tool call requirement)
// Sanitization can introduce collisions (e.g. `a|b` and `a:b` -> `a_b` or `ab`).
// Sanitization can introduce collisions (e.g. `a|b` and `a:b` -> `ab`).
// Fix by applying a stable, transcript-wide mapping and de-duping via suffix.
const map = new Map<string, string>();
const used = new Set<string>();

View File

@@ -0,0 +1,117 @@
import { isAntigravityClaude, isGoogleModelApi } from "./pi-embedded-helpers/google.js";
import { normalizeProviderId } from "./model-selection.js";
import type { ToolCallIdMode } from "./tool-call-id.js";
export type TranscriptSanitizeMode = "full" | "images-only";
export type TranscriptPolicy = {
sanitizeMode: TranscriptSanitizeMode;
sanitizeToolCallIds: boolean;
toolCallIdMode?: ToolCallIdMode;
repairToolUseResultPairing: boolean;
enforceToolCallLast: boolean;
preserveSignatures: boolean;
sanitizeThoughtSignatures?: {
allowBase64Only?: boolean;
includeCamelCase?: boolean;
};
normalizeAntigravityThinkingBlocks: boolean;
applyGoogleTurnOrdering: boolean;
validateGeminiTurns: boolean;
validateAnthropicTurns: boolean;
stripFinalTags: boolean;
allowSyntheticToolResults: boolean;
};
const MISTRAL_MODEL_HINTS = [
"mistral",
"mixtral",
"codestral",
"pixtral",
"devstral",
"ministral",
"mistralai",
];
const OPENAI_MODEL_APIS = new Set([
"openai",
"openai-completions",
"openai-responses",
"openai-codex-responses",
]);
const OPENAI_PROVIDERS = new Set(["openai", "openai-codex"]);
function isOpenAiApi(modelApi?: string | null): boolean {
if (!modelApi) return false;
return OPENAI_MODEL_APIS.has(modelApi);
}
function isOpenAiProvider(provider?: string | null): boolean {
if (!provider) return false;
return OPENAI_PROVIDERS.has(normalizeProviderId(provider));
}
function isAnthropicApi(modelApi?: string | null, provider?: string | null): boolean {
if (modelApi === "anthropic-messages") return true;
const normalized = normalizeProviderId(provider ?? "");
return normalized === "anthropic" || normalized === "minimax";
}
function isMistralModel(params: { provider?: string | null; modelId?: string | null }): boolean {
const provider = normalizeProviderId(params.provider ?? "");
if (provider === "mistral") return true;
const modelId = (params.modelId ?? "").toLowerCase();
if (!modelId) return false;
return MISTRAL_MODEL_HINTS.some((hint) => modelId.includes(hint));
}
export function resolveTranscriptPolicy(params: {
modelApi?: string | null;
provider?: string | null;
modelId?: string | null;
}): TranscriptPolicy {
const provider = normalizeProviderId(params.provider ?? "");
const modelId = params.modelId ?? "";
const isGoogle = isGoogleModelApi(params.modelApi);
const isAnthropic = isAnthropicApi(params.modelApi, provider);
const isOpenAi = isOpenAiProvider(provider) || (!provider && isOpenAiApi(params.modelApi));
const isMistral = isMistralModel({ provider, modelId });
const isOpenRouterGemini =
(provider === "openrouter" || provider === "opencode") &&
modelId.toLowerCase().includes("gemini");
const isAntigravityClaudeModel = isAntigravityClaude({
api: params.modelApi,
provider,
modelId,
});
const needsNonImageSanitize = isGoogle || isAnthropic || isMistral || isOpenRouterGemini;
const sanitizeToolCallIds = isGoogle || isMistral;
const toolCallIdMode: ToolCallIdMode | undefined = isMistral
? "strict9"
: sanitizeToolCallIds
? "strict"
: undefined;
const repairToolUseResultPairing = isGoogle || isAnthropic;
const enforceToolCallLast = isAnthropic;
const sanitizeThoughtSignatures = isOpenRouterGemini
? { allowBase64Only: true, includeCamelCase: true }
: undefined;
const normalizeAntigravityThinkingBlocks = isAntigravityClaudeModel;
return {
sanitizeMode: isOpenAi ? "images-only" : needsNonImageSanitize ? "full" : "images-only",
sanitizeToolCallIds: !isOpenAi && sanitizeToolCallIds,
toolCallIdMode,
repairToolUseResultPairing: !isOpenAi && repairToolUseResultPairing,
enforceToolCallLast: !isOpenAi && enforceToolCallLast,
preserveSignatures: isAntigravityClaudeModel,
sanitizeThoughtSignatures: isOpenAi ? undefined : sanitizeThoughtSignatures,
normalizeAntigravityThinkingBlocks,
applyGoogleTurnOrdering: !isOpenAi && isGoogle,
validateGeminiTurns: !isOpenAi && isGoogle,
validateAnthropicTurns: !isOpenAi && isAnthropic,
stripFinalTags: !isOpenAi && (isGoogle || isAnthropic),
allowSyntheticToolResults: !isOpenAi && (isGoogle || isAnthropic),
};
}