Fix OpenAI Responses transcript after model switch
This commit is contained in:
committed by
Peter Steinberger
parent
72020b37c3
commit
202d7af855
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { downgradeOpenAIReasoningBlocks } from "./pi-embedded-helpers.js";
|
||||||
|
|
||||||
|
describe("downgradeOpenAIReasoningBlocks", () => {
|
||||||
|
it("downgrades orphaned reasoning signatures to text", () => {
|
||||||
|
const input = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "thinking",
|
||||||
|
thinking: "internal reasoning",
|
||||||
|
thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }),
|
||||||
|
},
|
||||||
|
{ type: "text", text: "answer" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual([
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "internal reasoning" }, { type: "text", text: "answer" }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops empty thinking blocks with orphaned signatures", () => {
|
||||||
|
const input = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "thinking",
|
||||||
|
thinking: " ",
|
||||||
|
thinkingSignature: JSON.stringify({ id: "rs_abc", type: "reasoning" }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ role: "user", content: "next" },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual([{ role: "user", content: "next" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps non-reasoning thinking signatures", () => {
|
||||||
|
const input = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "thinking",
|
||||||
|
thinking: "t",
|
||||||
|
thinkingSignature: "reasoning_content",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual(input);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -31,6 +31,8 @@ export {
|
|||||||
parseImageDimensionError,
|
parseImageDimensionError,
|
||||||
} from "./pi-embedded-helpers/errors.js";
|
} from "./pi-embedded-helpers/errors.js";
|
||||||
export { isGoogleModelApi, sanitizeGoogleTurnOrdering } from "./pi-embedded-helpers/google.js";
|
export { isGoogleModelApi, sanitizeGoogleTurnOrdering } from "./pi-embedded-helpers/google.js";
|
||||||
|
|
||||||
|
export { downgradeOpenAIReasoningBlocks } from "./pi-embedded-helpers/openai.js";
|
||||||
export {
|
export {
|
||||||
isEmptyAssistantMessageContent,
|
isEmptyAssistantMessageContent,
|
||||||
sanitizeSessionMessagesImages,
|
sanitizeSessionMessagesImages,
|
||||||
|
|||||||
87
src/agents/pi-embedded-helpers/openai.ts
Normal file
87
src/agents/pi-embedded-helpers/openai.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||||
|
|
||||||
|
type OpenAIThinkingBlock = {
|
||||||
|
type?: unknown;
|
||||||
|
thinking?: unknown;
|
||||||
|
thinkingSignature?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isOrphanedOpenAIReasoningSignature(signature: string): boolean {
|
||||||
|
const trimmed = signature.trim();
|
||||||
|
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return false;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed) as { id?: unknown; type?: unknown };
|
||||||
|
const id = typeof parsed?.id === "string" ? parsed.id : "";
|
||||||
|
const type = typeof parsed?.type === "string" ? parsed.type : "";
|
||||||
|
if (!id.startsWith("rs_")) return false;
|
||||||
|
if (type === "reasoning") return true;
|
||||||
|
if (type.startsWith("reasoning.")) return true;
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAI Responses API can reject transcripts that contain a standalone `reasoning` item id
|
||||||
|
* without the required following item.
|
||||||
|
*
|
||||||
|
* Clawdbot persists provider-specific reasoning metadata in `thinkingSignature`; if that metadata
|
||||||
|
* is incomplete, we downgrade the block to plain text (or drop it if empty) to keep history usable.
|
||||||
|
*/
|
||||||
|
export function downgradeOpenAIReasoningBlocks(messages: AgentMessage[]): AgentMessage[] {
|
||||||
|
const out: AgentMessage[] = [];
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (!msg || typeof msg !== "object") {
|
||||||
|
out.push(msg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = (msg as { role?: unknown }).role;
|
||||||
|
if (role !== "assistant") {
|
||||||
|
out.push(msg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistantMsg = msg as Extract<AgentMessage, { role: "assistant" }>;
|
||||||
|
if (!Array.isArray(assistantMsg.content)) {
|
||||||
|
out.push(msg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
type AssistantContentBlock = (typeof assistantMsg.content)[number];
|
||||||
|
|
||||||
|
const nextContent = assistantMsg.content.flatMap((block): AssistantContentBlock[] => {
|
||||||
|
if (!block || typeof block !== "object") return [block as AssistantContentBlock];
|
||||||
|
|
||||||
|
const record = block as OpenAIThinkingBlock;
|
||||||
|
if (record.type !== "thinking") return [block as AssistantContentBlock];
|
||||||
|
|
||||||
|
const signature = typeof record.thinkingSignature === "string" ? record.thinkingSignature : "";
|
||||||
|
if (!signature || !isOrphanedOpenAIReasoningSignature(signature)) {
|
||||||
|
return [block as AssistantContentBlock];
|
||||||
|
}
|
||||||
|
|
||||||
|
const thinking = typeof record.thinking === "string" ? record.thinking : "";
|
||||||
|
const trimmed = thinking.trim();
|
||||||
|
changed = true;
|
||||||
|
if (!trimmed) return [];
|
||||||
|
return [{ type: "text" as const, text: thinking }];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
out.push(msg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextContent.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push({ ...assistantMsg, content: nextContent } as AgentMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import type { SessionManager } from "@mariozechner/pi-coding-agent";
|
|||||||
|
|
||||||
import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js";
|
import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js";
|
||||||
import {
|
import {
|
||||||
|
downgradeOpenAIReasoningBlocks,
|
||||||
isCompactionFailureError,
|
isCompactionFailureError,
|
||||||
isGoogleModelApi,
|
isGoogleModelApi,
|
||||||
sanitizeGoogleTurnOrdering,
|
sanitizeGoogleTurnOrdering,
|
||||||
@@ -292,12 +293,16 @@ export async function sanitizeSessionHistory(params: {
|
|||||||
? sanitizeToolUseResultPairing(sanitizedThinking)
|
? sanitizeToolUseResultPairing(sanitizedThinking)
|
||||||
: sanitizedThinking;
|
: sanitizedThinking;
|
||||||
|
|
||||||
|
const isOpenAIResponsesApi =
|
||||||
|
params.modelApi === "openai-responses" || params.modelApi === "openai-codex-responses";
|
||||||
|
const sanitizedOpenAI = isOpenAIResponsesApi ? downgradeOpenAIReasoningBlocks(repairedTools) : repairedTools;
|
||||||
|
|
||||||
if (!policy.applyGoogleTurnOrdering) {
|
if (!policy.applyGoogleTurnOrdering) {
|
||||||
return repairedTools;
|
return sanitizedOpenAI;
|
||||||
}
|
}
|
||||||
|
|
||||||
return applyGoogleTurnOrderingFix({
|
return applyGoogleTurnOrderingFix({
|
||||||
messages: repairedTools,
|
messages: sanitizedOpenAI,
|
||||||
modelApi: params.modelApi,
|
modelApi: params.modelApi,
|
||||||
sessionManager: params.sessionManager,
|
sessionManager: params.sessionManager,
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
|
|||||||
Reference in New Issue
Block a user