diff --git a/docs/reference/transcript-hygiene.md b/docs/reference/transcript-hygiene.md index bc1825fe6581..7574222e4966 100644 --- a/docs/reference/transcript-hygiene.md +++ b/docs/reference/transcript-hygiene.md @@ -118,7 +118,7 @@ inter-session user turns that only have provenance metadata. - Drop orphaned reasoning signatures (standalone reasoning items without a following content block) for OpenAI Responses/Codex transcripts, and drop replayable OpenAI reasoning after a model route switch. - Preserve replayable OpenAI Responses reasoning item payloads, including encrypted empty-summary items, so manual/WebSocket replay keeps required `rs_*` state paired with assistant output items. - Native ChatGPT Codex Responses follows Codex wire parity by replaying prior Responses reasoning/message/function payloads without prior item IDs while preserving session `prompt_cache_key`. -- No tool call id sanitization. +- OpenAI Responses-family replay preserves canonical `call_*|fc_*` same-model reasoning pairs, but deterministically normalizes malformed or overlong `call_id` / function-call item ids before pi-ai payload conversion. - Tool result pairing repair may move real matched outputs and synthesize Codex-style `aborted` outputs for missing tool calls. - No turn validation or reordering. - Missing OpenAI Responses-family tool outputs are synthesized as `aborted` to match Codex replay normalization. diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts index ce47ef8dcd06..0f41e127508a 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -52,6 +52,7 @@ export { isGoogleModelApi, sanitizeGoogleTurnOrdering } from "./pi-embedded-help export { downgradeOpenAIFunctionCallReasoningPairs, downgradeOpenAIReasoningBlocks, + normalizeOpenAIResponsesToolCallIds, } from "./pi-embedded-helpers/openai.js"; export { isEmptyAssistantMessageContent, diff --git a/src/agents/pi-embedded-helpers/openai.ts b/src/agents/pi-embedded-helpers/openai.ts index ab676979b237..6821fda328b3 100644 --- a/src/agents/pi-embedded-helpers/openai.ts +++ b/src/agents/pi-embedded-helpers/openai.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; type OpenAIThinkingBlock = { @@ -20,6 +21,10 @@ type DowngradeOpenAIReasoningBlocksOptions = { dropReplayableReasoning?: boolean; }; +const OPENAI_RESPONSES_ID_MAX_LENGTH = 64; +const OPENAI_RESPONSES_CALL_ID_RE = /^call_[A-Za-z0-9_-]{1,59}$/; +const OPENAI_RESPONSES_FUNCTION_CALL_ITEM_ID_RE = /^fc_[A-Za-z0-9_-]{1,61}$/; + function parseOpenAIReasoningSignature(value: unknown): OpenAIReasoningSignature | null { if (!value) { return null; @@ -86,6 +91,192 @@ function isOpenAIToolCallType(type: unknown): boolean { return type === "toolCall" || type === "toolUse" || type === "functionCall"; } +function shortOpenAIResponsesIdHash(id: string): string { + return createHash("sha256").update(id).digest("hex").slice(0, 10); +} + +function sanitizeOpenAIResponsesIdTail(value: string): string { + return value.replace(/[^A-Za-z0-9_-]/g, "_").replace(/^_+|_+$/g, ""); +} + +function normalizeOpenAIResponsesIdPart(params: { + value: string; + prefix: "call_" | "fc_"; + isValid: (value: string) => boolean; +}): string { + const trimmed = params.value.trim(); + if (params.isValid(trimmed)) { + return trimmed; + } + + const rawTail = trimmed.startsWith(params.prefix) ? trimmed.slice(params.prefix.length) : trimmed; + const hash = shortOpenAIResponsesIdHash(trimmed || params.prefix); + const maxTailLength = OPENAI_RESPONSES_ID_MAX_LENGTH - params.prefix.length; + const hashSuffix = `_${hash}`; + const safeTail = sanitizeOpenAIResponsesIdTail(rawTail); + const clippedBase = safeTail.slice(0, Math.max(1, maxTailLength - hashSuffix.length)); + const tail = `${clippedBase || "id"}${hashSuffix}`.slice(0, maxTailLength); + return `${params.prefix}${tail}`; +} + +function normalizeOpenAIResponsesFunctionCallId(id: string): string { + const { callId, itemId } = splitOpenAIFunctionCallPairing(id); + const normalizedCallId = normalizeOpenAIResponsesIdPart({ + value: callId, + prefix: "call_", + isValid: (value) => OPENAI_RESPONSES_CALL_ID_RE.test(value), + }); + + if (!itemId) { + return normalizedCallId; + } + + const normalizedItemId = normalizeOpenAIResponsesIdPart({ + value: itemId, + prefix: "fc_", + isValid: (value) => OPENAI_RESPONSES_FUNCTION_CALL_ITEM_ID_RE.test(value), + }); + return `${normalizedCallId}|${normalizedItemId}`; +} + +function shouldNormalizeOpenAIResponsesToolCallId(id: string): boolean { + const pairing = splitOpenAIFunctionCallPairing(id); + if (!OPENAI_RESPONSES_CALL_ID_RE.test(pairing.callId)) { + return true; + } + if (pairing.itemId === undefined) { + return false; + } + return !OPENAI_RESPONSES_FUNCTION_CALL_ITEM_ID_RE.test(pairing.itemId); +} + +function createOpenAIResponsesToolCallIdResolver(): { + resolveAssistantId: (id: string) => string; + resolveToolResultId: (id: string) => string; +} { + const rewrittenByOriginalId = new Map(); + + return { + resolveAssistantId(id: string): string { + const rewritten = rewrittenByOriginalId.get(id); + if (rewritten) { + return rewritten; + } + if (!shouldNormalizeOpenAIResponsesToolCallId(id)) { + return id; + } + const normalized = normalizeOpenAIResponsesFunctionCallId(id); + rewrittenByOriginalId.set(id, normalized); + return normalized; + }, + resolveToolResultId(id: string): string { + const rewritten = rewrittenByOriginalId.get(id); + if (rewritten) { + return rewritten; + } + if (!shouldNormalizeOpenAIResponsesToolCallId(id)) { + return id; + } + const normalized = normalizeOpenAIResponsesFunctionCallId(id); + rewrittenByOriginalId.set(id, normalized); + return normalized; + }, + }; +} + +/** + * OpenAI Responses validates replayed `function_call.call_id`, + * `function_call.id`, and matching `function_call_output.call_id` values. + * Keep canonical ids unchanged, but deterministically rewrite overlong or + * malformed persisted ids before pi-ai splits `call_id|fc_id` pairs. + */ +export function normalizeOpenAIResponsesToolCallIds(messages: AgentMessage[]): AgentMessage[] { + let changed = false; + const resolver = createOpenAIResponsesToolCallIdResolver(); + const rewrittenMessages: AgentMessage[] = []; + + for (const msg of messages) { + if (!msg || typeof msg !== "object") { + rewrittenMessages.push(msg); + continue; + } + + const role = (msg as { role?: unknown }).role; + if (role === "assistant") { + const assistantMsg = msg as Extract; + if (!Array.isArray(assistantMsg.content)) { + rewrittenMessages.push(msg); + continue; + } + + let assistantChanged = false; + const nextContent = assistantMsg.content.map((block) => { + if (!block || typeof block !== "object") { + return block; + } + const toolCallBlock = block as OpenAIToolCallBlock; + if (!isOpenAIToolCallType(toolCallBlock.type) || typeof toolCallBlock.id !== "string") { + return block; + } + + const nextId = resolver.resolveAssistantId(toolCallBlock.id); + if (nextId === toolCallBlock.id) { + return block; + } + assistantChanged = true; + return { + ...(block as unknown as Record), + id: nextId, + } as typeof block; + }); + + if (!assistantChanged) { + rewrittenMessages.push(msg); + continue; + } + changed = true; + rewrittenMessages.push({ ...assistantMsg, content: nextContent } as AgentMessage); + continue; + } + + if (role === "toolResult") { + const toolResult = msg as Extract & { + toolUseId?: unknown; + }; + let toolResultChanged = false; + const updates: Record = {}; + + if (typeof toolResult.toolCallId === "string") { + const nextToolCallId = resolver.resolveToolResultId(toolResult.toolCallId); + if (nextToolCallId !== toolResult.toolCallId) { + updates.toolCallId = nextToolCallId; + toolResultChanged = true; + } + } + + if (typeof toolResult.toolUseId === "string") { + const nextToolUseId = resolver.resolveToolResultId(toolResult.toolUseId); + if (nextToolUseId !== toolResult.toolUseId) { + updates.toolUseId = nextToolUseId; + toolResultChanged = true; + } + } + + if (!toolResultChanged) { + rewrittenMessages.push(msg); + continue; + } + changed = true; + rewrittenMessages.push({ ...toolResult, ...updates } as AgentMessage); + continue; + } + + rewrittenMessages.push(msg); + } + + return changed ? rewrittenMessages : messages; +} + /** * OpenAI can reject replayed `function_call` items with an `fc_*` id if the * matching `reasoning` item is absent in the same assistant turn. diff --git a/src/agents/pi-embedded-runner.openai-tool-id-preservation.test.ts b/src/agents/pi-embedded-runner.openai-tool-id-preservation.test.ts index 3afa46cd71e7..30080615c9d3 100644 --- a/src/agents/pi-embedded-runner.openai-tool-id-preservation.test.ts +++ b/src/agents/pi-embedded-runner.openai-tool-id-preservation.test.ts @@ -149,4 +149,41 @@ describe("sanitizeSessionHistory openai tool id preservation", () => { const userMessage = result[2] as { role?: string }; expect(userMessage.role).toBe("user"); }); + + it("normalizes overlong responses call ids and malformed item ids for replay", async () => { + const longCallId = `call_${"x".repeat(120)}`; + const longItemId = `notfc_${"y".repeat(120)}`; + const rawToolCallId = `${longCallId}|${longItemId}`; + + const result = await sanitizeSessionHistory({ + messages: [ + castAgentMessage({ + role: "assistant", + content: [{ type: "toolCall", id: rawToolCallId, name: "noop", arguments: {} }], + }), + castAgentMessage({ + role: "toolResult", + toolCallId: rawToolCallId, + toolName: "noop", + content: [{ type: "text", text: "ok" }], + isError: false, + }), + ], + modelApi: "openai-responses", + provider: "openai", + modelId: "gpt-5.4", + sessionManager: makeSessionManager(), + sessionId: "test-session", + }); + + const assistant = result[0] as { content?: Array<{ type?: string; id?: string }> }; + const toolCall = assistant.content?.find((block) => block.type === "toolCall"); + expect(toolCall?.id).toMatch(/^call_[A-Za-z0-9_-]{1,59}$/); + expect(toolCall?.id).not.toBe(rawToolCallId); + expect(toolCall?.id).not.toContain("|"); + expect(toolCall?.id?.length).toBeLessThanOrEqual(64); + + const toolResult = result[1] as { toolCallId?: string }; + expect(toolResult.toolCallId).toBe(toolCall?.id); + }); }); diff --git a/src/agents/pi-embedded-runner/replay-history.ts b/src/agents/pi-embedded-runner/replay-history.ts index 65ade3686711..0c0cf825a583 100644 --- a/src/agents/pi-embedded-runner/replay-history.ts +++ b/src/agents/pi-embedded-runner/replay-history.ts @@ -22,6 +22,7 @@ import { resolveImageSanitizationLimits } from "../image-sanitization.js"; import { downgradeOpenAIFunctionCallReasoningPairs, downgradeOpenAIReasoningBlocks, + normalizeOpenAIResponsesToolCallIds, sanitizeGoogleTurnOrdering, sanitizeSessionMessagesImages, validateAnthropicTurns, @@ -762,9 +763,11 @@ export async function sanitizeSessionHistory(params: { : sanitizedToolCalls; const openAISafeToolCalls = isOpenAIResponsesApi ? downgradeOpenAIFunctionCallReasoningPairs( - downgradeOpenAIReasoningBlocks(openAIRepairedToolCalls, { - dropReplayableReasoning: modelChanged, - }), + normalizeOpenAIResponsesToolCallIds( + downgradeOpenAIReasoningBlocks(openAIRepairedToolCalls, { + dropReplayableReasoning: modelChanged, + }), + ), ) : sanitizedToolCalls; const sanitizedToolIds = diff --git a/src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.test.ts b/src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.test.ts index e5839dd86a50..35c067006073 100644 --- a/src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.test.ts @@ -1,6 +1,7 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { describe, expect, it } from "vitest"; import { + sanitizeOpenAIResponsesReplayForStream, sanitizeReplayToolCallIdsForStream, shouldApplyReplayToolCallIdSanitizer, } from "./attempt.tool-call-normalization.js"; @@ -277,3 +278,64 @@ describe("sanitizeReplayToolCallIdsForStream", () => { }); }); }); + +describe("sanitizeOpenAIResponsesReplayForStream", () => { + it("normalizes live responses continuations before pi-ai splits ids", () => { + const longCallId = `call_${"x".repeat(120)}`; + const longItemId = `notfc_${"y".repeat(120)}`; + const rawToolCallId = `${longCallId}|${longItemId}`; + const messages: AgentMessage[] = [ + { + role: "assistant", + content: [{ type: "toolCall", id: rawToolCallId, name: "noop", arguments: {} }], + } as never, + { + role: "toolResult", + toolCallId: rawToolCallId, + toolName: "noop", + content: [{ type: "text", text: "ok" }], + isError: false, + } as never, + ]; + + const out = sanitizeOpenAIResponsesReplayForStream(messages); + const assistant = out[0] as Extract; + const toolCall = assistant.content.find( + (block) => + !!block && + typeof block === "object" && + (block as { type?: unknown }).type === "toolCall" && + typeof (block as { id?: unknown }).id === "string", + ) as { id: string } | undefined; + + expect(toolCall?.id).toMatch(/^call_[A-Za-z0-9_-]{1,59}$/); + expect(toolCall?.id).not.toBe(rawToolCallId); + expect(toolCall?.id).not.toContain("|"); + expect((out[1] as Extract).toolCallId).toBe(toolCall?.id); + }); + + it("preserves canonical same-model reasoning pairs", () => { + const messages: AgentMessage[] = [ + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "internal", + thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }), + }, + { type: "toolCall", id: "call_123|fc_123", name: "noop", arguments: {} }, + ], + } as never, + { + role: "toolResult", + toolCallId: "call_123|fc_123", + toolName: "noop", + content: [{ type: "text", text: "ok" }], + isError: false, + } as never, + ]; + + expect(sanitizeOpenAIResponsesReplayForStream(messages)).toBe(messages); + }); +}); diff --git a/src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.ts b/src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.ts index 03782c0b6223..f5d4519c271a 100644 --- a/src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.ts +++ b/src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.ts @@ -3,7 +3,13 @@ import { streamSimple } from "@earendil-works/pi-ai"; import { visitObjectContentBlocks } from "../../../shared/message-content-blocks.js"; import { normalizeLowercaseStringOrEmpty } from "../../../shared/string-coerce.js"; import { normalizeStringEntries } from "../../../shared/string-normalization.js"; -import { validateAnthropicTurns, validateGeminiTurns } from "../../pi-embedded-helpers.js"; +import { + downgradeOpenAIFunctionCallReasoningPairs, + downgradeOpenAIReasoningBlocks, + normalizeOpenAIResponsesToolCallIds, + validateAnthropicTurns, + validateGeminiTurns, +} from "../../pi-embedded-helpers.js"; import { sanitizeToolUseResultPairing } from "../../session-transcript-repair.js"; import { extractToolCallsFromAssistant, @@ -951,6 +957,12 @@ export function sanitizeReplayToolCallIdsForStream(params: { return sanitizeToolUseResultPairing(sanitized); } +export function sanitizeOpenAIResponsesReplayForStream(messages: AgentMessage[]): AgentMessage[] { + return downgradeOpenAIFunctionCallReasoningPairs( + normalizeOpenAIResponsesToolCallIds(downgradeOpenAIReasoningBlocks(messages)), + ); +} + export function wrapStreamFnSanitizeMalformedToolCalls( baseFn: StreamFn, allowedToolNames?: Set, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 70e1dd92b971..9f801ff4ea31 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -116,8 +116,6 @@ import { } from "../../pi-bundle-mcp-tools.js"; import type { EmbeddedContextFile } from "../../pi-embedded-helpers.js"; import { - downgradeOpenAIFunctionCallReasoningPairs, - downgradeOpenAIReasoningBlocks, isCloudCodeAssistFormatError, resolveBootstrapMaxChars, resolveBootstrapPromptTruncationWarningMode, @@ -369,8 +367,9 @@ import { wrapStreamFnRepairMalformedToolCallArguments, } from "./attempt.tool-call-argument-repair.js"; import { - shouldApplyReplayToolCallIdSanitizer, + sanitizeOpenAIResponsesReplayForStream, sanitizeReplayToolCallIdsForStream, + shouldApplyReplayToolCallIdSanitizer, wrapStreamFnSanitizeMalformedToolCalls, wrapStreamFnTrimToolCallNames, } from "./attempt.tool-call-normalization.js"; @@ -3082,10 +3081,7 @@ export async function runEmbeddedAttempt( if (!Array.isArray(messages)) { return inner(model, context, options); } - // Strip orphaned reasoning blocks first, then fix function-call - // pairing — matches the call order in google.ts. - const reasoningSanitized = downgradeOpenAIReasoningBlocks(messages as AgentMessage[]); - const sanitized = downgradeOpenAIFunctionCallReasoningPairs(reasoningSanitized); + const sanitized = sanitizeOpenAIResponsesReplayForStream(messages as AgentMessage[]); if (sanitized === messages) { return inner(model, context, options); }