























1+import { createHash } from "node:crypto";
12import type { AgentMessage } from "@earendil-works/pi-agent-core";
2334type OpenAIThinkingBlock = {
@@ -20,6 +21,10 @@ type DowngradeOpenAIReasoningBlocksOptions = {
2021dropReplayableReasoning?: boolean;
2122};
222324+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+2328function parseOpenAIReasoningSignature(value: unknown): OpenAIReasoningSignature | null {
2429if (!value) {
2530return null;
@@ -86,6 +91,193 @@ function isOpenAIToolCallType(type: unknown): boolean {
8691return type === "toolCall" || type === "toolUse" || type === "functionCall";
8792}
889394+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 rejects replayed `function_call.call_id`,
189+ * `function_call.id`, and matching `function_call_output.call_id` values
190+ * that exceed its 64-char `call_*` / `fc_*` shape. pi-ai skips its own
191+ * normalizer for same-model replay, then splits persisted `call_id|fc_id`
192+ * pairs directly into the provider payload, so OpenClaw must normalize here.
193+ */
194+export function normalizeOpenAIResponsesToolCallIds(messages: AgentMessage[]): AgentMessage[] {
195+let changed = false;
196+const resolver = createOpenAIResponsesToolCallIdResolver();
197+const rewrittenMessages: AgentMessage[] = [];
198+199+for (const msg of messages) {
200+if (!msg || typeof msg !== "object") {
201+rewrittenMessages.push(msg);
202+continue;
203+}
204+205+const role = (msg as { role?: unknown }).role;
206+if (role === "assistant") {
207+const assistantMsg = msg as Extract<AgentMessage, { role: "assistant" }>;
208+if (!Array.isArray(assistantMsg.content)) {
209+rewrittenMessages.push(msg);
210+continue;
211+}
212+213+let assistantChanged = false;
214+const nextContent = assistantMsg.content.map((block) => {
215+if (!block || typeof block !== "object") {
216+return block;
217+}
218+const toolCallBlock = block as OpenAIToolCallBlock;
219+if (!isOpenAIToolCallType(toolCallBlock.type) || typeof toolCallBlock.id !== "string") {
220+return block;
221+}
222+223+const nextId = resolver.resolveAssistantId(toolCallBlock.id);
224+if (nextId === toolCallBlock.id) {
225+return block;
226+}
227+assistantChanged = true;
228+return {
229+ ...(block as unknown as Record<string, unknown>),
230+id: nextId,
231+} as typeof block;
232+});
233+234+if (!assistantChanged) {
235+rewrittenMessages.push(msg);
236+continue;
237+}
238+changed = true;
239+rewrittenMessages.push({ ...assistantMsg, content: nextContent } as AgentMessage);
240+continue;
241+}
242+243+if (role === "toolResult") {
244+const toolResult = msg as Extract<AgentMessage, { role: "toolResult" }> & {
245+toolUseId?: unknown;
246+};
247+let toolResultChanged = false;
248+const updates: Record<string, string> = {};
249+250+if (typeof toolResult.toolCallId === "string") {
251+const nextToolCallId = resolver.resolveToolResultId(toolResult.toolCallId);
252+if (nextToolCallId !== toolResult.toolCallId) {
253+updates.toolCallId = nextToolCallId;
254+toolResultChanged = true;
255+}
256+}
257+258+if (typeof toolResult.toolUseId === "string") {
259+const nextToolUseId = resolver.resolveToolResultId(toolResult.toolUseId);
260+if (nextToolUseId !== toolResult.toolUseId) {
261+updates.toolUseId = nextToolUseId;
262+toolResultChanged = true;
263+}
264+}
265+266+if (!toolResultChanged) {
267+rewrittenMessages.push(msg);
268+continue;
269+}
270+changed = true;
271+rewrittenMessages.push({ ...toolResult, ...updates } as AgentMessage);
272+continue;
273+}
274+275+rewrittenMessages.push(msg);
276+}
277+278+return changed ? rewrittenMessages : messages;
279+}
280+89281/**
90282 * OpenAI can reject replayed `function_call` items with an `fc_*` id if the
91283 * matching `reasoning` item is absent in the same assistant turn.
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。