@benjamolina/pi-antigravity-guard 0.4.0 → 0.4.1

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.
package/README.md CHANGED
@@ -14,11 +14,11 @@ Choose browser login, or choose manual login and paste the complete callback URL
14
14
 
15
15
  ## Text registration is not tool enablement
16
16
 
17
- All seven rows below are registered for text. Every production route is currently **not enabled for tools**, so a context with a declaration, assistant tool call, or tool result fails locally before any network request. Text-only requests retain their released behavior.
17
+ All seven rows below are registered for text. The exact `antigravity-gemini-3.8-flash` `off` route is enabled for tools from recorded direct evidence; every other production route is **not enabled for tools**, so its context with a declaration, assistant tool call, or tool result fails locally before any network request. Text-only requests retain their released behavior.
18
18
 
19
- | Public ID | Exposed Pi levels | Tool state | Why tools are unavailable |
19
+ | Public ID | Exposed Pi levels | Tool state | Admission |
20
20
  |---|---|---|---|
21
- | `antigravity-gemini-3.8-flash` | off, low, medium, high | Disabled | missing-direct-evidence |
21
+ | `antigravity-gemini-3.8-flash` | off, low, medium, high | Enabled (off) | pi-json-tool-loop/gemini-3.8-flash-off-v1 |
22
22
  | `antigravity-gemini-3.7-flash` | off, low, medium, high | Disabled | missing-direct-evidence |
23
23
  | `antigravity-gemini-3.6-flash` | off, low, medium, high | Disabled | missing-direct-evidence |
24
24
  | `antigravity-gemini-3.1-pro` | off, low, high | Disabled | missing-direct-evidence |
@@ -28,7 +28,11 @@ All seven rows below are registered for text. Every production route is currentl
28
28
 
29
29
  `off` is available for every row even though it is omitted from Pi's level map; omitted reasoning also selects that row's `off` route. Unsupported levels are not advertised and are rejected before transport. `antigravity-gemini-3.5-flash` is not registered or advertised as supported because its recorded HTTP-200 response lacks the strict terminal metadata required for admission. A future `fixture-qualified` route remains disabled for ordinary tool use; only separately authorized direct validation for the exact route may make it enabled.
30
30
 
31
- Claude Sonnet and Claude Opus remain disabled until declaration, call, result replay, thinking-signature, resume, interruption, and continuation evidence is recorded for each exact route. No route is enabled by this package release.
31
+ Claude Sonnet and Claude Opus remain disabled until declaration, call, result replay, thinking-signature, resume, interruption, and continuation evidence is recorded for each exact route. Only Gemini 3.8 Flash `off` is enabled by this package release.
32
+
33
+ ## Opt-in direct tool-loop validation
34
+
35
+ Maintainers can run `npm run build:pi` followed by `npx tsx scripts/pi-tool-loop-probe.ts --live --expect-enabled` to verify the directly admitted Gemini 3.8 Flash `off` route. The isolated runner invokes Pi in `--mode json`, passes the deterministic non-terminating `pi_evidence_echo` prompt positionally, drains its event stream, and completes after Pi emits `agent_end` and the child exits. It stores only the passing route and sanitized assertions; it never stores message contents, tool details, names, IDs, arguments, credentials, headers, or raw provider output. Other routes remain fail-closed.
32
36
 
33
37
  ## Tool preflight boundaries
34
38
 
@@ -52,6 +56,6 @@ An explicit `maxTokens` must be a positive integer no greater than the row's out
52
56
 
53
57
  - Costs are reported as zero because subscription usage is unpriced, not because access is free.
54
58
  - The catalog is static: it performs no startup or runtime model discovery, catalog synchronization, or dynamic registration.
55
- - It has no account rotation, quota fallback, model substitution, image support, or enabled tools.
59
+ - It has no account rotation, quota fallback, model substitution, image support, or enabled tools beyond the directly admitted Gemini 3.8 Flash `off` route.
56
60
 
57
61
  To remove it, disable or uninstall `@benjamolina/pi-antigravity-guard` and reload Pi. Removal does not delete Pi-managed credentials or revoke tokens.
package/dist/catalog.js CHANGED
@@ -8,7 +8,7 @@ const CATALOG = freeze(defineCatalog([{
8
8
  maxTokens: 65_536,
9
9
  },
10
10
  routes: {
11
- off: { wireModel: "gemini-3.8-flash-tiered", thinking: { kind: "native-level", thinkingLevel: "low", includeThoughts: false }, tools: disabled("missing-direct-evidence") },
11
+ off: { wireModel: "gemini-3.8-flash-tiered", thinking: { kind: "native-level", thinkingLevel: "low", includeThoughts: false }, tools: enabled({ record: "pi-json-tool-loop", revision: "gemini-3.8-flash-off-v1", publicModelId: "antigravity-gemini-3.8-flash", reasoning: "off", wireModel: "gemini-3.8-flash-tiered" }) },
12
12
  low: { wireModel: "gemini-3.8-flash-tiered", thinking: { kind: "native-level", thinkingLevel: "low", includeThoughts: true }, tools: disabled("missing-direct-evidence") },
13
13
  medium: { wireModel: "gemini-3.8-flash-tiered", thinking: { kind: "native-level", thinkingLevel: "medium", includeThoughts: true }, tools: disabled("missing-direct-evidence") },
14
14
  high: { wireModel: "gemini-3.8-flash-tiered", thinking: { kind: "native-level", thinkingLevel: "high", includeThoughts: true }, tools: disabled("missing-direct-evidence") },
@@ -109,6 +109,9 @@ const lookup = new Map(CATALOG.map((entry) => [entry.publicId, entry]));
109
109
  function disabled(reason) {
110
110
  return { state: "disabled", contractRevision: 1, reason };
111
111
  }
112
+ function enabled(evidence) {
113
+ return { state: "enabled", contractRevision: 1, fixtureEvidence: evidence, directEvidence: evidence };
114
+ }
112
115
  /** Constructs deterministic capability data for hermetic serializer tests only. */
113
116
  export function createEnabledToolCapability(evidence) {
114
117
  return freeze({ state: "enabled", contractRevision: 1, fixtureEvidence: evidence, directEvidence: evidence });
package/dist/context.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
2
2
  import { type GenerationSelection } from "./catalog.ts";
3
+ import { type ToolReplayDiagnostics } from "./tool-context.ts";
3
4
  interface Part {
4
5
  text: string;
5
6
  thought?: true;
@@ -9,6 +10,11 @@ interface Content {
9
10
  role: "user" | "model";
10
11
  parts: object[];
11
12
  }
13
+ interface GeminiFunctionDeclaration {
14
+ readonly name: string;
15
+ readonly description: string;
16
+ readonly parametersJsonSchema: object;
17
+ }
12
18
  export interface GenerationRequest {
13
19
  project: string;
14
20
  model: string;
@@ -18,7 +24,7 @@ export interface GenerationRequest {
18
24
  parts: Part[];
19
25
  };
20
26
  tools?: {
21
- functionDeclarations: unknown;
27
+ functionDeclarations: readonly GeminiFunctionDeclaration[];
22
28
  }[];
23
29
  toolConfig?: {
24
30
  functionCallingConfig: {
@@ -50,6 +56,6 @@ export interface SerializeTextContextInput {
50
56
  }
51
57
  export declare class ContextSerializationError extends Error {
52
58
  }
53
- export declare function serializeContext(input: SerializeTextContextInput, injectedSelection?: GenerationSelection, onRecovery?: (recoveryCount: number) => void): GenerationRequest;
59
+ export declare function serializeContext(input: SerializeTextContextInput, injectedSelection?: GenerationSelection, onDiagnostics?: (diagnostics: ToolReplayDiagnostics) => void): GenerationRequest;
54
60
  export declare function serializeTextContext(input: SerializeTextContextInput): GenerationRequest;
55
61
  export {};
package/dist/context.js CHANGED
@@ -6,7 +6,7 @@ const RESERVED_HEADERS = new Set(["authorization", "host", "content-type", "cont
6
6
  const THINKING_LEVELS = ["low", "medium", "high"];
7
7
  export class ContextSerializationError extends Error {
8
8
  }
9
- export function serializeContext(input, injectedSelection, onRecovery) {
9
+ export function serializeContext(input, injectedSelection, onDiagnostics) {
10
10
  const context = input.context;
11
11
  if (!hasToolContext(context))
12
12
  return serializeTextContext(input);
@@ -24,12 +24,21 @@ export function serializeContext(input, injectedSelection, onRecovery) {
24
24
  const { tools: _tools, messages, ...textContext } = context;
25
25
  const { toolChoice: _choice, ...textOptions } = isRecord(options) ? options : {};
26
26
  const text = serializeTextContext({ ...input, context: { ...textContext, messages: [{ role: "user", content: "placeholder", timestamp: 0 }] }, options: textOptions });
27
- const contents = replayToolHistory(isDenseArray(messages), (part, message) => messagePart(part, field(message, "role") === "assistant", field(message, "role") === "assistant" && entry.replay.kind === "same-public-model" && isSameProviderAndModel(message, entry.publicId)), onRecovery);
27
+ const sameModel = (message) => field(message, "role") === "assistant" && entry.replay.kind === "same-public-model" && isSameProviderAndModel(message, entry.publicId);
28
+ const signedToolReplay = geminiRequiresSignedToolReplay(selection.route.wireModel) ? {
29
+ requireSignedToolCalls: true,
30
+ isSameModel: sameModel,
31
+ toolCallSignature: (part, message) => sameModel(message) ? validThoughtSignature(field(part, "thoughtSignature")) : undefined,
32
+ } : undefined;
33
+ const contents = replayToolHistory(isDenseArray(messages), (part, message) => messagePart(part, field(message, "role") === "assistant", sameModel(message)), onDiagnostics, signedToolReplay, prepared?.declarations.length ?? 0);
28
34
  const { systemInstruction, generationConfig } = text.request;
29
35
  return { ...text, request: {
30
36
  contents,
31
37
  ...(systemInstruction ? { systemInstruction } : {}),
32
- ...(prepared ? { tools: [{ functionDeclarations: prepared.declarations }], toolConfig: { functionCallingConfig: { mode: prepared.mode } } } : {}),
38
+ ...(prepared ? {
39
+ tools: [{ functionDeclarations: prepared.declarations.map(({ name, description, parameters }) => ({ name, description, parametersJsonSchema: parameters })) }],
40
+ ...(prepared.mode === "NONE" ? { toolConfig: { functionCallingConfig: { mode: "NONE" } } } : {}),
41
+ } : {}),
33
42
  generationConfig,
34
43
  } };
35
44
  }
@@ -172,6 +181,10 @@ function resolveOutputTokens(maxTokens, maxAllowed, thinking) {
172
181
  }
173
182
  return thinking.kind === "budget" && thinking.budget > 0 ? Math.max(4096, thinking.budget + 1024) : 4096;
174
183
  }
184
+ function geminiRequiresSignedToolReplay(wireModel) {
185
+ const match = /^gemini-(\d+)(?:[.-])/.exec(wireModel);
186
+ return match !== null && Number(match[1]) >= 3;
187
+ }
175
188
  function validThoughtSignature(value) {
176
189
  if (typeof value !== "string" || !value || value.length % 4 !== 0)
177
190
  return undefined;
package/dist/response.js CHANGED
@@ -83,9 +83,18 @@ function candidate(value, events, semantics) {
83
83
  const parts = field(content, "parts");
84
84
  if (!Array.isArray(parts))
85
85
  throw new ResponseSemanticError("Antigravity returned invalid content.");
86
+ const explicitIds = new Set();
87
+ for (const part of parts) {
88
+ const call = field(part, "functionCall");
89
+ if (call !== undefined) {
90
+ const id = field(call, "id");
91
+ if (typeof id === "string")
92
+ explicitIds.add(id);
93
+ }
94
+ }
86
95
  for (const part of parts) {
87
96
  if (field(part, "functionCall") !== undefined) {
88
- const call = toolCall(part, semantics, ids, callCount++);
97
+ const call = toolCall(part, semantics, ids, explicitIds, callCount++);
89
98
  ids.push(call.id);
90
99
  local.push(call);
91
100
  }
@@ -102,8 +111,8 @@ function candidate(value, events, semantics) {
102
111
  const finishReason = field(item, "finishReason");
103
112
  if (finishReason !== undefined) {
104
113
  const hasCalls = semantics.callIndex() + callCount > 0;
105
- const reason = finishReason === "STOP" ? "stop" : finishReason === "MAX_TOKENS" ? "length" : finishReason === "OTHER" && hasCalls ? "toolUse" : undefined;
106
- if (!reason || (hasCalls && reason !== "toolUse"))
114
+ const reason = hasCalls ? "toolUse" : finishReason === "STOP" ? "stop" : finishReason === "MAX_TOKENS" ? "length" : undefined;
115
+ if (!reason)
107
116
  throw new ResponseSemanticError("Antigravity returned an unsupported finish reason.");
108
117
  semantics.setFinish(reason);
109
118
  }
@@ -119,13 +128,14 @@ function candidate(value, events, semantics) {
119
128
  events.push(event);
120
129
  }
121
130
  }
122
- function toolCall(part, semantics, ids, offset) {
131
+ function toolCall(part, semantics, ids, explicitIds, offset) {
123
132
  if (semantics.policy.kind !== "accept" || keys(part, ["functionCall", "thoughtSignature"]))
124
133
  throw new ResponseSemanticError("Antigravity returned unsupported content.");
125
134
  const call = field(part, "functionCall");
126
135
  if (keys(call, ["id", "name", "args"]))
127
136
  throw new ResponseSemanticError("Antigravity returned unsupported content.");
128
- const id = field(call, "id");
137
+ const suppliedId = field(call, "id");
138
+ const id = suppliedId === undefined ? localCallId(semantics, ids, explicitIds, offset) : suppliedId;
129
139
  const name = field(call, "name");
130
140
  const args = field(call, "args");
131
141
  if (typeof id !== "string" || !id.trim() || typeof name !== "string" || !semantics.policy.declaredNames.has(name) || semantics.hasId(id) || ids.includes(id))
@@ -146,6 +156,15 @@ function toolCall(part, semantics, ids, offset) {
146
156
  const signature = validThoughtSignature(field(part, "thoughtSignature"));
147
157
  return { type: "toolCall", callIndex: semantics.callIndex() + offset, id, name, arguments: argumentsValue, argumentsJson, ...(signature ? { signature } : {}) };
148
158
  }
159
+ function localCallId(semantics, ids, explicitIds, offset) {
160
+ let suffix = semantics.callIndex() + offset + 1;
161
+ let id = `pi-gemini-call-${suffix}`;
162
+ while (semantics.hasId(id) || ids.includes(id) || explicitIds.has(id)) {
163
+ suffix++;
164
+ id = `pi-gemini-call-${suffix}`;
165
+ }
166
+ return id;
167
+ }
149
168
  function depth(value, level = 0) {
150
169
  let maximum = level;
151
170
  for (const item of Object.values(value))
package/dist/stream.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
2
2
  import { type GenerationSelection } from "./catalog.ts";
3
+ import { type ToolReplayMode } from "./tool-context.ts";
3
4
  import type { ResponseSemantic } from "./response.ts";
4
5
  type StreamErrorKind = "aborted" | "access" | "capability" | "model" | "quota" | "preflight" | "response" | "transport" | "callback";
5
6
  export interface ToolStreamDiagnostics {
@@ -9,7 +10,17 @@ export interface ToolStreamDiagnostics {
9
10
  readonly preflight?: "accepted" | string;
10
11
  readonly preflightCategory?: string;
11
12
  readonly preflightPath?: string;
12
- readonly recoveryCount?: number;
13
+ readonly replayMode: ToolReplayMode;
14
+ readonly recoveryCount: number;
15
+ readonly userMessageCount: number;
16
+ readonly assistantMessageCount: number;
17
+ readonly toolResultMessageCount: number;
18
+ readonly assistantToolCallBlockCount: number;
19
+ readonly declaredToolCount: number;
20
+ readonly failure?: {
21
+ readonly kind: StreamErrorKind;
22
+ readonly status?: number;
23
+ };
13
24
  }
14
25
  type ToolDiagnosticSemantic = {
15
26
  type: "toolDiagnostics";
package/dist/stream.js CHANGED
@@ -55,7 +55,7 @@ export function createPiLifecycleStream(input) {
55
55
  currentBlock = undefined;
56
56
  };
57
57
  let removeAbort = () => { };
58
- const finalize = (reason, errorMessage, details) => {
58
+ const finalize = (reason, errorMessage, details, error) => {
59
59
  if (complete)
60
60
  return;
61
61
  complete = true;
@@ -69,8 +69,9 @@ export function createPiLifecycleStream(input) {
69
69
  }
70
70
  output.stopReason = reason;
71
71
  const diagnostic = details ?? toolDiagnostics;
72
+ const failure = error && isLocalStreamError(error) ? diagnosticFailure(error) : undefined;
72
73
  if (sawToolCall || diagnostic)
73
- output.diagnostics = [{ type: "antigravity-guard.tools", timestamp: input.now(), details: { ...diagnostic, terminal: reason } }];
74
+ output.diagnostics = [{ type: "antigravity-guard.tools", timestamp: input.now(), details: { ...diagnostic, ...(failure ? { failure } : {}), terminal: reason } }];
74
75
  if (reason === "stop" || reason === "length" || reason === "toolUse") {
75
76
  closeBlock();
76
77
  stream.push({ type: "done", reason, message: output });
@@ -153,7 +154,7 @@ export function createPiLifecycleStream(input) {
153
154
  };
154
155
  stream.push({ type: "start", partial: output });
155
156
  if (input.signal) {
156
- const abort = () => finalize("aborted");
157
+ const abort = () => finalize("aborted", undefined, undefined, streamError("aborted", "Generation was cancelled."));
157
158
  input.signal.addEventListener("abort", abort, { once: true });
158
159
  removeAbort = () => input.signal?.removeEventListener("abort", abort);
159
160
  if (input.signal.aborted)
@@ -161,7 +162,7 @@ export function createPiLifecycleStream(input) {
161
162
  }
162
163
  if (!complete)
163
164
  void Promise.resolve().then(() => input.runTransport({ onSemantic, signal })).then(() => { if (!complete)
164
- finalize(signal.aborted ? "aborted" : "error"); }, (error) => finalize(signal.aborted || input.signal?.aborted ? "aborted" : "error", isLocalStreamError(error) ? error.message : undefined, isLocalStreamError(error) ? error.details : undefined));
165
+ finalize(signal.aborted ? "aborted" : "error"); }, (error) => finalize(signal.aborted || input.signal?.aborted ? "aborted" : "error", isLocalStreamError(error) ? error.message : undefined, isLocalStreamError(error) ? error.details : undefined, isLocalStreamError(error) ? error : undefined));
165
166
  return stream;
166
167
  }
167
168
  function isContentSemantic(semantic) {
@@ -173,13 +174,13 @@ export async function executeStreamTransport(input) {
173
174
  const headers = requestHeaders(input);
174
175
  let responseBody = null;
175
176
  let selection;
176
- let recoveryCount = 0;
177
+ let replayDiagnostics = emptyReplayDiagnostics();
177
178
  try {
178
179
  const entry = getCatalogEntry(input.model.id);
179
180
  selection = input.selection ?? resolveGenerationSelection(entry, input.generationOptions?.reasoning);
180
- const original = serializeContext({ context: input.context, model: input.model, options: input.generationOptions, project: input.projectId, requestId: input.requestId }, selection, (count) => { recoveryCount = count; });
181
+ const original = serializeContext({ context: input.context, model: input.model, options: input.generationOptions, project: input.projectId, requestId: input.requestId }, selection, (diagnostics) => { replayDiagnostics = diagnostics; });
181
182
  if (hasToolContext(input.context))
182
- await deliver(input.onSemantic, { type: "toolDiagnostics", details: toolDiagnostics(entry.publicId, selection, recoveryCount, "accepted") });
183
+ await deliver(input.onSemantic, { type: "toolDiagnostics", details: toolDiagnostics(entry.publicId, selection, replayDiagnostics, "accepted") });
183
184
  const payload = await payloadHook(input, original, signal);
184
185
  const response = await abortable(input.fetch(ENDPOINT, { method: "POST", redirect: "error", headers, body: JSON.stringify(payload), signal }), signal);
185
186
  await responseHook(input, response, signal);
@@ -199,11 +200,11 @@ export async function executeStreamTransport(input) {
199
200
  if (signal.aborted)
200
201
  throw streamError("aborted", "Generation was cancelled.");
201
202
  if (error instanceof ToolPreflightError && selection) {
202
- const details = toolDiagnostics(getCatalogEntry(input.model.id).publicId, selection, recoveryCount, error.code, error.path);
203
+ const details = toolDiagnostics(getCatalogEntry(input.model.id).publicId, selection, replayDiagnostics, error.code, error.path);
203
204
  throw streamError("preflight", error.message, undefined, details);
204
205
  }
205
206
  if (error instanceof ContextSerializationError && error.message.startsWith("PI_TOOL_CAPABILITY_NOT_ENABLED") && selection) {
206
- throw streamError("capability", error.message, undefined, toolDiagnostics(getCatalogEntry(input.model.id).publicId, selection, recoveryCount, "PI_TOOL_CAPABILITY_NOT_ENABLED"));
207
+ throw streamError("capability", error.message, undefined, toolDiagnostics(getCatalogEntry(input.model.id).publicId, selection, replayDiagnostics, "PI_TOOL_CAPABILITY_NOT_ENABLED"));
207
208
  }
208
209
  if (error instanceof SseFrameError || error instanceof ResponseSemanticError)
209
210
  throw streamError("response", "Antigravity returned an invalid stream.");
@@ -214,13 +215,31 @@ function validateInput(input) {
214
215
  if (!getCatalogEntry(input.model.id) || input.model.api !== API)
215
216
  throw streamError("response", "The selected Antigravity model or API is unsupported.");
216
217
  }
217
- function toolDiagnostics(publicModelId, selection, recoveryCount, preflight, preflightPath) {
218
+ function toolDiagnostics(publicModelId, selection, replayDiagnostics, preflight, preflightPath) {
218
219
  return {
219
220
  publicModelId,
220
221
  reasoning: selection.level,
221
222
  capabilityState: selection.tools.state,
223
+ replayMode: replayDiagnostics.replayMode,
224
+ recoveryCount: replayDiagnostics.recoveryCount,
225
+ userMessageCount: replayDiagnostics.userMessageCount,
226
+ assistantMessageCount: replayDiagnostics.assistantMessageCount,
227
+ toolResultMessageCount: replayDiagnostics.toolResultMessageCount,
228
+ assistantToolCallBlockCount: replayDiagnostics.assistantToolCallBlockCount,
229
+ declaredToolCount: replayDiagnostics.declaredToolCount,
222
230
  preflight,
223
- ...(preflight === "accepted" ? { recoveryCount } : { preflightCategory: preflightCategory(preflight), ...(preflightPath ? { preflightPath } : {}) }),
231
+ ...(preflight === "accepted" ? {} : { preflightCategory: preflightCategory(preflight), ...(preflightPath ? { preflightPath } : {}) }),
232
+ };
233
+ }
234
+ function emptyReplayDiagnostics() {
235
+ return {
236
+ replayMode: "none",
237
+ recoveryCount: 0,
238
+ userMessageCount: 0,
239
+ assistantMessageCount: 0,
240
+ toolResultMessageCount: 0,
241
+ assistantToolCallBlockCount: 0,
242
+ declaredToolCount: 0,
224
243
  };
225
244
  }
226
245
  function preflightCategory(code) {
@@ -236,9 +255,9 @@ function preflightCategory(code) {
236
255
  }
237
256
  function responsePolicy(payload, selection) {
238
257
  const declarations = payload.request.tools?.[0]?.functionDeclarations;
239
- if (selection.tools.state !== "enabled" || payload.request.toolConfig?.functionCallingConfig.mode !== "AUTO" || !Array.isArray(declarations))
258
+ if (selection.tools.state !== "enabled" || payload.request.toolConfig?.functionCallingConfig.mode === "NONE" || !Array.isArray(declarations))
240
259
  return { kind: "reject" };
241
- const names = declarations.map((item) => typeof item === "object" && item !== null ? Object.getOwnPropertyDescriptor(item, "name")?.value : undefined);
260
+ const names = declarations.map((declaration) => Object.getOwnPropertyDescriptor(declaration, "name")?.value);
242
261
  return names.every((name) => typeof name === "string") ? { kind: "accept", declaredNames: new Set(names) } : { kind: "reject" };
243
262
  }
244
263
  async function payloadHook(input, payload, signal) {
@@ -364,6 +383,18 @@ function isSse(value) { return value?.split(";", 1)[0]?.trim().toLowerCase() ===
364
383
  function inactivityTimeout(input) {
365
384
  return input.inactivityTimeoutMs === undefined ? INACTIVITY_TIMEOUT_MS : input.inactivityTimeoutMs > 0 && Number.isFinite(input.inactivityTimeoutMs) ? Math.min(input.inactivityTimeoutMs, INACTIVITY_TIMEOUT_MS) : 0;
366
385
  }
386
+ function diagnosticFailure(error) {
387
+ if (!isStreamErrorKind(error.kind))
388
+ return undefined;
389
+ const status = error.status;
390
+ return {
391
+ kind: error.kind,
392
+ ...(typeof status === "number" && Number.isInteger(status) && status >= 100 && status <= 599 ? { status } : {}),
393
+ };
394
+ }
395
+ function isStreamErrorKind(value) {
396
+ return value === "aborted" || value === "access" || value === "capability" || value === "model" || value === "quota" || value === "preflight" || value === "response" || value === "transport" || value === "callback";
397
+ }
367
398
  function streamError(kind, message, status, details) {
368
399
  const error = new StreamTransportError(kind, message, status, details);
369
400
  LOCAL_ERRORS.add(error);
@@ -10,5 +10,20 @@ type WireContent = {
10
10
  role: "user" | "model";
11
11
  parts: WirePart[];
12
12
  };
13
- export declare function replayToolHistory(messages: unknown[], serializePart: (part: Record<string, unknown>, message: Record<string, unknown>) => WirePart, onRecovery?: (recoveryCount: number) => void): WireContent[];
13
+ export type ToolReplayMode = "none" | "signed-function-response" | "unsigned-observation";
14
+ export interface ToolReplayDiagnostics {
15
+ readonly replayMode: ToolReplayMode;
16
+ readonly recoveryCount: number;
17
+ readonly userMessageCount: number;
18
+ readonly assistantMessageCount: number;
19
+ readonly toolResultMessageCount: number;
20
+ readonly assistantToolCallBlockCount: number;
21
+ readonly declaredToolCount: number;
22
+ }
23
+ export interface ToolReplayPolicy {
24
+ readonly requireSignedToolCalls: boolean;
25
+ readonly isSameModel: (message: Record<string, unknown>) => boolean;
26
+ readonly toolCallSignature: (part: Record<string, unknown>, message: Record<string, unknown>) => string | undefined;
27
+ }
28
+ export declare function replayToolHistory(messages: unknown[], serializePart: (part: Record<string, unknown>, message: Record<string, unknown>) => WirePart, onDiagnostics?: (diagnostics: ToolReplayDiagnostics) => void, policy?: ToolReplayPolicy, declaredToolCount?: number): WireContent[];
14
29
  export {};
@@ -29,27 +29,45 @@ export function prepareToolContext(tools, choice) {
29
29
  unsupportedChoice();
30
30
  return { declarations, mode: choice === "none" ? "NONE" : "AUTO" };
31
31
  }
32
- export function replayToolHistory(messages, serializePart, onRecovery) {
32
+ export function replayToolHistory(messages, serializePart, onDiagnostics, policy, declaredToolCount = 0) {
33
33
  const output = [];
34
34
  const calls = new Set();
35
35
  const results = new Set();
36
36
  const recoveryCount = { value: 0 };
37
+ const shape = {
38
+ userMessageCount: 0,
39
+ assistantMessageCount: 0,
40
+ toolResultMessageCount: 0,
41
+ assistantToolCallBlockCount: 0,
42
+ declaredToolCount: safeCount(declaredToolCount),
43
+ };
44
+ let replayMode = "none";
37
45
  let pending;
46
+ const appendTurn = (role, parts) => {
47
+ if (!parts.length)
48
+ return;
49
+ const last = output.at(-1);
50
+ if (last?.role === role)
51
+ last.parts.push(...parts);
52
+ else
53
+ output.push({ role, parts });
54
+ };
38
55
  const finalize = () => {
39
56
  if (!pending)
40
57
  return;
41
- output.push({ role: "user", parts: pending.calls.map((call) => {
42
- const actual = pending.results.get(call.id);
43
- if (actual)
44
- return actual;
45
- recoveryCount.value += 1;
46
- return missingResult(call);
47
- }) });
58
+ appendTurn("user", pending.calls.map((call) => {
59
+ const actual = pending.results.get(call.id);
60
+ if (actual)
61
+ return actual;
62
+ recoveryCount.value += 1;
63
+ return pending.observations ? observation(call, missingResultText()) : missingResult(call);
64
+ }));
48
65
  pending = undefined;
49
66
  };
50
67
  for (const message of messages) {
51
68
  const current = record(message);
52
69
  if (value(current, "role") === "toolResult") {
70
+ shape.toolResultMessageCount += 1;
53
71
  const id = nonempty(value(current, "toolCallId"), "PI_TOOL_RESULT_FOREIGN");
54
72
  if (results.has(id))
55
73
  history("PI_TOOL_RESULT_DUPLICATE");
@@ -68,8 +86,8 @@ export function replayToolHistory(messages, serializePart, onRecovery) {
68
86
  history("PI_TOOL_RESULT_MEDIA_UNSUPPORTED");
69
87
  return value(part, "text");
70
88
  }).join("\n\n");
71
- const response = value(current, "isError") === true ? { error: text } : { result: text };
72
- pending.results.set(id, { functionResponse: { name: call.name, id, response } });
89
+ const response = value(current, "isError") === true ? { error: text } : { output: text };
90
+ pending.results.set(id, pending.observations ? observation(call, text) : { functionResponse: { name: call.name, response } });
73
91
  results.add(id);
74
92
  continue;
75
93
  }
@@ -77,53 +95,82 @@ export function replayToolHistory(messages, serializePart, onRecovery) {
77
95
  const role = value(current, "role");
78
96
  if (role !== "user" && role !== "assistant")
79
97
  history("PI_TOOL_CALL_INVALID");
98
+ if (role === "user")
99
+ shape.userMessageCount += 1;
100
+ else
101
+ shape.assistantMessageCount += 1;
80
102
  const rawContent = value(current, "content");
81
103
  if (typeof rawContent === "string") {
82
104
  if (!rawContent)
83
105
  history("PI_TOOL_CALL_INVALID");
84
- output.push({ role: role === "assistant" ? "model" : "user", parts: [{ text: rawContent }] });
106
+ appendTurn(role === "assistant" ? "model" : "user", [{ text: rawContent }]);
85
107
  continue;
86
108
  }
87
109
  const content = dense(rawContent);
88
110
  const hasCalls = role === "assistant" && content.some((part) => value(record(part), "type") === "toolCall");
89
111
  if (!hasCalls) {
90
- output.push({ role: role === "assistant" ? "model" : "user", parts: content.map((part) => serializePart(record(part), current)) });
112
+ appendTurn(role === "assistant" ? "model" : "user", content.map((part) => serializePart(record(part), current)));
91
113
  continue;
92
114
  }
93
115
  if (value(current, "stopReason") !== "toolUse")
94
116
  history("PI_TOOL_CALL_INVALID");
95
117
  const group = [];
96
- const parts = content.map((item) => {
118
+ const parts = content.flatMap((item) => {
97
119
  const part = record(item);
98
- if (value(part, "type") !== "toolCall")
99
- return serializePart(part, current);
120
+ if (value(part, "type") !== "toolCall") {
121
+ if (isEmptyUnsignedText(part))
122
+ return [];
123
+ return [serializePart(part, current)];
124
+ }
125
+ shape.assistantToolCallBlockCount += 1;
100
126
  const id = nonempty(value(part, "id"), "PI_TOOL_CALL_INVALID");
101
127
  const name = nonempty(value(part, "name"), "PI_TOOL_CALL_INVALID");
102
128
  if (!/^[A-Za-z_][A-Za-z0-9_.:-]*$/.test(name) || calls.has(id))
103
129
  history(calls.has(id) ? "PI_TOOL_CALL_DUPLICATE" : "PI_TOOL_CALL_INVALID");
104
130
  const args = canonicalJson(value(part, "arguments"), name, "$.arguments");
105
- if (Array.isArray(args) || args === null)
131
+ if (typeof args !== "object" || Array.isArray(args) || args === null)
106
132
  history("PI_TOOL_CALL_INVALID");
107
133
  calls.add(id);
108
- group.push({ id, name });
109
- return { functionCall: { name, args, id } };
134
+ const call = { id, name, args };
135
+ group.push(call);
136
+ return [{ call, signature: policy?.toolCallSignature(part, current), signaturePresent: value(part, "thoughtSignature") !== undefined }];
110
137
  });
111
- output.push({ role: "model", parts });
112
- pending = { calls: group, results: new Map() };
138
+ const toolParts = parts.filter((part) => "call" in part);
139
+ const callsAreSigned = policy?.requireSignedToolCalls === true && policy.isSameModel(current) && Boolean(toolParts[0]?.signature) && toolParts.every((part) => !part.signaturePresent || Boolean(part.signature));
140
+ const observations = policy?.requireSignedToolCalls === true && !callsAreSigned;
141
+ if (observations)
142
+ replayMode = "unsigned-observation";
143
+ else if (replayMode === "none" && callsAreSigned)
144
+ replayMode = "signed-function-response";
145
+ appendTurn("model", parts.flatMap((part) => {
146
+ if (!("call" in part))
147
+ return [part];
148
+ if (observations)
149
+ return [];
150
+ return [{ functionCall: { name: part.call.name, args: part.call.args }, ...(part.signature ? { thoughtSignature: part.signature } : {}) }];
151
+ }));
152
+ pending = { calls: group, results: new Map(), observations };
113
153
  }
114
154
  finalize();
115
- onRecovery?.(recoveryCount.value);
155
+ onDiagnostics?.({ replayMode, recoveryCount: recoveryCount.value, ...shape });
116
156
  return output;
117
157
  }
158
+ function observation(call, text) {
159
+ const args = JSON.stringify(call.args);
160
+ const label = args === "{}" ? `\`${call.name}\`` : `\`${call.name}\` (${args})`;
161
+ return { text: `[Observation from ${label}:\n${text}]` };
162
+ }
163
+ function missingResultText() {
164
+ return "Tool execution did not complete or its result was not recorded. Treat the call as failed; do not assume it had no side effects and do not retry it automatically.";
165
+ }
118
166
  function missingResult(call) {
119
167
  return {
120
168
  functionResponse: {
121
169
  name: call.name,
122
- id: call.id,
123
170
  response: {
124
171
  error: {
125
172
  code: "PI_TOOL_RESULT_MISSING",
126
- message: "Tool execution did not complete or its result was not recorded. Treat the call as failed; do not assume it had no side effects and do not retry it automatically.",
173
+ message: missingResultText(),
127
174
  },
128
175
  },
129
176
  },
@@ -142,11 +189,18 @@ function dense(value) {
142
189
  history("PI_TOOL_CALL_INVALID");
143
190
  return value;
144
191
  }
192
+ function isEmptyUnsignedText(part) {
193
+ const text = value(part, "text");
194
+ return value(part, "type") === "text" && typeof text === "string" && !text.trim() && value(part, "textSignature") === undefined;
195
+ }
145
196
  function nonempty(value, code) {
146
197
  if (typeof value !== "string" || !value.trim())
147
198
  history(code);
148
199
  return value;
149
200
  }
201
+ function safeCount(value) {
202
+ return Number.isSafeInteger(value) && value >= 0 ? value : 0;
203
+ }
150
204
  function unsupportedChoice() {
151
205
  throw new ToolPreflightError("PI_TOOL_CHOICE_UNSUPPORTED", "tools", "$");
152
206
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@benjamolina/pi-antigravity-guard",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Pi adapter for Antigravity Guard",
5
5
  "type": "module",
6
6
  "license": "MIT",