Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 1 addition & 17 deletions src/provider/openai-compatible-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
173 changes: 172 additions & 1 deletion src/provider/replay-sanitizer.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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();
});
});
22 changes: 20 additions & 2 deletions src/provider/replay-sanitizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ContentBlock["type"]>([
Expand Down Expand Up @@ -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
Expand All @@ -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 });
}

/**
Expand Down
Loading