@jameslovespancakes/pi-plus 1.0.15 → 1.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,259 @@
1
+ import type { Api, Model, ModelThinkingLevel, ToolChoice, TranscriptContext } from "@earendil-works/pi-ai";
2
+ import { sanitizeSurrogates } from "@earendil-works/pi-ai/utils/sanitize-unicode";
3
+ import { getCurrentSystemPrompt, getCurrentTools } from "@earendil-works/pi-ai/utils/transcript";
4
+ import { stableUuid } from "./client.ts";
5
+ import { runtimeModelId, thinkingConfig } from "./models.ts";
6
+ import { bridgeSchema, selfContainedSchema } from "./schema.ts";
7
+
8
+ /**
9
+ * Builds a Gemini `streamGenerateContent` request.
10
+ *
11
+ * Inside the envelope the body is ordinary Gemini, so message conversion,
12
+ * thought-signature validation, tool-call ids and function-calling mode all
13
+ * come from pi's own Google adapter. What is added here is only what this
14
+ * backend demands beyond the public Gemini API: the runtime model id, its
15
+ * thinking budget, the Claude/GPT-OSS schema bridge, a few conversation-shape
16
+ * repairs it enforces, and the agent envelope it expects.
17
+ */
18
+
19
+ type GoogleShared = typeof import("@earendil-works/pi-ai/api/google-shared");
20
+ let googleSharedModule: Promise<GoogleShared> | undefined;
21
+
22
+ /** Loaded on first request, as pi loads its own Google adapter: it pulls in `@google/genai`. */
23
+ export function googleShared(): Promise<GoogleShared> {
24
+ return googleSharedModule ??= import("@earendil-works/pi-ai/api/google-shared");
25
+ }
26
+
27
+ export interface Part {
28
+ text?: string;
29
+ thought?: boolean;
30
+ thoughtSignature?: string;
31
+ inlineData?: { mimeType?: string; data?: string };
32
+ functionCall?: { name?: string; args?: Record<string, unknown>; id?: string };
33
+ functionResponse?: { name?: string; id?: string; response?: Record<string, unknown>; parts?: Part[] };
34
+ }
35
+
36
+ export interface Content {
37
+ role: "user" | "model";
38
+ parts: Part[];
39
+ }
40
+
41
+ export interface RequestOptions {
42
+ /** Level after pi's clamp; undefined means thinking off. */
43
+ reasoning?: ModelThinkingLevel;
44
+ temperature?: number;
45
+ maxTokens?: number;
46
+ toolChoice?: ToolChoice | "any";
47
+ sessionId?: string;
48
+ }
49
+
50
+ export interface GeminiRequest {
51
+ project: string;
52
+ model: string;
53
+ request: Record<string, unknown>;
54
+ requestType: "agent";
55
+ userAgent: "antigravity";
56
+ requestId: string;
57
+ }
58
+
59
+ /** The only text this module ever adds to a conversation; see {@link repairContents}. */
60
+ export const CONTINUE_TEXT = "Continue the active task using the available instructions and context.";
61
+
62
+ const isText = (part: Part) => typeof part.text === "string" && part.text.trim().length > 0 && !part.thought;
63
+ const hasFunctionCall = (turn: Content) => turn.parts.some((part) => part.functionCall);
64
+
65
+ /** Gemini 3+ rejects a replayed function call that lacks its thought signature. */
66
+ export function requiresThoughtSignatures(runtimeId: string): boolean {
67
+ if (!runtimeId.startsWith("gemini-")) return false;
68
+ const major = /^gemini-(\d+)/.exec(runtimeId)?.[1];
69
+ // Unversioned agent runtimes (`gemini-pro-agent`) are current-generation.
70
+ return major === undefined || Number(major) >= 3;
71
+ }
72
+
73
+ /** Claude and GPT-OSS are served through the custom-tool bridge. */
74
+ export function usesToolBridge(runtimeId: string): boolean {
75
+ return runtimeId.startsWith("claude-") || runtimeId.startsWith("gpt-oss-");
76
+ }
77
+
78
+ function observationText(name: string, args: Record<string, unknown> | undefined, response: Part["functionResponse"]): string {
79
+ const argsText = args && Object.keys(args).length > 0 ? ` (${JSON.stringify(args)})` : "";
80
+ const payload = response?.response ?? {};
81
+ const failed = "error" in payload;
82
+ const value = failed ? payload.error : "output" in payload ? payload.output : payload;
83
+ const body = typeof value === "string" ? value : JSON.stringify(value);
84
+ return `[${failed ? "Failed observation" : "Observation"} from \`${name}\`${argsText}:\n${body}]`;
85
+ }
86
+
87
+ /**
88
+ * History from another model carries tool calls without this model's thought
89
+ * signature, which Gemini 3 rejects outright. Such a call and its result are
90
+ * replayed as plain text instead, so the model keeps what happened without an
91
+ * unverifiable call in its own voice. Gemini only checks the first call of a
92
+ * turn, so a signed first call keeps the whole turn intact.
93
+ */
94
+ function observeUnsignedCalls(contents: Content[]): Content[] {
95
+ const pending = new Map<string, { name: string; args?: Record<string, unknown> }>();
96
+ const keyOf = (call: { id?: string; name?: string }) => call.id || `name:${call.name ?? ""}`;
97
+
98
+ return contents.map((turn) => {
99
+ if (turn.role === "model") {
100
+ const calls = turn.parts.filter((part) => part.functionCall);
101
+ if (calls.length === 0 || calls[0].thoughtSignature) return turn;
102
+ for (const { functionCall } of calls) {
103
+ pending.set(keyOf(functionCall!), { name: functionCall!.name ?? "tool", args: functionCall!.args });
104
+ }
105
+ return { ...turn, parts: turn.parts.filter((part) => !part.functionCall) };
106
+ }
107
+ if (pending.size === 0) return turn;
108
+ return {
109
+ ...turn,
110
+ parts: turn.parts.flatMap((part): Part[] => {
111
+ const response = part.functionResponse;
112
+ const call = response && pending.get(keyOf(response));
113
+ if (!response || !call) return [part];
114
+ pending.delete(keyOf(response));
115
+ return [{ text: sanitizeSurrogates(observationText(call.name, call.args, response)) }, ...(response.parts ?? [])];
116
+ }),
117
+ };
118
+ });
119
+ }
120
+
121
+ /** Adjacent turns with the same role become one; empty turns disappear. */
122
+ function mergeTurns(contents: Content[]): Content[] {
123
+ const merged: Content[] = [];
124
+ for (const turn of contents) {
125
+ if (turn.parts.length === 0) continue;
126
+ const last = merged.at(-1);
127
+ if (last?.role === turn.role) last.parts.push(...turn.parts);
128
+ else merged.push({ role: turn.role, parts: [...turn.parts] });
129
+ }
130
+ return merged;
131
+ }
132
+
133
+ /**
134
+ * Conversation shapes the public Gemini API tolerates but this backend
135
+ * rejects with a 400. Compaction and model switches produce every one of
136
+ * them, so they are repaired rather than surfaced:
137
+ *
138
+ * - an unsigned tool call on Gemini 3 (see {@link observeUnsignedCalls});
139
+ * - a conversation that does not open with a user turn;
140
+ * - no natural-language user text anywhere, e.g. a tool-only continuation;
141
+ * - a request that ends on a model turn.
142
+ */
143
+ export function repairContents(contents: Content[], requireSignatures: boolean): Content[] {
144
+ const turns = mergeTurns(requireSignatures ? observeUnsignedCalls(contents) : contents);
145
+ const bridge = (): Part => ({ text: CONTINUE_TEXT });
146
+
147
+ if (turns.length === 0 || turns[0].role === "model") turns.unshift({ role: "user", parts: [bridge()] });
148
+
149
+ if (!turns.some((turn) => turn.role === "user" && turn.parts.some(isText))) {
150
+ turns.find((turn) => turn.role === "user")!.parts.push(bridge());
151
+ }
152
+
153
+ const last = turns.at(-1)!;
154
+ if (last.role === "model") {
155
+ if (hasFunctionCall(last)) throw new Error("Gemini request ends on a tool call with no result.");
156
+ turns.push({ role: "user", parts: [bridge()] });
157
+ }
158
+ return turns;
159
+ }
160
+
161
+ function toolDeclarations(declared: { functionDeclarations: Record<string, unknown>[] }[], bridge: boolean) {
162
+ return declared.map((group) => ({
163
+ functionDeclarations: group.functionDeclarations.map(({ parametersJsonSchema, parameters, ...rest }) => ({
164
+ ...rest,
165
+ ...(bridge
166
+ ? { parameters: bridgeSchema(parametersJsonSchema ?? parameters) }
167
+ : { parametersJsonSchema: selfContainedSchema(parametersJsonSchema ?? parameters) }),
168
+ })),
169
+ }));
170
+ }
171
+
172
+ /** Random signed 64-bit decimal, the shape the Antigravity CLI uses for session ids. */
173
+ function randomSessionId(): string {
174
+ const bytes = crypto.getRandomValues(new Uint8Array(8));
175
+ return new DataView(bytes.buffer).getBigInt64(0, true).toString();
176
+ }
177
+
178
+ /**
179
+ * The agent envelope the Antigravity CLI sends. Ids are derived from the pi
180
+ * session, so one conversation keeps one trajectory across requests and
181
+ * restarts without any state held here.
182
+ */
183
+ function envelope(context: TranscriptContext, contents: Content[], runtimeId: string, sessionId?: string) {
184
+ const first = context.messages[0];
185
+ const seed = sessionId ?? (first ? `${first.role}:${first.timestamp ?? ""}` : crypto.randomUUID());
186
+ const conversationId = stableUuid(`antigravity:conv:${seed}`);
187
+ const trajectoryId = stableUuid(`antigravity:traj:${seed}`);
188
+ const step = Math.max(1, contents.length);
189
+ const turn = context.messages.filter((message) =>
190
+ message.role === "assistant" && message.stopReason !== "error" && message.stopReason !== "aborted").length;
191
+ const claude = String(runtimeId.startsWith("claude-"));
192
+
193
+ return {
194
+ sessionId: sessionId ?? randomSessionId(),
195
+ requestId: `agent/${conversationId}/${Date.now()}/${trajectoryId}/${step}`,
196
+ labels: {
197
+ last_step_index: String(step - 1),
198
+ request_id: `${trajectoryId}-${turn}`,
199
+ trajectory_id: trajectoryId,
200
+ used_claude: claude,
201
+ used_claude_conservative: claude,
202
+ used_non_gemini_model: String(!runtimeId.startsWith("gemini-")),
203
+ },
204
+ };
205
+ }
206
+
207
+ export async function buildRequest(
208
+ model: Model<Api>,
209
+ context: TranscriptContext,
210
+ projectId: string,
211
+ options: RequestOptions = {},
212
+ ): Promise<GeminiRequest> {
213
+ const google = await googleShared();
214
+ // pi's Google helpers are typed against its own Google APIs; the body inside
215
+ // the envelope has exactly the same model semantics.
216
+ const googleModel = model as unknown as Model<"google-generative-ai">;
217
+ const runtimeId = runtimeModelId(model, options.reasoning);
218
+
219
+ const contents = repairContents(
220
+ google.convertMessages(googleModel, context) as unknown as Content[],
221
+ requiresThoughtSignatures(runtimeId),
222
+ );
223
+
224
+ // The system prompt and tools live in the transcript's system messages,
225
+ // never on the context object; reading them any other way sends neither.
226
+ const systemPrompt = getCurrentSystemPrompt(context.messages);
227
+ const tools = getCurrentTools(context.messages);
228
+ // Strict tool sampling (Gemini's VALIDATED mode) is not offered by this backend.
229
+ const declared = google.convertTools(tools, false, false);
230
+ const mode = tools.length > 0
231
+ ? google.resolveGoogleFunctionCallingMode(tools, options.toolChoice, false)
232
+ : undefined;
233
+
234
+ const thinking = thinkingConfig(runtimeId, options.reasoning);
235
+ const generationConfig = {
236
+ ...(options.temperature !== undefined && { temperature: options.temperature }),
237
+ maxOutputTokens: Math.min(options.maxTokens ?? model.maxTokens, model.maxTokens),
238
+ ...(thinking && { thinkingConfig: thinking }),
239
+ };
240
+
241
+ const { sessionId, requestId, labels } = envelope(context, contents, runtimeId, options.sessionId);
242
+
243
+ return {
244
+ project: projectId,
245
+ model: runtimeId,
246
+ request: {
247
+ contents,
248
+ ...(systemPrompt && { systemInstruction: { role: "user", parts: [{ text: sanitizeSurrogates(systemPrompt) }] } }),
249
+ generationConfig,
250
+ ...(declared && { tools: toolDeclarations(declared, usesToolBridge(runtimeId)) }),
251
+ ...(mode !== undefined && { toolConfig: { functionCallingConfig: { mode } } }),
252
+ sessionId,
253
+ labels,
254
+ },
255
+ requestType: "agent",
256
+ userAgent: "antigravity",
257
+ requestId,
258
+ };
259
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Tool schemas in the two shapes the Antigravity backend accepts.
3
+ *
4
+ * - Gemini reads full JSON Schema from `parametersJsonSchema`, but only
5
+ * self-contained: `$ref` must already be resolved and `$defs` removed.
6
+ * - Claude and GPT-OSS go through a custom-tool bridge that reads the
7
+ * legacy protobuf `parameters` field. It rejects every keyword it does not
8
+ * know (`nullable`, `anyOf`, `format`, `const`, …) with a 400, so the
9
+ * schema is reduced to an allowlist rather than a denylist: a new JSON
10
+ * Schema keyword can never break a request. pi still validates the
11
+ * arguments the model returns against the original schema.
12
+ *
13
+ * Tool schemas can come from MCP servers, so expansion is bounded. A
14
+ * reference that cannot be resolved — missing, circular, or past a bound —
15
+ * becomes the unconstrained schema `{}` instead of failing the whole request.
16
+ */
17
+
18
+ type Json = Record<string, unknown>;
19
+
20
+ const MAX_DEPTH = 32;
21
+ const MAX_NODES = 10_000;
22
+
23
+ const METADATA = new Set(["$schema", "$id", "$anchor", "$dynamicAnchor", "$vocabulary", "$comment", "$defs", "definitions"]);
24
+ /** Keywords whose value is a map of *names* to schemas; the names are never keywords. */
25
+ const SCHEMA_MAPS = new Set(["properties", "patternProperties", "dependentSchemas"]);
26
+ /** Keywords whose value is a schema, or an array of schemas. */
27
+ const SCHEMA_VALUES = new Set([
28
+ "items", "prefixItems", "additionalItems", "additionalProperties", "unevaluatedItems", "unevaluatedProperties",
29
+ "contains", "propertyNames", "not", "if", "then", "else", "contentSchema", "allOf", "anyOf", "oneOf",
30
+ ]);
31
+
32
+ const BRIDGE_KEYWORDS = new Set(["type", "description", "properties", "required", "items", "enum"]);
33
+
34
+ function isRecord(value: unknown): value is Json {
35
+ return typeof value === "object" && value !== null && !Array.isArray(value);
36
+ }
37
+
38
+ /** RFC 6901 pointer into the root schema; only local (`#…`) references resolve. */
39
+ function resolvePointer(root: unknown, ref: string): unknown {
40
+ if (ref === "#") return root;
41
+ if (!ref.startsWith("#/")) return undefined;
42
+ let node: unknown = root;
43
+ for (const raw of ref.slice(2).split("/")) {
44
+ const key = raw.replace(/~1/g, "/").replace(/~0/g, "~");
45
+ if (Array.isArray(node)) {
46
+ if (!/^(0|[1-9]\d*)$/.test(key)) return undefined;
47
+ node = node[Number(key)];
48
+ } else if (isRecord(node) && Object.hasOwn(node, key)) {
49
+ node = node[key];
50
+ } else {
51
+ return undefined;
52
+ }
53
+ }
54
+ return node;
55
+ }
56
+
57
+ /** A JSON Schema with every local `$ref` inlined and schema metadata removed. */
58
+ export function selfContainedSchema(schema: unknown): Json {
59
+ let nodes = 0;
60
+
61
+ const walk = (node: unknown, refs: ReadonlySet<string>, depth: number): unknown => {
62
+ if (++nodes > MAX_NODES || depth > MAX_DEPTH) return {};
63
+ if (Array.isArray(node)) return node.map((item) => walk(item, refs, depth + 1));
64
+ if (!isRecord(node)) return node;
65
+
66
+ if (typeof node.$ref === "string") {
67
+ const { $ref: ref, ...siblings } = node;
68
+ const target = refs.has(ref) ? undefined : resolvePointer(schema, ref);
69
+ const resolved = target === undefined ? {} : walk(target, new Set([...refs, ref]), depth + 1);
70
+ const rest = walk(siblings, refs, depth + 1);
71
+ return { ...(isRecord(resolved) ? resolved : {}), ...(isRecord(rest) ? rest : {}) };
72
+ }
73
+
74
+ const out: Json = {};
75
+ for (const [key, value] of Object.entries(node)) {
76
+ if (METADATA.has(key)) continue;
77
+ if (SCHEMA_MAPS.has(key) && isRecord(value)) {
78
+ out[key] = Object.fromEntries(Object.entries(value).map(([name, sub]) => [name, walk(sub, refs, depth + 1)]));
79
+ } else if (SCHEMA_VALUES.has(key)) {
80
+ out[key] = walk(value, refs, depth + 1);
81
+ } else {
82
+ // `enum`, `default`, `examples`… are data, not schemas: copied verbatim.
83
+ out[key] = value;
84
+ }
85
+ }
86
+ return out;
87
+ };
88
+
89
+ return asObjectRoot(walk(schema, new Set(), 0));
90
+ }
91
+
92
+ /** Function declarations must describe an object, even for a tool with no parameters. */
93
+ function asObjectRoot(schema: unknown): Json {
94
+ if (!isRecord(schema)) return { type: "object", properties: {} };
95
+ return schema.type ? schema : { ...schema, type: "object", properties: schema.properties ?? {} };
96
+ }
97
+
98
+ /** `["string", "null"]` → `"string"`: the bridge takes a single type. */
99
+ function singleType(value: unknown): string | undefined {
100
+ if (typeof value === "string") return value;
101
+ return Array.isArray(value) ? value.find((entry): entry is string => typeof entry === "string" && entry !== "null") : undefined;
102
+ }
103
+
104
+ /**
105
+ * A union the bridge cannot express is narrowed to its first non-null branch,
106
+ * which keeps the common `T | null` shape typed instead of unconstrained.
107
+ */
108
+ function firstBranch(node: Json): Json | undefined {
109
+ const branches = Array.isArray(node.anyOf) ? node.anyOf : Array.isArray(node.oneOf) ? node.oneOf : undefined;
110
+ return branches?.find((branch): branch is Json => isRecord(branch) && branch.type !== "null");
111
+ }
112
+
113
+ function toBridge(node: unknown): unknown {
114
+ if (!isRecord(node)) return node;
115
+ const branch = node.type === undefined ? firstBranch(node) : undefined;
116
+ const source = branch ? { ...branch, ...(node.description !== undefined && { description: node.description }) } : node;
117
+
118
+ const out: Json = {};
119
+ for (const [key, value] of Object.entries(source)) {
120
+ if (!BRIDGE_KEYWORDS.has(key)) continue;
121
+ if (key === "type") {
122
+ const type = singleType(value);
123
+ if (type) out.type = type;
124
+ } else if (key === "properties" && isRecord(value)) {
125
+ out.properties = Object.fromEntries(Object.entries(value).map(([name, sub]) => [name, toBridge(sub)]));
126
+ } else if (key === "enum") {
127
+ // The bridge's enum is string-only; a mixed enum is dropped rather than coerced.
128
+ if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) out.enum = value;
129
+ } else if (key === "items") {
130
+ out.items = toBridge(Array.isArray(value) ? value[0] : value);
131
+ } else {
132
+ out[key] = value;
133
+ }
134
+ }
135
+ if (typeof source.const === "string" && out.enum === undefined) out.enum = [source.const];
136
+ return out;
137
+ }
138
+
139
+ /** The subset of a tool schema Gemini's Claude/GPT-OSS bridge accepts. */
140
+ export function bridgeSchema(schema: unknown): Json {
141
+ return asObjectRoot(toBridge(selfContainedSchema(schema)));
142
+ }