docs: add transcript hygiene reference
This commit is contained in:
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -381,7 +381,6 @@ export async function runEmbeddedAttempt(
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: params.sessionKey,
|
||||
allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults,
|
||||
stripFinalTags: transcriptPolicy.stripFinalTags,
|
||||
});
|
||||
trackSessionManagerAccess(params.sessionFile);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user