docs: add transcript hygiene reference

This commit is contained in:
Peter Steinberger
2026-01-23 01:34:21 +00:00
parent 17a09cc721
commit 2424404fb4
14 changed files with 120 additions and 146 deletions

View File

@@ -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 <final> 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.

View File

@@ -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 shouldnt 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)
---

View File

@@ -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 `<final>` 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.

View File

@@ -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 = [
{

View File

@@ -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 = [

View File

@@ -21,13 +21,6 @@ export function isEmptyAssistantMessageContent(
});
}
function isEmptyAssistantErrorMessage(
message: Extract<AgentMessage, { role: "assistant" }>,
): 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<AgentMessage, { role: "assistant" }>;
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) {

View File

@@ -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);

View File

@@ -73,7 +73,7 @@ export function buildEmbeddedExtensionPaths(params: {
modelId: string;
model: Model<Api> | undefined;
}): string[] {
const paths = [resolvePiExtensionPath("transcript-sanitize")];
const paths: string[] = [];
if (resolveCompactionMode(params.cfg) === "safeguard") {
paths.push(resolvePiExtensionPath("compaction-safeguard"));
}

View File

@@ -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,
});

View File

@@ -381,7 +381,6 @@ export async function runEmbeddedAttempt(
agentId: sessionAgentId,
sessionKey: params.sessionKey,
allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults,
stripFinalTags: transcriptPolicy.stripFinalTags,
});
trackSessionManagerAccess(params.sessionFile);

View File

@@ -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;

View File

@@ -142,26 +142,4 @@ describe("installSessionToolResultGuard", () => {
.map((e) => (e as { message: AgentMessage }).message);
expect(messages.map((m) => m.role)).toEqual(["assistant", "toolResult"]);
});
it("strips <final> tags from assistant text blocks", () => {
const sm = SessionManager.inMemory();
installSessionToolResultGuard(sm);
sm.appendMessage({
role: "assistant",
content: [
{ type: "text", text: "<final>Hey!</final>" },
{ type: "text", text: "More <final>text</final> 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.");
});
});

View File

@@ -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<AgentMessage, { role: "assistant" }>) {
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<AgentMessage, { role: "assistant" }>): 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 <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.
@@ -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<AgentMessage, { role: "assistant" }>)
: message;
const toolCalls =
role === "assistant"
? extractAssistantToolCalls(sanitized as Extract<AgentMessage, { role: "assistant" }>)
? extractAssistantToolCalls(message as Extract<AgentMessage, { role: "assistant" }>)
: [];
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 }

View File

@@ -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),
};
}