fix: gate transcript sanitization by provider
This commit is contained in:
@@ -28,7 +28,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status.
|
||||||
|
|
||||||
|
|||||||
@@ -43,49 +43,6 @@ describe("sanitizeSessionMessagesImages", () => {
|
|||||||
expect(toolResult.toolCallId).toBe("call_123|fc_456");
|
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 () => {
|
it("sanitizes tool call + tool result IDs in strict mode (alphanumeric only)", async () => {
|
||||||
const input = [
|
const input = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,40 +23,6 @@ describe("sanitizeSessionMessagesImages", () => {
|
|||||||
expect((content as Array<{ type?: string }>)[0]?.type).toBe("toolCall");
|
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 () => {
|
it("sanitizes tool ids in strict mode (alphanumeric only)", async () => {
|
||||||
const input = [
|
const input = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,19 +2,19 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { sanitizeToolCallId } from "./pi-embedded-helpers.js";
|
import { sanitizeToolCallId } from "./pi-embedded-helpers.js";
|
||||||
|
|
||||||
describe("sanitizeToolCallId", () => {
|
describe("sanitizeToolCallId", () => {
|
||||||
describe("standard mode (default)", () => {
|
describe("strict mode (default)", () => {
|
||||||
it("keeps valid alphanumeric tool call IDs", () => {
|
it("keeps valid alphanumeric tool call IDs", () => {
|
||||||
expect(sanitizeToolCallId("callabc123")).toBe("callabc123");
|
expect(sanitizeToolCallId("callabc123")).toBe("callabc123");
|
||||||
});
|
});
|
||||||
it("keeps underscores and hyphens for readability", () => {
|
it("strips underscores and hyphens", () => {
|
||||||
expect(sanitizeToolCallId("call_abc-123")).toBe("call_abc-123");
|
expect(sanitizeToolCallId("call_abc-123")).toBe("callabc123");
|
||||||
expect(sanitizeToolCallId("call_abc_def")).toBe("call_abc_def");
|
expect(sanitizeToolCallId("call_abc_def")).toBe("callabcdef");
|
||||||
});
|
});
|
||||||
it("replaces invalid characters with underscores", () => {
|
it("strips invalid characters", () => {
|
||||||
expect(sanitizeToolCallId("call_abc|item:456")).toBe("call_abc_item_456");
|
expect(sanitizeToolCallId("call_abc|item:456")).toBe("callabcitem456");
|
||||||
});
|
});
|
||||||
it("returns default for empty IDs", () => {
|
it("returns default for empty IDs", () => {
|
||||||
expect(sanitizeToolCallId("")).toBe("default_tool_id");
|
expect(sanitizeToolCallId("")).toBe("defaulttoolid");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ export async function sanitizeSessionMessagesImages(
|
|||||||
messages: AgentMessage[],
|
messages: AgentMessage[],
|
||||||
label: string,
|
label: string,
|
||||||
options?: {
|
options?: {
|
||||||
|
sanitizeMode?: "full" | "images-only";
|
||||||
sanitizeToolCallIds?: boolean;
|
sanitizeToolCallIds?: boolean;
|
||||||
/**
|
/**
|
||||||
* Mode for tool call ID sanitization:
|
* Mode for tool call ID sanitization:
|
||||||
* - "standard" (default, preserves _-)
|
|
||||||
* - "strict" (alphanumeric only)
|
* - "strict" (alphanumeric only)
|
||||||
* - "strict9" (alphanumeric only, length 9)
|
* - "strict9" (alphanumeric only, length 9)
|
||||||
*/
|
*/
|
||||||
@@ -48,9 +48,12 @@ export async function sanitizeSessionMessagesImages(
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
): Promise<AgentMessage[]> {
|
): Promise<AgentMessage[]> {
|
||||||
|
const sanitizeMode = options?.sanitizeMode ?? "full";
|
||||||
|
const allowNonImageSanitization = sanitizeMode === "full";
|
||||||
// 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 (see MAX_IMAGE_DIMENSION_PX).
|
// if the transcript contains oversized base64 images (see MAX_IMAGE_DIMENSION_PX).
|
||||||
const sanitizedIds = options?.sanitizeToolCallIds
|
const sanitizedIds =
|
||||||
|
allowNonImageSanitization && options?.sanitizeToolCallIds
|
||||||
? sanitizeToolCallIdsForCloudCodeAssist(messages, options.toolCallIdMode)
|
? sanitizeToolCallIdsForCloudCodeAssist(messages, options.toolCallIdMode)
|
||||||
: messages;
|
: messages;
|
||||||
const out: AgentMessage[] = [];
|
const out: AgentMessage[] = [];
|
||||||
@@ -87,11 +90,19 @@ export async function sanitizeSessionMessagesImages(
|
|||||||
|
|
||||||
if (role === "assistant") {
|
if (role === "assistant") {
|
||||||
const assistantMsg = msg as Extract<AgentMessage, { role: "assistant" }>;
|
const assistantMsg = msg as Extract<AgentMessage, { role: "assistant" }>;
|
||||||
if (isEmptyAssistantErrorMessage(assistantMsg)) {
|
if (allowNonImageSanitization && isEmptyAssistantErrorMessage(assistantMsg)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const content = assistantMsg.content;
|
const content = assistantMsg.content;
|
||||||
if (Array.isArray(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
|
const strippedContent = options?.preserveSignatures
|
||||||
? content // Keep signatures for Antigravity Claude
|
? content // Keep signatures for Antigravity Claude
|
||||||
: stripThoughtSignatures(content, options?.sanitizeThoughtSignatures); // Strip for Gemini
|
: stripThoughtSignatures(content, options?.sanitizeThoughtSignatures); // Strip for Gemini
|
||||||
|
|||||||
@@ -40,17 +40,16 @@ describe("sanitizeSessionHistory", () => {
|
|||||||
|
|
||||||
await sanitizeSessionHistory({
|
await sanitizeSessionHistory({
|
||||||
messages: mockMessages,
|
messages: mockMessages,
|
||||||
modelApi: "google-gemini",
|
modelApi: "google-generative-ai",
|
||||||
provider: "google-vertex",
|
provider: "google-vertex",
|
||||||
sessionManager: mockSessionManager,
|
sessionManager: mockSessionManager,
|
||||||
sessionId: "test-session",
|
sessionId: "test-session",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("google-gemini");
|
|
||||||
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
|
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
|
||||||
mockMessages,
|
mockMessages,
|
||||||
"session:history",
|
"session:history",
|
||||||
expect.objectContaining({ sanitizeToolCallIds: true }),
|
expect.objectContaining({ sanitizeMode: "full", sanitizeToolCallIds: true }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -69,7 +68,11 @@ describe("sanitizeSessionHistory", () => {
|
|||||||
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
|
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
|
||||||
mockMessages,
|
mockMessages,
|
||||||
"session:history",
|
"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",
|
sessionId: "test-session",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("anthropic-messages");
|
|
||||||
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
|
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
|
||||||
mockMessages,
|
mockMessages,
|
||||||
"session:history",
|
"session:history",
|
||||||
expect.objectContaining({ sanitizeToolCallIds: false }),
|
expect.objectContaining({ sanitizeMode: "full", sanitizeToolCallIds: false }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -103,11 +105,10 @@ describe("sanitizeSessionHistory", () => {
|
|||||||
sessionId: "test-session",
|
sessionId: "test-session",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("openai-responses");
|
|
||||||
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
|
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
|
||||||
mockMessages,
|
mockMessages,
|
||||||
"session:history",
|
"session:history",
|
||||||
expect.objectContaining({ sanitizeToolCallIds: false }),
|
expect.objectContaining({ sanitizeMode: "images-only", sanitizeToolCallIds: false }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,8 +138,27 @@ describe("sanitizeSessionHistory", () => {
|
|||||||
sessionId: "test-session",
|
sessionId: "test-session",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("openai-responses");
|
|
||||||
expect(result).toHaveLength(2);
|
expect(result).toHaveLength(2);
|
||||||
expect(result[1]?.role).toBe("assistant");
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
import { createClawdbotCodingTools } from "../pi-tools.js";
|
import { createClawdbotCodingTools } from "../pi-tools.js";
|
||||||
import { resolveSandboxContext } from "../sandbox.js";
|
import { resolveSandboxContext } from "../sandbox.js";
|
||||||
import { guardSessionManager } from "../session-tool-result-guard-wrapper.js";
|
import { guardSessionManager } from "../session-tool-result-guard-wrapper.js";
|
||||||
|
import { resolveTranscriptPolicy } from "../transcript-policy.js";
|
||||||
import { acquireSessionWriteLock } from "../session-write-lock.js";
|
import { acquireSessionWriteLock } from "../session-write-lock.js";
|
||||||
import {
|
import {
|
||||||
applySkillEnvOverrides,
|
applySkillEnvOverrides,
|
||||||
@@ -315,9 +316,16 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await prewarmSessionFile(params.sessionFile);
|
await prewarmSessionFile(params.sessionFile);
|
||||||
|
const transcriptPolicy = resolveTranscriptPolicy({
|
||||||
|
modelApi: model.api,
|
||||||
|
provider,
|
||||||
|
modelId,
|
||||||
|
});
|
||||||
const sessionManager = guardSessionManager(SessionManager.open(params.sessionFile), {
|
const sessionManager = guardSessionManager(SessionManager.open(params.sessionFile), {
|
||||||
agentId: sessionAgentId,
|
agentId: sessionAgentId,
|
||||||
sessionKey: params.sessionKey,
|
sessionKey: params.sessionKey,
|
||||||
|
allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults,
|
||||||
|
stripFinalTags: transcriptPolicy.stripFinalTags,
|
||||||
});
|
});
|
||||||
trackSessionManagerAccess(params.sessionFile);
|
trackSessionManagerAccess(params.sessionFile);
|
||||||
const settingsManager = SettingsManager.create(effectiveWorkspace, agentDir);
|
const settingsManager = SettingsManager.create(effectiveWorkspace, agentDir);
|
||||||
@@ -364,9 +372,14 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
provider,
|
provider,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
|
policy: transcriptPolicy,
|
||||||
});
|
});
|
||||||
const validatedGemini = validateGeminiTurns(prior);
|
const validatedGemini = transcriptPolicy.validateGeminiTurns
|
||||||
const validated = validateAnthropicTurns(validatedGemini);
|
? validateGeminiTurns(prior)
|
||||||
|
: prior;
|
||||||
|
const validated = transcriptPolicy.validateAnthropicTurns
|
||||||
|
? validateAnthropicTurns(validatedGemini)
|
||||||
|
: validatedGemini;
|
||||||
const limited = limitHistoryTurns(
|
const limited = limitHistoryTurns(
|
||||||
validated,
|
validated,
|
||||||
getDmHistoryLimitFromSessionKey(params.sessionKey, params.config),
|
getDmHistoryLimitFromSessionKey(params.sessionKey, params.config),
|
||||||
|
|||||||
@@ -12,10 +12,9 @@ import {
|
|||||||
import { sanitizeToolUseResultPairing } from "../session-transcript-repair.js";
|
import { sanitizeToolUseResultPairing } from "../session-transcript-repair.js";
|
||||||
import { log } from "./logger.js";
|
import { log } from "./logger.js";
|
||||||
import { describeUnknownError } from "./utils.js";
|
import { describeUnknownError } from "./utils.js";
|
||||||
import { isAntigravityClaude } from "../pi-embedded-helpers/google.js";
|
|
||||||
import { cleanToolSchemaForGemini } from "../pi-tools.schema.js";
|
import { cleanToolSchemaForGemini } from "../pi-tools.schema.js";
|
||||||
import { normalizeProviderId } from "../model-selection.js";
|
import type { TranscriptPolicy } from "../transcript-policy.js";
|
||||||
import type { ToolCallIdMode } from "../tool-call-id.js";
|
import { resolveTranscriptPolicy } from "../transcript-policy.js";
|
||||||
|
|
||||||
const GOOGLE_TURN_ORDERING_CUSTOM_TYPE = "google-turn-ordering-bootstrap";
|
const GOOGLE_TURN_ORDERING_CUSTOM_TYPE = "google-turn-ordering-bootstrap";
|
||||||
const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
|
const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
|
||||||
@@ -40,15 +39,6 @@ const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
|
|||||||
"minProperties",
|
"minProperties",
|
||||||
"maxProperties",
|
"maxProperties",
|
||||||
]);
|
]);
|
||||||
const MISTRAL_MODEL_HINTS = [
|
|
||||||
"mistral",
|
|
||||||
"mixtral",
|
|
||||||
"codestral",
|
|
||||||
"pixtral",
|
|
||||||
"devstral",
|
|
||||||
"ministral",
|
|
||||||
"mistralai",
|
|
||||||
];
|
|
||||||
const ANTIGRAVITY_SIGNATURE_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
const ANTIGRAVITY_SIGNATURE_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||||
|
|
||||||
function isValidAntigravitySignature(value: unknown): value is string {
|
function isValidAntigravitySignature(value: unknown): value is string {
|
||||||
@@ -59,19 +49,6 @@ function isValidAntigravitySignature(value: unknown): value is string {
|
|||||||
return ANTIGRAVITY_SIGNATURE_RE.test(trimmed);
|
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[] {
|
function sanitizeAntigravityThinkingBlocks(messages: AgentMessage[]): AgentMessage[] {
|
||||||
let touched = false;
|
let touched = false;
|
||||||
const out: AgentMessage[] = [];
|
const out: AgentMessage[] = [];
|
||||||
@@ -271,32 +248,33 @@ export async function sanitizeSessionHistory(params: {
|
|||||||
provider?: string;
|
provider?: string;
|
||||||
sessionManager: SessionManager;
|
sessionManager: SessionManager;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
policy?: TranscriptPolicy;
|
||||||
}): Promise<AgentMessage[]> {
|
}): Promise<AgentMessage[]> {
|
||||||
const isAntigravityClaudeModel = isAntigravityClaude({
|
const policy =
|
||||||
api: params.modelApi,
|
params.policy ??
|
||||||
|
resolveTranscriptPolicy({
|
||||||
|
modelApi: params.modelApi,
|
||||||
provider: params.provider,
|
provider: params.provider,
|
||||||
modelId: params.modelId,
|
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 sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", {
|
const sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", {
|
||||||
sanitizeToolCallIds,
|
sanitizeMode: policy.sanitizeMode,
|
||||||
toolCallIdMode,
|
sanitizeToolCallIds: policy.sanitizeToolCallIds,
|
||||||
enforceToolCallLast: params.modelApi === "anthropic-messages",
|
toolCallIdMode: policy.toolCallIdMode,
|
||||||
preserveSignatures: isAntigravityClaudeModel,
|
enforceToolCallLast: policy.enforceToolCallLast,
|
||||||
sanitizeThoughtSignatures: isOpenRouterGemini
|
preserveSignatures: policy.preserveSignatures,
|
||||||
? { allowBase64Only: true, includeCamelCase: true }
|
sanitizeThoughtSignatures: policy.sanitizeThoughtSignatures,
|
||||||
: undefined,
|
|
||||||
});
|
});
|
||||||
const sanitizedThinking = isAntigravityClaudeModel
|
const sanitizedThinking = policy.normalizeAntigravityThinkingBlocks
|
||||||
? sanitizeAntigravityThinkingBlocks(sanitizedImages)
|
? sanitizeAntigravityThinkingBlocks(sanitizedImages)
|
||||||
: sanitizedImages;
|
: sanitizedImages;
|
||||||
const repairedTools = sanitizeToolUseResultPairing(sanitizedThinking);
|
const repairedTools = policy.repairToolUseResultPairing
|
||||||
|
? sanitizeToolUseResultPairing(sanitizedThinking)
|
||||||
|
: sanitizedThinking;
|
||||||
|
|
||||||
|
if (!policy.applyGoogleTurnOrdering) {
|
||||||
|
return repairedTools;
|
||||||
|
}
|
||||||
|
|
||||||
return applyGoogleTurnOrderingFix({
|
return applyGoogleTurnOrderingFix({
|
||||||
messages: repairedTools,
|
messages: repairedTools,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
import { createClawdbotCodingTools } from "../../pi-tools.js";
|
import { createClawdbotCodingTools } from "../../pi-tools.js";
|
||||||
import { resolveSandboxContext } from "../../sandbox.js";
|
import { resolveSandboxContext } from "../../sandbox.js";
|
||||||
import { guardSessionManager } from "../../session-tool-result-guard-wrapper.js";
|
import { guardSessionManager } from "../../session-tool-result-guard-wrapper.js";
|
||||||
|
import { resolveTranscriptPolicy } from "../../transcript-policy.js";
|
||||||
import { acquireSessionWriteLock } from "../../session-write-lock.js";
|
import { acquireSessionWriteLock } from "../../session-write-lock.js";
|
||||||
import {
|
import {
|
||||||
applySkillEnvOverrides,
|
applySkillEnvOverrides,
|
||||||
@@ -369,10 +370,18 @@ export async function runEmbeddedAttempt(
|
|||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false);
|
.catch(() => false);
|
||||||
|
|
||||||
|
const transcriptPolicy = resolveTranscriptPolicy({
|
||||||
|
modelApi: params.model?.api,
|
||||||
|
provider: params.provider,
|
||||||
|
modelId: params.modelId,
|
||||||
|
});
|
||||||
|
|
||||||
await prewarmSessionFile(params.sessionFile);
|
await prewarmSessionFile(params.sessionFile);
|
||||||
sessionManager = guardSessionManager(SessionManager.open(params.sessionFile), {
|
sessionManager = guardSessionManager(SessionManager.open(params.sessionFile), {
|
||||||
agentId: sessionAgentId,
|
agentId: sessionAgentId,
|
||||||
sessionKey: params.sessionKey,
|
sessionKey: params.sessionKey,
|
||||||
|
allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults,
|
||||||
|
stripFinalTags: transcriptPolicy.stripFinalTags,
|
||||||
});
|
});
|
||||||
trackSessionManagerAccess(params.sessionFile);
|
trackSessionManagerAccess(params.sessionFile);
|
||||||
|
|
||||||
@@ -473,10 +482,15 @@ export async function runEmbeddedAttempt(
|
|||||||
provider: params.provider,
|
provider: params.provider,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
|
policy: transcriptPolicy,
|
||||||
});
|
});
|
||||||
cacheTrace?.recordStage("session:sanitized", { messages: prior });
|
cacheTrace?.recordStage("session:sanitized", { messages: prior });
|
||||||
const validatedGemini = validateGeminiTurns(prior);
|
const validatedGemini = transcriptPolicy.validateGeminiTurns
|
||||||
const validated = validateAnthropicTurns(validatedGemini);
|
? validateGeminiTurns(prior)
|
||||||
|
: prior;
|
||||||
|
const validated = transcriptPolicy.validateAnthropicTurns
|
||||||
|
? validateAnthropicTurns(validatedGemini)
|
||||||
|
: validatedGemini;
|
||||||
const limited = limitHistoryTurns(
|
const limited = limitHistoryTurns(
|
||||||
validated,
|
validated,
|
||||||
getDmHistoryLimitFromSessionKey(params.sessionKey, params.config),
|
getDmHistoryLimitFromSessionKey(params.sessionKey, params.config),
|
||||||
|
|||||||
@@ -9,19 +9,30 @@
|
|||||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||||
import type { ContextEvent, ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
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 { repairToolUseResultPairing } from "../session-transcript-repair.js";
|
||||||
import { sanitizeToolCallIdsForCloudCodeAssist } from "../tool-call-id.js";
|
import { sanitizeToolCallIdsForCloudCodeAssist } from "../tool-call-id.js";
|
||||||
|
import { resolveTranscriptPolicy } from "../transcript-policy.js";
|
||||||
|
|
||||||
export default function transcriptSanitizeExtension(api: ExtensionAPI): void {
|
export default function transcriptSanitizeExtension(api: ExtensionAPI): void {
|
||||||
api.on("context", (event: ContextEvent, ctx: ExtensionContext) => {
|
api.on("context", (event: ContextEvent, ctx: ExtensionContext) => {
|
||||||
let next = event.messages as AgentMessage[];
|
let next = event.messages as AgentMessage[];
|
||||||
|
|
||||||
|
const policy = resolveTranscriptPolicy({
|
||||||
|
modelApi: ctx.model?.api,
|
||||||
|
provider: ctx.model?.provider,
|
||||||
|
modelId: ctx.model?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (policy.repairToolUseResultPairing) {
|
||||||
const repaired = repairToolUseResultPairing(next);
|
const repaired = repairToolUseResultPairing(next);
|
||||||
if (repaired.messages !== next) next = repaired.messages;
|
if (repaired.messages !== next) next = repaired.messages;
|
||||||
|
}
|
||||||
|
|
||||||
if (isGoogleModelApi(ctx.model?.api)) {
|
if (policy.sanitizeToolCallIds) {
|
||||||
const repairedIds = sanitizeToolCallIdsForCloudCodeAssist(next);
|
const repairedIds = sanitizeToolCallIdsForCloudCodeAssist(
|
||||||
|
next,
|
||||||
|
policy.toolCallIdMode ?? "strict",
|
||||||
|
);
|
||||||
if (repairedIds !== next) next = repairedIds;
|
if (repairedIds !== next) next = repairedIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ export type GuardedSessionManager = SessionManager & {
|
|||||||
*/
|
*/
|
||||||
export function guardSessionManager(
|
export function guardSessionManager(
|
||||||
sessionManager: SessionManager,
|
sessionManager: SessionManager,
|
||||||
opts?: { agentId?: string; sessionKey?: string },
|
opts?: {
|
||||||
|
agentId?: string;
|
||||||
|
sessionKey?: string;
|
||||||
|
allowSyntheticToolResults?: boolean;
|
||||||
|
stripFinalTags?: boolean;
|
||||||
|
},
|
||||||
): GuardedSessionManager {
|
): GuardedSessionManager {
|
||||||
if (typeof (sessionManager as GuardedSessionManager).flushPendingToolResults === "function") {
|
if (typeof (sessionManager as GuardedSessionManager).flushPendingToolResults === "function") {
|
||||||
return sessionManager as GuardedSessionManager;
|
return sessionManager as GuardedSessionManager;
|
||||||
@@ -43,6 +48,8 @@ export function guardSessionManager(
|
|||||||
|
|
||||||
const guard = installSessionToolResultGuard(sessionManager, {
|
const guard = installSessionToolResultGuard(sessionManager, {
|
||||||
transformToolResultForPersistence: transform,
|
transformToolResultForPersistence: transform,
|
||||||
|
allowSyntheticToolResults: opts?.allowSyntheticToolResults,
|
||||||
|
stripFinalTags: opts?.stripFinalTags,
|
||||||
});
|
});
|
||||||
(sessionManager as GuardedSessionManager).flushPendingToolResults = guard.flushPendingToolResults;
|
(sessionManager as GuardedSessionManager).flushPendingToolResults = guard.flushPendingToolResults;
|
||||||
return sessionManager as GuardedSessionManager;
|
return sessionManager as GuardedSessionManager;
|
||||||
|
|||||||
@@ -79,6 +79,16 @@ export function installSessionToolResultGuard(
|
|||||||
message: AgentMessage,
|
message: AgentMessage,
|
||||||
meta: { toolCallId?: string; toolName?: string; isSynthetic?: boolean },
|
meta: { toolCallId?: string; toolName?: string; isSynthetic?: boolean },
|
||||||
) => AgentMessage;
|
) => 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;
|
flushPendingToolResults: () => void;
|
||||||
@@ -95,8 +105,12 @@ export function installSessionToolResultGuard(
|
|||||||
return transformer ? transformer(message, meta) : message;
|
return transformer ? transformer(message, meta) : message;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const allowSyntheticToolResults = opts?.allowSyntheticToolResults ?? true;
|
||||||
|
const stripFinalTags = opts?.stripFinalTags ?? true;
|
||||||
|
|
||||||
const flushPendingToolResults = () => {
|
const flushPendingToolResults = () => {
|
||||||
if (pending.size === 0) return;
|
if (pending.size === 0) return;
|
||||||
|
if (allowSyntheticToolResults) {
|
||||||
for (const [id, name] of pending.entries()) {
|
for (const [id, name] of pending.entries()) {
|
||||||
const synthetic = makeMissingToolResult({ toolCallId: id, toolName: name });
|
const synthetic = makeMissingToolResult({ toolCallId: id, toolName: name });
|
||||||
originalAppend(
|
originalAppend(
|
||||||
@@ -107,6 +121,7 @@ export function installSessionToolResultGuard(
|
|||||||
}) as never,
|
}) as never,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
pending.clear();
|
pending.clear();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -127,7 +142,7 @@ export function installSessionToolResultGuard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sanitized =
|
const sanitized =
|
||||||
role === "assistant"
|
role === "assistant" && stripFinalTags
|
||||||
? stripFinalTagsFromAssistant(message as Extract<AgentMessage, { role: "assistant" }>)
|
? stripFinalTagsFromAssistant(message as Extract<AgentMessage, { role: "assistant" }>)
|
||||||
: message;
|
: message;
|
||||||
const toolCalls =
|
const toolCalls =
|
||||||
@@ -135,6 +150,7 @@ export function installSessionToolResultGuard(
|
|||||||
? extractAssistantToolCalls(sanitized as Extract<AgentMessage, { role: "assistant" }>)
|
? extractAssistantToolCalls(sanitized as Extract<AgentMessage, { role: "assistant" }>)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
if (allowSyntheticToolResults) {
|
||||||
// If previous tool calls are still pending, flush before non-tool results.
|
// If previous tool calls are still pending, flush before non-tool results.
|
||||||
if (pending.size > 0 && (toolCalls.length === 0 || role !== "assistant")) {
|
if (pending.size > 0 && (toolCalls.length === 0 || role !== "assistant")) {
|
||||||
flushPendingToolResults();
|
flushPendingToolResults();
|
||||||
@@ -143,6 +159,7 @@ export function installSessionToolResultGuard(
|
|||||||
if (pending.size > 0 && toolCalls.length > 0) {
|
if (pending.size > 0 && toolCalls.length > 0) {
|
||||||
flushPendingToolResults();
|
flushPendingToolResults();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = originalAppend(sanitized as never);
|
const result = originalAppend(sanitized as never);
|
||||||
|
|
||||||
|
|||||||
@@ -7,16 +7,16 @@ import {
|
|||||||
} from "./tool-call-id.js";
|
} from "./tool-call-id.js";
|
||||||
|
|
||||||
describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
|
describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
|
||||||
describe("standard mode (default)", () => {
|
describe("strict mode (default)", () => {
|
||||||
it("is a no-op for already-valid non-colliding IDs", () => {
|
it("is a no-op for already-valid non-colliding IDs", () => {
|
||||||
const input = [
|
const input = [
|
||||||
{
|
{
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
|
content: [{ type: "toolCall", id: "call1", name: "read", arguments: {} }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: "toolResult",
|
role: "toolResult",
|
||||||
toolCallId: "call_1",
|
toolCallId: "call1",
|
||||||
toolName: "read",
|
toolName: "read",
|
||||||
content: [{ type: "text", text: "ok" }],
|
content: [{ type: "text", text: "ok" }],
|
||||||
},
|
},
|
||||||
@@ -26,7 +26,7 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
|
|||||||
expect(out).toBe(input);
|
expect(out).toBe(input);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("replaces invalid characters with underscores (preserves readability)", () => {
|
it("strips non-alphanumeric characters from tool call IDs", () => {
|
||||||
const input = [
|
const input = [
|
||||||
{
|
{
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
@@ -45,9 +45,9 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
|
|||||||
|
|
||||||
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
|
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
|
||||||
const toolCall = assistant.content?.[0] as { id?: string };
|
const toolCall = assistant.content?.[0] as { id?: string };
|
||||||
// Standard mode preserves underscores for readability
|
// Strict mode strips all non-alphanumeric characters
|
||||||
expect(toolCall.id).toBe("call_item_123");
|
expect(toolCall.id).toBe("callitem123");
|
||||||
expect(isValidCloudCodeAssistToolId(toolCall.id as string)).toBe(true);
|
expect(isValidCloudCodeAssistToolId(toolCall.id as string, "strict")).toBe(true);
|
||||||
|
|
||||||
const result = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
|
const result = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||||
expect(result.toolCallId).toBe(toolCall.id);
|
expect(result.toolCallId).toBe(toolCall.id);
|
||||||
@@ -85,8 +85,8 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
|
|||||||
expect(typeof a.id).toBe("string");
|
expect(typeof a.id).toBe("string");
|
||||||
expect(typeof b.id).toBe("string");
|
expect(typeof b.id).toBe("string");
|
||||||
expect(a.id).not.toBe(b.id);
|
expect(a.id).not.toBe(b.id);
|
||||||
expect(isValidCloudCodeAssistToolId(a.id as string)).toBe(true);
|
expect(isValidCloudCodeAssistToolId(a.id as string, "strict")).toBe(true);
|
||||||
expect(isValidCloudCodeAssistToolId(b.id as string)).toBe(true);
|
expect(isValidCloudCodeAssistToolId(b.id as string, "strict")).toBe(true);
|
||||||
|
|
||||||
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
|
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||||
const r2 = out[2] 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).not.toBe(b.id);
|
||||||
expect(a.id?.length).toBeLessThanOrEqual(40);
|
expect(a.id?.length).toBeLessThanOrEqual(40);
|
||||||
expect(b.id?.length).toBeLessThanOrEqual(40);
|
expect(b.id?.length).toBeLessThanOrEqual(40);
|
||||||
expect(isValidCloudCodeAssistToolId(a.id as string)).toBe(true);
|
expect(isValidCloudCodeAssistToolId(a.id as string, "strict")).toBe(true);
|
||||||
expect(isValidCloudCodeAssistToolId(b.id as string)).toBe(true);
|
expect(isValidCloudCodeAssistToolId(b.id as string, "strict")).toBe(true);
|
||||||
|
|
||||||
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
|
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||||
const r2 = out[2] as Extract<AgentMessage, { role: "toolResult" }>;
|
const r2 = out[2] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||||
|
|||||||
@@ -2,27 +2,20 @@ import { createHash } from "node:crypto";
|
|||||||
|
|
||||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||||
|
|
||||||
export type ToolCallIdMode = "standard" | "strict" | "strict9";
|
export type ToolCallIdMode = "strict" | "strict9";
|
||||||
|
|
||||||
const STRICT9_LEN = 9;
|
const STRICT9_LEN = 9;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize a tool call ID to be compatible with various providers.
|
* 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]
|
* - "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)
|
||||||
*/
|
*/
|
||||||
export function sanitizeToolCallId(id: string, mode: ToolCallIdMode = "standard"): string {
|
export function sanitizeToolCallId(id: string, mode: ToolCallIdMode = "strict"): string {
|
||||||
if (!id || typeof id !== "string") {
|
if (!id || typeof id !== "string") {
|
||||||
if (mode === "strict9") return "defaultid";
|
if (mode === "strict9") return "defaultid";
|
||||||
return mode === "strict" ? "defaulttoolid" : "default_tool_id";
|
return "defaulttoolid";
|
||||||
}
|
|
||||||
|
|
||||||
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";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === "strict9") {
|
if (mode === "strict9") {
|
||||||
@@ -32,26 +25,18 @@ export function sanitizeToolCallId(id: string, mode: ToolCallIdMode = "standard"
|
|||||||
return shortHash("sanitized", STRICT9_LEN);
|
return shortHash("sanitized", STRICT9_LEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Standard mode: allow underscores and hyphens for better readability in logs
|
// Some providers require strictly alphanumeric tool call IDs.
|
||||||
const sanitized = id.replace(/[^a-zA-Z0-9_-]/g, "_");
|
const alphanumericOnly = id.replace(/[^a-zA-Z0-9]/g, "");
|
||||||
const trimmed = sanitized.replace(/^[^a-zA-Z0-9_-]+/, "");
|
return alphanumericOnly.length > 0 ? alphanumericOnly : "sanitizedtoolid";
|
||||||
return trimmed.length > 0 ? trimmed : "sanitized_tool_id";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isValidCloudCodeAssistToolId(
|
export function isValidCloudCodeAssistToolId(id: string, mode: ToolCallIdMode = "strict"): boolean {
|
||||||
id: string,
|
|
||||||
mode: ToolCallIdMode = "standard",
|
|
||||||
): boolean {
|
|
||||||
if (!id || typeof id !== "string") return false;
|
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") {
|
if (mode === "strict9") {
|
||||||
return /^[a-zA-Z0-9]{9}$/.test(id);
|
return /^[a-zA-Z0-9]{9}$/.test(id);
|
||||||
}
|
}
|
||||||
// Standard mode allows underscores and hyphens
|
// Strictly alphanumeric for providers with tighter tool ID constraints
|
||||||
return /^[a-zA-Z0-9_-]+$/.test(id);
|
return /^[a-zA-Z0-9]+$/.test(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function shortHash(text: string, length = 8): string {
|
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;
|
if (!params.used.has(base)) return base;
|
||||||
|
|
||||||
const hash = shortHash(params.id);
|
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 separator = params.mode === "strict" ? "" : "_";
|
||||||
const maxBaseLen = MAX_LEN - separator.length - hash.length;
|
const maxBaseLen = MAX_LEN - separator.length - hash.length;
|
||||||
const clippedBase = base.length > maxBaseLen ? base.slice(0, maxBaseLen) : base;
|
const clippedBase = base.length > maxBaseLen ? base.slice(0, maxBaseLen) : base;
|
||||||
@@ -154,16 +139,15 @@ function rewriteToolResultIds(params: {
|
|||||||
* Sanitize tool call IDs for provider compatibility.
|
* Sanitize tool call IDs for provider compatibility.
|
||||||
*
|
*
|
||||||
* @param messages - The messages to sanitize
|
* @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(
|
export function sanitizeToolCallIdsForCloudCodeAssist(
|
||||||
messages: AgentMessage[],
|
messages: AgentMessage[],
|
||||||
mode: ToolCallIdMode = "standard",
|
mode: ToolCallIdMode = "strict",
|
||||||
): AgentMessage[] {
|
): AgentMessage[] {
|
||||||
// Standard mode: allows [a-zA-Z0-9_-] for better readability in session logs
|
|
||||||
// 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)
|
||||||
// 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.
|
// Fix by applying a stable, transcript-wide mapping and de-duping via suffix.
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
const used = new Set<string>();
|
const used = new Set<string>();
|
||||||
|
|||||||
117
src/agents/transcript-policy.ts
Normal file
117
src/agents/transcript-policy.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user