diff --git a/CHANGELOG.md b/CHANGELOG.md index 8100eec1..ed8532ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ### Agent +- **Thinking-only replay no longer collapses into an identical request.** Assistant turns with no text or tool_call (empty content, leftover thinking/citation) are replaced with a stable `[thinking-only turn omitted]` marker so the turn is kept, roles still alternate, and the next `buildRequest` body differs from the previous one. + - **Compaction keeps scored work, not retry loops.** Errored tool results are no longer auto-pinned; identical errors collapse to one representative. Anchors are scored (writes, successful task completions, plan updates) and pair diff --git a/src/provider/openai-compatible-adapter.ts b/src/provider/openai-compatible-adapter.ts index ef007c0a..ed6bbf7a 100644 --- a/src/provider/openai-compatible-adapter.ts +++ b/src/provider/openai-compatible-adapter.ts @@ -28,23 +28,7 @@ export function createOpenAICompatibleAdapter(source: AdapterSource): ProviderAd }; const buildRequest: ProviderAdapter["buildRequest"] = (messages, model, options) => { - // Strip assistant turns with no text or tool_call content (e.g. a turn that - // produced only thinking blocks). transform.ts should handle this but misses - // the case where filteredContent is non-empty; the API rejects such turns - // with HTTP 400. Fast-path: only allocate when a bad turn is actually found. - const needsSanitize = messages.some( - (msg) => - msg.role === "assistant" && - !msg.content.some((b) => b.type === "text" || b.type === "tool_call"), - ); - const sanitized = needsSanitize - ? messages.filter( - (msg) => - msg.role !== "assistant" || - msg.content.some((b) => b.type === "text" || b.type === "tool_call"), - ) - : messages; - const built = base.buildRequest(sanitized, model, options); + const built = base.buildRequest(messages, model, options); const providerOptions = options.providerOptions; const hasProviderOptions = providerOptions !== undefined && Object.keys(providerOptions).length > 0; diff --git a/src/provider/replay-sanitizer.test.ts b/src/provider/replay-sanitizer.test.ts index 2a8aea83..aa82a95c 100644 --- a/src/provider/replay-sanitizer.test.ts +++ b/src/provider/replay-sanitizer.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from "bun:test"; +import type { AdapterRegistry } from "@intx/inference"; import { createBuiltinRegistry } from "@intx/inference/providers"; import type { ConversationTurn, LastCycleSource } from "@intx/types/runtime"; -import { sanitizeReplayTurns, withReplaySanitizer } from "./replay-sanitizer.js"; +import { createGrokResponsesAdapter } from "./grok-responses-adapter.js"; +import { createOpenAICompatibleAdapter } from "./openai-compatible-adapter.js"; +import { + sanitizeReplayTurns, + THINKING_ONLY_OMITTED, + withReplaySanitizer, +} from "./replay-sanitizer.js"; const GROK_SIGNATURE = "grok-opaque-signature-blob"; @@ -33,6 +40,59 @@ function resolveSanitized(source: LastCycleSource) { return withReplaySanitizer(createBuiltinRegistry()).resolve(source); } +function corbitsRegistry(): AdapterRegistry { + const builtin = createBuiltinRegistry(); + return { + has: (provider) => + provider === "openai-compatible" || provider === "grok-responses" || builtin.has(provider), + resolve(source, quirks) { + if (source.provider === "openai-compatible") { + return createOpenAICompatibleAdapter(source); + } + if (source.provider === "grok-responses") { + return createGrokResponsesAdapter(source); + } + return builtin.resolve(source, quirks); + }, + }; +} + +function thinkingOnlyHistory(): ConversationTurn[] { + return [ + { + role: "user", + content: [{ type: "text", text: "hello" }], + timestamp: 1, + }, + { + role: "assistant", + model: "grok-4", + content: [{ type: "thinking", thinking: "pondering" }], + timestamp: 2, + }, + { + role: "user", + content: [{ type: "text", text: "continue" }], + timestamp: 3, + }, + ]; +} + +const USER_ONLY: ConversationTurn[] = [ + { + role: "user", + content: [{ type: "text", text: "hello" }], + timestamp: 1, + }, +]; + +const THINKING_ONLY_TAIL: ConversationTurn = { + role: "assistant", + model: "grok-4", + content: [{ type: "thinking", thinking: "pondering" }], + timestamp: 2, +}; + describe("sanitizeReplayTurns", () => { it("strips foreign thinking blocks and signatures", () => { const turns = sanitizeReplayTurns(grokThinkingHistory(), "claude-opus-4"); @@ -101,6 +161,68 @@ describe("sanitizeReplayTurns", () => { expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ callId: "call_1", isError: true }); }); + + it("replaces a thinking-only assistant between users with a marker and keeps roles", () => { + const turns = sanitizeReplayTurns(thinkingOnlyHistory(), "claude-opus-4"); + expect(turns.map((t) => t.role)).toEqual(["user", "assistant", "user"]); + expect(turns[1]?.content).toEqual([{ type: "text", text: THINKING_ONLY_OMITTED }]); + }); + + it("replaces empty and leftover-only assistant turns with the same marker", () => { + const empty = sanitizeReplayTurns( + [ + { + role: "assistant", + model: "grok-4", + content: [], + timestamp: 1, + }, + ], + "claude-opus-4", + ); + expect(empty[0]?.content).toEqual([{ type: "text", text: THINKING_ONLY_OMITTED }]); + + const leftovers = sanitizeReplayTurns( + [ + { + role: "assistant", + model: "claude-opus-4", + content: [ + { type: "thinking", thinking: "pondering" }, + { type: "redacted_thinking", data: "opaque" }, + { type: "citation", citedText: "quote", source: {} }, + ], + timestamp: 1, + }, + ], + "gemini-2.5-pro", + ); + expect(leftovers[0]?.content).toEqual([{ type: "text", text: THINKING_ONLY_OMITTED }]); + expect(JSON.stringify(leftovers)).not.toContain("pondering"); + expect(JSON.stringify(leftovers)).not.toContain("opaque"); + }); + + it("leaves assistant turns with text and/or tool_call unchanged", () => { + const withText = sanitizeReplayTurns(grokThinkingHistory(), "grok-4"); + expect(withText[1]?.content).toEqual([ + { type: "thinking", thinking: "pondering", signature: GROK_SIGNATURE }, + { type: "text", text: "answer", signature: GROK_SIGNATURE }, + ]); + + const withTool = [ + { + role: "assistant" as const, + model: "grok-4", + content: [ + { type: "thinking" as const, thinking: "need a tool" }, + { type: "tool_call" as const, id: "call_1", name: "ls", arguments: {} }, + ], + timestamp: 1, + }, + ]; + const kept = sanitizeReplayTurns(withTool, "grok-4"); + expect(kept[0]?.content).toEqual(withTool[0]?.content); + }); }); describe("withReplaySanitizer", () => { @@ -186,4 +308,53 @@ describe("withReplaySanitizer", () => { expect(request.body).toContain("tool_result"); expect(request.body).toContain("call_1"); }); + + it("changes buildRequest bodies after a thinking-only turn for builtin and Corbits adapters", () => { + const sanitized = withReplaySanitizer(corbitsRegistry()); + const cases: LastCycleSource[] = [ + { sourceId: "s1", provider: "anthropic", model: "claude-opus-4" }, + { sourceId: "s1", provider: "google-genai", model: "gemini-2.5-pro" }, + { sourceId: "s1", provider: "openai", model: "gpt-5" }, + { sourceId: "s1", provider: "openai-compatible", model: "kimi-k2" }, + { sourceId: "s1", provider: "grok-responses", model: "grok-4.5" }, + ]; + for (const source of cases) { + const adapter = sanitized.resolve(source); + const without = adapter.buildRequest(USER_ONLY, source.model, {}); + const withThinking = adapter.buildRequest( + [...USER_ONLY, THINKING_ONLY_TAIL], + source.model, + {}, + ); + expect(withThinking.body).not.toEqual(without.body); + expect(withThinking.body).toContain(THINKING_ONLY_OMITTED); + } + }); + + it("builds requests for thinking-only and leftover-only assistant turns", () => { + const adapter = resolveSanitized({ + sourceId: "s1", + provider: "anthropic", + model: "claude-opus-4", + }); + const leftoverHistory: ConversationTurn[] = [ + { + role: "user", + content: [{ type: "text", text: "hello" }], + timestamp: 1, + }, + { + role: "assistant", + model: "grok-4", + content: [ + { type: "thinking", thinking: "pondering" }, + { type: "redacted_thinking", data: "opaque" }, + { type: "citation", citedText: "quote", source: {} }, + ], + timestamp: 2, + }, + ]; + expect(() => adapter.buildRequest(thinkingOnlyHistory(), "claude-opus-4", {})).not.toThrow(); + expect(() => adapter.buildRequest(leftoverHistory, "claude-opus-4", {})).not.toThrow(); + }); }); diff --git a/src/provider/replay-sanitizer.ts b/src/provider/replay-sanitizer.ts index a4d3639b..9da27f01 100644 --- a/src/provider/replay-sanitizer.ts +++ b/src/provider/replay-sanitizer.ts @@ -11,6 +11,8 @@ import type { ContentBlock, ConversationTurn } from "@intx/types/runtime"; // audio, video, code execution) that make adapter builders throw, and opaque // provider signatures that a foreign provider rejects when echoed back. +export const THINKING_ONLY_OMITTED = "[thinking-only turn omitted]"; + // Output-only shapes a foreign provider cannot round-trip; adapter builders // throw on them, so they are dropped from foreign-model turns before build. const FOREIGN_UNMAPPABLE_TYPES = new Set([ @@ -43,6 +45,21 @@ function stripForeignBlocks(turn: ConversationTurn): ConversationTurn { return { ...turn, content }; } +function hasTextOrToolCall(content: ContentBlock[]): boolean { + return content.some((block) => block.type === "text" || block.type === "tool_call"); +} + +// transformMessages drops an assistant turn only when stripping thinking +// leaves empty content. Same-model thinking-only and leftover-only turns +// survive with no text/tool_call; adapters then 400 or used to drop them, +// producing an identical next request and a thinking-only loop. Replace +// the unusable turn with a stable text marker so the turn stays, roles +// alternate, and the wire body changes. +function replaceUnusableAssistantTurn(turn: ConversationTurn): ConversationTurn { + if (turn.role !== "assistant" || hasTextOrToolCall(turn.content)) return turn; + return { ...turn, content: [{ type: "text", text: THINKING_ONLY_OMITTED }] }; +} + /** * Repair persisted turns for replay against `targetModel`. Assistant turns * produced by a different model lose blocks the target provider cannot @@ -52,10 +69,11 @@ export function sanitizeReplayTurns( turns: ConversationTurn[], targetModel: string, ): ConversationTurn[] { - const repaired = turns.map((turn) => + const stripped = turns.map((turn) => turn.role === "assistant" && turn.model !== targetModel ? stripForeignBlocks(turn) : turn, ); - return transformMessages(repaired, { targetModel }); + const marked = stripped.map(replaceUnusableAssistantTurn); + return transformMessages(marked, { targetModel }); } /**