@herbertgao/pi-extensions 2026.9.18 → 2026.9.19

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.
Files changed (48) hide show
  1. package/README.md +19 -20
  2. package/THIRD_PARTY_NOTICES.md +1 -1
  3. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/working-message.ts +21 -6
  4. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/index.ts +15 -4
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/index.ts +43 -7
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +7 -2
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +1 -1
  8. package/node_modules/@herbertgao/pi-cc-extensions/package.json +2 -2
  9. package/node_modules/pi-antigravity/README.md +29 -6
  10. package/node_modules/pi-antigravity/package.json +12 -7
  11. package/node_modules/pi-antigravity/src/auth/accounts.ts +327 -0
  12. package/node_modules/pi-antigravity/src/auth/index.ts +9 -0
  13. package/node_modules/pi-antigravity/src/client/client.ts +1 -1
  14. package/node_modules/pi-antigravity/src/image/image.ts +4 -4
  15. package/node_modules/pi-antigravity/src/index.ts +203 -13
  16. package/node_modules/pi-antigravity/src/search/index.ts +1 -0
  17. package/node_modules/pi-antigravity/src/search/search.ts +325 -0
  18. package/node_modules/pi-antigravity/src/stream/stream.ts +191 -14
  19. package/node_modules/pi-antigravity/src/types/enums.ts +45 -36
  20. package/node_modules/pi-antigravity/src/types/types.ts +5 -12
  21. package/node_modules/pi-antigravity/src/utils/http.ts +7 -2
  22. package/node_modules/pi-antigravity/src/utils/util.ts +31 -3
  23. package/node_modules/pi-typesafe/README.md +2 -0
  24. package/node_modules/pi-typesafe/package.json +2 -1
  25. package/node_modules/pi-web-access/CHANGELOG.md +72 -0
  26. package/node_modules/pi-web-access/README.md +34 -13
  27. package/node_modules/pi-web-access/chrome-cookies.ts +79 -25
  28. package/node_modules/pi-web-access/curator-page.ts +5 -1
  29. package/node_modules/pi-web-access/curator-server.ts +2 -0
  30. package/node_modules/pi-web-access/dist/index.js +1337 -1009
  31. package/node_modules/pi-web-access/exa.ts +20 -52
  32. package/node_modules/pi-web-access/extract.ts +41 -4
  33. package/node_modules/pi-web-access/gemini-search.ts +12 -4
  34. package/node_modules/pi-web-access/index.ts +29 -37
  35. package/node_modules/pi-web-access/openai-search.ts +35 -7
  36. package/node_modules/pi-web-access/package.json +6 -5
  37. package/node_modules/pi-web-access/page-query.ts +2 -2
  38. package/node_modules/pi-web-access/query-rewrite.ts +2 -3
  39. package/node_modules/pi-web-access/ssrf-protection.ts +27 -10
  40. package/node_modules/pi-web-access/storage.ts +48 -77
  41. package/node_modules/pi-web-access/summary-model-scope.ts +6 -77
  42. package/node_modules/pi-web-access/summary-review.ts +3 -4
  43. package/node_modules/pi-web-access/tavily.ts +67 -20
  44. package/node_modules/pi-web-access/tool-activation.ts +53 -33
  45. package/node_modules/pi-web-access/utils.ts +1 -1
  46. package/node_modules/pi-web-access/you.ts +164 -0
  47. package/node_modules/pi-web-access/zai.ts +161 -0
  48. package/package.json +6 -6
@@ -0,0 +1,325 @@
1
+ import {
2
+ antigravityHeaders,
3
+ endpointCandidates,
4
+ jsonOrTextError,
5
+ parseApiKey,
6
+ } from "../client/client.js";
7
+ import { AntigravityRequestType, AntigravityUserAgent, GeminiRole } from "../types/enums.js";
8
+ import { antigravityFetch } from "../utils/http.js";
9
+ import { safeError } from "../utils/security.js";
10
+ import { antigravityRequestEnvelope, isRecord } from "../utils/util.js";
11
+
12
+ /** Default search engine model: fast, natively supports groundings and reasoning. */
13
+ export const DEFAULT_SEARCH_MODEL = "gemini-3-flash";
14
+
15
+ /** Fallback runtime candidates if the primary flash model is undergoing rollout or capacity limits. */
16
+ export const SEARCH_MODEL_FALLBACKS = [
17
+ DEFAULT_SEARCH_MODEL,
18
+ "gemini-3.6-flash-low",
19
+ "gemini-2.5-flash",
20
+ ] as const;
21
+
22
+ export const SEARCH_SYSTEM_INSTRUCTION = `You are an expert deep-research investigator and technical analyst.
23
+ Your objective is to use Google Search Grounding to unearth rich, high-signal, multi-perspective facts.
24
+
25
+ Guidelines:
26
+ 1. DO NOT settle for generic marketing summaries, public relations announcements, or shallow overviews.
27
+ 2. Formulate multiple distinct, targeted search queries covering technical architecture, specific parameters, benchmark comparisons, developer issues, pitfalls, and community feedback.
28
+ 3. Prioritize hard technical details: exact version numbers, hardware requirements, protocol constraints, benchmarks, error codes, and configuration snippets.
29
+ 4. Structure your response into clean, logical Markdown sections citing direct sources.`;
30
+
31
+ export type SearchSource = {
32
+ title: string;
33
+ url: string;
34
+ };
35
+
36
+ export type SearchResult = {
37
+ text: string;
38
+ sources: SearchSource[];
39
+ queries: string[];
40
+ };
41
+
42
+ export type ExecuteSearchOptions = {
43
+ apiKey?: string;
44
+ query: string;
45
+ instruction?: string;
46
+ urls?: string[];
47
+ thinking?: boolean;
48
+ signal?: AbortSignal;
49
+ };
50
+
51
+ export type SearchCommandArgs = {
52
+ query: string;
53
+ urls?: string[];
54
+ thinking?: boolean;
55
+ };
56
+
57
+ /**
58
+ * Parse arguments for the interactive `/antigravity.search` command.
59
+ * Supports flags:
60
+ * --thinking: enable deep reasoning for search planning
61
+ * --url <url>: pass target URL for context analysis (can be repeated)
62
+ *
63
+ * @param args - Raw string of CLI arguments.
64
+ * @returns Parsed command arguments containing query, optional URLs, and thinking flag.
65
+ */
66
+ export function parseSearchCommandArgs(args: string): SearchCommandArgs {
67
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
68
+ const out: SearchCommandArgs = { query: "" };
69
+ const rest: string[] = [];
70
+
71
+ for (let i = 0; i < tokens.length; i++) {
72
+ const token = tokens[i];
73
+ if (token === undefined) continue;
74
+ const next = tokens[i + 1];
75
+
76
+ if (token === "--thinking") {
77
+ out.thinking = true;
78
+ continue;
79
+ }
80
+ if (token === "--url" && next) {
81
+ out.urls = out.urls ?? [];
82
+ out.urls.push(next);
83
+ i += 1;
84
+ continue;
85
+ }
86
+ rest.push(token);
87
+ }
88
+
89
+ out.query = rest.join(" ");
90
+ return out;
91
+ }
92
+
93
+ /**
94
+ * Builds the Antigravity wire request payload for Gemini Google Search Grounding.
95
+ * Includes the required `requestType` and `requestId` envelope fields.
96
+ *
97
+ * @param options - Options including search query, lead agent directives, target URLs, and thinking preference.
98
+ * @param model - Target model identifier (e.g., `gemini-3-flash`).
99
+ * @param projectId - Google Cloud Project ID.
100
+ * @returns Serialized request object matching the Antigravity wire contract.
101
+ */
102
+ export function buildSearchRequest(
103
+ options: {
104
+ query: string;
105
+ instruction?: string;
106
+ urls?: string[];
107
+ thinking?: boolean;
108
+ },
109
+ model: string,
110
+ projectId: string,
111
+ ): Record<string, unknown> {
112
+ let systemInstructionText = SEARCH_SYSTEM_INSTRUCTION;
113
+ if (options.instruction?.trim()) {
114
+ systemInstructionText += `\n\n[SPECIFIC DIRECTIVE FROM LEAD AGENT]:\n${options.instruction.trim()}\nYou MUST strictly follow this directive during search query formulation and synthesis.`;
115
+ }
116
+
117
+ let prompt = options.query.trim();
118
+ if (options.instruction?.trim()) {
119
+ prompt = `[Lead Agent Directive]: ${options.instruction.trim()}\n\nSearch Query: ${prompt}`;
120
+ }
121
+ if (options.urls && options.urls.length > 0) {
122
+ prompt += `\n\nURLs to analyze in detail:\n${options.urls.join("\n")}`;
123
+ }
124
+
125
+ const tools: Array<Record<string, unknown>> = [{ googleSearch: {} }];
126
+ if (options.urls && options.urls.length > 0) {
127
+ tools.push({ urlContext: {} });
128
+ }
129
+
130
+ // Thinking budget: 4096 if explicitly requested, baseline 2048 to trigger multi-step search planning
131
+ const thinkingBudget = options.thinking ? 4096 : 2048;
132
+ const envelope = antigravityRequestEnvelope(model, false);
133
+
134
+ return {
135
+ project: projectId,
136
+ model,
137
+ request: {
138
+ systemInstruction: {
139
+ role: GeminiRole.User,
140
+ parts: [{ text: systemInstructionText }],
141
+ },
142
+ contents: [
143
+ {
144
+ role: GeminiRole.User,
145
+ parts: [{ text: prompt }],
146
+ },
147
+ ],
148
+ tools,
149
+ generationConfig: {
150
+ thinkingConfig: {
151
+ thinkingBudget,
152
+ includeThoughts: false,
153
+ },
154
+ },
155
+ },
156
+ requestType: AntigravityRequestType.Agent,
157
+ userAgent: AntigravityUserAgent.Antigravity,
158
+ requestId: envelope.requestId,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Parse candidate text and Grounding metadata from Antigravity API response.
164
+ *
165
+ * @param data - Raw JSON response from Antigravity generateContent API.
166
+ * @returns Structured SearchResult with synthesized text, web queries, and cited source URLs.
167
+ */
168
+ function candidateList(data: unknown): unknown {
169
+ if (!isRecord(data)) return undefined;
170
+ if (isRecord(data.response)) return data.response.candidates;
171
+ return data.candidates;
172
+ }
173
+
174
+ function firstUnknownItem(value: unknown): unknown {
175
+ if (!Array.isArray(value)) return undefined;
176
+ return (value as unknown[])[0];
177
+ }
178
+
179
+ export function parseSearchResponse(data: unknown): SearchResult {
180
+ const result: SearchResult = { text: "", sources: [], queries: [] };
181
+ if (!isRecord(data)) return result;
182
+
183
+ const responseObj = isRecord(data.response) ? data.response : data;
184
+ const candidates = Array.isArray(responseObj.candidates)
185
+ ? (responseObj.candidates as unknown[])
186
+ : [];
187
+ const candidate = candidates[0];
188
+
189
+ if (!isRecord(candidate)) {
190
+ const errorObj = isRecord(data.error)
191
+ ? data.error
192
+ : isRecord(responseObj.error)
193
+ ? responseObj.error
194
+ : undefined;
195
+ const msg =
196
+ typeof errorObj?.message === "string"
197
+ ? errorObj.message
198
+ : "No candidate returned from Antigravity Search";
199
+ result.text = `Error: ${msg}`;
200
+ return result;
201
+ }
202
+
203
+ // Extract synthesized text (ignoring thinking parts)
204
+ const content = isRecord(candidate.content) ? candidate.content : undefined;
205
+ if (Array.isArray(content?.parts)) {
206
+ result.text = content.parts
207
+ .filter((p) => isRecord(p) && !p.thought && typeof p.text === "string")
208
+ .map((p) => (p as { text: string }).text)
209
+ .filter(Boolean)
210
+ .join("\n\n");
211
+ }
212
+
213
+ // Extract grounding citations & executed queries
214
+ const grounding = isRecord(candidate.groundingMetadata) ? candidate.groundingMetadata : undefined;
215
+ if (grounding) {
216
+ if (Array.isArray(grounding.webSearchQueries)) {
217
+ result.queries = grounding.webSearchQueries.filter((q): q is string => typeof q === "string");
218
+ }
219
+ if (Array.isArray(grounding.groundingChunks)) {
220
+ for (const chunk of grounding.groundingChunks) {
221
+ if (!isRecord(chunk)) continue;
222
+ const web = isRecord(chunk.web) ? chunk.web : undefined;
223
+ if (typeof web?.uri === "string") {
224
+ result.sources.push({
225
+ title: typeof web.title === "string" && web.title.trim() ? web.title.trim() : web.uri,
226
+ url: web.uri,
227
+ });
228
+ }
229
+ }
230
+ }
231
+ }
232
+
233
+ return result;
234
+ }
235
+
236
+ /**
237
+ * Formats structured SearchResult into clean Markdown for agent consumption.
238
+ *
239
+ * @param res - Structured search result containing text, sources, and executed queries.
240
+ * @returns Formatted markdown string.
241
+ */
242
+ export function formatSearchResult(res: SearchResult): string {
243
+ const sections: string[] = [];
244
+
245
+ if (res.text) {
246
+ sections.push(res.text);
247
+ }
248
+
249
+ if (res.sources.length > 0) {
250
+ const list = res.sources.map((s) => `- [${s.title}](${s.url})`).join("\n");
251
+ sections.push(`### Sources\n${list}`);
252
+ }
253
+
254
+ if (res.queries.length > 0) {
255
+ const queries = res.queries.map((q) => `\`${q}\``).join(", ");
256
+ sections.push(`*Search queries: ${queries}*`);
257
+ }
258
+
259
+ return sections.join("\n\n");
260
+ }
261
+
262
+ /**
263
+ * Execute real-time Google Search grounding via Antigravity backend with automatic fallback.
264
+ *
265
+ * @param options - Execution options containing query, API credentials, and optional directives.
266
+ * @returns Markdown formatted search result with synthesis, citations, and search queries.
267
+ */
268
+ export async function executeAntigravitySearch(options: ExecuteSearchOptions): Promise<string> {
269
+ const query = options.query.trim();
270
+ if (!query) throw new Error("Search query is required.");
271
+
272
+ const creds = parseApiKey(options.apiKey);
273
+ const headers = antigravityHeaders(creds.token);
274
+
275
+ let lastError = "no endpoint available";
276
+
277
+ for (const model of SEARCH_MODEL_FALLBACKS) {
278
+ const body = JSON.stringify(buildSearchRequest(options, model, creds.projectId));
279
+
280
+ for (const endpoint of endpointCandidates()) {
281
+ if (options.signal?.aborted) throw new Error("Search request was aborted");
282
+
283
+ try {
284
+ const response = await antigravityFetch(`${endpoint}/v1internal:generateContent`, {
285
+ method: "POST",
286
+ headers,
287
+ body,
288
+ signal: options.signal,
289
+ });
290
+
291
+ if (!response.ok) {
292
+ lastError = jsonOrTextError(await response.text()).slice(0, 400);
293
+ if (response.status === 404 || [429, 500, 502, 503, 504].includes(response.status)) {
294
+ continue; // Retry next endpoint for transient failures or missing model endpoints
295
+ }
296
+ // Fail fast on non-retryable client errors (e.g. 400 Bad Request, 401 Unauthorized, 403 Forbidden)
297
+ throw Object.assign(new Error(lastError), { fatal: true });
298
+ }
299
+
300
+ const data: unknown = await response.json();
301
+ const cand = firstUnknownItem(candidateList(data));
302
+
303
+ const candidateText =
304
+ isRecord(cand) && isRecord(cand.content) && Array.isArray(cand.content.parts)
305
+ ? (cand.content.parts[0] as { text?: string })?.text
306
+ : undefined;
307
+
308
+ // Skip responses indicating deprecated model and move to next fallback model
309
+ if (candidateText && candidateText.includes("is no longer available. Please switch")) {
310
+ lastError = `Model ${model} deprecated: ${candidateText.slice(0, 100)}`;
311
+ break;
312
+ }
313
+
314
+ const parsed = parseSearchResponse(data);
315
+ return formatSearchResult(parsed);
316
+ } catch (error) {
317
+ if (options.signal?.aborted) throw error;
318
+ if (error instanceof Error && (error as { fatal?: boolean }).fatal) throw error;
319
+ lastError = safeError(error).slice(0, 400);
320
+ }
321
+ }
322
+ }
323
+
324
+ throw new Error(`Antigravity Google Search failed: ${lastError}`);
325
+ }
@@ -10,6 +10,9 @@ import {
10
10
  type Tool,
11
11
  type ToolCall,
12
12
  } from "@earendil-works/pi-ai";
13
+ // Namespace import: transcript helpers exist on pi-ai >= 0.86; resolved at runtime.
14
+ import * as piAi from "@earendil-works/pi-ai";
15
+ import { failoverToNextAccount } from "../auth/accounts.js";
13
16
  import {
14
17
  antigravityHeaders,
15
18
  endpointCandidates,
@@ -71,7 +74,7 @@ import {
71
74
  resolveSessionTrajectory,
72
75
  sanitizeText,
73
76
  } from "../utils/util.js";
74
- import { antigravityFetch } from "../utils/http.js";
77
+ import { antigravityFetch, prewarmConnection } from "../utils/http.js";
75
78
 
76
79
  export { ANTIGRAVITY_API };
77
80
 
@@ -82,6 +85,7 @@ const ANTIGRAVITY_SYSTEM_INSTRUCTION =
82
85
  const ANTIGRAVITY_NO_PREAMBLE_INSTRUCTION =
83
86
  'CRITICAL: NEVER output rule checks, formatting guidelines, constraint checklists (e.g. "No emdashes"), or your thinking/personality preambles in the final response. Output only the final response.';
84
87
 
88
+ const CONTINUATION_TEXT = "Continue the active task using the available instructions and context.";
85
89
  let toolCallCounter = 0;
86
90
 
87
91
  function sanitizeToolCallId(id: string, fallbackName?: string): string {
@@ -99,6 +103,103 @@ function toolCallIdNeeded(modelId: string, runtimeModel: string): boolean {
99
103
  );
100
104
  }
101
105
 
106
+ interface SystemMessageLike {
107
+ role?: string;
108
+ content?: string | Array<{ type?: string; text?: string }>;
109
+ sections?: Record<string, unknown>;
110
+ toolsAdded?: Tool[];
111
+ toolsRemoved?: Array<{ name: string }>;
112
+ tools?: Tool[];
113
+ }
114
+
115
+ interface PiAiTranscriptModule {
116
+ getCurrentTools?: (messages: Context["messages"]) => Tool[];
117
+ getCurrentSystemPrompt?: (messages: Context["messages"]) => string;
118
+ }
119
+
120
+ function transcriptModule(): PiAiTranscriptModule {
121
+ return piAi as unknown as PiAiTranscriptModule;
122
+ }
123
+
124
+ function asSystemMessages(messages: Context["messages"] | undefined): SystemMessageLike[] {
125
+ return [...(messages ?? [])] as SystemMessageLike[];
126
+ }
127
+
128
+ function extractTranscriptSystemPrompt(messages: readonly SystemMessageLike[]): string {
129
+ const parts: string[] = [];
130
+ for (const message of messages) {
131
+ if (message?.role !== "system") continue;
132
+ if (message.sections && typeof message.sections === "object") {
133
+ const sectionParts = Object.values(message.sections).filter(
134
+ (value): value is string => typeof value === "string" && value.trim().length > 0,
135
+ );
136
+ if (sectionParts.length > 0) {
137
+ parts.push(sectionParts.join("\n\n"));
138
+ continue;
139
+ }
140
+ }
141
+ if (typeof message.content === "string" && message.content.trim()) {
142
+ parts.push(message.content);
143
+ } else if (Array.isArray(message.content)) {
144
+ const texts = message.content
145
+ .map((block) => (typeof block === "string" ? block : block?.text || ""))
146
+ .filter((text) => text.trim().length > 0);
147
+ if (texts.length > 0) parts.push(texts.join("\n\n"));
148
+ }
149
+ }
150
+ return parts.join("\n\n");
151
+ }
152
+
153
+ function extractTranscriptTools(messages: readonly SystemMessageLike[]): Tool[] {
154
+ const tools = new Map<string, Tool>();
155
+ for (const message of messages) {
156
+ if (message?.role !== "system") continue;
157
+ for (const tool of message.toolsRemoved ?? []) {
158
+ tools.delete(tool.name);
159
+ }
160
+ for (const tool of [...(message.toolsAdded ?? []), ...(message.tools ?? [])]) {
161
+ if (tool && typeof tool.name === "string") tools.set(tool.name, tool);
162
+ }
163
+ }
164
+ return [...tools.values()];
165
+ }
166
+
167
+ /**
168
+ * pi >= 0.86 hands providers a normalized TranscriptContext: the system prompt and
169
+ * tool declarations live in system messages and must be read with
170
+ * `getCurrentSystemPrompt()` / `getCurrentTools()`. Older releases still pass the
171
+ * flat Context fields. Resolve both so requests are not sent without tools.
172
+ */
173
+ export function resolveCurrentSystemPrompt(context: Context): string | undefined {
174
+ const helper = transcriptModule().getCurrentSystemPrompt;
175
+ if (typeof helper === "function") {
176
+ try {
177
+ const fromHelper = helper(context.messages ?? []);
178
+ if (fromHelper?.trim()) return fromHelper;
179
+ } catch {
180
+ // Fall through to local replay / legacy fields.
181
+ }
182
+ }
183
+ const fromTranscript = extractTranscriptSystemPrompt(asSystemMessages(context.messages));
184
+ if (fromTranscript.trim()) return fromTranscript;
185
+ return context.systemPrompt;
186
+ }
187
+
188
+ export function resolveCurrentTools(context: Context): Tool[] | undefined {
189
+ const helper = transcriptModule().getCurrentTools;
190
+ if (typeof helper === "function") {
191
+ try {
192
+ const fromHelper = helper(context.messages ?? []);
193
+ if (fromHelper.length > 0) return fromHelper;
194
+ } catch {
195
+ // Fall through to local replay / legacy fields.
196
+ }
197
+ }
198
+ const fromTranscript = extractTranscriptTools(asSystemMessages(context.messages));
199
+ if (fromTranscript.length > 0) return fromTranscript;
200
+ return context.tools;
201
+ }
202
+
102
203
  const base64SignaturePattern = /^[A-Za-z0-9+/]+={0,2}$/;
103
204
  function isValidThoughtSignature(signature?: string): boolean {
104
205
  if (!signature || typeof signature !== "string" || signature.length === 0) return false;
@@ -208,6 +309,7 @@ export function convertMessages(
208
309
  const requiresSig = geminiRequiresThoughtSignature(runtimeModel);
209
310
  const droppedToolCallIds = new Map<string, string>();
210
311
  for (const msg of context.messages) {
312
+ if ((msg.role as string) === "system") continue;
211
313
  if (msg.role === "user") {
212
314
  const parts = asTextParts(msg.content);
213
315
  appendTurn(contents, GeminiRole.User, parts);
@@ -314,6 +416,24 @@ export function convertMessages(
314
416
  }
315
417
  }
316
418
 
419
+ // A function-call model turn is only valid immediately after a user turn
420
+ // (including a function-response turn). Compacted history can drop that
421
+ // boundary, so restore it before applying the natural-language bridge.
422
+ for (let index = 0; index < contents.length; index += 1) {
423
+ const turn = contents[index];
424
+ if (
425
+ turn?.role === GeminiRole.Model &&
426
+ turn.parts.some((part) => "functionCall" in part) &&
427
+ contents[index - 1]?.role !== GeminiRole.User
428
+ ) {
429
+ contents.splice(index, 0, {
430
+ role: GeminiRole.User,
431
+ parts: [{ text: CONTINUATION_TEXT }],
432
+ });
433
+ index += 1;
434
+ }
435
+ }
436
+
317
437
  // Google Antigravity requires a natural-language user part in the request,
318
438
  // including tool-only continuation turns. Keep injected Skills in the system
319
439
  // instruction, then add this protocol bridge only when existing context gives
@@ -325,13 +445,23 @@ export function convertMessages(
325
445
  );
326
446
  if (!hasUserText && contents.length > 0) {
327
447
  const bridge = {
328
- text: "Continue the active task using the available instructions and context.",
448
+ text: CONTINUATION_TEXT,
329
449
  };
330
450
  const userTurn = contents.find((turn) => turn.role === GeminiRole.User);
331
451
  if (userTurn) userTurn.parts.push(bridge);
332
452
  else contents.unshift({ role: GeminiRole.User, parts: [bridge] });
333
453
  }
334
454
 
455
+ const lastTurn = contents.at(-1);
456
+ if (lastTurn?.role === GeminiRole.Model) {
457
+ if (lastTurn.parts.some((part) => "functionCall" in part)) {
458
+ throw new Error(
459
+ "Antigravity request is missing tool result(s) for the final assistant tool call. Provide the corresponding tool result before continuing.",
460
+ );
461
+ }
462
+ appendTurn(contents, GeminiRole.User, [{ text: CONTINUATION_TEXT }]);
463
+ }
464
+
335
465
  return contents;
336
466
  }
337
467
 
@@ -756,8 +886,10 @@ export function buildRequest(
756
886
  const injectedSkills = context.messages.flatMap((msg) =>
757
887
  msg.role === "user" ? skillBlocks(msg.content) : [],
758
888
  );
759
- const systemParts = context.systemPrompt
760
- ? [{ text: sanitizeText(context.systemPrompt) }]
889
+ const systemPromptText = resolveCurrentSystemPrompt(context);
890
+ const declaredTools = resolveCurrentTools(context);
891
+ const systemParts = systemPromptText
892
+ ? [{ text: sanitizeText(systemPromptText) }]
761
893
  : [{ text: ANTIGRAVITY_SYSTEM_INSTRUCTION }, { text: ANTIGRAVITY_NO_PREAMBLE_INSTRUCTION }];
762
894
  systemParts.push(...injectedSkills.map((skill) => ({ text: sanitizeText(skill) })));
763
895
 
@@ -767,7 +899,7 @@ export function buildRequest(
767
899
  turn.role === GeminiRole.User &&
768
900
  turn.parts.some((part) => "text" in part && Boolean(part.text.trim())),
769
901
  );
770
- if (!hasUserText && (injectedSkills.length > 0 || Boolean(context.systemPrompt))) {
902
+ if (!hasUserText && (injectedSkills.length > 0 || Boolean(systemPromptText))) {
771
903
  contents.unshift({
772
904
  role: GeminiRole.User,
773
905
  parts: [{ text: "Apply the active system instructions." }],
@@ -795,7 +927,7 @@ export function buildRequest(
795
927
  if (Object.keys(generationConfig).length) request.generationConfig = generationConfig;
796
928
 
797
929
  const isClaude = model.id.startsWith("claude-") || runtimeModel.startsWith("claude-");
798
- const tools = convertTools(context.tools, isClaude || model.id.startsWith("gpt-oss-"));
930
+ const tools = convertTools(declaredTools, isClaude || model.id.startsWith("gpt-oss-"));
799
931
  if (tools) {
800
932
  request.tools = tools;
801
933
  }
@@ -825,7 +957,7 @@ export function buildRequest(
825
957
  (m) => m.role === "assistant" && m.stopReason !== "error" && m.stopReason !== "aborted",
826
958
  ).length ?? 0;
827
959
 
828
- const { conversationId, trajectoryId } = resolveSessionTrajectory(context);
960
+ const { conversationId, trajectoryId } = resolveSessionTrajectory(context, options.sessionId);
829
961
 
830
962
  const envelope = antigravityRequestEnvelope(runtimeModel, {
831
963
  isClaude,
@@ -860,6 +992,16 @@ export function mapStopReason(reason: string | undefined): StopReason {
860
992
  export function friendlyAntigravityError(status: number | undefined, text: string): string {
861
993
  const msg = redactSecrets(jsonOrTextError(text)).slice(0, 500);
862
994
  if (status === 400) {
995
+ if (/Requests ending with a model turn are not supported/i.test(msg)) {
996
+ return "Antigravity rejected an invalid conversation message boundary. Next: update the extension or add a user message / start a new session, then retry.";
997
+ }
998
+ if (
999
+ /function call turn comes immediately after a user turn or after a function response turn/i.test(
1000
+ msg,
1001
+ )
1002
+ ) {
1003
+ return "Antigravity rejected an invalid function-call message boundary. Next: update the extension or start a new session, then retry; re-login is not required.";
1004
+ }
863
1005
  if (/API key not valid|API_KEY_INVALID/i.test(msg)) {
864
1006
  return "Antigravity login expired or credentials are invalid. Next: run /login antigravity, then retry.";
865
1007
  }
@@ -895,10 +1037,20 @@ export function friendlyAntigravityError(status: number | undefined, text: strin
895
1037
  if (/Individual quota reached/i.test(msg)) {
896
1038
  return `Quota reached. Please wait ${wait || "for reset"}. Next: switch models or try again after reset.`;
897
1039
  }
898
- if (/quota/i.test(msg)) {
1040
+ // Google answers a real quota wall with a "Resets in …" hint, but uses generic
1041
+ // RESOURCE_EXHAUSTED ("Resource has been exhausted (e.g. check quota).") for
1042
+ // transient throttling and capacity pressure. Classifying on the word "quota"
1043
+ // alone wrongly marked transient throttling as a hard quota wall, disabling
1044
+ // Pi's automatic retry backoff. Keep real quota walls non-retryable, and
1045
+ // format transient throttling so Pi's retry mechanism engages.
1046
+ const hardLimit =
1047
+ Boolean(wait) ||
1048
+ (!/rate.?limit/i.test(msg) &&
1049
+ /quota exceeded|exceeded your|limit reached|reached your|daily limit/i.test(msg));
1050
+ if (hardLimit) {
899
1051
  return `Quota reached.${wait ? ` Please wait ${wait}.` : ""} Next: switch models or retry later.`;
900
1052
  }
901
- return `Rate limited by Antigravity. Next: wait a bit and retry.${wait ? ` Reset: ${wait}.` : ""}`;
1053
+ return "Rate limited by Antigravity (429 ResourceExhausted). Next: retrying automatically; if it persists, switch models.";
902
1054
  }
903
1055
  if (status === 500) {
904
1056
  return "Antigravity had an internal server error. Next: retry in a moment or switch models.";
@@ -1003,7 +1155,6 @@ function guardResponseBody(
1003
1155
  if (stallMs <= 0) return;
1004
1156
  if (timer) clearTimeout(timer);
1005
1157
  timer = setTimeout(() => controller.abort(stallError(stallMs)), stallMs);
1006
- timer.unref?.();
1007
1158
  };
1008
1159
  let streamController: ReadableStreamDefaultController<Uint8Array> | undefined;
1009
1160
  const abortBody = () => {
@@ -1278,10 +1429,13 @@ export function streamAntigravity(
1278
1429
  const startTime = Date.now();
1279
1430
  const output = createOutput(model);
1280
1431
  try {
1281
- const creds = parseApiKey(opts.apiKey);
1432
+ let creds = parseApiKey(opts.apiKey);
1433
+ const triedAccessTokens = new Set<string>([creds.token]);
1434
+ const primaryEndpoint = endpointCandidates()[0];
1435
+ if (primaryEndpoint) prewarmConnection(primaryEndpoint);
1282
1436
  // Skip loadCodeAssist roundtrip when credentials already carry a projectId.
1283
1437
  const warmedProject = creds.projectId ? null : await loadCodeAssist(creds.token);
1284
- const projectId = resolveProjectId({
1438
+ let projectId = resolveProjectId({
1285
1439
  token: creds.token,
1286
1440
  warmedProject,
1287
1441
  credentialProjectId: creds.projectId,
@@ -1309,7 +1463,7 @@ export function streamAntigravity(
1309
1463
  runtimeCandidates.push(fallback);
1310
1464
  }
1311
1465
 
1312
- const requestHeaders = antigravityHeaders(creds.token);
1466
+ let requestHeaders = antigravityHeaders(creds.token);
1313
1467
 
1314
1468
  let response: Response | undefined;
1315
1469
  let lastText = "";
@@ -1344,7 +1498,15 @@ export function streamAntigravity(
1344
1498
  setLastStatus(response.status);
1345
1499
  if (response.ok) break;
1346
1500
  lastText = await response.text();
1347
- if (response.status === 429 && /Individual quota reached/i.test(lastText)) break;
1501
+ if (
1502
+ response.status === 429 &&
1503
+ (/Individual quota reached/i.test(lastText) ||
1504
+ /Resets? in /i.test(lastText) ||
1505
+ (!/rate.?limit/i.test(lastText) &&
1506
+ /quota exceeded|exceeded your|daily limit/i.test(lastText)))
1507
+ ) {
1508
+ break;
1509
+ }
1348
1510
  if (![403, 404, 429, 500, 502, 503, 504].includes(response.status)) break;
1349
1511
  }
1350
1512
 
@@ -1405,6 +1567,21 @@ export function streamAntigravity(
1405
1567
  }
1406
1568
  const friendly = friendlyAntigravityError(response?.status, lastText);
1407
1569
  if (response?.status === 429 && /Quota reached\./i.test(friendly)) {
1570
+ const next = await failoverToNextAccount(triedAccessTokens);
1571
+ if (next) {
1572
+ triedAccessTokens.add(next.token);
1573
+ creds = next;
1574
+ requestHeaders = antigravityHeaders(creds.token);
1575
+ const switchedProject = creds.projectId ? null : await loadCodeAssist(creds.token);
1576
+ projectId = resolveProjectId({
1577
+ token: creds.token,
1578
+ warmedProject: switchedProject,
1579
+ credentialProjectId: creds.projectId,
1580
+ });
1581
+ setLastProjectId(projectId);
1582
+ emptyAttempt = -1;
1583
+ continue;
1584
+ }
1408
1585
  throw new Error(friendly);
1409
1586
  }
1410
1587
  throw new Error(