mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(openai): normalize responses replay tool ids
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -52,6 +52,7 @@ export { isGoogleModelApi, sanitizeGoogleTurnOrdering } from "./pi-embedded-help
|
||||
export {
|
||||
downgradeOpenAIFunctionCallReasoningPairs,
|
||||
downgradeOpenAIReasoningBlocks,
|
||||
normalizeOpenAIResponsesToolCallIds,
|
||||
} from "./pi-embedded-helpers/openai.js";
|
||||
export {
|
||||
isEmptyAssistantMessageContent,
|
||||
|
||||
@@ -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<string, string>();
|
||||
|
||||
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<AgentMessage, { role: "assistant" }>;
|
||||
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<string, unknown>),
|
||||
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<AgentMessage, { role: "toolResult" }> & {
|
||||
toolUseId?: unknown;
|
||||
};
|
||||
let toolResultChanged = false;
|
||||
const updates: Record<string, string> = {};
|
||||
|
||||
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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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<AgentMessage, { role: "assistant" }>;
|
||||
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<AgentMessage, { role: "toolResult" }>).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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string>,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user