Skip to content
Open
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
17 changes: 17 additions & 0 deletions extensions/ai-providers/antigravity/google-conversion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ function transformMessages(
? { ...message, toolCallId: normalizedId }
: message;
}
// Only assistant turns are rewritten. Any other role must pass through
// untouched: a transcript system message (Pi 0.86+) reaching this branch would
// be iterated as if it held assistant content and silently emptied.
if (message.role !== "assistant") return message;

const isSameModel =
message.provider === model.provider &&
Expand Down Expand Up @@ -346,6 +350,11 @@ export function convertMessages(
continue;
}

// Everything that is not a conversation turn is not representable on the wire.
// Skipping it is what keeps a stray role from becoming a nameless
// `functionResponse`, which Cloud Code Assist rejects outright.
if (message.role !== "toolResult") continue;

const textResult = message.content
.filter((part) => part.type === "text")
.map((part) => part.text)
Expand All @@ -361,6 +370,14 @@ export function convertMessages(
: hasImages
? "(see attached image)"
: "";
// A result with no tool name carries no identity to report back; emitting it
// as `functionResponse` would fail the whole request. Keep its text instead.
if (!message.toolName) {
if (responseValue.length > 0) {
contents.push({ role: "user", parts: [{ text: responseValue }] });
}
continue;
}
const imageParts: GooglePart[] = imageContent.map((image) => ({
inlineData: { mimeType: image.mimeType, data: image.data },
}));
Expand Down
26 changes: 14 additions & 12 deletions extensions/ai-providers/antigravity/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ import type {
Context,
Model,
SimpleStreamOptions,
Tool,
ToolCall,
} from "@earendil-works/pi-ai/compat";
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai/compat";
import { resolveTranscript } from "../transcript.ts";
import { emptyUsage } from "../usage.ts";
import { decodeApiKey } from "./credentials.ts";
import {
Expand Down Expand Up @@ -186,13 +188,6 @@ function isClaudeRoute(modelId: string): boolean {
return modelId.toLowerCase().includes("claude");
}

function normalizeSystemPrompts(
systemPrompt: Context["systemPrompt"],
): string[] {
if (!systemPrompt) return [];
return Array.isArray(systemPrompt) ? systemPrompt : [systemPrompt];
}

/** Deterministic conversation id: hash of the first user text, like the client. */
function deriveSessionId(context: Context): string {
for (const message of context.messages) {
Expand Down Expand Up @@ -271,11 +266,10 @@ function buildToolConfig(

/** Convert pi tools to CCA functionDeclarations with sanitized schemas. */
function buildTools(
context: Context,
tools: Tool[] | undefined,
toolChoice: AntigravityStreamOptions["toolChoice"],
): Record<string, unknown>[] | undefined {
if (toolChoice === "none") return undefined;
const tools = context.tools;
if (!tools || tools.length === 0) return undefined;
const converted = convertTools([...tools], true) as
| { functionDeclarations: Record<string, unknown>[] }[]
Expand All @@ -297,18 +291,26 @@ export function buildRequestBody(
projectId: string,
state?: AntigravitySessionState,
): Record<string, unknown> {
const contents = convertMessages(model, context);
// Pi 0.86+ folds the system prompt and tool declarations into transcript system
// messages; Pi <= 0.85.1 still passes them as Context fields. Resolve both shapes
// before converting, so the conversation never carries a system message and the
// prompt/tools are never silently dropped.
const transcript = resolveTranscript(context);
const contents = convertMessages(model, {
...context,
messages: transcript.messages,
});

const request: Record<string, unknown> = { contents };
const systemPrompts = normalizeSystemPrompts(context.systemPrompt);
const systemPrompts = transcript.systemPrompts;
if (systemPrompts.length > 0) {
request.systemInstruction = {
role: "user",
parts: systemPrompts.map((text) => ({ text })),
};
}

const tools = buildTools(context, options?.toolChoice);
const tools = buildTools(transcript.tools, options?.toolChoice);
if (tools) request.tools = tools;
const toolConfig = buildToolConfig(
model,
Expand Down
10 changes: 8 additions & 2 deletions extensions/ai-providers/cursor/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
ToolCall,
} from "@earendil-works/pi-ai/compat";
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai/compat";
import { applyTranscript } from "../transcript.ts";
import { emptyUsage } from "../usage.ts";
import { ConnectFrameReader } from "./connect-frame-reader.ts";
import {
Expand Down Expand Up @@ -548,9 +549,10 @@ function resolveWireModel(model: Model<Api>): {
/** Build the protobuf Run request and retain blobs for the same Connect stream. */
export async function buildCursorRequest(
model: Model<Api>,
context: Context,
rawContext: Context,
options?: SimpleStreamOptions,
): Promise<CursorRequestBuild> {
const context = applyTranscript(rawContext);
const store: CursorBlobStore = new Map();
const activeIndex =
context.messages.at(-1)?.role === "user"
Expand Down Expand Up @@ -687,9 +689,13 @@ function errorFromEndStream(data: Uint8Array): Error | undefined {
/** Cursor AgentService/Run with Pi-owned tool execution across provider turns. */
export function streamCursor(
model: Model<Api>,
context: Context,
rawContext: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream {
// Pi 0.86+ folds the system prompt and tools into transcript system messages;
// resolving here keeps every downstream read of `context.systemPrompt` /
// `context.tools` / `context.messages` correct on both Pi input shapes.
const context = applyTranscript(rawContext);
const stream = createAssistantMessageEventStream();
(async () => {
const output: AssistantMessage = {
Expand Down
133 changes: 133 additions & 0 deletions extensions/ai-providers/transcript.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* Transcript resolution shared by the opt-in AI providers.
*
* Pi 0.86.0 changed the input handed to a custom provider's stream from `Context`
* to a normalized `TranscriptContext`: `systemPrompt` and `tools` are folded into
* a leading transcript system message, and later system messages carry prompt and
* tool deltas. Pi <= 0.85.1 still passes those two fields directly.
*
* Both shapes have to work from one implementation. OpenPI's published peer range
* (`>=0.85.1`) admits 0.86+, while the locked development baseline is still
* 0.85.1, so a provider that only reads `context.systemPrompt` / `context.tools`
* silently degrades to an empty system prompt and zero tool declarations on 0.86+
* — and, for converters that treat unknown roles as tool results, to an invalid
* `functionResponse` as well.
*
* The replay below mirrors Pi's `getCurrentSystemMessage` / `getCurrentTools`
* behavior without importing them: those helpers do not exist before Pi 0.86.
*/

import type { Context, Message, Tool } from "@earendil-works/pi-ai/compat";

/** Transcript system message as emitted by Pi 0.86+ (`TranscriptContext`). */
export interface TranscriptSystemMessage {
role: "system";
content?: string | { type: "text"; text: string }[];
sections?: Record<string, string | null>;
toolsAdded?: Tool[];
toolsRemoved?: { name: string }[];
timestamp?: number;
}

type TranscriptMessage = Message | TranscriptSystemMessage;

export interface ResolvedTranscript {
/**
* Which input shape produced this result. `context` means Pi <= 0.85.1 passed
* `systemPrompt` / `tools` as separate fields and nothing had to be replayed.
*/
source: "context" | "transcript";
/** System prompts in wire order; empty when the transcript declares none. */
systemPrompts: string[];
/** Tool declarations in effect at the end of the transcript. */
tools: Tool[] | undefined;
/** Conversation messages, with every transcript system message removed. */
messages: Message[];
}

function isSystemMessage(
message: TranscriptMessage,
): message is TranscriptSystemMessage {
return message.role === "system";
}

function textFromContent(content: TranscriptSystemMessage["content"]): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("\n");
}

function normalizeSystemPrompts(
systemPrompt: Context["systemPrompt"],
): string[] {
if (!systemPrompt) return [];
return Array.isArray(systemPrompt) ? systemPrompt : [systemPrompt];
}

/**
* Replay a transcript into the system prompt and tool set currently in effect.
*
* Later system messages patch earlier ones: `sections` are replaced by name (a
* `null` value removes the section), and tool deltas are applied in order.
*/
export function resolveTranscript(context: Context): ResolvedTranscript {
const messages = context.messages as unknown as TranscriptMessage[];
const systemMessages = messages.filter(isSystemMessage);
if (systemMessages.length === 0) {
return {
source: "context",
systemPrompts: normalizeSystemPrompts(context.systemPrompt),
tools: context.tools,
messages: context.messages,
};
}

const promptParts: string[] = [];
const sections = new Map<string, string>();
const tools = new Map<string, Tool>();
for (const message of systemMessages) {
const text = textFromContent(message.content);
if (text.length > 0) promptParts.push(text);
for (const [name, value] of Object.entries(message.sections ?? {})) {
if (value === null) sections.delete(name);
else sections.set(name, value);
}
for (const tool of message.toolsRemoved ?? []) tools.delete(tool.name);
for (const tool of message.toolsAdded ?? []) tools.set(tool.name, tool);
}
for (const value of sections.values()) {
if (value.length > 0) promptParts.push(value);
}

return {
source: "transcript",
systemPrompts: promptParts.length > 0 ? [promptParts.join("\n\n")] : [],
tools: tools.size > 0 ? [...tools.values()] : undefined,
messages: messages.filter(
(message): message is Message => !isSystemMessage(message),
),
};
}

/**
* Adapt either input shape to the `Context` a provider already understands.
*
* Returns the original context untouched on Pi <= 0.85.1, so existing behavior is
* preserved exactly; on 0.86+ it re-materializes the folded fields.
*/
export function applyTranscript(context: Context): Context {
const resolved = resolveTranscript(context);
if (resolved.source === "context") return context;
return {
...context,
systemPrompt:
resolved.systemPrompts.length > 0
? resolved.systemPrompts.join("\n\n")
: undefined,
tools: resolved.tools,
messages: resolved.messages,
};
}
Loading
Loading