diff --git a/CHANGELOG.md b/CHANGELOG.md index c19037cd3..405339abe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,8 +30,13 @@ Docs: https://docs.clawd.bot - 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: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider. +<<<<<<< Updated upstream - Agents: make tool summaries more readable and only show optional params when set. - Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic. +||||||| Stash base +======= +- Agents: centralize transcript sanitization in the runner; keep tags and error turns intact. +>>>>>>> Stashed changes - 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. diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index 948e48591..536305218 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -12,6 +12,7 @@ This document explains how Clawdbot manages sessions end-to-end: - **Session routing** (how inbound messages map to a `sessionKey`) - **Session store** (`sessions.json`) and what it tracks - **Transcript persistence** (`*.jsonl`) and its structure +- **Transcript hygiene** (provider-specific fixups before runs) - **Context limits** (context window vs tracked tokens) - **Compaction** (manual + auto-compaction) and where to hook pre-compaction work - **Silent housekeeping** (e.g. memory writes that shouldn’t produce user-visible output) @@ -20,6 +21,7 @@ If you want a higher-level overview first, start with: - [/concepts/session](/concepts/session) - [/concepts/compaction](/concepts/compaction) - [/concepts/session-pruning](/concepts/session-pruning) +- [/reference/transcript-hygiene](/reference/transcript-hygiene) --- diff --git a/docs/reference/transcript-hygiene.md b/docs/reference/transcript-hygiene.md new file mode 100644 index 000000000..fa466cef5 --- /dev/null +++ b/docs/reference/transcript-hygiene.md @@ -0,0 +1,94 @@ +--- +summary: "Reference: provider-specific transcript sanitization and repair rules" +read_when: + - You are debugging provider request rejections tied to transcript shape + - You are changing transcript sanitization or tool-call repair logic + - You are investigating tool-call id mismatches across providers +--- +# Transcript Hygiene (Provider Fixups) + +This document describes **provider-specific fixes** applied to transcripts before a run +(building model context). These are **in-memory** adjustments used to satisfy strict +provider requirements. They do **not** rewrite the stored JSONL transcript on disk. + +Scope includes: +- Tool call id sanitization +- Tool result pairing repair +- Turn validation / ordering +- Thought signature cleanup +- Image payload sanitization + +If you need transcript storage details, see: +- [/reference/session-management-compaction](/reference/session-management-compaction) + +--- + +## Where this runs + +All transcript hygiene is centralized in the embedded runner: +- Policy selection: `src/agents/transcript-policy.ts` +- Sanitization/repair application: `sanitizeSessionHistory` in `src/agents/pi-embedded-runner/google.ts` + +The policy uses `provider`, `modelApi`, and `modelId` to decide what to apply. + +--- + +## Global rule: image sanitization + +Image payloads are always sanitized to prevent provider-side rejection due to size +limits (downscale/recompress oversized base64 images). + +Implementation: +- `sanitizeSessionMessagesImages` in `src/agents/pi-embedded-helpers/images.ts` +- `sanitizeContentBlocksImages` in `src/agents/tool-images.ts` + +--- + +## Provider matrix (current behavior) + +**OpenAI / OpenAI Codex** +- Image sanitization only. +- No tool call id sanitization. +- No tool result pairing repair. +- No turn validation or reordering. +- No synthetic tool results. +- No thought signature stripping. + +**Google (Generative AI / Gemini CLI / Antigravity)** +- Tool call id sanitization: strict alphanumeric. +- Tool result pairing repair and synthetic tool results. +- Turn validation (Gemini-style turn alternation). +- Google turn ordering fixup (prepend a tiny user bootstrap if history starts with assistant). +- Antigravity Claude: normalize thinking signatures; drop unsigned thinking blocks. + +**Anthropic / Minimax (Anthropic-compatible)** +- Tool result pairing repair and synthetic tool results. +- Turn validation (merge consecutive user turns to satisfy strict alternation). + +**Mistral (including model-id based detection)** +- Tool call id sanitization: strict9 (alphanumeric length 9). + +**OpenRouter Gemini** +- Thought signature cleanup: strip non-base64 `thought_signature` values (keep base64). + +**Everything else** +- Image sanitization only. + +--- + +## Historical behavior (pre-2026.1.22) + +Before the 2026.1.22 release, Clawdbot applied multiple layers of transcript hygiene: + +- A **transcript-sanitize extension** ran on every context build and could: + - Repair tool use/result pairing. + - Sanitize tool call ids (including a non-strict mode that preserved `_`/`-`). +- The runner also performed provider-specific sanitization, which duplicated work. +- Additional mutations occurred outside the provider policy, including: + - Stripping `` tags from assistant text before persistence. + - Dropping empty assistant error turns. + - Trimming assistant content after tool calls. + +This complexity caused cross-provider regressions (notably `openai-responses` +`call_id|fc_id` pairing). The 2026.1.22 cleanup removed the extension, centralized +logic in the runner, and made OpenAI **no-touch** beyond image sanitization. diff --git a/src/agents/pi-embedded-helpers.sanitize-session-messages-images.keeps-tool-call-tool-result-ids-unchanged.test.ts b/src/agents/pi-embedded-helpers.sanitize-session-messages-images.keeps-tool-call-tool-result-ids-unchanged.test.ts index 29bb6244c..1b3210790 100644 --- a/src/agents/pi-embedded-helpers.sanitize-session-messages-images.keeps-tool-call-tool-result-ids-unchanged.test.ts +++ b/src/agents/pi-embedded-helpers.sanitize-session-messages-images.keeps-tool-call-tool-result-ids-unchanged.test.ts @@ -86,43 +86,6 @@ describe("sanitizeSessionMessagesImages", () => { expect(toolResult.role).toBe("toolResult"); expect(toolResult.toolCallId).toBe("call123fc456"); }); - it("drops assistant blocks after a tool call when enforceToolCallLast is enabled", async () => { - const input = [ - { - role: "assistant", - content: [ - { type: "text", text: "before" }, - { type: "toolCall", id: "call_1", name: "read", arguments: {} }, - { type: "thinking", thinking: "after", thinkingSignature: "sig" }, - { type: "text", text: "after text" }, - ], - }, - ] satisfies AgentMessage[]; - - const out = await sanitizeSessionMessagesImages(input, "test", { - enforceToolCallLast: true, - }); - const assistant = out[0] as { content?: Array<{ type?: string }> }; - expect(assistant.content?.map((b) => b.type)).toEqual(["text", "toolCall"]); - }); - it("keeps assistant blocks after a tool call when enforceToolCallLast is disabled", async () => { - const input = [ - { - role: "assistant", - content: [ - { type: "text", text: "before" }, - { type: "toolCall", id: "call_1", name: "read", arguments: {} }, - { type: "thinking", thinking: "after", thinkingSignature: "sig" }, - { type: "text", text: "after text" }, - ], - }, - ] satisfies AgentMessage[]; - - const out = await sanitizeSessionMessagesImages(input, "test"); - const assistant = out[0] as { content?: Array<{ type?: string }> }; - expect(assistant.content?.map((b) => b.type)).toEqual(["text", "toolCall", "thinking", "text"]); - }); - it("does not synthesize tool call input when missing", async () => { const input = [ { diff --git a/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.test.ts b/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.test.ts index 25e6e94e9..4d03c3ffe 100644 --- a/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.test.ts +++ b/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.test.ts @@ -87,7 +87,7 @@ describe("sanitizeSessionMessagesImages", () => { expect(out).toHaveLength(1); expect(out[0]?.role).toBe("user"); }); - it("drops empty assistant error messages", async () => { + it("keeps empty assistant error messages", async () => { const input = [ { role: "user", content: "hello" }, { role: "assistant", stopReason: "error", content: [] }, @@ -96,8 +96,10 @@ describe("sanitizeSessionMessagesImages", () => { const out = await sanitizeSessionMessagesImages(input, "test"); - expect(out).toHaveLength(1); + expect(out).toHaveLength(3); expect(out[0]?.role).toBe("user"); + expect(out[1]?.role).toBe("assistant"); + expect(out[2]?.role).toBe("assistant"); }); it("leaves non-assistant messages unchanged", async () => { const input = [ diff --git a/src/agents/pi-embedded-helpers/images.ts b/src/agents/pi-embedded-helpers/images.ts index 6711621d6..518226ae0 100644 --- a/src/agents/pi-embedded-helpers/images.ts +++ b/src/agents/pi-embedded-helpers/images.ts @@ -21,13 +21,6 @@ export function isEmptyAssistantMessageContent( }); } -function isEmptyAssistantErrorMessage( - message: Extract, -): boolean { - if (message.stopReason !== "error") return false; - return isEmptyAssistantMessageContent(message); -} - export async function sanitizeSessionMessagesImages( messages: AgentMessage[], label: string, @@ -40,7 +33,6 @@ export async function sanitizeSessionMessagesImages( * - "strict9" (alphanumeric only, length 9) */ toolCallIdMode?: ToolCallIdMode; - enforceToolCallLast?: boolean; preserveSignatures?: boolean; sanitizeThoughtSignatures?: { allowBase64Only?: boolean; @@ -90,7 +82,17 @@ export async function sanitizeSessionMessagesImages( if (role === "assistant") { const assistantMsg = msg as Extract; - if (allowNonImageSanitization && isEmptyAssistantErrorMessage(assistantMsg)) { + if (assistantMsg.stopReason === "error") { + const content = assistantMsg.content; + if (Array.isArray(content)) { + const nextContent = (await sanitizeContentBlocksImages( + content as unknown as ContentBlock[], + label, + )) as unknown as typeof assistantMsg.content; + out.push({ ...assistantMsg, content: nextContent }); + } else { + out.push(assistantMsg); + } continue; } const content = assistantMsg.content; @@ -113,25 +115,8 @@ export async function sanitizeSessionMessagesImages( if (rec.type !== "text" || typeof rec.text !== "string") return true; return rec.text.trim().length > 0; }); - - const normalizedContent = options?.enforceToolCallLast - ? (() => { - let lastToolIndex = -1; - for (let i = filteredContent.length - 1; i >= 0; i -= 1) { - const block = filteredContent[i]; - if (!block || typeof block !== "object") continue; - const type = (block as { type?: unknown }).type; - if (type === "functionCall" || type === "toolUse" || type === "toolCall") { - lastToolIndex = i; - break; - } - } - if (lastToolIndex === -1) return filteredContent; - return filteredContent.slice(0, lastToolIndex + 1); - })() - : filteredContent; const finalContent = (await sanitizeContentBlocksImages( - normalizedContent as unknown as ContentBlock[], + filteredContent as unknown as ContentBlock[], label, )) as unknown as typeof assistantMsg.content; if (finalContent.length === 0) { diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 95e484db3..9c0f420b6 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -325,7 +325,6 @@ export async function compactEmbeddedPiSession(params: { agentId: sessionAgentId, sessionKey: params.sessionKey, allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults, - stripFinalTags: transcriptPolicy.stripFinalTags, }); trackSessionManagerAccess(params.sessionFile); const settingsManager = SettingsManager.create(effectiveWorkspace, agentDir); diff --git a/src/agents/pi-embedded-runner/extensions.ts b/src/agents/pi-embedded-runner/extensions.ts index 48d9d22e6..73deae21d 100644 --- a/src/agents/pi-embedded-runner/extensions.ts +++ b/src/agents/pi-embedded-runner/extensions.ts @@ -73,7 +73,7 @@ export function buildEmbeddedExtensionPaths(params: { modelId: string; model: Model | undefined; }): string[] { - const paths = [resolvePiExtensionPath("transcript-sanitize")]; + const paths: string[] = []; if (resolveCompactionMode(params.cfg) === "safeguard") { paths.push(resolvePiExtensionPath("compaction-safeguard")); } diff --git a/src/agents/pi-embedded-runner/google.ts b/src/agents/pi-embedded-runner/google.ts index f18f97e0a..04ee11620 100644 --- a/src/agents/pi-embedded-runner/google.ts +++ b/src/agents/pi-embedded-runner/google.ts @@ -261,7 +261,6 @@ export async function sanitizeSessionHistory(params: { sanitizeMode: policy.sanitizeMode, sanitizeToolCallIds: policy.sanitizeToolCallIds, toolCallIdMode: policy.toolCallIdMode, - enforceToolCallLast: policy.enforceToolCallLast, preserveSignatures: policy.preserveSignatures, sanitizeThoughtSignatures: policy.sanitizeThoughtSignatures, }); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index b8c99ec11..093588cb3 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -381,7 +381,6 @@ export async function runEmbeddedAttempt( agentId: sessionAgentId, sessionKey: params.sessionKey, allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults, - stripFinalTags: transcriptPolicy.stripFinalTags, }); trackSessionManagerAccess(params.sessionFile); diff --git a/src/agents/session-tool-result-guard-wrapper.ts b/src/agents/session-tool-result-guard-wrapper.ts index f1728cb5c..956247a24 100644 --- a/src/agents/session-tool-result-guard-wrapper.ts +++ b/src/agents/session-tool-result-guard-wrapper.ts @@ -18,7 +18,6 @@ export function guardSessionManager( agentId?: string; sessionKey?: string; allowSyntheticToolResults?: boolean; - stripFinalTags?: boolean; }, ): GuardedSessionManager { if (typeof (sessionManager as GuardedSessionManager).flushPendingToolResults === "function") { @@ -49,7 +48,6 @@ 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; diff --git a/src/agents/session-tool-result-guard.test.ts b/src/agents/session-tool-result-guard.test.ts index 03dd1c494..1bfcb31ed 100644 --- a/src/agents/session-tool-result-guard.test.ts +++ b/src/agents/session-tool-result-guard.test.ts @@ -142,26 +142,4 @@ describe("installSessionToolResultGuard", () => { .map((e) => (e as { message: AgentMessage }).message); expect(messages.map((m) => m.role)).toEqual(["assistant", "toolResult"]); }); - - it("strips tags from assistant text blocks", () => { - const sm = SessionManager.inMemory(); - installSessionToolResultGuard(sm); - - sm.appendMessage({ - role: "assistant", - content: [ - { type: "text", text: "Hey!" }, - { type: "text", text: "More text here." }, - ], - } as AgentMessage); - - const messages = sm - .getEntries() - .filter((e) => e.type === "message") - .map((e) => (e as { message: AgentMessage }).message); - - const assistant = messages[0] as { content?: Array<{ type?: string; text?: string }> }; - expect(assistant.content?.[0]?.text).toBe("Hey!"); - expect(assistant.content?.[1]?.text).toBe("More text here."); - }); }); diff --git a/src/agents/session-tool-result-guard.ts b/src/agents/session-tool-result-guard.ts index 6e8fff5eb..feb6b854c 100644 --- a/src/agents/session-tool-result-guard.ts +++ b/src/agents/session-tool-result-guard.ts @@ -6,41 +6,6 @@ import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js"; type ToolCall = { id: string; name?: string }; -const FINAL_TAG_RE = /<\s*\/?\s*final\s*>/gi; - -function stripFinalTagsFromText(text: string): string { - if (!text) return text; - return text.replace(FINAL_TAG_RE, ""); -} - -function stripFinalTagsFromAssistant(message: Extract) { - const content = message.content; - if (typeof content === "string") { - const cleaned = stripFinalTagsFromText(content); - return cleaned === content - ? message - : ({ ...message, content: cleaned } as unknown as AgentMessage); - } - if (!Array.isArray(content)) return message; - - let changed = false; - const next = content.map((block) => { - if (!block || typeof block !== "object") return block; - const record = block as { type?: unknown; text?: unknown }; - if (record.type === "text" && typeof record.text === "string") { - const cleaned = stripFinalTagsFromText(record.text); - if (cleaned !== record.text) { - changed = true; - return { ...record, text: cleaned }; - } - } - return block; - }); - - if (!changed) return message; - return { ...message, content: next } as AgentMessage; -} - function extractAssistantToolCalls(msg: Extract): ToolCall[] { const content = msg.content; if (!Array.isArray(content)) return []; @@ -79,11 +44,6 @@ export function installSessionToolResultGuard( message: AgentMessage, meta: { toolCallId?: string; toolName?: string; isSynthetic?: boolean }, ) => AgentMessage; - /** - * Whether to strip tags from assistant text before persistence. - * Defaults to true. - */ - stripFinalTags?: boolean; /** * Whether to synthesize missing tool results to satisfy strict providers. * Defaults to true. @@ -106,7 +66,6 @@ export function installSessionToolResultGuard( }; const allowSyntheticToolResults = opts?.allowSyntheticToolResults ?? true; - const stripFinalTags = opts?.stripFinalTags ?? true; const flushPendingToolResults = () => { if (pending.size === 0) return; @@ -141,13 +100,9 @@ export function installSessionToolResultGuard( ); } - const sanitized = - role === "assistant" && stripFinalTags - ? stripFinalTagsFromAssistant(message as Extract) - : message; const toolCalls = role === "assistant" - ? extractAssistantToolCalls(sanitized as Extract) + ? extractAssistantToolCalls(message as Extract) : []; if (allowSyntheticToolResults) { @@ -161,7 +116,7 @@ export function installSessionToolResultGuard( } } - const result = originalAppend(sanitized as never); + const result = originalAppend(message as never); const sessionFile = ( sessionManager as { getSessionFile?: () => string | null } diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index 58d33dbcc..3ea06ce88 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -9,7 +9,6 @@ export type TranscriptPolicy = { sanitizeToolCallIds: boolean; toolCallIdMode?: ToolCallIdMode; repairToolUseResultPairing: boolean; - enforceToolCallLast: boolean; preserveSignatures: boolean; sanitizeThoughtSignatures?: { allowBase64Only?: boolean; @@ -19,7 +18,6 @@ export type TranscriptPolicy = { applyGoogleTurnOrdering: boolean; validateGeminiTurns: boolean; validateAnthropicTurns: boolean; - stripFinalTags: boolean; allowSyntheticToolResults: boolean; }; @@ -93,7 +91,6 @@ export function resolveTranscriptPolicy(params: { ? "strict" : undefined; const repairToolUseResultPairing = isGoogle || isAnthropic; - const enforceToolCallLast = isAnthropic; const sanitizeThoughtSignatures = isOpenRouterGemini ? { allowBase64Only: true, includeCamelCase: true } : undefined; @@ -104,14 +101,12 @@ export function resolveTranscriptPolicy(params: { 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), }; }