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

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