Skip to content

Commit c7cd53e

Browse files
committed
fix(openai): normalize responses replay tool ids
1 parent 44c1cc8 commit c7cd53e

8 files changed

Lines changed: 314 additions & 12 deletions

File tree

docs/reference/transcript-hygiene.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ inter-session user turns that only have provenance metadata.
118118
- 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.
119119
- 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.
120120
- 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`.
121-
- No tool call id sanitization.
121+
- 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.
122122
- Tool result pairing repair may move real matched outputs and synthesize Codex-style `aborted` outputs for missing tool calls.
123123
- No turn validation or reordering.
124124
- Missing OpenAI Responses-family tool outputs are synthesized as `aborted` to match Codex replay normalization.

src/agents/pi-embedded-helpers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export { isGoogleModelApi, sanitizeGoogleTurnOrdering } from "./pi-embedded-help
5252
export {
5353
downgradeOpenAIFunctionCallReasoningPairs,
5454
downgradeOpenAIReasoningBlocks,
55+
normalizeOpenAIResponsesToolCallIds,
5556
} from "./pi-embedded-helpers/openai.js";
5657
export {
5758
isEmptyAssistantMessageContent,

src/agents/pi-embedded-helpers/openai.ts

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createHash } from "node:crypto";
12
import type { AgentMessage } from "@earendil-works/pi-agent-core";
23

34
type OpenAIThinkingBlock = {
@@ -20,6 +21,10 @@ type DowngradeOpenAIReasoningBlocksOptions = {
2021
dropReplayableReasoning?: boolean;
2122
};
2223

24+
const OPENAI_RESPONSES_ID_MAX_LENGTH = 64;
25+
const OPENAI_RESPONSES_CALL_ID_RE = /^call_[A-Za-z0-9_-]{1,59}$/;
26+
const OPENAI_RESPONSES_FUNCTION_CALL_ITEM_ID_RE = /^fc_[A-Za-z0-9_-]{1,61}$/;
27+
2328
function parseOpenAIReasoningSignature(value: unknown): OpenAIReasoningSignature | null {
2429
if (!value) {
2530
return null;
@@ -86,6 +91,192 @@ function isOpenAIToolCallType(type: unknown): boolean {
8691
return type === "toolCall" || type === "toolUse" || type === "functionCall";
8792
}
8893

94+
function shortOpenAIResponsesIdHash(id: string): string {
95+
return createHash("sha256").update(id).digest("hex").slice(0, 10);
96+
}
97+
98+
function sanitizeOpenAIResponsesIdTail(value: string): string {
99+
return value.replace(/[^A-Za-z0-9_-]/g, "_").replace(/^_+|_+$/g, "");
100+
}
101+
102+
function normalizeOpenAIResponsesIdPart(params: {
103+
value: string;
104+
prefix: "call_" | "fc_";
105+
isValid: (value: string) => boolean;
106+
}): string {
107+
const trimmed = params.value.trim();
108+
if (params.isValid(trimmed)) {
109+
return trimmed;
110+
}
111+
112+
const rawTail = trimmed.startsWith(params.prefix) ? trimmed.slice(params.prefix.length) : trimmed;
113+
const hash = shortOpenAIResponsesIdHash(trimmed || params.prefix);
114+
const maxTailLength = OPENAI_RESPONSES_ID_MAX_LENGTH - params.prefix.length;
115+
const hashSuffix = `_${hash}`;
116+
const safeTail = sanitizeOpenAIResponsesIdTail(rawTail);
117+
const clippedBase = safeTail.slice(0, Math.max(1, maxTailLength - hashSuffix.length));
118+
const tail = `${clippedBase || "id"}${hashSuffix}`.slice(0, maxTailLength);
119+
return `${params.prefix}${tail}`;
120+
}
121+
122+
function normalizeOpenAIResponsesFunctionCallId(id: string): string {
123+
const { callId, itemId } = splitOpenAIFunctionCallPairing(id);
124+
const normalizedCallId = normalizeOpenAIResponsesIdPart({
125+
value: callId,
126+
prefix: "call_",
127+
isValid: (value) => OPENAI_RESPONSES_CALL_ID_RE.test(value),
128+
});
129+
130+
if (!itemId) {
131+
return normalizedCallId;
132+
}
133+
134+
const normalizedItemId = normalizeOpenAIResponsesIdPart({
135+
value: itemId,
136+
prefix: "fc_",
137+
isValid: (value) => OPENAI_RESPONSES_FUNCTION_CALL_ITEM_ID_RE.test(value),
138+
});
139+
return `${normalizedCallId}|${normalizedItemId}`;
140+
}
141+
142+
function shouldNormalizeOpenAIResponsesToolCallId(id: string): boolean {
143+
const pairing = splitOpenAIFunctionCallPairing(id);
144+
if (!OPENAI_RESPONSES_CALL_ID_RE.test(pairing.callId)) {
145+
return true;
146+
}
147+
if (pairing.itemId === undefined) {
148+
return false;
149+
}
150+
return !OPENAI_RESPONSES_FUNCTION_CALL_ITEM_ID_RE.test(pairing.itemId);
151+
}
152+
153+
function createOpenAIResponsesToolCallIdResolver(): {
154+
resolveAssistantId: (id: string) => string;
155+
resolveToolResultId: (id: string) => string;
156+
} {
157+
const rewrittenByOriginalId = new Map<string, string>();
158+
159+
return {
160+
resolveAssistantId(id: string): string {
161+
const rewritten = rewrittenByOriginalId.get(id);
162+
if (rewritten) {
163+
return rewritten;
164+
}
165+
if (!shouldNormalizeOpenAIResponsesToolCallId(id)) {
166+
return id;
167+
}
168+
const normalized = normalizeOpenAIResponsesFunctionCallId(id);
169+
rewrittenByOriginalId.set(id, normalized);
170+
return normalized;
171+
},
172+
resolveToolResultId(id: string): string {
173+
const rewritten = rewrittenByOriginalId.get(id);
174+
if (rewritten) {
175+
return rewritten;
176+
}
177+
if (!shouldNormalizeOpenAIResponsesToolCallId(id)) {
178+
return id;
179+
}
180+
const normalized = normalizeOpenAIResponsesFunctionCallId(id);
181+
rewrittenByOriginalId.set(id, normalized);
182+
return normalized;
183+
},
184+
};
185+
}
186+
187+
/**
188+
* OpenAI Responses validates replayed `function_call.call_id`,
189+
* `function_call.id`, and matching `function_call_output.call_id` values.
190+
* Keep canonical ids unchanged, but deterministically rewrite overlong or
191+
* malformed persisted ids before pi-ai splits `call_id|fc_id` pairs.
192+
*/
193+
export function normalizeOpenAIResponsesToolCallIds(messages: AgentMessage[]): AgentMessage[] {
194+
let changed = false;
195+
const resolver = createOpenAIResponsesToolCallIdResolver();
196+
const rewrittenMessages: AgentMessage[] = [];
197+
198+
for (const msg of messages) {
199+
if (!msg || typeof msg !== "object") {
200+
rewrittenMessages.push(msg);
201+
continue;
202+
}
203+
204+
const role = (msg as { role?: unknown }).role;
205+
if (role === "assistant") {
206+
const assistantMsg = msg as Extract<AgentMessage, { role: "assistant" }>;
207+
if (!Array.isArray(assistantMsg.content)) {
208+
rewrittenMessages.push(msg);
209+
continue;
210+
}
211+
212+
let assistantChanged = false;
213+
const nextContent = assistantMsg.content.map((block) => {
214+
if (!block || typeof block !== "object") {
215+
return block;
216+
}
217+
const toolCallBlock = block as OpenAIToolCallBlock;
218+
if (!isOpenAIToolCallType(toolCallBlock.type) || typeof toolCallBlock.id !== "string") {
219+
return block;
220+
}
221+
222+
const nextId = resolver.resolveAssistantId(toolCallBlock.id);
223+
if (nextId === toolCallBlock.id) {
224+
return block;
225+
}
226+
assistantChanged = true;
227+
return {
228+
...(block as unknown as Record<string, unknown>),
229+
id: nextId,
230+
} as typeof block;
231+
});
232+
233+
if (!assistantChanged) {
234+
rewrittenMessages.push(msg);
235+
continue;
236+
}
237+
changed = true;
238+
rewrittenMessages.push({ ...assistantMsg, content: nextContent } as AgentMessage);
239+
continue;
240+
}
241+
242+
if (role === "toolResult") {
243+
const toolResult = msg as Extract<AgentMessage, { role: "toolResult" }> & {
244+
toolUseId?: unknown;
245+
};
246+
let toolResultChanged = false;
247+
const updates: Record<string, string> = {};
248+
249+
if (typeof toolResult.toolCallId === "string") {
250+
const nextToolCallId = resolver.resolveToolResultId(toolResult.toolCallId);
251+
if (nextToolCallId !== toolResult.toolCallId) {
252+
updates.toolCallId = nextToolCallId;
253+
toolResultChanged = true;
254+
}
255+
}
256+
257+
if (typeof toolResult.toolUseId === "string") {
258+
const nextToolUseId = resolver.resolveToolResultId(toolResult.toolUseId);
259+
if (nextToolUseId !== toolResult.toolUseId) {
260+
updates.toolUseId = nextToolUseId;
261+
toolResultChanged = true;
262+
}
263+
}
264+
265+
if (!toolResultChanged) {
266+
rewrittenMessages.push(msg);
267+
continue;
268+
}
269+
changed = true;
270+
rewrittenMessages.push({ ...toolResult, ...updates } as AgentMessage);
271+
continue;
272+
}
273+
274+
rewrittenMessages.push(msg);
275+
}
276+
277+
return changed ? rewrittenMessages : messages;
278+
}
279+
89280
/**
90281
* OpenAI can reject replayed `function_call` items with an `fc_*` id if the
91282
* matching `reasoning` item is absent in the same assistant turn.

src/agents/pi-embedded-runner.openai-tool-id-preservation.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,4 +149,41 @@ describe("sanitizeSessionHistory openai tool id preservation", () => {
149149
const userMessage = result[2] as { role?: string };
150150
expect(userMessage.role).toBe("user");
151151
});
152+
153+
it("normalizes overlong responses call ids and malformed item ids for replay", async () => {
154+
const longCallId = `call_${"x".repeat(120)}`;
155+
const longItemId = `notfc_${"y".repeat(120)}`;
156+
const rawToolCallId = `${longCallId}|${longItemId}`;
157+
158+
const result = await sanitizeSessionHistory({
159+
messages: [
160+
castAgentMessage({
161+
role: "assistant",
162+
content: [{ type: "toolCall", id: rawToolCallId, name: "noop", arguments: {} }],
163+
}),
164+
castAgentMessage({
165+
role: "toolResult",
166+
toolCallId: rawToolCallId,
167+
toolName: "noop",
168+
content: [{ type: "text", text: "ok" }],
169+
isError: false,
170+
}),
171+
],
172+
modelApi: "openai-responses",
173+
provider: "openai",
174+
modelId: "gpt-5.4",
175+
sessionManager: makeSessionManager(),
176+
sessionId: "test-session",
177+
});
178+
179+
const assistant = result[0] as { content?: Array<{ type?: string; id?: string }> };
180+
const toolCall = assistant.content?.find((block) => block.type === "toolCall");
181+
expect(toolCall?.id).toMatch(/^call_[A-Za-z0-9_-]{1,59}$/);
182+
expect(toolCall?.id).not.toBe(rawToolCallId);
183+
expect(toolCall?.id).not.toContain("|");
184+
expect(toolCall?.id?.length).toBeLessThanOrEqual(64);
185+
186+
const toolResult = result[1] as { toolCallId?: string };
187+
expect(toolResult.toolCallId).toBe(toolCall?.id);
188+
});
152189
});

src/agents/pi-embedded-runner/replay-history.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { resolveImageSanitizationLimits } from "../image-sanitization.js";
2222
import {
2323
downgradeOpenAIFunctionCallReasoningPairs,
2424
downgradeOpenAIReasoningBlocks,
25+
normalizeOpenAIResponsesToolCallIds,
2526
sanitizeGoogleTurnOrdering,
2627
sanitizeSessionMessagesImages,
2728
validateAnthropicTurns,
@@ -762,9 +763,11 @@ export async function sanitizeSessionHistory(params: {
762763
: sanitizedToolCalls;
763764
const openAISafeToolCalls = isOpenAIResponsesApi
764765
? downgradeOpenAIFunctionCallReasoningPairs(
765-
downgradeOpenAIReasoningBlocks(openAIRepairedToolCalls, {
766-
dropReplayableReasoning: modelChanged,
767-
}),
766+
normalizeOpenAIResponsesToolCallIds(
767+
downgradeOpenAIReasoningBlocks(openAIRepairedToolCalls, {
768+
dropReplayableReasoning: modelChanged,
769+
}),
770+
),
768771
)
769772
: sanitizedToolCalls;
770773
const sanitizedToolIds =

src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { AgentMessage } from "@earendil-works/pi-agent-core";
22
import { describe, expect, it } from "vitest";
33
import {
4+
sanitizeOpenAIResponsesReplayForStream,
45
sanitizeReplayToolCallIdsForStream,
56
shouldApplyReplayToolCallIdSanitizer,
67
} from "./attempt.tool-call-normalization.js";
@@ -277,3 +278,64 @@ describe("sanitizeReplayToolCallIdsForStream", () => {
277278
});
278279
});
279280
});
281+
282+
describe("sanitizeOpenAIResponsesReplayForStream", () => {
283+
it("normalizes live responses continuations before pi-ai splits ids", () => {
284+
const longCallId = `call_${"x".repeat(120)}`;
285+
const longItemId = `notfc_${"y".repeat(120)}`;
286+
const rawToolCallId = `${longCallId}|${longItemId}`;
287+
const messages: AgentMessage[] = [
288+
{
289+
role: "assistant",
290+
content: [{ type: "toolCall", id: rawToolCallId, name: "noop", arguments: {} }],
291+
} as never,
292+
{
293+
role: "toolResult",
294+
toolCallId: rawToolCallId,
295+
toolName: "noop",
296+
content: [{ type: "text", text: "ok" }],
297+
isError: false,
298+
} as never,
299+
];
300+
301+
const out = sanitizeOpenAIResponsesReplayForStream(messages);
302+
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
303+
const toolCall = assistant.content.find(
304+
(block) =>
305+
!!block &&
306+
typeof block === "object" &&
307+
(block as { type?: unknown }).type === "toolCall" &&
308+
typeof (block as { id?: unknown }).id === "string",
309+
) as { id: string } | undefined;
310+
311+
expect(toolCall?.id).toMatch(/^call_[A-Za-z0-9_-]{1,59}$/);
312+
expect(toolCall?.id).not.toBe(rawToolCallId);
313+
expect(toolCall?.id).not.toContain("|");
314+
expect((out[1] as Extract<AgentMessage, { role: "toolResult" }>).toolCallId).toBe(toolCall?.id);
315+
});
316+
317+
it("preserves canonical same-model reasoning pairs", () => {
318+
const messages: AgentMessage[] = [
319+
{
320+
role: "assistant",
321+
content: [
322+
{
323+
type: "thinking",
324+
thinking: "internal",
325+
thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }),
326+
},
327+
{ type: "toolCall", id: "call_123|fc_123", name: "noop", arguments: {} },
328+
],
329+
} as never,
330+
{
331+
role: "toolResult",
332+
toolCallId: "call_123|fc_123",
333+
toolName: "noop",
334+
content: [{ type: "text", text: "ok" }],
335+
isError: false,
336+
} as never,
337+
];
338+
339+
expect(sanitizeOpenAIResponsesReplayForStream(messages)).toBe(messages);
340+
});
341+
});

src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import { streamSimple } from "@earendil-works/pi-ai";
33
import { visitObjectContentBlocks } from "../../../shared/message-content-blocks.js";
44
import { normalizeLowercaseStringOrEmpty } from "../../../shared/string-coerce.js";
55
import { normalizeStringEntries } from "../../../shared/string-normalization.js";
6-
import { validateAnthropicTurns, validateGeminiTurns } from "../../pi-embedded-helpers.js";
6+
import {
7+
downgradeOpenAIFunctionCallReasoningPairs,
8+
downgradeOpenAIReasoningBlocks,
9+
normalizeOpenAIResponsesToolCallIds,
10+
validateAnthropicTurns,
11+
validateGeminiTurns,
12+
} from "../../pi-embedded-helpers.js";
713
import { sanitizeToolUseResultPairing } from "../../session-transcript-repair.js";
814
import {
915
extractToolCallsFromAssistant,
@@ -951,6 +957,12 @@ export function sanitizeReplayToolCallIdsForStream(params: {
951957
return sanitizeToolUseResultPairing(sanitized);
952958
}
953959

960+
export function sanitizeOpenAIResponsesReplayForStream(messages: AgentMessage[]): AgentMessage[] {
961+
return downgradeOpenAIFunctionCallReasoningPairs(
962+
normalizeOpenAIResponsesToolCallIds(downgradeOpenAIReasoningBlocks(messages)),
963+
);
964+
}
965+
954966
export function wrapStreamFnSanitizeMalformedToolCalls(
955967
baseFn: StreamFn,
956968
allowedToolNames?: Set<string>,

0 commit comments

Comments
 (0)