Compare commits
2 Commits
main
...
fix/1540-o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a8101a96b | ||
|
|
aa02b5d7a9 |
@ -20,6 +20,7 @@ Docs: https://docs.clawd.bot
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Agents: ignore IDENTITY.md template placeholders when parsing identity to avoid placeholder replies. (#1556)
|
- Agents: ignore IDENTITY.md template placeholders when parsing identity to avoid placeholder replies. (#1556)
|
||||||
|
- Agents: drop orphaned OpenAI Responses reasoning blocks on model switches. (#1562) Thanks @roshanasingh4.
|
||||||
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
|
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
|
||||||
- Sessions: reject array-backed session stores to prevent silent wipes. (#1469)
|
- Sessions: reject array-backed session stores to prevent silent wipes. (#1469)
|
||||||
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
|
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
|
||||||
|
|||||||
@ -48,6 +48,7 @@ Implementation:
|
|||||||
|
|
||||||
**OpenAI / OpenAI Codex**
|
**OpenAI / OpenAI Codex**
|
||||||
- Image sanitization only.
|
- Image sanitization only.
|
||||||
|
- On model switch into OpenAI Responses/Codex, drop orphaned reasoning signatures (standalone reasoning items without a following content block).
|
||||||
- No tool call id sanitization.
|
- No tool call id sanitization.
|
||||||
- No tool result pairing repair.
|
- No tool result pairing repair.
|
||||||
- No turn validation or reordering.
|
- No turn validation or reordering.
|
||||||
|
|||||||
@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { downgradeOpenAIReasoningBlocks } from "./pi-embedded-helpers.js";
|
||||||
|
|
||||||
|
describe("downgradeOpenAIReasoningBlocks", () => {
|
||||||
|
it("keeps reasoning signatures when followed by content", () => {
|
||||||
|
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(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops orphaned reasoning blocks without following content", () => {
|
||||||
|
const input = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "thinking",
|
||||||
|
thinkingSignature: JSON.stringify({ id: "rs_abc", type: "reasoning" }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ role: "user", content: "next" },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual([
|
||||||
|
{ role: "user", content: "next" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops object-form orphaned signatures", () => {
|
||||||
|
const input = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "thinking",
|
||||||
|
thinkingSignature: { id: "rs_obj", type: "reasoning" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
|||||||
118
src/agents/pi-embedded-helpers/openai.ts
Normal file
118
src/agents/pi-embedded-helpers/openai.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||||
|
|
||||||
|
type OpenAIThinkingBlock = {
|
||||||
|
type?: unknown;
|
||||||
|
thinking?: unknown;
|
||||||
|
thinkingSignature?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenAIReasoningSignature = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseOpenAIReasoningSignature(value: unknown): OpenAIReasoningSignature | null {
|
||||||
|
if (!value) return null;
|
||||||
|
let candidate: { id?: unknown; type?: unknown } | null = null;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
|
||||||
|
try {
|
||||||
|
candidate = JSON.parse(trimmed) as { id?: unknown; type?: unknown };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else if (typeof value === "object") {
|
||||||
|
candidate = value as { id?: unknown; type?: unknown };
|
||||||
|
}
|
||||||
|
if (!candidate) return null;
|
||||||
|
const id = typeof candidate.id === "string" ? candidate.id : "";
|
||||||
|
const type = typeof candidate.type === "string" ? candidate.type : "";
|
||||||
|
if (!id.startsWith("rs_")) return null;
|
||||||
|
if (type === "reasoning" || type.startsWith("reasoning.")) {
|
||||||
|
return { id, type };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasFollowingNonThinkingBlock(
|
||||||
|
content: Extract<AgentMessage, { role: "assistant" }>["content"],
|
||||||
|
index: number,
|
||||||
|
): boolean {
|
||||||
|
for (let i = index + 1; i < content.length; i++) {
|
||||||
|
const block = content[i];
|
||||||
|
if (!block || typeof block !== "object") return true;
|
||||||
|
if ((block as { type?: unknown }).type !== "thinking") return true;
|
||||||
|
}
|
||||||
|
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, drop the block 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: AssistantContentBlock[] = [];
|
||||||
|
for (let i = 0; i < assistantMsg.content.length; i++) {
|
||||||
|
const block = assistantMsg.content[i];
|
||||||
|
if (!block || typeof block !== "object") {
|
||||||
|
nextContent.push(block as AssistantContentBlock);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const record = block as OpenAIThinkingBlock;
|
||||||
|
if (record.type !== "thinking") {
|
||||||
|
nextContent.push(block as AssistantContentBlock);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const signature = parseOpenAIReasoningSignature(record.thinkingSignature);
|
||||||
|
if (!signature) {
|
||||||
|
nextContent.push(block as AssistantContentBlock);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (hasFollowingNonThinkingBlock(assistantMsg.content, i)) {
|
||||||
|
nextContent.push(block as AssistantContentBlock);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
out.push(msg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextContent.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push({ ...assistantMsg, content: nextContent } as AgentMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@ -161,4 +161,92 @@ describe("sanitizeSessionHistory", () => {
|
|||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0]?.role).toBe("assistant");
|
expect(result[0]?.role).toBe("assistant");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not downgrade openai reasoning when the model has not changed", async () => {
|
||||||
|
const sessionEntries: Array<{ type: string; customType: string; data: unknown }> = [
|
||||||
|
{
|
||||||
|
type: "custom",
|
||||||
|
customType: "model-snapshot",
|
||||||
|
data: {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
provider: "openai",
|
||||||
|
modelApi: "openai-responses",
|
||||||
|
modelId: "gpt-5.2-codex",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const sessionManager = {
|
||||||
|
getEntries: vi.fn(() => sessionEntries),
|
||||||
|
appendCustomEntry: vi.fn((customType: string, data: unknown) => {
|
||||||
|
sessionEntries.push({ type: "custom", customType, data });
|
||||||
|
}),
|
||||||
|
} as unknown as SessionManager;
|
||||||
|
const messages: AgentMessage[] = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "thinking",
|
||||||
|
thinking: "reasoning",
|
||||||
|
thinkingSignature: JSON.stringify({ id: "rs_test", type: "reasoning" }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await sanitizeSessionHistory({
|
||||||
|
messages,
|
||||||
|
modelApi: "openai-responses",
|
||||||
|
provider: "openai",
|
||||||
|
modelId: "gpt-5.2-codex",
|
||||||
|
sessionManager,
|
||||||
|
sessionId: "test-session",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(messages);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("downgrades openai reasoning only when the model changes", async () => {
|
||||||
|
const sessionEntries: Array<{ type: string; customType: string; data: unknown }> = [
|
||||||
|
{
|
||||||
|
type: "custom",
|
||||||
|
customType: "model-snapshot",
|
||||||
|
data: {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
provider: "anthropic",
|
||||||
|
modelApi: "anthropic-messages",
|
||||||
|
modelId: "claude-3-7",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const sessionManager = {
|
||||||
|
getEntries: vi.fn(() => sessionEntries),
|
||||||
|
appendCustomEntry: vi.fn((customType: string, data: unknown) => {
|
||||||
|
sessionEntries.push({ type: "custom", customType, data });
|
||||||
|
}),
|
||||||
|
} as unknown as SessionManager;
|
||||||
|
const messages: AgentMessage[] = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "thinking",
|
||||||
|
thinking: "reasoning",
|
||||||
|
thinkingSignature: { id: "rs_test", type: "reasoning" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await sanitizeSessionHistory({
|
||||||
|
messages,
|
||||||
|
modelApi: "openai-responses",
|
||||||
|
provider: "openai",
|
||||||
|
modelId: "gpt-5.2-codex",
|
||||||
|
sessionManager,
|
||||||
|
sessionId: "test-session",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
@ -211,7 +212,50 @@ registerUnhandledRejectionHandler((reason) => {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
type CustomEntryLike = { type?: unknown; customType?: unknown };
|
type CustomEntryLike = { type?: unknown; customType?: unknown; data?: unknown };
|
||||||
|
|
||||||
|
type ModelSnapshotEntry = {
|
||||||
|
timestamp: number;
|
||||||
|
provider?: string;
|
||||||
|
modelApi?: string | null;
|
||||||
|
modelId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MODEL_SNAPSHOT_CUSTOM_TYPE = "model-snapshot";
|
||||||
|
|
||||||
|
function readLastModelSnapshot(sessionManager: SessionManager): ModelSnapshotEntry | null {
|
||||||
|
try {
|
||||||
|
const entries = sessionManager.getEntries();
|
||||||
|
for (let i = entries.length - 1; i >= 0; i--) {
|
||||||
|
const entry = entries[i] as CustomEntryLike;
|
||||||
|
if (entry?.type !== "custom" || entry?.customType !== MODEL_SNAPSHOT_CUSTOM_TYPE) continue;
|
||||||
|
const data = entry?.data as ModelSnapshotEntry | undefined;
|
||||||
|
if (data && typeof data === "object") {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendModelSnapshot(sessionManager: SessionManager, data: ModelSnapshotEntry): void {
|
||||||
|
try {
|
||||||
|
sessionManager.appendCustomEntry(MODEL_SNAPSHOT_CUSTOM_TYPE, data);
|
||||||
|
} catch {
|
||||||
|
// ignore persistence failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameModelSnapshot(a: ModelSnapshotEntry, b: ModelSnapshotEntry): boolean {
|
||||||
|
const normalize = (value?: string | null) => value ?? "";
|
||||||
|
return (
|
||||||
|
normalize(a.provider) === normalize(b.provider) &&
|
||||||
|
normalize(a.modelApi) === normalize(b.modelApi) &&
|
||||||
|
normalize(a.modelId) === normalize(b.modelId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function hasGoogleTurnOrderingMarker(sessionManager: SessionManager): boolean {
|
function hasGoogleTurnOrderingMarker(sessionManager: SessionManager): boolean {
|
||||||
try {
|
try {
|
||||||
@ -292,12 +336,38 @@ export async function sanitizeSessionHistory(params: {
|
|||||||
? sanitizeToolUseResultPairing(sanitizedThinking)
|
? sanitizeToolUseResultPairing(sanitizedThinking)
|
||||||
: sanitizedThinking;
|
: sanitizedThinking;
|
||||||
|
|
||||||
|
const isOpenAIResponsesApi =
|
||||||
|
params.modelApi === "openai-responses" || params.modelApi === "openai-codex-responses";
|
||||||
|
const hasSnapshot = Boolean(params.provider || params.modelApi || params.modelId);
|
||||||
|
const priorSnapshot = hasSnapshot ? readLastModelSnapshot(params.sessionManager) : null;
|
||||||
|
const modelChanged = priorSnapshot
|
||||||
|
? !isSameModelSnapshot(priorSnapshot, {
|
||||||
|
timestamp: 0,
|
||||||
|
provider: params.provider,
|
||||||
|
modelApi: params.modelApi,
|
||||||
|
modelId: params.modelId,
|
||||||
|
})
|
||||||
|
: false;
|
||||||
|
const sanitizedOpenAI =
|
||||||
|
isOpenAIResponsesApi && modelChanged
|
||||||
|
? downgradeOpenAIReasoningBlocks(repairedTools)
|
||||||
|
: repairedTools;
|
||||||
|
|
||||||
|
if (hasSnapshot && (!priorSnapshot || modelChanged)) {
|
||||||
|
appendModelSnapshot(params.sessionManager, {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
provider: params.provider,
|
||||||
|
modelApi: params.modelApi,
|
||||||
|
modelId: params.modelId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user