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
4 changes: 2 additions & 2 deletions docs/multi-provider-meta-harness.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,8 @@ Anthropic │ assistant: { content: [tool_use blocks] }
OpenAI │ assistant: { tool_calls: [function call objects] }
│ tool: { tool_call_id, content }
───────────┼───────────────────────────────────────────
Gemini │ model: { parts: [functionCall parts] }
user: { parts: [functionResponse parts] }
Gemini │ model: { parts: [functionCall parts] }
function: { parts: [functionResponse parts] }
```

Conversion is deterministic. Each provider API validates that tool_result messages reference declared tools. To satisfy this, every provider call includes the full canonical tool definition list regardless of which subset appears in history.
Expand Down
4 changes: 3 additions & 1 deletion docs/providers-pi.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ The bridge announces itself on session start; **if it fails to load, turns fail
## Switching backends mid-session

`/provider pi` (or `session.set_provider`) swaps a live session's backend while keeping the session id, scrollback, transcript, and identity.
The conversation so far is carried into the incoming backend as a rendered transcript (oldest turns dropped first past ~24k chars) — a faithful transcript, **not** a native continuation: tool_use structures, prompt-cache state, and extension state don't cross.
The conversation so far is carried into the incoming backend as a structured-text transcript — tool calls rendered as structured blocks (name, input JSON, fenced output), oldest turns dropped first past ~24k chars.
Stateless backends (gemini, openai) go further: they replay the history in their native function-call structure on every request.
Warm backends (claude, pi) stay a faithful transcript, **not** a native continuation — neither the Claude Agent SDK nor pi RPC accepts synthesized native-history injection, so prompt-cache state and extension state don't cross.
Switching resets the model to the new backend's default, and mid-turn switches are rejected — interrupt first.
`/provider claude` brings the session back the same way.
10 changes: 6 additions & 4 deletions packages/protocol/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -964,10 +964,12 @@ export interface SessionRotateMsg extends BaseClientMsg {
* session id, scrollback, transcript, and identity all stay; the backing
* agent is torn down and replaced, and the accumulated canonical history is
* handed to the incoming provider (`seedFromHistory`) so it can continue
* the conversation. Fidelity contract: the new backend receives a faithful
* TRANSCRIPT (tool calls flattened to text), not a native continuation —
* provider-native structures (tool_use blocks, prompt cache, extension
* state) do not survive the switch.
* the conversation. Fidelity contract: STATELESS backends (gemini, openai)
* replay the history in their native structure (real function-call turns)
* on every subsequent request; WARM backends (claude, pi) receive a
* faithful structured-text transcript prepended to their first prompt —
* neither accepts synthesized native-history injection, so prompt cache
* and extension state still do not survive the switch.
*
* Rejected with `invalid_request` when the provider is unknown (fail-closed,
* same rule as `session.create`) or the session is mid-turn — interrupt
Expand Down
264 changes: 181 additions & 83 deletions src/daemon/providers/canonical.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,25 @@
* Canonical conversation history — the format codeoid uses internally so
* multiple providers can share turns.
*
* Phase 1: types + constants + history converters + CanonicalHistoryAccumulator.
* - Tool calls are rendered as inline text for non-Claude providers so they
* see what ran and what it returned, without needing function-calling support.
* Capture (CanonicalHistoryAccumulator) has always been structured:
* CanonicalTurn carries content, thinking, and CanonicalToolCall[] with
* ids/inputs/outputs. Phase 2 (this file's converters) renders that history
* in each provider's NATIVE structure — Anthropic tool_use/tool_result
* blocks, Gemini functionCall/functionResponse parts, OpenAI tool_calls +
* tool-role messages — so a switched session's history reads as real
* tool-call turns, not a narrated summary.
*
* Phase 2 (future): replace the inline-text fallback in each converter with
* the provider's native function_call / function_response format. The
* CanonicalToolCall type already captures everything needed.
* Two honest fidelity limits remain:
* - `thinking` is captured but NOT replayed into any provider payload:
* Anthropic requires a cryptographic signature on replayed thinking
* blocks (ours are synthesized, so they'd be rejected — and the API
* ignores prior-turn thinking anyway), and neither Gemini nor OpenAI
* accepts imported reasoning. Display-only by design.
* - A CanonicalTurn flattens an agent loop (text → tool → text …) into
* one turn, so converters emit the parallel-tool-call shape: one
* assistant message with every tool_use, then one message with every
* result. Valid everywhere, but intra-turn interleaving is not
* reconstructed.
*/

import { type ProviderEvent, isSubagentEvent } from "./interface.js";
Expand Down Expand Up @@ -99,55 +111,88 @@ export function limitToolOutput(canonicalName: string, output: string): string {
return `${output.slice(0, limit)}\n…output truncated at ${limit} chars (full output was ${output.length} chars)`;
}

// ── History converters ────────────────────────────────────────────────────────
// ── Native message shapes ─────────────────────────────────────────────────────
//
// Structural subsets of each provider's message-param types, declared here so
// canonical.ts stays dependency-free. Providers pass the converter output to
// their SDKs, where structural typing (or a single cast at the call site)
// takes over.

/** Anthropic Messages API content blocks (the subset codeoid emits). */
export type AnthropicContentBlock =
| { type: "text"; text: string }
| { type: "tool_use"; id: string; name: string; input: Record<string, unknown> }
| { type: "tool_result"; tool_use_id: string; content: string; is_error?: boolean };

export interface AnthropicMessageParam {
role: "user" | "assistant";
content: string | AnthropicContentBlock[];
}

/**
* Render a CanonicalToolCall as inline text for providers that don't natively
* support function calling (Phase 1 fallback).
*/
function toolCallToText(tc: CanonicalToolCall): string {
const inputStr = Object.entries(tc.input)
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
.join(", ");
const status = tc.success ? "" : " [ERROR]";
const outputPreview = tc.output.length > 500
? `${tc.output.slice(0, 500)}…`
: tc.output;
return `[Tool: ${tc.name}(${inputStr})${status}]\n${outputPreview}`;
export type GeminiPart =
| { text: string }
| { functionCall: { name: string; args: Record<string, unknown> } }
| { functionResponse: { name: string; response: Record<string, unknown> } };

/** @google/generative-ai Content — functionResponse parts ride role "function". */
export interface GeminiContent {
role: "user" | "model" | "function";
parts: GeminiPart[];
}

export type OpenAIMessageParam =
| { role: "system" | "user"; content: string }
| {
role: "assistant";
content: string | null;
tool_calls?: Array<{
id: string;
type: "function";
function: { name: string; arguments: string };
}>;
}
| { role: "tool"; tool_call_id: string; content: string };

// ── History converters ────────────────────────────────────────────────────────

/**
* Render a CanonicalTurn[] as Google Gemini Content[].
*
* Phase 1: tool calls are inlined as text in the model turn.
* Phase 2: replace the inline-text block below with proper
* { functionCall: { name, args } } parts in the model turn and a
* follow-up user turn with { functionResponse: { name, response } } parts.
* Tool calls become native { functionCall } parts on the model turn,
* followed by a role:"function" turn carrying { functionResponse } parts
* (the shape @google/generative-ai validates for chat history). Gemini
* pairs responses by NAME, not id — a limitation of its wire format.
*/
export function toGeminiContent(
history: readonly CanonicalTurn[],
): Array<{ role: "user" | "model"; parts: Array<{ text: string }> }> {
const out: Array<{ role: "user" | "model"; parts: Array<{ text: string }> }> = [];
export function toGeminiContent(history: readonly CanonicalTurn[]): GeminiContent[] {
const out: GeminiContent[] = [];

for (const turn of history) {
if (turn.role === "user") {
out.push({ role: "user", parts: [{ text: turn.content }] });
continue;
}

// Assistant turn — build text with optional tool-call summary.
const parts: string[] = [];
if (turn.content) parts.push(turn.content);

// Phase 2: replace this block with native functionCall/functionResponse parts.
if (turn.toolCalls && turn.toolCalls.length > 0) {
parts.push("\n\n[Tool calls executed by previous agent:]");
for (const tc of turn.toolCalls) {
parts.push(toolCallToText(tc));
}
if (!turn.toolCalls || turn.toolCalls.length === 0) {
out.push({ role: "model", parts: [{ text: turn.content }] });
continue;
}

out.push({ role: "model", parts: [{ text: parts.join("\n") }] });
const parts: GeminiPart[] = [];
if (turn.content) parts.push({ text: turn.content });
for (const tc of turn.toolCalls) {
parts.push({ functionCall: { name: tc.name, args: tc.input } });
}
out.push({ role: "model", parts });
out.push({
role: "function",
parts: turn.toolCalls.map((tc) => ({
functionResponse: {
name: tc.name,
// functionResponse.response must be an OBJECT — wrap the text.
response: { output: tc.output, success: tc.success },
},
})),
});
}

return out;
Expand All @@ -156,69 +201,93 @@ export function toGeminiContent(
/**
* Render a CanonicalTurn[] as OpenAI ChatCompletionMessageParam[].
*
* Phase 1: tool calls are inlined as text in the assistant turn.
* Phase 2: replace the inline-text block below with proper
* assistant.tool_calls[] + { role: "tool" } messages.
* Tool calls become native assistant `tool_calls[]` followed by one
* role:"tool" message per call (paired by `tool_call_id`). OpenAI accepts
* tool-history replay without the original function schemas declared.
*/
export function toOpenAIMessages(
history: readonly CanonicalTurn[],
): Array<{ role: "user" | "assistant" | "system"; content: string }> {
const out: Array<{ role: "user" | "assistant" | "system"; content: string }> = [];
export function toOpenAIMessages(history: readonly CanonicalTurn[]): OpenAIMessageParam[] {
const out: OpenAIMessageParam[] = [];

for (const turn of history) {
if (turn.role === "user") {
out.push({ role: "user", content: turn.content });
continue;
}

const parts: string[] = [];
if (turn.content) parts.push(turn.content);

// Phase 2: replace this block with tool_calls[] + tool role messages.
if (turn.toolCalls && turn.toolCalls.length > 0) {
parts.push("\n\n[Tool calls executed by previous agent:]");
for (const tc of turn.toolCalls) {
parts.push(toolCallToText(tc));
}
if (!turn.toolCalls || turn.toolCalls.length === 0) {
out.push({ role: "assistant", content: turn.content });
continue;
}

out.push({ role: "assistant", content: parts.join("\n") });
out.push({
role: "assistant",
// OpenAI wants null (not "") when the turn is tool-calls-only.
content: turn.content || null,
tool_calls: turn.toolCalls.map((tc) => ({
id: tc.id,
type: "function" as const,
function: { name: tc.name, arguments: JSON.stringify(tc.input) },
})),
});
for (const tc of turn.toolCalls) {
// OpenAI has no is_error equivalent — failures are carried in the
// content string (design doc §11.3).
out.push({
role: "tool",
tool_call_id: tc.id,
content: tc.success ? tc.output : `Error: ${tc.output}`,
});
}
}

return out;
}

/**
* Render a CanonicalTurn[] as Anthropic API messages[].
* Used when ClaudeProvider is switched into stateless mode (Phase 2),
* Used when ClaudeProvider is switched into stateless mode,
* or when seeding a new Claude backing session with prior context.
*
* Phase 1: tool calls are inlined as text.
* Phase 2: replace with proper tool_use/tool_result content blocks.
* Tool calls become native `tool_use` blocks on the assistant message,
* followed by a user message of `tool_result` blocks paired by
* `tool_use_id` (the CanonicalToolCall id round-trips). Consecutive
* same-role messages are legal — the API merges them into one turn.
*
* `thinking` is deliberately NOT emitted: replayed thinking blocks must
* carry the API's signature, which synthesized history can't produce.
*/
export function toAnthropicMessages(
history: readonly CanonicalTurn[],
): Array<{ role: "user" | "assistant"; content: string }> {
const out: Array<{ role: "user" | "assistant"; content: string }> = [];
): AnthropicMessageParam[] {
const out: AnthropicMessageParam[] = [];

for (const turn of history) {
if (turn.role === "user") {
out.push({ role: "user", content: turn.content });
continue;
}

const parts: string[] = [];
if (turn.content) parts.push(turn.content);

// Phase 2: replace with tool_use + tool_result content blocks.
if (turn.toolCalls && turn.toolCalls.length > 0) {
parts.push("\n\n[Tool calls executed by previous agent:]");
for (const tc of turn.toolCalls) {
parts.push(toolCallToText(tc));
}
if (!turn.toolCalls || turn.toolCalls.length === 0) {
out.push({ role: "assistant", content: turn.content });
continue;
}

out.push({ role: "assistant", content: parts.join("\n") });
const blocks: AnthropicContentBlock[] = [];
// Empty text blocks are rejected by the API — only emit when non-empty.
if (turn.content) blocks.push({ type: "text", text: turn.content });
for (const tc of turn.toolCalls) {
blocks.push({ type: "tool_use", id: tc.id, name: tc.name, input: tc.input });
}
out.push({ role: "assistant", content: blocks });
out.push({
role: "user",
content: turn.toolCalls.map((tc) => ({
type: "tool_result" as const,
tool_use_id: tc.id,
content: tc.output,
Comment thread
saucam marked this conversation as resolved.
...(tc.success ? {} : { is_error: true }),
})),
});
}

return out;
Expand Down Expand Up @@ -338,17 +407,45 @@ export class CanonicalHistoryAccumulator {
/** Character budget for a rendered history seed (~6k tokens). */
export const HISTORY_SEED_MAX_CHARS = 24_000;

/** Per-tool output budget inside the seed — the global budget drops whole
* turns oldest-first, this keeps one chatty tool from eating a turn. */
const SEED_TOOL_OUTPUT_MAX_CHARS = 2_000;

/**
* Render one tool call as a structured text block for the seed: full input
* as JSON, fenced output. Structured-text — richer than a one-line summary,
* still prose (see renderHistorySeed's fidelity contract).
*/
function toolCallToSeedText(tc: CanonicalToolCall): string {
const output =
tc.output.length > SEED_TOOL_OUTPUT_MAX_CHARS
? `${tc.output.slice(0, SEED_TOOL_OUTPUT_MAX_CHARS)}\n…output truncated for seed…`
: tc.output;
return [
`### Tool call: ${tc.name} → ${tc.success ? "ok" : "ERROR"}`,
`input: ${JSON.stringify(tc.input)}`,
"output:",
"```",
output,
"```",
].join("\n");
}

/**
* Render the canonical history as a plain-text transcript block for seeding
* a NEW warm backend after `session.set_provider`. Stateless providers
* ignore this (they consume `TurnOpts.history` natively every turn); warm
* providers (claude, pi) prepend it to their first post-switch prompt so
* the incoming agent can continue the conversation.
* Render the canonical history as a structured-text transcript block for
* seeding a NEW warm backend after `session.set_provider`. Stateless
* providers ignore this (they consume `TurnOpts.history` natively every
* turn — see the to*Messages converters above); warm providers (claude, pi)
* prepend it to their first post-switch prompt so the incoming agent can
* continue the conversation.
*
* Fidelity contract: this is a faithful TRANSCRIPT, not a native
* continuation — tool calls are flattened to text and provider-native
* structures don't survive. Oldest turns are dropped first when the budget
* is exceeded (recent context matters most), with an elision note.
* continuation. Tool calls are rendered as structured text blocks (name,
* JSON input, fenced output) rather than native tool_use structures —
* neither the Claude Agent SDK nor pi's RPC accepts synthesized native
* history injection, so a prompt-prefix transcript is the warm-backend
* ceiling. Oldest turns are dropped first when the budget is exceeded
* (recent context matters most), with an elision note.
*/
export function renderHistorySeed(
history: readonly CanonicalTurn[],
Expand All @@ -364,7 +461,7 @@ export function renderHistorySeed(
const parts: string[] = [`## Assistant (${turn.providerId}/${turn.model})`];
if (turn.content) parts.push(turn.content);
for (const tc of turn.toolCalls ?? []) {
parts.push(toolCallToText(tc));
parts.push(toolCallToSeedText(tc));
}
return parts.join("\n");
});
Expand All @@ -386,9 +483,10 @@ export function renderHistorySeed(
return [
"<conversation-history>",
"You are taking over an ongoing session from another agent backend.",
"The conversation so far (tool calls flattened to text, possibly",
"truncated) follows. Continue it seamlessly — do not re-introduce",
"yourself or repeat completed work.",
"The conversation so far follows — tool calls appear as structured",
"blocks (name, input JSON, fenced output), possibly truncated.",
"Continue it seamlessly — do not re-introduce yourself or repeat",
"completed work.",
"",
kept.join("\n\n"),
"</conversation-history>",
Expand Down
Loading
Loading