fix(agents): sanitize strict openai-compatible turn ordering from #39252 (thanks @scoootscooob)

Co-authored-by: scoootscooob <zhentongfan@gmail.com>
This commit is contained in:
Peter Steinberger
2026-03-07 23:41:27 +00:00
parent ada4ee08d9
commit f304ca09b1
5 changed files with 61 additions and 8 deletions

View File

@@ -307,6 +307,7 @@ Docs: https://docs.openclaw.ai
- Signal/inbound attachment fan-in: forward all successfully fetched inbound attachments through `MediaPaths`/`MediaUrls`/`MediaTypes` (instead of only the first), and improve multi-attachment placeholder summaries in mention-gated pending history. (#39212) Thanks @joeykrug.
- Nodes/system.run dispatch-wrapper boundary: keep shell-wrapper approval classification active at the depth boundary so `env` wrapper stacks cannot reach `/bin/sh -c` execution without the expected approval gate. Thanks @tdjackey for reporting.
- Docker/token persistence on reconfigure: reuse the existing `.env` gateway token during `docker-setup.sh` reruns and align compose token env defaults, so Docker installs stop silently rotating tokens and breaking existing dashboard sessions. Landed from contributor PR #33097 by @chengzhichao-xydt. Thanks @chengzhichao-xydt.
- Agents/strict OpenAI turn ordering: apply assistant-first transcript bootstrap sanitization to strict OpenAI-compatible providers (for example vLLM/Gemma via `openai-completions`) without adding Google-specific session markers, preventing assistant-first history rejections. (#39252) Thanks @scoootscooob.
## 2026.3.2

View File

@@ -255,6 +255,34 @@ describe("sanitizeSessionHistory", () => {
);
});
it("prepends a bootstrap user turn for strict OpenAI-compatible assistant-first history", async () => {
setNonGoogleModelApi();
const sessionEntries: Array<{ type: string; customType: string; data: unknown }> = [];
const sessionManager = makeInMemorySessionManager(sessionEntries);
const messages = castAgentMessages([
{
role: "assistant",
content: [{ type: "text", text: "hello from previous turn" }],
},
]);
const result = await sanitizeSessionHistory({
messages,
modelApi: "openai-completions",
provider: "vllm",
modelId: "gemma-3-27b",
sessionManager,
sessionId: TEST_SESSION_ID,
});
expect(result[0]?.role).toBe("user");
expect((result[0] as { content?: unknown } | undefined)?.content).toBe("(session bootstrap)");
expect(result[1]?.role).toBe("assistant");
expect(
sessionEntries.some((entry) => entry.customType === "google-turn-ordering-bootstrap"),
).toBe(false);
});
it("annotates inter-session user messages before context sanitization", async () => {
setNonGoogleModelApi();

View File

@@ -594,10 +594,19 @@ export async function sanitizeSessionHistory(params: {
return sanitizedOpenAI;
}
return applyGoogleTurnOrderingFix({
messages: sanitizedOpenAI,
modelApi: params.modelApi,
sessionManager: params.sessionManager,
sessionId: params.sessionId,
}).messages;
// Google models use the full wrapper with logging and session markers.
if (isGoogleModelApi(params.modelApi)) {
return applyGoogleTurnOrderingFix({
messages: sanitizedOpenAI,
modelApi: params.modelApi,
sessionManager: params.sessionManager,
sessionId: params.sessionId,
}).messages;
}
// Strict OpenAI-compatible providers (vLLM, Gemma, etc.) also reject
// conversations that start with an assistant turn (e.g. delivery-mirror
// messages after /new). Apply the same ordering fix without the
// Google-specific session markers. See #38962.
return sanitizeGoogleTurnOrdering(sanitizedOpenAI);
}

View File

@@ -60,6 +60,8 @@ describe("resolveTranscriptPolicy", () => {
modelId: "kimi-k2.5",
modelApi: "openai-completions",
});
expect(policy.applyGoogleTurnOrdering).toBe(true);
expect(policy.validateGeminiTurns).toBe(true);
expect(policy.validateAnthropicTurns).toBe(true);
});
@@ -120,12 +122,25 @@ describe("resolveTranscriptPolicy", () => {
expect(policy.preserveSignatures).toBe(false);
});
it("enables turn-ordering and assistant-merge for strict OpenAI-compatible providers (#38962)", () => {
const policy = resolveTranscriptPolicy({
provider: "vllm",
modelId: "gemma-3-27b",
modelApi: "openai-completions",
});
expect(policy.applyGoogleTurnOrdering).toBe(true);
expect(policy.validateGeminiTurns).toBe(true);
expect(policy.validateAnthropicTurns).toBe(true);
});
it("keeps OpenRouter on its existing turn-validation path", () => {
const policy = resolveTranscriptPolicy({
provider: "openrouter",
modelId: "openai/gpt-4.1",
modelApi: "openai-completions",
});
expect(policy.applyGoogleTurnOrdering).toBe(false);
expect(policy.validateGeminiTurns).toBe(false);
expect(policy.validateAnthropicTurns).toBe(false);
});
});

View File

@@ -127,8 +127,8 @@ export function resolveTranscriptPolicy(params: {
sanitizeThoughtSignatures: isOpenAi ? undefined : sanitizeThoughtSignatures,
sanitizeThinkingSignatures: false,
dropThinkingBlocks,
applyGoogleTurnOrdering: !isOpenAi && isGoogle,
validateGeminiTurns: !isOpenAi && isGoogle,
applyGoogleTurnOrdering: !isOpenAi && (isGoogle || isStrictOpenAiCompatible),
validateGeminiTurns: !isOpenAi && (isGoogle || isStrictOpenAiCompatible),
validateAnthropicTurns: !isOpenAi && (isAnthropic || isStrictOpenAiCompatible),
allowSyntheticToolResults: !isOpenAi && (isGoogle || isAnthropic),
};