agent-accelerator 0.4.5 → 0.5.0

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 (45) hide show
  1. package/README.md +149 -57
  2. package/bunfig.toml +1 -0
  3. package/package.json +2 -3
  4. package/src/agent/agent.ts +401 -120
  5. package/src/agent/context.ts +11 -16
  6. package/src/agent/delegation.ts +111 -274
  7. package/src/agent/loop.ts +253 -98
  8. package/src/agent/subagent.ts +4 -12
  9. package/src/agent/worker.ts +435 -0
  10. package/src/index.ts +39 -5
  11. package/src/models/catalog-cache.ts +65 -25
  12. package/src/models/catalog.ts +21 -2
  13. package/src/providers/google.ts +282 -74
  14. package/src/providers/openai-compat.ts +61 -41
  15. package/src/providers/openai.ts +46 -31
  16. package/src/providers/openrouter.ts +42 -31
  17. package/src/providers/shared.ts +110 -16
  18. package/src/providers.ts +119 -6
  19. package/src/scheduler/scheduled-actions.md +41 -0
  20. package/src/scheduler/scheduler.ts +814 -0
  21. package/src/scheduler/store.ts +263 -0
  22. package/src/scheduler/time.ts +148 -0
  23. package/src/scheduler/tool.ts +151 -0
  24. package/src/session/store.ts +211 -104
  25. package/src/streaming/event-stream.ts +6 -4
  26. package/src/telemetry-file.ts +31 -0
  27. package/src/telemetry.ts +65 -0
  28. package/src/tools/executor.ts +163 -38
  29. package/src/tools/subagent-result.ts +60 -0
  30. package/src/types/agent.ts +57 -20
  31. package/src/types/model.ts +18 -0
  32. package/src/types/response.ts +2 -0
  33. package/src/types/scheduler.ts +81 -0
  34. package/src/types/tool.ts +15 -0
  35. package/src/utils/cache.ts +14 -5
  36. package/src/utils/documents.ts +112 -52
  37. package/src/utils/errors.ts +11 -0
  38. package/src/utils/headers.ts +15 -15
  39. package/src/utils/home.ts +30 -0
  40. package/src/utils/media.ts +57 -12
  41. package/src/utils/raw-capture.ts +76 -0
  42. package/src/utils/retry.ts +11 -3
  43. package/src/utils/safe-io.ts +252 -0
  44. package/src/utils/session.ts +7 -8
  45. package/src/data/README.md +0 -84
@@ -16,9 +16,10 @@
16
16
  * - Stateful chaining via `previous_interaction_id` keyed by session; falls
17
17
  * back to stateless full-history (`store: false`) when the session mixed
18
18
  * providers, keeping the canonical conversation provider-agnostic.
19
- * - Unlike the legacy transport path, Gemini 2.x is NOT rejected here —
20
- * the Interactions API documents 2.5 support; the catalog still governs
21
- * thinking validation upstream.
19
+ * - Unlike the legacy transport path, Gemini 2.x is rejected here —
20
+ * the minimum supported generation is Gemini 3 (deprecated 1.x/2.x fail
21
+ * fast before any network call); the catalog still governs thinking
22
+ * validation upstream.
22
23
  */
23
24
  import type {
24
25
  Provider,
@@ -43,14 +44,21 @@ import { withRetries } from "../utils/retry.ts";
43
44
  import { setHeaderCaseInsensitive, shouldEchoSignature } from "../utils/headers.ts";
44
45
  import {
45
46
  newToolCallId,
47
+ parseArguments,
46
48
  redactedHeaders,
47
49
  readErrorPayload,
48
50
  incompleteStreamError,
51
+ isNotFoundChainError,
49
52
  combinedSignal,
50
53
  timeoutFor,
51
54
  hasAuthHeader,
52
55
  clampToolCallId,
56
+ openStreamResponse,
57
+ retryOptionsFrom,
58
+ headersToRecord,
59
+ parseRetryAfterMs,
53
60
  } from "./shared.ts";
61
+ import { telemetryBus } from "../telemetry.ts";
54
62
  import { createGenericModelSpec } from "../models/catalog.ts";
55
63
  import { getModelFromCatalog, getModelsForProvider, modelSupportsStructuredOutput } from "../models/catalog.ts";
56
64
  import {
@@ -61,7 +69,9 @@ import {
61
69
  lastProviderFor,
62
70
  noteProviderTurn,
63
71
  parseStreamedToolArguments,
72
+ selectRawArguments,
64
73
  mapOutputToGoogle,
74
+ mapGenerationParamsToGoogle,
65
75
  normalizeStructuredOutput,
66
76
  augmentSystemPromptWithStructuredOutput,
67
77
  emitProviderWarning,
@@ -80,7 +90,7 @@ type InteractionContentBlock =
80
90
 
81
91
  type InteractionInputStep =
82
92
  | { type: "user_input"; content: string | InteractionContentBlock[] }
83
- | { type: "thought"; signature: string; summary?: Array<{ type: string; text?: string }> }
93
+ | { type: "thought"; signature?: string; summary?: Array<{ type: string; text?: string }> }
84
94
  | { type: "function_call"; id: string; name: string; arguments: Record<string, unknown> }
85
95
  | {
86
96
  type: "function_result";
@@ -122,6 +132,7 @@ interface InteractionObject {
122
132
  created?: string;
123
133
  updated?: string;
124
134
  service_tier?: string;
135
+ error?: { message?: string; code?: string | number } | null;
125
136
  steps?: InteractionStep[];
126
137
  usage?: {
127
138
  total_tokens?: number;
@@ -149,9 +160,34 @@ const INTERNAL_HEADERS = new Set([
149
160
  interface ChainEntry {
150
161
  interactionId: string;
151
162
  model: string;
163
+ /** Wall-clock ms of the turn that established this chain (drives the TTL gate). */
164
+ at: number;
152
165
  }
153
166
  const interactionChains = new Map<string, ChainEntry>();
154
167
 
168
+ /** Interaction storage TTLs: free 24h / paid 55d; local gate fires 1h/1d early. */
169
+ const FREE_CHAIN_TTL_MS = 23 * 3600 * 1000;
170
+ const PAID_CHAIN_TTL_MS = 54 * 24 * 3600 * 1000;
171
+
172
+ /** Last-turn total tokens per session for the rehydration invariant (Rule 3). */
173
+ const lastTurnTotals = new Map<string, number>();
174
+
175
+ function resolveInteractionTier(options?: ProviderRequestOptions): "free" | "paid" {
176
+ return options?.googleTier === "paid" ? "paid" : "free";
177
+ }
178
+
179
+ function chainTtlMs(options?: ProviderRequestOptions): number {
180
+ return resolveInteractionTier(options) === "paid" ? PAID_CHAIN_TTL_MS : FREE_CHAIN_TTL_MS;
181
+ }
182
+
183
+ /** Emits a chain anomaly on the telemetry bus (+ legacy global hook). Never throws. */
184
+ function reportChainAnomaly(detail: { type: string; [k: string]: unknown }): void {
185
+ telemetryBus.emitWarning(detail.type, `[Agent Accelerator] [google] ${String(detail.type)}`, detail);
186
+ try {
187
+ (globalThis as unknown as { __agentAccelAnomaly?: (d: unknown) => void }).__agentAccelAnomaly?.(detail);
188
+ } catch {}
189
+ }
190
+
155
191
  function chainFor(sessionId: string | undefined): ChainEntry | undefined {
156
192
  if (!sessionId) return undefined;
157
193
  return interactionChains.get(sessionId);
@@ -162,10 +198,57 @@ function setChain(sessionId: string | undefined, entry: ChainEntry): void {
162
198
  interactionChains.set(sessionId, entry);
163
199
  }
164
200
 
201
+ /**
202
+ * Drops a chain entry whose storage TTL elapsed locally (Rule 1: zero wasted
203
+ * RTT). Returns undefined when gated so the caller falls through to the
204
+ * full-history seeding branch. Also resets the token baseline so the reseed
205
+ * never trips the rehydration invariant.
206
+ */
207
+ function liveChainFor(sessionId: string | undefined, options?: ProviderRequestOptions): ChainEntry | undefined {
208
+ const entry = chainFor(sessionId);
209
+ if (!entry || !sessionId) return entry;
210
+ if (typeof entry.at === "number" && Date.now() - entry.at >= chainTtlMs(options)) {
211
+ interactionChains.delete(sessionId);
212
+ lastTurnTotals.delete(sessionId);
213
+ reportChainAnomaly({ type: "interaction_chain_ttl_expired", sessionId, model: entry.model });
214
+ return undefined;
215
+ }
216
+ return entry;
217
+ }
218
+
219
+ /**
220
+ * Records a successful turn's token total and alerts when input tokens drop
221
+ * below the previous turn's total (Rule 3: a caller dropped the chain without
222
+ * rehydrating history). Never throws; observability-only.
223
+ */
224
+ function checkTurnTokens(sessionId: string | undefined, usage: { inputTokens: number; outputTokens: number; cachedTokens?: number }): void {
225
+ try {
226
+ if (!sessionId) return;
227
+ const total = (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
228
+ const prev = lastTurnTotals.get(sessionId);
229
+ lastTurnTotals.set(sessionId, total);
230
+ if (prev !== undefined && prev > 0 && (usage.inputTokens ?? 0) < prev) {
231
+ const input = usage.inputTokens ?? 0;
232
+ reportChainAnomaly({
233
+ type: "interaction_history_shrink",
234
+ sessionId,
235
+ inputTokens: input,
236
+ previousTotalTokens: prev,
237
+ cacheHitRate: input > 0 ? (usage.cachedTokens ?? 0) / input : 0,
238
+ });
239
+ }
240
+ } catch {}
241
+ }
242
+
165
243
  /** Clears chained interaction state (mainly for tests). */
166
244
  export function clearInteractionChains(sessionId?: string): void {
167
- if (sessionId) interactionChains.delete(sessionId);
168
- else interactionChains.clear();
245
+ if (sessionId) {
246
+ interactionChains.delete(sessionId);
247
+ lastTurnTotals.delete(sessionId);
248
+ } else {
249
+ interactionChains.clear();
250
+ lastTurnTotals.clear();
251
+ }
169
252
  }
170
253
 
171
254
  // ---------------------------------------------------------------------------
@@ -193,6 +276,33 @@ function cleanModelId(model: string | ModelSpec): string {
193
276
  return rawId.replace(/^(google\/|gemini\/|models\/)/i, "");
194
277
  }
195
278
 
279
+ /**
280
+ * Enforces the Gemini 3 minimum generation floor: Gemini 1.x/2.x are
281
+ * deprecated and unsupported — fail fast before any network call.
282
+ * Non-`gemini-` model ids are left to the catalog/provider verdict.
283
+ */
284
+ function assertSupportedGeneration(clean: string): void {
285
+ const match = clean.match(/^gemini-(\d+)(?:[.\-]|$)/i);
286
+ if (match && Number(match[1]) < 3) {
287
+ throw new Error(
288
+ `[Agent Accelerator] [google/${clean}] Unsupported generation: Gemini ${match[1]}.x and older are strictly rejected by this SDK. ` +
289
+ `Use a Gemini 3 or later model for full support and feature coverage.`
290
+ );
291
+ }
292
+ }
293
+
294
+ /** Throws a concise error for failed/cancelled interactions (never silent empty success). */
295
+ function throwIfFailedInteraction(interaction: InteractionObject, modelId: string): void {
296
+ if (interaction.status !== "failed" && interaction.status !== "cancelled") return;
297
+ const message =
298
+ interaction.error && typeof interaction.error.message === "string" && interaction.error.message
299
+ ? interaction.error.message
300
+ : `Google interaction ${interaction.status}`;
301
+ const failure: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
302
+ if (interaction.error?.code !== undefined) failure["code"] = interaction.error.code;
303
+ throw toConciseProviderError(failure, "google", modelId);
304
+ }
305
+
196
306
  function toGoogleTools(tools?: StandardToolDeclaration[]): Array<Record<string, unknown>> | undefined {
197
307
  if (!tools || tools.length === 0) return undefined;
198
308
  return tools.map((t) => ({
@@ -260,19 +370,40 @@ function resultToText(result: unknown): string {
260
370
  return safeStringify(result);
261
371
  }
262
372
 
263
- /** Latest-turn input for stateful chaining (server already holds history). */
373
+ /**
374
+ * Latest-turn input for stateful chaining (the server already holds history):
375
+ * everything AFTER the last assistant message, in order. That is normally one
376
+ * user prompt (sent as plain text/blocks), but after tool execution it is the
377
+ * `function_result` steps, and a mid-run `steer()` can append user messages
378
+ * behind them. Sending only the last message would drop the tool results and
379
+ * leave the chained interaction waiting on its function calls.
380
+ */
264
381
  async function latestTurnInput(context: ProviderContext): Promise<string | InteractionContentBlock[] | InteractionInputStep[]> {
265
382
  const messages = context.messages;
266
- // Trailing tool messages -> function_result steps.
267
- const trailingTool: Message[] = [];
383
+ let lastAssistant = -1;
268
384
  for (let i = messages.length - 1; i >= 0; i--) {
269
- const m = messages[i]!;
270
- if (m.role === "tool") trailingTool.unshift(m);
271
- else break;
385
+ if (messages[i]!.role === "assistant") {
386
+ lastAssistant = i;
387
+ break;
388
+ }
272
389
  }
273
- if (trailingTool.length > 0) {
274
- const steps: InteractionInputStep[] = [];
275
- for (const m of trailingTool) {
390
+ const tail = messages.slice(lastAssistant + 1).filter((m) => m.role !== "system");
391
+ if (tail.length === 0) return "";
392
+
393
+ const hasToolResults = tail.some((m) => m.role === "tool");
394
+ if (!hasToolResults && tail.length === 1) {
395
+ const only = tail[0]!;
396
+ if (typeof only.content === "string") return only.content;
397
+ const blocks = await contentPartsToBlocks(only.content);
398
+ if (blocks.length === 1 && blocks[0]!.type === "text") {
399
+ return (blocks[0] as { type: "text"; text: string }).text;
400
+ }
401
+ return blocks;
402
+ }
403
+
404
+ const steps: InteractionInputStep[] = [];
405
+ for (const m of tail) {
406
+ if (m.role === "tool") {
276
407
  if (!Array.isArray(m.content)) continue;
277
408
  for (const part of m.content) {
278
409
  if (part.type === "tool_result") {
@@ -284,17 +415,16 @@ async function latestTurnInput(context: ProviderContext): Promise<string | Inter
284
415
  });
285
416
  }
286
417
  }
418
+ } else if (m.role === "user") {
419
+ if (typeof m.content === "string") {
420
+ steps.push({ type: "user_input", content: [{ type: "text", text: m.content }] });
421
+ } else {
422
+ const blocks = await contentPartsToBlocks(m.content);
423
+ if (blocks.length > 0) steps.push({ type: "user_input", content: blocks });
424
+ }
287
425
  }
288
- return steps;
289
- }
290
- const last = messages[messages.length - 1];
291
- if (!last) return "";
292
- if (typeof last.content === "string") return last.content;
293
- const blocks = await contentPartsToBlocks(last.content);
294
- if (blocks.length === 1 && blocks[0]!.type === "text") {
295
- return (blocks[0] as { type: "text"; text: string }).text;
296
426
  }
297
- return blocks;
427
+ return steps;
298
428
  }
299
429
 
300
430
  /** Full-history Steps for stateless (provider-switch) turns. */
@@ -324,16 +454,18 @@ async function fullHistorySteps(context: ProviderContext): Promise<InteractionIn
324
454
  if (part.type === "thinking" && part.thinking) {
325
455
  // Q-18: preserve namespaced signatures only — foreign-provider
326
456
  // signatures never echo here; untagged legacy echoes (compat).
457
+ // Absent signatures are omitted (never sent as empty strings).
327
458
  const partProvider = (part as { thoughtSignatureProvider?: unknown }).thoughtSignatureProvider;
328
459
  const msgProvider = (m as { thoughtSignatureProvider?: unknown }).thoughtSignatureProvider;
329
460
  const sig = shouldEchoSignature(partProvider ?? msgProvider, "google")
330
461
  ? part.thoughtSignature || m.thoughtSignature || ""
331
462
  : "";
332
- steps.push({
463
+ const thoughtStep: { type: "thought"; signature?: string; summary: Array<{ type: string; text?: string }> } = {
333
464
  type: "thought",
334
- signature: sig,
335
465
  summary: [{ type: "text", text: part.thinking }],
336
- });
466
+ };
467
+ if (sig) thoughtStep.signature = sig;
468
+ steps.push(thoughtStep);
337
469
  } else if (part.type === "tool_call") {
338
470
  steps.push({
339
471
  type: "function_call",
@@ -382,7 +514,11 @@ async function fullHistorySteps(context: ProviderContext): Promise<InteractionIn
382
514
 
383
515
  function mapUsage(raw?: InteractionObject["usage"]): TokenUsage {
384
516
  const input = raw?.total_input_tokens ?? 0;
385
- const output = raw?.total_output_tokens ?? 0;
517
+ // Google reports thought tokens separately from `total_output_tokens`, but bills them as output
518
+ // ("response pricing is the sum of output tokens and thinking tokens"). Fold them into output so
519
+ // pricing is right and `thinkingTokens` stays a subset of `outputTokens`.
520
+ const thought = raw?.total_thought_tokens ?? 0;
521
+ const output = (raw?.total_output_tokens ?? 0) + thought;
386
522
  // Canonical invariant: cached hits are a SUBSET of input. Google usually
387
523
  // honors this, but long chained runs have been observed reporting
388
524
  // total_cached_tokens > total_input_tokens (which yields >100% hit rates
@@ -395,13 +531,14 @@ function mapUsage(raw?: InteractionObject["usage"]): TokenUsage {
395
531
  cachedTokens: cached,
396
532
  cacheReadTokens: cached,
397
533
  cacheWriteTokens: 0,
398
- thinkingTokens: raw?.total_thought_tokens ?? 0,
534
+ thinkingTokens: thought,
399
535
  };
400
536
  }
401
537
 
402
538
  function mapFinishReason(status?: string): string {
403
539
  if (status === "requires_action") return "tool_calls";
404
540
  if (status === "incomplete") return "length";
541
+ if (status === "failed" || status === "cancelled" || status === "error") return "error";
405
542
  return "stop";
406
543
  }
407
544
 
@@ -411,6 +548,9 @@ function parseInteraction(
411
548
  durationMs: number,
412
549
  raw: ProviderRawData
413
550
  ): ProviderGenerateResult {
551
+ // Failed/cancelled interactions must surface loudly — never resolve as
552
+ // empty successful stops.
553
+ throwIfFailedInteraction(interaction, modelId);
414
554
  let text = "";
415
555
  const thinkingParts: string[] = [];
416
556
  let thoughtSignature: string | undefined;
@@ -434,8 +574,15 @@ function parseInteraction(
434
574
  if (block.type === "text" && block.text) text += block.text;
435
575
  }
436
576
  } else if (step.type === "function_call") {
577
+ // Object args as-is; a JSON string is parsed (unparseable text becomes the
578
+ // truncated marker); absent args mean a zero-argument call.
579
+ const rawArgs: unknown = step.arguments;
437
580
  const args =
438
- step.arguments && typeof step.arguments === "object" ? step.arguments : { raw: step.arguments };
581
+ rawArgs && typeof rawArgs === "object"
582
+ ? rawArgs
583
+ : typeof rawArgs === "string"
584
+ ? parseArguments(rawArgs)
585
+ : {};
439
586
  toolCalls.push({
440
587
  id: step.id || newToolCallId(),
441
588
  name: step.name || "unknown",
@@ -520,6 +667,8 @@ export class GoogleInteractionsProvider implements Provider {
520
667
  options?.thinking?.enabled !== false && (options?.thinking?.level ?? "dynamic") !== "none";
521
668
  if (thinkingActive) generationConfig["thinking_summaries"] = "auto";
522
669
  if (toolChoice) generationConfig["tool_choice"] = toolChoice;
670
+ // max_output_tokens only; stop and seed warn+drop.
671
+ Object.assign(generationConfig, mapGenerationParamsToGoogle(options, `google/${modelId}`));
523
672
 
524
673
  const body: InteractionRequestBody = {
525
674
  model: modelId,
@@ -566,10 +715,15 @@ export class GoogleInteractionsProvider implements Provider {
566
715
 
567
716
  const prevProvider = lastProviderFor(sessionId);
568
717
  const switched = !!prevProvider && prevProvider !== "google";
569
- const chain = chainFor(sessionId);
718
+ // Proactive TTL gate (Rule 1): expired chains are dropped locally so
719
+ // this turn seeds fresh instead of wasting an RTT on a dead pointer.
720
+ const chain = liveChainFor(sessionId, options);
570
721
  // Interaction ids are model-scoped server-side: chaining a 2.5 id into
571
722
  // a 3.5 request 400s, so a same-provider model switch resumes stateless.
572
723
  const continuing = !!chain && !switched && chain.model === modelId && context.messages.length > 1;
724
+ // A model switch reseeds from full history and tokenizes differently: drop the token
725
+ // baseline so the new model's first turn is not mistaken for a shrunken history.
726
+ if (chain && sessionId && chain.model !== modelId) lastTurnTotals.delete(sessionId);
573
727
 
574
728
  if (switched) {
575
729
  // Cross-provider turn: the Google server never saw the other
@@ -620,7 +774,7 @@ export class GoogleInteractionsProvider implements Provider {
620
774
  ): Promise<{ status: number; statusText: string; headers: Record<string, string>; text: string }> {
621
775
  const { signal: effective, cleanup } = combinedSignal(signal, timeoutFor(options as { timeoutMs?: number } | undefined));
622
776
  try {
623
- const res = await fetch(url, this.requestInit(body, options, apiKey, effective));
777
+ const res = await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, effective));
624
778
  const text = await res.text();
625
779
  const headers: Record<string, string> = {};
626
780
  res.headers.forEach((v, k) => {
@@ -637,7 +791,8 @@ export class GoogleInteractionsProvider implements Provider {
637
791
  statusText: string,
638
792
  url: string,
639
793
  bodyText: string,
640
- modelId: string
794
+ modelId: string,
795
+ headers?: Record<string, string>
641
796
  ): void {
642
797
  if (status >= 200 && status < 300) return;
643
798
  const { message, code, errorType, raw } = readErrorPayload(bodyText);
@@ -655,6 +810,8 @@ export class GoogleInteractionsProvider implements Provider {
655
810
  if (code !== undefined) (err as Record<string, unknown>)["code"] = code;
656
811
  if (errorType) (err as Record<string, unknown>)["errorType"] = errorType;
657
812
  if (raw) (err as Record<string, unknown>)["raw"] = raw;
813
+ const retryAfterMs = parseRetryAfterMs(headers);
814
+ if (retryAfterMs !== undefined) (err as Record<string, unknown>)["retryAfterMs"] = retryAfterMs;
658
815
  throw toConciseProviderError(err, "google", modelId);
659
816
  }
660
817
 
@@ -665,6 +822,8 @@ export class GoogleInteractionsProvider implements Provider {
665
822
  ): Promise<ProviderGenerateResult> {
666
823
  const startTime = Date.now();
667
824
  const clean = cleanModelId(model);
825
+ // Hard generation floor: Gemini 1.x/2.x fail fast before any network call.
826
+ assertSupportedGeneration(clean);
668
827
  const apiKey = resolveApiKey(options);
669
828
  if (!apiKey) {
670
829
  throw new Error(
@@ -679,9 +838,9 @@ export class GoogleInteractionsProvider implements Provider {
679
838
  const url = `${baseUrl}/interactions`;
680
839
  const sessionId = options?.sessionId || options?.cache?.sessionId;
681
840
  const tools = options?.tools as StandardToolDeclaration[] | undefined;
682
- const body = await this.buildBody(clean, context, options, sessionId, tools, false);
841
+ let body = await this.buildBody(clean, context, options, sessionId, tools, false);
683
842
 
684
- const rawRequest = {
843
+ const makeRawRequest = (b: InteractionRequestBody): ProviderRawData["request"] => ({
685
844
  url,
686
845
  method: "POST",
687
846
  headers: redactedHeaders({
@@ -691,12 +850,13 @@ export class GoogleInteractionsProvider implements Provider {
691
850
  ),
692
851
  "x-goog-api-key": "[REDACTED]",
693
852
  }),
694
- body,
695
- };
853
+ body: b,
854
+ });
855
+ let rawRequest = makeRawRequest(body);
696
856
 
697
857
  const doCall = async (): Promise<ProviderGenerateResult> => {
698
858
  const res = await this.doFetch(url, body, options, apiKey, options?.signal);
699
- this.throwIfError(res.status, res.statusText, url, res.text, clean);
859
+ this.throwIfError(res.status, res.statusText, url, res.text, clean, res.headers);
700
860
  let interaction: InteractionObject;
701
861
  try {
702
862
  interaction = JSON.parse(res.text) as InteractionObject;
@@ -712,28 +872,41 @@ export class GoogleInteractionsProvider implements Provider {
712
872
  );
713
873
  }
714
874
  if (interaction.id && sessionId && body.store !== false) {
715
- setChain(sessionId, { interactionId: interaction.id, model: clean });
875
+ setChain(sessionId, { interactionId: interaction.id, model: clean, at: Date.now() });
716
876
  } else if (body.store === false && sessionId) {
717
877
  interactionChains.delete(sessionId);
878
+ lastTurnTotals.delete(sessionId);
718
879
  }
719
880
  noteProviderTurn(sessionId, "google");
720
- return parseInteraction(
881
+ const parsed = parseInteraction(
721
882
  interaction,
722
883
  clean,
723
884
  Date.now() - startTime,
724
885
  { request: rawRequest, response: { status: res.status, statusText: res.statusText, headers: res.headers, body: interaction } }
725
886
  );
887
+ checkTurnTokens(sessionId, parsed.usage);
888
+ return parsed;
726
889
  };
727
890
 
891
+ const retryOptions = {
892
+ ...retryOptionsFrom(options),
893
+ signal: options?.signal,
894
+ label: { providerId: "google", modelId: clean },
895
+ };
728
896
  try {
729
- return await withRetries(doCall, {
730
- maxRetries: options?.maxRetries,
731
- maxRetryDelayMs: options?.maxRetryDelayMs,
732
- signal: options?.signal,
733
- label: { providerId: "google", modelId: clean },
734
- });
897
+ return await withRetries(doCall, retryOptions);
735
898
  } catch (err) {
736
899
  if (err instanceof Error && (err as { name?: string }).name === "AbortError") throw err;
900
+ // Reactive self-heal (Rule 2): a chained pointer the server no longer
901
+ // has is retried once against the full canonical history with the dead
902
+ // chain cleared, so the turn succeeds instead of wedging the session.
903
+ if (sessionId && body.previous_interaction_id && isNotFoundChainError(err)) {
904
+ reportChainAnomaly({ type: "interaction_chain_evicted", sessionId, model: clean });
905
+ clearInteractionChains(sessionId);
906
+ body = await this.buildBody(clean, context, options, sessionId, tools, false);
907
+ rawRequest = makeRawRequest(body);
908
+ return await withRetries(doCall, retryOptions);
909
+ }
737
910
  throw err;
738
911
  }
739
912
  }
@@ -766,7 +939,13 @@ export class GoogleInteractionsProvider implements Provider {
766
939
  });
767
940
 
768
941
  (async () => {
942
+ // Hoisted for the catch-block self-heal (consts inside try are not
943
+ // visible in catch).
944
+ let evictSessionId: string | undefined;
945
+ let usedChain = false;
769
946
  try {
947
+ // Hard generation floor (mirrors generate): no network on legacy ids.
948
+ assertSupportedGeneration(clean);
770
949
  const apiKey = resolveApiKey(options);
771
950
  if (!apiKey) {
772
951
  throw new Error(
@@ -779,6 +958,8 @@ export class GoogleInteractionsProvider implements Provider {
779
958
  const sessionId = options?.sessionId || options?.cache?.sessionId;
780
959
  const tools = options?.tools as StandardToolDeclaration[] | undefined;
781
960
  const body = await this.buildBody(clean, context, options, sessionId, tools, true);
961
+ evictSessionId = sessionId;
962
+ usedChain = body.previous_interaction_id !== undefined;
782
963
 
783
964
  // Audit trail mirrors generate (custom headers included); the
784
965
  // Interactions API sends no session-affinity headers (implicit
@@ -797,25 +978,30 @@ export class GoogleInteractionsProvider implements Provider {
797
978
  };
798
979
 
799
980
  const streamTimeoutMs = timeoutFor(options as { timeoutMs?: number } | undefined);
800
- const streamTimeoutTimer =
801
- streamTimeoutMs && streamTimeoutMs > 0
802
- ? setTimeout(() => {
803
- try {
804
- linked.abort();
805
- } catch {}
806
- }, streamTimeoutMs)
807
- : undefined;
808
- let res: Response;
809
- try {
810
- res = await fetch(url, this.requestInit(body, options, apiKey, linked.signal));
811
- } finally {
812
- if (streamTimeoutTimer) clearTimeout(streamTimeoutTimer);
813
- }
814
- if (!res.ok || !res.body) {
815
- const text = !res.ok ? await res.text().catch(() => "") : "";
816
- if (!res.ok) this.throwIfError(res.status, res.statusText, url, text, clean);
817
- throw toConciseProviderError(new Error("Google streaming response had no body"), "google", clean);
818
- }
981
+ // Transient failures (429/5xx/network) are retried here, before the
982
+ // first byte; once the stream is live it is never retried.
983
+ const res = await openStreamResponse({
984
+ fetchOnce: async () => {
985
+ const timer =
986
+ streamTimeoutMs && streamTimeoutMs > 0
987
+ ? setTimeout(() => {
988
+ try {
989
+ linked.abort();
990
+ } catch {}
991
+ }, streamTimeoutMs)
992
+ : undefined;
993
+ try {
994
+ return await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, linked.signal));
995
+ } finally {
996
+ if (timer) clearTimeout(timer);
997
+ }
998
+ },
999
+ onHttpError: (r, text) => this.throwIfError(r.status, r.statusText, url, text, clean, headersToRecord(r.headers)),
1000
+ noBodyError: () => toConciseProviderError(new Error("Google streaming response had no body"), "google", clean),
1001
+ retry: retryOptionsFrom(options),
1002
+ signal: linked.signal,
1003
+ label: { providerId: "google", modelId: clean },
1004
+ });
819
1005
  const responseHeaders: Record<string, string> = {};
820
1006
  res.headers.forEach((v, k) => {
821
1007
  responseHeaders[k] = v;
@@ -824,7 +1010,7 @@ export class GoogleInteractionsProvider implements Provider {
824
1010
  eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
825
1011
 
826
1012
  const parser = new SSEParser();
827
- const reader = res.body.getReader();
1013
+ const reader = res.body!.getReader(); // openStreamResponse guarantees a body
828
1014
  const decoder = new TextDecoder();
829
1015
  let text = "";
830
1016
  let thinking = "";
@@ -921,6 +1107,9 @@ export class GoogleInteractionsProvider implements Provider {
921
1107
  }
922
1108
  } else if (type === "interaction.completed") {
923
1109
  const interaction = (msg["interaction"] as InteractionObject) ?? {};
1110
+ // Failed/cancelled interactions must surface loudly — never
1111
+ // resolve as empty successful stops.
1112
+ throwIfFailedInteraction(interaction, clean);
924
1113
  completedBody = msg["interaction"];
925
1114
  if (interaction.id) responseId = interaction.id;
926
1115
  if (interaction.usage) {
@@ -961,17 +1150,21 @@ export class GoogleInteractionsProvider implements Provider {
961
1150
  id: c.id,
962
1151
  name: c.name,
963
1152
  arguments: args,
964
- rawArguments: c.startArgs + c.deltaArgs,
1153
+ rawArguments: selectRawArguments(c.startArgs, c.deltaArgs),
965
1154
  };
966
1155
  toolCalls.push(record);
967
1156
  eventStream.push({ type: "tool_call_complete", toolCall: record });
968
1157
  }
969
1158
 
970
1159
  if (sessionId) {
971
- if (responseId && body.store !== false) setChain(sessionId, { interactionId: responseId, model: clean });
972
- else if (body.store === false) interactionChains.delete(sessionId);
1160
+ if (responseId && body.store !== false) setChain(sessionId, { interactionId: responseId, model: clean, at: Date.now() });
1161
+ else if (body.store === false) {
1162
+ interactionChains.delete(sessionId);
1163
+ lastTurnTotals.delete(sessionId);
1164
+ }
973
1165
  }
974
1166
  noteProviderTurn(sessionId, "google");
1167
+ checkTurnTokens(sessionId, usage);
975
1168
 
976
1169
  // Q-23: terminal-state guard — fail `incomplete_stream` (retryable)
977
1170
  // instead of resolving empty success. Skipped for non-SSE payloads
@@ -1015,11 +1208,26 @@ export class GoogleInteractionsProvider implements Provider {
1015
1208
  const isAbort =
1016
1209
  linked.signal.aborted ||
1017
1210
  eventStream.isCancelled() ||
1018
- (raw as { name?: string }).name === "AbortError" ||
1019
- /abort|cancell?ed/i.test(String((raw as { message?: string }).message ?? raw));
1020
- eventStream.fail(
1021
- isAbort ? Object.assign(new Error("Stream aborted"), { name: "AbortError" }) : raw
1022
- );
1211
+ (raw as { name?: string }).name === "AbortError";
1212
+ if (isAbort) {
1213
+ eventStream.fail(
1214
+ Object.assign(new Error("Stream aborted"), { name: "AbortError" })
1215
+ );
1216
+ } else if (evictSessionId && usedChain && isNotFoundChainError(raw)) {
1217
+ // Reactive self-heal, fail-clean variant (Rule 2): the `start`
1218
+ // event is already delivered, so a transparent in-stream retry
1219
+ // would duplicate it. Clear the dead pointer now so the next turn
1220
+ // reseeds from full history; mark retryable so a plain caller retry
1221
+ // of this turn succeeds.
1222
+ reportChainAnomaly({ type: "interaction_chain_evicted", sessionId: evictSessionId, model: clean });
1223
+ clearInteractionChains(evictSessionId);
1224
+ const retryable = toConciseProviderError(raw, "google", clean);
1225
+ (retryable as unknown as Record<string, unknown>)["retryable"] = true;
1226
+ (retryable as unknown as Record<string, unknown>)["transient"] = true;
1227
+ eventStream.fail(retryable);
1228
+ } else {
1229
+ eventStream.fail(raw);
1230
+ }
1023
1231
  } finally {
1024
1232
  try {
1025
1233
  options?.signal?.removeEventListener("abort", forwardUserAbort);