agent-accelerator 0.5.1 → 0.5.2

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
@@ -520,6 +520,24 @@ Model resolution never silently selects a default model: an empty model throws.
520
520
 
521
521
  An unknown prefix with no catalog match still routes to a custom provider, allowing private model IDs and endpoints.
522
522
 
523
+ ### Google long decode (Gemini 4)
524
+
525
+ Google caps one request at 65,536 output tokens. Set `maxOutputTokens` above
526
+ that on a `gemini-4-*` model and the SDK runs the request as a background
527
+ interaction automatically (no extra option): up to 1,048,576 tokens minus the
528
+ input, for up to 24 hours. `run()` polls until it finishes (2 s, then every
529
+ 5 s); `stream()` reconnects after a dropped or silent connection
530
+ (`last_event_id`, 15 min of silence counts as dropped). Aborting cancels the
531
+ interaction on Google's side, so it stops billing. Each HTTP request is still
532
+ timed by `limits.requestTimeoutMs`, but the whole run is not. Any other Google
533
+ model with `maxOutputTokens` above 65,536 fails before the network with a
534
+ `ConfigError`. Use it for hard reasoning, not chat: you pay for every token of
535
+ every 64K hop.
536
+
537
+ A custom `Provider` can opt into the same treatment with the optional
538
+ `isLongRunning(model, options)` method: when it returns `true`, the loop skips
539
+ its whole-call, first-byte and idle timers and leaves timing to the adapter.
540
+
523
541
  ### Registry
524
542
 
525
543
  * `resolveModel("google/x" | ModelSpec)` → `{ provider, modelId, modelSpec? }`. Use to inspect routing before execution.
@@ -1118,10 +1136,19 @@ Message {
1118
1136
  role: "system" | "user" | "assistant" | "tool",
1119
1137
  content: string | ContentPart[],
1120
1138
  name?,
1121
- thoughtSignature?
1139
+ thoughtSignature?,
1140
+ providerSteps? // { provider, steps }: raw output steps of an assistant turn
1122
1141
  }
1123
1142
  ```
1124
1143
 
1144
+ `providerSteps` keeps a provider's raw output for one assistant turn (today:
1145
+ Google's `steps`, including thought signatures). When Google needs the full
1146
+ history again (after a restart, an expired chain or a provider switch), those
1147
+ turns are resent exactly as received, which keeps reasoning continuity and
1148
+ the implicit-cache prefix intact. Other providers ignore it. Adapters return
1149
+ it on `ProviderGenerateResult` / per-turn stream `AgentResponse`; it is never
1150
+ part of `toJSON()`.
1151
+
1125
1152
  ### ProviderContext
1126
1153
 
1127
1154
  ```ts
@@ -1140,9 +1167,9 @@ Supported content parts include:
1140
1167
  * `ThinkingPart { type:"thinking", thinking, thoughtSignature? }` — preserved reasoning.
1141
1168
  * `ToolCallPart { type:"tool_call", id, name, arguments, rawArguments?, thoughtSignature? }`
1142
1169
  * `ToolResultPart { type:"tool_result", id, name, result, isError? }`
1143
- * `ImagePart { type:"image", image, mimeType? }`
1170
+ * `ImagePart { type:"image", image, mimeType?, resolution? }`
1144
1171
  * `AudioPart { type:"audio", audio, mimeType? }`
1145
- * `VideoPart { type:"video", video, mimeType? }`
1172
+ * `VideoPart { type:"video", video, mimeType?, resolution? }`
1146
1173
  * `FilePart { type:"file", file, mimeType?, filename? }` — PDFs/documents.
1147
1174
 
1148
1175
  `image`, `audio`, `video`, and `file` inputs accept:
@@ -1171,6 +1198,20 @@ Media normalization is handled automatically by providers (mapped to provider-na
1171
1198
  Provider matrix: text + image + wav/mp3 audio + PDF work on all supported
1172
1199
  providers. Video works on Gemini only.
1173
1200
 
1201
+ On Google, links Google fetches itself go as a `uri` without downloading:
1202
+ YouTube links, Files API links, `gs://` objects and public `https://` URLs of
1203
+ URL-supported types (PDF, CSV, text, JSON, PNG/JPEG/WebP/BMP, common video;
1204
+ max 15 MB). Audio URLs and `http://` links are downloaded and inlined. Inline
1205
+ types are checked before the network; Google reads only PDF and CSV
1206
+ documents. The supported lists are the `GOOGLE_*_MIME` constants at the top of
1207
+ `src/providers/google.ts`.
1208
+
1209
+ `resolution` (`MediaResolution`: `"low" | "medium" | "high" | "ultra_high"`)
1210
+ sets how much detail Google spends on one image or video, and so how many
1211
+ input tokens it costs (image: 280 / 560 / 1120 / 2240; video per frame:
1212
+ `low` and `medium` 70, `high` 280). Omit it for Google's default. Other
1213
+ providers ignore it with one `provider_warning`.
1214
+
1174
1215
  Before any network call, the executor checks `image` / `audio` / `video` /
1175
1216
  `file`-as-PDF parts against the catalog's `modalities.input` for that
1176
1217
  provider/model and fails fast with a one-line error naming the gap
@@ -1285,7 +1326,7 @@ saveSessionFile(".session.json", agent, telemetry);
1285
1326
 
1286
1327
  * `agent.exportSession(telemetry)` / `agent.importSession(saved)` round-trip messages, system prompt, model, thinking level, instructions, cache, worker model, and sub-agent traces without touching `AgentContext` internals.
1287
1328
  * `SessionTelemetry` accumulates `input / output / cacheRead / cacheWrite / reasoning / cost`, with clamped `turnHitRate()` / `totalHitRate()`, a dual `formatBar(res)`, and `formatSessionBanner(saved, telemetry, path)` for startup. Cost follows the [TokenUsage](#response-usage-and-raw-data) rules.
1288
- * Files are JSON with one line per top-level field and one line per turn (2-space indent), written atomically (temp + rename).
1329
+ * Files are JSON with one line per top-level field and one line per turn (2-space indent), written atomically (temp + rename). Google turns also store their raw `providerSteps`, so a resumed session replays them exactly.
1289
1330
  * Binary media (bytes, data URLs, base64) is extracted to `media/` on save via `saveSessionDir("sessions", agent, telemetry)` → `sessions/<sessionId>/{session.json, media/*}`; remote URLs and local paths stay references. `loadSessionDir(dir)` resolves `media/…` refs back to absolute paths, and `findLatestSessionDir("sessions")` resumes the most recent session.
1290
1331
  * Sub-agent traces persist in a dedicated `subagents` section keyed by TrackingID (`{ trackingId, name, sessionId, parentSessionId, status, task, turns, usage, steps, text }`). Files stay lean: redundant `rawArguments` and repeated step text are dropped on save (rebuilt/kept live in memory).
1291
1332
  * With `persist: { dir }` or `{ file }`, the session file is rewritten after every step, so a crash loses at most the in-flight step.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-accelerator",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "High-performance SDK for AI agents with a unified API across AI providers. Optimized for high cache hit rates, observability, and seamless agent orchestration. Built to power production-grade agentic systems and harnesses for teams at any scale.",
5
5
  "license": "MIT",
6
6
  "author": "Sashvat Bharat",
@@ -1,4 +1,4 @@
1
- import type { Message, ContentPart } from "../types/message.ts";
1
+ import type { Message, ContentPart, ProviderSteps } from "../types/message.ts";
2
2
  import type { ToolCallRecord, ToolResultRecord } from "../types/tool.ts";
3
3
  import { safeStringify } from "../utils/serialization.ts";
4
4
  import { clearSessionRouting } from "../providers.ts";
@@ -56,11 +56,13 @@ export class AgentContext {
56
56
  });
57
57
  }
58
58
 
59
+ // Stores one model turn as an assistant message (text, thinking, tool calls, raw provider steps for replay); in: a provider turn from the loop, out: history.
59
60
  addAssistantMessage(
60
61
  text: string,
61
62
  toolCalls?: ToolCallRecord[],
62
63
  thinking?: string,
63
- thoughtSignature?: string
64
+ thoughtSignature?: string,
65
+ providerSteps?: ProviderSteps
64
66
  ): void {
65
67
  const parts: ContentPart[] = [];
66
68
  const hasToolCalls = Boolean(toolCalls && toolCalls.length > 0);
@@ -110,6 +112,7 @@ export class AgentContext {
110
112
  role: "assistant",
111
113
  content: parts.length > 0 ? parts : text,
112
114
  thoughtSignature,
115
+ ...(providerSteps ? { providerSteps } : {}),
113
116
  });
114
117
 
115
118
  if (thoughtSignature) {
package/src/agent/loop.ts CHANGED
@@ -666,6 +666,8 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
666
666
  if (gTier === "paid" || gTier === "free") providerOptions.googleTier = gTier;
667
667
  } catch {}
668
668
 
669
+ // A long-running call (e.g. Google long decode) times each HTTP request itself: no whole-call race.
670
+ const longRunning = provider.isLongRunning?.(modelId, providerOptions) === true;
669
671
  const genResult = await generateWithTimeout(
670
672
  () => provider.generate(
671
673
  modelId,
@@ -675,7 +677,7 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
675
677
  },
676
678
  providerOptions
677
679
  ),
678
- limits.requestTimeoutMs
680
+ longRunning ? Infinity : limits.requestTimeoutMs
679
681
  );
680
682
 
681
683
  finalResult = genResult;
@@ -710,7 +712,8 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
710
712
  genResult.text,
711
713
  genResult.toolCalls,
712
714
  genResult.thinking,
713
- genResult.thoughtSignature
715
+ genResult.thoughtSignature,
716
+ genResult.providerSteps
714
717
  );
715
718
 
716
719
  // Thinking-trace telemetry (observability-only, full text, no limit):
@@ -1053,6 +1056,7 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1053
1056
  if (gTier === "paid" || gTier === "free") providerOptions.googleTier = gTier;
1054
1057
  } catch {}
1055
1058
 
1059
+ const longRunning = provider.isLongRunning?.(modelId, providerOptions) === true;
1056
1060
  const innerStream = provider.stream(
1057
1061
  modelId,
1058
1062
  {
@@ -1071,7 +1075,8 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1071
1075
  let first = true;
1072
1076
  let doneIter = false;
1073
1077
  while (!doneIter) {
1074
- const waitMs = first ? streamLimits.firstByteTimeoutMs : streamLimits.streamIdleTimeoutMs;
1078
+ // Long-running streams pause for minutes between chunks; the adapter guards stalls and reconnects.
1079
+ const waitMs = longRunning ? Infinity : first ? streamLimits.firstByteTimeoutMs : streamLimits.streamIdleTimeoutMs;
1075
1080
  let timer: ReturnType<typeof setTimeout> | undefined;
1076
1081
  const timeoutP =
1077
1082
  Number.isFinite(waitMs) && waitMs !== Infinity && waitMs > 0
@@ -1147,7 +1152,8 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1147
1152
  turnResponse.text,
1148
1153
  turnResponse.toolCalls,
1149
1154
  turnResponse.thinking,
1150
- turnResponse.thoughtSignature
1155
+ turnResponse.thoughtSignature,
1156
+ turnResponse.providerSteps
1151
1157
  );
1152
1158
 
1153
1159
  // Thinking-trace telemetry (observability-only, full text, no limit):
package/src/index.ts CHANGED
@@ -362,6 +362,8 @@ export type {
362
362
  ToolResultPart,
363
363
  ThinkingPart,
364
364
  ProviderContext,
365
+ ProviderSteps,
366
+ MediaResolution,
365
367
  } from "./types/message.ts";
366
368
 
367
369
  export type {
@@ -29,18 +29,19 @@ import type {
29
29
  ProviderGenerateResult,
30
30
  ProviderRawData,
31
31
  } from "../types/model.ts";
32
- import type { ProviderContext, Message, ContentPart } from "../types/message.ts";
32
+ import type { ProviderContext, Message, ContentPart, ProviderSteps, ToolResultPart, MediaResolution } from "../types/message.ts";
33
33
  import type { StandardToolDeclaration, ToolCallRecord } from "../types/tool.ts";
34
34
  import type { TokenUsage } from "../types/core.ts";
35
35
  import { AssistantMessageEventStream } from "../streaming/event-stream.ts";
36
36
  import { SSEParser } from "../streaming/sse-parser.ts";
37
37
  import { AgentResponse } from "../types/response.ts";
38
38
  import { getApiKey, getEnv } from "../utils/env.ts";
39
- import { normalizeMediaInput } from "../utils/media.ts";
39
+ import { normalizeMediaInput, inferMimeType } from "../utils/media.ts";
40
+ import { ConfigError, ValidationError, isAbortError } from "../types/errors.ts";
40
41
  import { safeStringify } from "../utils/serialization.ts";
41
42
  import { stripSchemaForGoogle } from "../tools/schema.ts";
42
43
  import { toConciseProviderError, assertModalitiesSupported } from "../utils/errors.ts";
43
- import { withRetries } from "../utils/retry.ts";
44
+ import { withRetries, sleep } from "../utils/retry.ts";
44
45
  import { setHeaderCaseInsensitive, shouldEchoSignature } from "../utils/headers.ts";
45
46
  import {
46
47
  newToolCallId,
@@ -77,16 +78,102 @@ import {
77
78
  emitProviderWarning,
78
79
  } from "../providers.ts";
79
80
 
81
+ // ---------------------------------------------------------------------------
82
+ // Supported inputs (api-ref `interactions-api.md` enums, `docs/file-input-methods.md`)
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /** Image MIME types Google accepts inline. */
86
+ export const GOOGLE_IMAGE_MIME = ["image/png", "image/jpeg", "image/webp", "image/heic", "image/heif", "image/gif", "image/bmp", "image/tiff"] as const;
87
+ /** Audio MIME types Google accepts inline (audio is never fetched by URL). */
88
+ export const GOOGLE_AUDIO_MIME = ["audio/wav", "audio/mp3", "audio/aiff", "audio/aac", "audio/ogg", "audio/flac", "audio/mpeg", "audio/m4a", "audio/l16", "audio/opus", "audio/alaw", "audio/mulaw", "audio/webm"] as const;
89
+ /** Video MIME types Google accepts inline. */
90
+ export const GOOGLE_VIDEO_MIME = ["video/mp4", "video/mpeg", "video/mpg", "video/mov", "video/avi", "video/x-flv", "video/webm", "video/wmv", "video/3gpp"] as const;
91
+ /** Document MIME types Google accepts (the only two). */
92
+ export const GOOGLE_DOCUMENT_MIME = ["application/pdf", "text/csv"] as const;
93
+ /** MIME types Google fetches itself from a public `https://` URL. */
94
+ export const GOOGLE_URL_MIME = [
95
+ "text/html", "text/css", "text/plain", "text/xml", "text/csv", "text/rtf", "text/javascript",
96
+ "application/json", "application/pdf",
97
+ "image/bmp", "image/jpeg", "image/png", "image/webp",
98
+ "video/mp4", "video/mpeg", "video/quicktime", "video/avi", "video/x-flv", "video/mpg", "video/webm", "video/wmv", "video/3gpp",
99
+ ] as const;
100
+ /** Common MIME spellings mapped to Google's inline names. */
101
+ export const GOOGLE_INLINE_MIME_ALIASES: Readonly<Record<string, string>> = {
102
+ "video/quicktime": "video/mov",
103
+ "audio/x-wav": "audio/wav",
104
+ "audio/wave": "audio/wav",
105
+ "image/jpg": "image/jpeg",
106
+ };
107
+ // Documented size limits. Not enforced yet: oversized inputs get Google's own error.
108
+ /** Max inline payload per request (bytes). */
109
+ export const GOOGLE_MAX_INLINE_REQUEST_BYTES = 100 * 1024 * 1024;
110
+ /** Max inline PDF size (bytes). */
111
+ export const GOOGLE_MAX_INLINE_PDF_BYTES = 50 * 1024 * 1024;
112
+ /** Max size of one file Google fetches by URL (bytes). */
113
+ export const GOOGLE_MAX_URL_FILE_BYTES = 15 * 1024 * 1024;
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Long decode (`docs/long-decode.md`, `docs/background-execution.md`)
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /** Output tokens one non-background request may produce; above it Gemini 4 runs as a background interaction (long decode). */
120
+ export const GOOGLE_MAX_SYNC_OUTPUT_TOKENS = 65_536;
121
+ // A background stream pauses between ~5K-token chunks; only this much silence counts as a dropped connection (docs: 15-20 min).
122
+ const BACKGROUND_STREAM_STALL_MS = 15 * 60_000;
123
+ // Reconnects in a row that bring no new event before a background stream gives up.
124
+ const BACKGROUND_MAX_RECONNECTS = 5;
125
+
126
+ // True when a request needs Gemini 4 long decode (background); any other model above the limit fails before the network; in: model id + maxOutputTokens, out: buildBody, isLongRunning.
127
+ function needsLongDecode(modelId: string, maxOutputTokens: number | undefined): boolean {
128
+ if (!maxOutputTokens || maxOutputTokens <= GOOGLE_MAX_SYNC_OUTPUT_TOKENS) return false;
129
+ if (/^gemini-4/i.test(modelId)) return true;
130
+ throw new ConfigError(
131
+ `[Agent Accelerator] [google/${modelId}] maxOutputTokens ${maxOutputTokens} is above Google's ${GOOGLE_MAX_SYNC_OUTPUT_TOKENS} limit; only Gemini 4 models can write more (long decode). Lower maxOutputTokens or use a gemini-4 model.`
132
+ );
133
+ }
134
+
135
+ // True while a background interaction is still working; in: Interaction.status, out: polling and stream reconnects.
136
+ function isRunning(status: string | undefined): boolean {
137
+ return status === "queued" || status === "in_progress";
138
+ }
139
+
140
+ // The two reader calls the stream loop uses (Bun ships two incompatible reader typings, so only these are typed).
141
+ type StreamReader = { read(): Promise<{ done: boolean; value?: Uint8Array }>; cancel(): Promise<void> };
142
+
143
+ // Reads the next chunk, or cancels the reader and rejects after `ms` of silence; in: a background stream reader, out: the stream read loop.
144
+ async function readWithin(reader: StreamReader, ms: number) {
145
+ let timer: ReturnType<typeof setTimeout> | undefined;
146
+ const stalled = new Promise<never>((_, reject) => {
147
+ timer = setTimeout(() => {
148
+ void reader.cancel().catch(() => {});
149
+ reject(new Error("Google background stream stalled"));
150
+ }, ms);
151
+ });
152
+ try {
153
+ return await Promise.race([reader.read(), stalled]);
154
+ } finally {
155
+ clearTimeout(timer);
156
+ }
157
+ }
158
+
159
+ type MediaBlockType = "image" | "audio" | "video" | "document";
160
+
161
+ const INLINE_MIME: Record<MediaBlockType, readonly string[]> = {
162
+ image: GOOGLE_IMAGE_MIME,
163
+ audio: GOOGLE_AUDIO_MIME,
164
+ video: GOOGLE_VIDEO_MIME,
165
+ document: GOOGLE_DOCUMENT_MIME,
166
+ };
167
+
168
+ const FILES_API_PREFIX = "https://generativelanguage.googleapis.com/v1beta/files/";
169
+ const YOUTUBE_URL = /^https:\/\/(www\.|m\.)?(youtube\.com|youtu\.be)\//i;
170
+
80
171
  // ---------------------------------------------------------------------------
81
172
  // Interactions wire shapes (subset used by this adapter)
82
173
  // ---------------------------------------------------------------------------
83
174
 
84
- type InteractionContentBlock =
85
- | { type: "text"; text: string }
86
- | { type: "image"; mime_type: string; data?: string; uri?: string }
87
- | { type: "audio"; mime_type: string; data?: string; uri?: string }
88
- | { type: "video"; mime_type: string; data?: string; uri?: string }
89
- | { type: "document"; mime_type: string; data?: string; uri?: string };
175
+ type MediaBlock = { type: MediaBlockType; mime_type?: string; data?: string; uri?: string; resolution?: MediaResolution };
176
+ type InteractionContentBlock = { type: "text"; text: string } | MediaBlock;
90
177
 
91
178
  type InteractionInputStep =
92
179
  | { type: "user_input"; content: string | InteractionContentBlock[] }
@@ -97,6 +184,7 @@ type InteractionInputStep =
97
184
  name: string;
98
185
  call_id: string;
99
186
  result: InteractionContentBlock[];
187
+ is_error?: boolean;
100
188
  }
101
189
  | { type: string; [k: string]: unknown };
102
190
 
@@ -111,6 +199,7 @@ interface InteractionRequestBody {
111
199
  store?: boolean;
112
200
  previous_interaction_id?: string;
113
201
  stream?: boolean;
202
+ background?: boolean;
114
203
  }
115
204
 
116
205
  interface InteractionStep {
@@ -147,6 +236,14 @@ interface InteractionObject {
147
236
  [k: string]: unknown;
148
237
  }
149
238
 
239
+ /** One parsed Interactions HTTP reply (create POST or poll GET). */
240
+ interface InteractionReply {
241
+ status: number;
242
+ statusText: string;
243
+ headers: Record<string, string>;
244
+ interaction: InteractionObject;
245
+ }
246
+
150
247
  const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
151
248
 
152
249
  /** Per-session interaction chaining (provider-generated ids only). */
@@ -321,48 +418,102 @@ function toGoogleToolChoice(
321
418
  return { allowed_tools: { mode: norm.mode } };
322
419
  }
323
420
 
324
- function mediaBlockType(partType: string): "image" | "audio" | "video" | "document" {
325
- if (partType === "image" || partType === "audio" || partType === "video") return partType;
326
- // Canonical `file` parts are PDFs/documents. The guides reference document
327
- // processing with the same content-block pattern; the wire type is
328
- // `document`. If the server ever rejects it, the concise error surfaces it.
329
- return "document";
421
+ type MediaValue = string | Uint8Array | ArrayBuffer;
422
+
423
+ // Lower-cases a MIME type and drops parameters (`text/csv; charset=utf-8` -> `text/csv`); in: part or response MIME, out: list checks.
424
+ function bareMime(mime: string | undefined): string | undefined {
425
+ return mime ? mime.split(";")[0]!.trim().toLowerCase() || undefined : undefined;
426
+ }
427
+
428
+ // Returns the `uri` block for a link Google fetches itself (YouTube, Files API, `gs://`, https URL of a URL-supported type), else undefined; in: a media part's string value, out: contentPartsToBlocks.
429
+ function uriBlock(type: MediaBlockType, value: string, mimeType?: string): MediaBlock | undefined {
430
+ const explicit = bareMime(mimeType);
431
+ const withMime = (mime?: string): MediaBlock => ({ type, uri: value, ...(mime ? { mime_type: mime } : {}) });
432
+ if (type === "video" && YOUTUBE_URL.test(value)) return withMime(explicit);
433
+ const mime = explicit ?? bareMime(inferMimeType(value, ""));
434
+ if (value.startsWith(FILES_API_PREFIX) || value.startsWith("gs://")) return withMime(mime);
435
+ return value.startsWith("https://") && mime && (GOOGLE_URL_MIME as readonly string[]).includes(mime) ? withMime(mime) : undefined;
436
+ }
437
+
438
+ // Inlines a media part as base64 under Google's MIME name, failing before the network on an unsupported type; in: any media value (bytes, data URL, path, http URL), out: contentPartsToBlocks.
439
+ async function inlineBlock(type: MediaBlockType, value: MediaValue, mimeType?: string): Promise<MediaBlock> {
440
+ const norm = await normalizeMediaInput(value, mimeType);
441
+ const raw = bareMime(norm.mimeType) ?? "";
442
+ const mime = GOOGLE_INLINE_MIME_ALIASES[raw] ?? raw;
443
+ if (!INLINE_MIME[type].includes(mime)) {
444
+ const what = type === "document" ? "Google reads only PDF and CSV documents" : `Google does not accept ${type} type "${mime}"`;
445
+ throw new ValidationError(
446
+ `[Agent Accelerator] ${what} (got "${mime}"; supported: ${INLINE_MIME[type].join(", ")}). Convert the file or pass the correct mimeType.`
447
+ );
448
+ }
449
+ return { type, mime_type: mime, data: norm.base64Data };
330
450
  }
331
451
 
452
+ // Maps canonical text and media parts to Interactions content blocks (one builder for chained and full-history input); in: a message's parts, out: request `input`.
332
453
  async function contentPartsToBlocks(parts: ContentPart[]): Promise<InteractionContentBlock[]> {
333
454
  const blocks: InteractionContentBlock[] = [];
334
455
  for (const part of parts) {
335
- if (part.type === "text" && part.text) {
336
- blocks.push({ type: "text", text: part.text });
337
- } else if (
338
- part.type === "image" ||
339
- part.type === "audio" ||
340
- part.type === "video" ||
341
- part.type === "file"
342
- ) {
343
- const raw = (part as { image?: unknown; audio?: unknown; video?: unknown; file?: unknown }).image ??
344
- (part as { audio?: unknown }).audio ??
345
- (part as { video?: unknown }).video ??
346
- (part as { file?: unknown }).file;
347
- const norm = await normalizeMediaInput(
348
- raw as string | Uint8Array | ArrayBuffer,
349
- (part as { mimeType?: string }).mimeType
350
- );
351
- blocks.push({
352
- type: mediaBlockType(part.type),
353
- mime_type: norm.mimeType,
354
- data: norm.base64Data,
355
- } as InteractionContentBlock);
456
+ if (part.type === "text") {
457
+ if (part.text) blocks.push({ type: "text", text: part.text });
458
+ continue;
356
459
  }
460
+ if (part.type !== "image" && part.type !== "audio" && part.type !== "video" && part.type !== "file") continue;
461
+ const type: MediaBlockType = part.type === "file" ? "document" : part.type;
462
+ const value = (part as unknown as Record<string, MediaValue>)[part.type]!;
463
+ const uri = typeof value === "string" ? uriBlock(type, value, part.mimeType) : undefined;
464
+ const block = uri ?? (await inlineBlock(type, value, part.mimeType));
465
+ // Per-item detail level (image and video blocks only, api-ref `MediaResolution`).
466
+ const resolution = part.type === "image" || part.type === "video" ? part.resolution : undefined;
467
+ blocks.push(resolution ? { ...block, resolution } : block);
357
468
  }
358
469
  return blocks;
359
470
  }
360
471
 
472
+ // Keeps a turn's model-generated steps exactly as received (input echoes and empty signatures dropped); in: REST `steps` or assembled stream steps, out: ProviderGenerateResult.providerSteps.
473
+ function replaySteps(steps: unknown[] | undefined): ProviderSteps | undefined {
474
+ const kept = (steps ?? [])
475
+ .filter((s) => {
476
+ const type = (s as { type?: unknown } | null)?.type;
477
+ return typeof type === "string" && type !== "user_input" && type !== "function_result";
478
+ })
479
+ .map((s) => {
480
+ const { signature, ...rest } = s as Record<string, unknown>;
481
+ return signature === "" ? rest : s;
482
+ });
483
+ return kept.length > 0 ? { provider: "google", steps: kept } : undefined;
484
+ }
485
+
486
+ // Appends streamed text to the last text block of a step's `content`/`summary`, or starts one; in: step.delta, out: the assembled stream step.
487
+ function appendBlockText(step: Record<string, unknown> | undefined, key: "content" | "summary", text: string): void {
488
+ if (!step) return;
489
+ const blocks = (Array.isArray(step[key]) ? step[key] : (step[key] = [])) as Array<{ type?: string; text?: string }>;
490
+ const last = blocks[blocks.length - 1];
491
+ if (last?.type === "text" && typeof last.text === "string") last.text += text;
492
+ else blocks.push({ type: "text", text });
493
+ }
494
+
495
+ // Joins the `text` of content/summary blocks; in: a `step.start` step's `content` or `summary`, out: stream text/thinking.
496
+ function blockText(blocks: unknown): string {
497
+ if (!Array.isArray(blocks)) return "";
498
+ return blocks.map((b) => (typeof b?.text === "string" ? b.text : "")).join("");
499
+ }
500
+
361
501
  function resultToText(result: unknown): string {
362
502
  if (typeof result === "string") return result;
363
503
  return safeStringify(result);
364
504
  }
365
505
 
506
+ // Builds the documented `function_result` step (`is_error` only when the tool failed); in: a canonical tool_result part, out: chained or full-history `input`.
507
+ function functionResultStep(part: ToolResultPart): InteractionInputStep {
508
+ return {
509
+ type: "function_result",
510
+ name: part.name,
511
+ call_id: part.id,
512
+ result: [{ type: "text", text: resultToText(part.result) }],
513
+ ...(part.isError ? { is_error: true } : {}),
514
+ };
515
+ }
516
+
366
517
  /**
367
518
  * Latest-turn input for stateful chaining (the server already holds history):
368
519
  * everything AFTER the last assistant message, in order. That is normally one
@@ -400,12 +551,7 @@ async function latestTurnInput(context: ProviderContext): Promise<string | Inter
400
551
  if (!Array.isArray(m.content)) continue;
401
552
  for (const part of m.content) {
402
553
  if (part.type === "tool_result") {
403
- steps.push({
404
- type: "function_result",
405
- name: part.name,
406
- call_id: part.id,
407
- result: [{ type: "text", text: resultToText(part.result) }],
408
- });
554
+ steps.push(functionResultStep(part));
409
555
  }
410
556
  }
411
557
  } else if (m.role === "user") {
@@ -420,7 +566,7 @@ async function latestTurnInput(context: ProviderContext): Promise<string | Inter
420
566
  return steps;
421
567
  }
422
568
 
423
- /** Full-history Steps for stateless (provider-switch) turns. */
569
+ // Rebuilds the whole conversation as Interactions steps; Google turns replay their stored raw steps verbatim, others are reconstructed; in: AgentContext messages, out: request `input` on reseed/switch turns.
424
570
  async function fullHistorySteps(context: ProviderContext): Promise<InteractionInputStep[]> {
425
571
  const steps: InteractionInputStep[] = [];
426
572
  for (const m of context.messages) {
@@ -433,6 +579,11 @@ async function fullHistorySteps(context: ProviderContext): Promise<InteractionIn
433
579
  if (blocks.length > 0) steps.push({ type: "user_input", content: blocks });
434
580
  }
435
581
  } else if (m.role === "assistant") {
582
+ // Exact replay: same bytes as the server returned (thought signatures included), best for implicit caching.
583
+ if (m.providerSteps?.provider === "google") {
584
+ steps.push(...(m.providerSteps.steps as InteractionInputStep[]));
585
+ continue;
586
+ }
436
587
  if (typeof m.content === "string") {
437
588
  if (m.content) {
438
589
  steps.push({
@@ -443,6 +594,7 @@ async function fullHistorySteps(context: ProviderContext): Promise<InteractionIn
443
594
  continue;
444
595
  }
445
596
  const texts: string[] = [];
597
+ const turnStart = steps.length;
446
598
  for (const part of m.content) {
447
599
  if (part.type === "thinking" && part.thinking) {
448
600
  // Q-18: preserve namespaced signatures only — foreign-provider
@@ -475,7 +627,7 @@ async function fullHistorySteps(context: ProviderContext): Promise<InteractionIn
475
627
  type: "model_output",
476
628
  content: texts.map((t) => ({ type: "text", text: t })),
477
629
  } as InteractionInputStep);
478
- } else if (m.thoughtSignature && !steps.some((s) => s.type === "thought")) {
630
+ } else if (m.thoughtSignature && !steps.slice(turnStart).some((s) => s.type === "thought")) {
479
631
  // Preserve a signature-only thought so stateless chaining keeps
480
632
  // reasoning continuity even when no summary text was retained.
481
633
  // Q-18: only for google/untagged signatures.
@@ -488,12 +640,7 @@ async function fullHistorySteps(context: ProviderContext): Promise<InteractionIn
488
640
  if (!Array.isArray(m.content)) continue;
489
641
  for (const part of m.content) {
490
642
  if (part.type === "tool_result") {
491
- steps.push({
492
- type: "function_result",
493
- name: part.name,
494
- call_id: part.id,
495
- result: [{ type: "text", text: resultToText(part.result) }],
496
- });
643
+ steps.push(functionResultStep(part));
497
644
  }
498
645
  }
499
646
  }
@@ -597,6 +744,7 @@ function parseInteraction(
597
744
  provider: "google",
598
745
  raw,
599
746
  durationMs,
747
+ providerSteps: replaySteps(interaction.steps),
600
748
  };
601
749
  }
602
750
 
@@ -661,6 +809,7 @@ export class GoogleInteractionsProvider implements Provider {
661
809
  model: modelId,
662
810
  input: "",
663
811
  ...(stream ? { stream: true } : {}),
812
+ ...(needsLongDecode(modelId, options?.maxOutputTokens) ? { background: true } : {}),
664
813
  };
665
814
  // Structured outputs: native `response_format` when supported, else
666
815
  // emulated via system instruction (catalog `structured_output: false`
@@ -717,7 +866,8 @@ export class GoogleInteractionsProvider implements Provider {
717
866
  // provider's turns, so send the full canonical history explicitly and
718
867
  // statelessly, then resume chaining fresh on the next turn.
719
868
  body.input = await fullHistorySteps(context);
720
- body.store = false;
869
+ // Background runs need storage (`store: false` is a 400); this turn then simply starts a fresh chain.
870
+ if (!body.background) body.store = false;
721
871
  } else if (continuing) {
722
872
  body.input = await latestTurnInput(context);
723
873
  body.previous_interaction_id = chain!.interactionId;
@@ -735,7 +885,8 @@ export class GoogleInteractionsProvider implements Provider {
735
885
  }
736
886
 
737
887
  private requestInit(
738
- body: InteractionRequestBody,
888
+ method: "POST" | "GET",
889
+ body: InteractionRequestBody | undefined,
739
890
  options: ProviderRequestOptions | undefined,
740
891
  apiKey: string,
741
892
  signal?: AbortSignal
@@ -746,19 +897,20 @@ export class GoogleInteractionsProvider implements Provider {
746
897
  // `X-Goog-Api-Key` never duplicates the canonical header. Proxy flows
747
898
  // (`apiKey === "unused"`) preserve custom auth instead of overwriting.
748
899
  if (apiKey !== "unused") setHeaderCaseInsensitive(headers, "x-goog-api-key", apiKey);
749
- return { method: "POST", headers, body: JSON.stringify(body), signal };
900
+ return { method, headers, ...(body ? { body: JSON.stringify(body) } : {}), signal };
750
901
  }
751
902
 
752
903
  private async doFetch(
753
904
  url: string,
754
- body: InteractionRequestBody,
905
+ method: "POST" | "GET",
906
+ body: InteractionRequestBody | undefined,
755
907
  options: ProviderRequestOptions | undefined,
756
908
  apiKey: string,
757
909
  signal?: AbortSignal
758
910
  ): Promise<{ status: number; statusText: string; headers: Record<string, string>; text: string }> {
759
911
  const { signal: effective, cleanup } = combinedSignal(signal, timeoutFor(options as { timeoutMs?: number } | undefined));
760
912
  try {
761
- const res = await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, effective));
913
+ const res = await (options?.fetch ?? fetch)(url, this.requestInit(method, body, options, apiKey, effective));
762
914
  const text = await res.text();
763
915
  const headers: Record<string, string> = {};
764
916
  res.headers.forEach((v, k) => {
@@ -799,6 +951,45 @@ export class GoogleInteractionsProvider implements Provider {
799
951
  throw toConciseProviderError(err, "google", modelId);
800
952
  }
801
953
 
954
+ /** Poll delays (ms) for background interactions: before the first poll, then between polls. */
955
+ pollDelaysMs: [number, number] = [2_000, 5_000];
956
+
957
+ /** True for Gemini 4 long decode (`maxOutputTokens` above 65,536): the request runs in the background for up to 24 h. */
958
+ isLongRunning(model: string | ModelSpec, options?: ProviderRequestOptions): boolean {
959
+ return needsLongDecode(cleanModelId(model), options?.maxOutputTokens);
960
+ }
961
+
962
+ // One Interactions HTTP call parsed into its interaction (errors mapped concisely); in: create POST or poll GET, out: generate.
963
+ private async fetchInteraction(
964
+ url: string,
965
+ method: "POST" | "GET",
966
+ body: InteractionRequestBody | undefined,
967
+ options: ProviderRequestOptions | undefined,
968
+ apiKey: string,
969
+ modelId: string
970
+ ): Promise<InteractionReply> {
971
+ const res = await this.doFetch(url, method, body, options, apiKey, options?.signal);
972
+ this.throwIfError(res.status, res.statusText, url, res.text, modelId, res.headers);
973
+ try {
974
+ return { status: res.status, statusText: res.statusText, headers: res.headers, interaction: JSON.parse(res.text) as InteractionObject };
975
+ } catch {
976
+ throw toConciseProviderError(
977
+ Object.assign(new Error("Invalid JSON response from Google Interactions API"), {
978
+ statusCode: res.status,
979
+ responseBody: res.text.slice(0, 500),
980
+ url,
981
+ }),
982
+ "google",
983
+ modelId
984
+ );
985
+ }
986
+ }
987
+
988
+ // Fire-and-forget cancel so an aborted background run stops billing; in: an aborted generate/stream, out: POST {id}/cancel.
989
+ private cancelInteraction(baseUrl: string, id: string, options: ProviderRequestOptions | undefined, apiKey: string): void {
990
+ void this.doFetch(`${baseUrl}/interactions/${id}/cancel`, "POST", undefined, options, apiKey).catch(() => {});
991
+ }
992
+
802
993
  async generate(
803
994
  model: string | ModelSpec,
804
995
  context: ProviderContext,
@@ -835,24 +1026,33 @@ export class GoogleInteractionsProvider implements Provider {
835
1026
  body: b,
836
1027
  });
837
1028
  let rawRequest = makeRawRequest(body);
1029
+ const retryOptions = {
1030
+ ...retryOptionsFrom(options),
1031
+ signal: options?.signal,
1032
+ label: { providerId: "google", modelId: clean },
1033
+ };
838
1034
 
839
- const doCall = async (): Promise<ProviderGenerateResult> => {
840
- const res = await this.doFetch(url, body, options, apiKey, options?.signal);
841
- this.throwIfError(res.status, res.statusText, url, res.text, clean, res.headers);
842
- let interaction: InteractionObject;
1035
+ // Polls a background interaction until it leaves queued/in_progress; an abort also cancels it server-side.
1036
+ const waitForCompletion = async (reply: InteractionReply): Promise<InteractionReply> => {
1037
+ const id = reply.interaction.id;
1038
+ if (!id) return reply;
843
1039
  try {
844
- interaction = JSON.parse(res.text) as InteractionObject;
845
- } catch {
846
- throw toConciseProviderError(
847
- Object.assign(new Error("Invalid JSON response from Google Interactions API"), {
848
- statusCode: res.status,
849
- responseBody: res.text.slice(0, 500),
850
- url,
851
- }),
852
- "google",
853
- clean
854
- );
1040
+ for (let poll = 0; isRunning(reply.interaction.status); poll++) {
1041
+ await sleep(this.pollDelaysMs[poll === 0 ? 0 : 1], options?.signal);
1042
+ reply = await withRetries(() => this.fetchInteraction(`${url}/${id}`, "GET", undefined, options, apiKey, clean), retryOptions);
1043
+ }
1044
+ return reply;
1045
+ } catch (err) {
1046
+ if (options?.signal?.aborted || isAbortError(err)) this.cancelInteraction(baseUrl, id, options, apiKey);
1047
+ throw err;
855
1048
  }
1049
+ };
1050
+
1051
+ // Create (retried as a whole), wait if it runs in the background, then record the chain and map the result.
1052
+ const run = async (): Promise<ProviderGenerateResult> => {
1053
+ let reply = await withRetries(() => this.fetchInteraction(url, "POST", body, options, apiKey, clean), retryOptions);
1054
+ if (body.background) reply = await waitForCompletion(reply);
1055
+ const { interaction } = reply;
856
1056
  if (interaction.id && sessionId && body.store !== false) {
857
1057
  setChain(sessionId, { interactionId: interaction.id, model: clean, at: Date.now() });
858
1058
  } else if (body.store === false && sessionId) {
@@ -864,19 +1064,14 @@ export class GoogleInteractionsProvider implements Provider {
864
1064
  interaction,
865
1065
  clean,
866
1066
  Date.now() - startTime,
867
- { request: rawRequest, response: { status: res.status, statusText: res.statusText, headers: res.headers, body: interaction } }
1067
+ { request: rawRequest, response: { status: reply.status, statusText: reply.statusText, headers: reply.headers, body: interaction } }
868
1068
  );
869
1069
  checkTurnTokens(sessionId, parsed.usage);
870
1070
  return parsed;
871
1071
  };
872
1072
 
873
- const retryOptions = {
874
- ...retryOptionsFrom(options),
875
- signal: options?.signal,
876
- label: { providerId: "google", modelId: clean },
877
- };
878
1073
  try {
879
- return await withRetries(doCall, retryOptions);
1074
+ return await run();
880
1075
  } catch (err) {
881
1076
  if (err instanceof Error && (err as { name?: string }).name === "AbortError") throw err;
882
1077
  // Reactive self-heal (Rule 2): a chained pointer the server no longer
@@ -887,7 +1082,7 @@ export class GoogleInteractionsProvider implements Provider {
887
1082
  clearInteractionChains(sessionId);
888
1083
  body = await this.buildBody(clean, context, options, sessionId, tools, false);
889
1084
  rawRequest = makeRawRequest(body);
890
- return await withRetries(doCall, retryOptions);
1085
+ return await run();
891
1086
  }
892
1087
  throw err;
893
1088
  }
@@ -925,6 +1120,8 @@ export class GoogleInteractionsProvider implements Provider {
925
1120
  // visible in catch).
926
1121
  let evictSessionId: string | undefined;
927
1122
  let usedChain = false;
1123
+ // Set once a background interaction id is known: an abort then cancels it server-side.
1124
+ let cancelBackground = (): void => {};
928
1125
  try {
929
1126
  // Hard generation floor (mirrors generate): no network on legacy ids.
930
1127
  assertSupportedGeneration(clean);
@@ -958,30 +1155,32 @@ export class GoogleInteractionsProvider implements Provider {
958
1155
  };
959
1156
 
960
1157
  const streamTimeoutMs = timeoutFor(options as { timeoutMs?: number } | undefined);
961
- // Transient failures (429/5xx/network) are retried here, before the
962
- // first byte; once the stream is live it is never retried.
963
- const res = await openStreamResponse({
964
- fetchOnce: async () => {
965
- const timer =
966
- streamTimeoutMs && streamTimeoutMs > 0
967
- ? setTimeout(() => {
968
- try {
969
- linked.abort();
970
- } catch {}
971
- }, streamTimeoutMs)
972
- : undefined;
973
- try {
974
- return await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, linked.signal));
975
- } finally {
976
- if (timer) clearTimeout(timer);
977
- }
978
- },
979
- onHttpError: (r, text) => this.throwIfError(r.status, r.statusText, url, text, clean, headersToRecord(r.headers)),
980
- noBodyError: () => toConciseProviderError(new Error("Google streaming response had no body"), "google", clean),
981
- retry: retryOptionsFrom(options),
982
- signal: linked.signal,
983
- label: { providerId: "google", modelId: clean },
984
- });
1158
+ // Opens one SSE connection (the create POST, or a background resume GET). Transient failures
1159
+ // (429/5xx/network) are retried before the first byte; a live stream is never retried.
1160
+ const open = (target: string, method: "POST" | "GET"): Promise<Response> =>
1161
+ openStreamResponse({
1162
+ fetchOnce: async () => {
1163
+ const timer =
1164
+ streamTimeoutMs && streamTimeoutMs > 0
1165
+ ? setTimeout(() => {
1166
+ try {
1167
+ linked.abort();
1168
+ } catch {}
1169
+ }, streamTimeoutMs)
1170
+ : undefined;
1171
+ try {
1172
+ return await (options?.fetch ?? fetch)(target, this.requestInit(method, method === "POST" ? body : undefined, options, apiKey, linked.signal));
1173
+ } finally {
1174
+ if (timer) clearTimeout(timer);
1175
+ }
1176
+ },
1177
+ onHttpError: (r, text) => this.throwIfError(r.status, r.statusText, target, text, clean, headersToRecord(r.headers)),
1178
+ noBodyError: () => toConciseProviderError(new Error("Google streaming response had no body"), "google", clean),
1179
+ retry: retryOptionsFrom(options),
1180
+ signal: linked.signal,
1181
+ label: { providerId: "google", modelId: clean },
1182
+ });
1183
+ const res = await open(url, "POST");
985
1184
  const responseHeaders: Record<string, string> = {};
986
1185
  res.headers.forEach((v, k) => {
987
1186
  responseHeaders[k] = v;
@@ -989,13 +1188,14 @@ export class GoogleInteractionsProvider implements Provider {
989
1188
  const responseMeta: Record<string, unknown> & { status: number; statusText: string; headers: Record<string, string> } = { status: res.status, statusText: res.statusText, headers: responseHeaders };
990
1189
  eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
991
1190
 
992
- const parser = new SSEParser();
993
- const reader = res.body!.getReader(); // openStreamResponse guarantees a body
994
- const decoder = new TextDecoder();
1191
+ let reader: StreamReader | undefined;
1192
+ let lastEventId: string | undefined;
995
1193
  let text = "";
996
1194
  let thinking = "";
997
1195
  let signature: string | undefined;
998
1196
  const calls = new Map<number, { id: string; name: string; startArgs: string; deltaArgs: string }>();
1197
+ // Every step as the server would return it (start + deltas), for exact history replay.
1198
+ const steps = new Map<number, Record<string, unknown>>();
999
1199
  let usage: TokenUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
1000
1200
  let finishReason = "stop";
1001
1201
  let sawFinishReason = false;
@@ -1010,11 +1210,14 @@ export class GoogleInteractionsProvider implements Provider {
1010
1210
  () => {
1011
1211
  aborted = true;
1012
1212
  try {
1013
- void reader.cancel();
1213
+ void reader?.cancel();
1014
1214
  } catch {}
1015
1215
  },
1016
1216
  { once: true }
1017
1217
  );
1218
+ cancelBackground = () => {
1219
+ if (body.background && responseId) this.cancelInteraction(baseUrl, responseId, options, apiKey);
1220
+ };
1018
1221
 
1019
1222
  const handleMessage = (event: string | undefined, data: string): void => {
1020
1223
  if (!data) return;
@@ -1031,6 +1234,7 @@ export class GoogleInteractionsProvider implements Provider {
1031
1234
  return;
1032
1235
  }
1033
1236
  const type = (msg["event_type"] as string) || event;
1237
+ if (typeof msg["event_id"] === "string") lastEventId = msg["event_id"] as string;
1034
1238
  if (type === "error") {
1035
1239
  // SSE error event (e.g. rate_limit_exceeded mid-stream). Must
1036
1240
  // surface — silently ending with empty text + zero usage hides it.
@@ -1050,16 +1254,20 @@ export class GoogleInteractionsProvider implements Provider {
1050
1254
  const delta = (msg["delta"] as Record<string, unknown>) ?? {};
1051
1255
  const dType = delta["type"] as string;
1052
1256
  if (dType === "text" && typeof delta["text"] === "string") {
1257
+ appendBlockText(steps.get(index), "content", delta["text"] as string);
1053
1258
  text += delta["text"] as string;
1054
1259
  eventStream.push({ type: "text_delta", delta: delta["text"] as string, partialText: text });
1055
1260
  } else if (dType === "thought_summary") {
1056
1261
  const content = delta["content"] as { type?: string; text?: string } | undefined;
1057
1262
  const t = content?.text ?? (typeof delta["text"] === "string" ? (delta["text"] as string) : "");
1058
1263
  if (t) {
1264
+ appendBlockText(steps.get(index), "summary", t);
1059
1265
  thinking += t;
1060
1266
  eventStream.push({ type: "thinking_delta", thinkingDelta: t, partialThinking: thinking });
1061
1267
  }
1062
1268
  } else if (dType === "thought_signature" && typeof delta["signature"] === "string") {
1269
+ const s = steps.get(index);
1270
+ if (s) s["signature"] = delta["signature"];
1063
1271
  // Wire truth: signature arrives as its own delta (no text event).
1064
1272
  if (!signature) signature = delta["signature"] as string;
1065
1273
  } else if (dType === "arguments" || dType === "arguments_delta") {
@@ -1073,7 +1281,21 @@ export class GoogleInteractionsProvider implements Provider {
1073
1281
  } else if (type === "step.start") {
1074
1282
  const index = (msg["index"] as number) ?? 0;
1075
1283
  const step = (msg["step"] as Record<string, unknown>) ?? {};
1076
- if (step["type"] === "function_call") {
1284
+ steps.set(index, step);
1285
+ if (step["type"] === "model_output") {
1286
+ const t = blockText(step["content"]);
1287
+ if (t) {
1288
+ text += t;
1289
+ eventStream.push({ type: "text_delta", delta: t, partialText: text });
1290
+ }
1291
+ } else if (step["type"] === "thought") {
1292
+ const t = blockText(step["summary"]);
1293
+ if (t) {
1294
+ thinking += t;
1295
+ eventStream.push({ type: "thinking_delta", thinkingDelta: t, partialThinking: thinking });
1296
+ }
1297
+ if (!signature && typeof step["signature"] === "string" && step["signature"]) signature = step["signature"];
1298
+ } else if (step["type"] === "function_call") {
1077
1299
  const args = step["arguments"];
1078
1300
  const startArgs = typeof args === "string" ? args : args ? JSON.stringify(args) : "";
1079
1301
  calls.set(index, {
@@ -1083,6 +1305,13 @@ export class GoogleInteractionsProvider implements Provider {
1083
1305
  deltaArgs: "",
1084
1306
  });
1085
1307
  }
1308
+ } else if (type === "step.stop" || type === "interaction.status_update") {
1309
+ // Cumulative usage so far: keeps usage when the stream is cut before `interaction.completed`.
1310
+ const stepUsage = msg["usage"] as InteractionObject["usage"] | undefined;
1311
+ if (stepUsage) {
1312
+ usage = mapUsage(stepUsage);
1313
+ sawUsage = true;
1314
+ }
1086
1315
  } else if (type === "interaction.completed") {
1087
1316
  const interaction = (msg["interaction"] as InteractionObject) ?? {};
1088
1317
  // Failed/cancelled interactions must surface loudly — never
@@ -1100,30 +1329,57 @@ export class GoogleInteractionsProvider implements Provider {
1100
1329
  }
1101
1330
  };
1102
1331
 
1103
- while (true) {
1104
- if (linked.signal.aborted || eventStream.isCancelled()) {
1332
+ // Reads one connection to its end. A background connection that drops or stalls just ends
1333
+ // (the caller reconnects); provider error events from handleMessage always throw.
1334
+ const readAll = async (response: Response): Promise<void> => {
1335
+ const parser = new SSEParser();
1336
+ const decoder = new TextDecoder();
1337
+ const current = (reader = response.body!.getReader()); // openStreamResponse guarantees a body
1338
+ try {
1339
+ while (true) {
1340
+ if (linked.signal.aborted || eventStream.isCancelled()) {
1341
+ try {
1342
+ await current.cancel();
1343
+ } catch {}
1344
+ throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
1345
+ }
1346
+ let chunk: { done: boolean; value?: Uint8Array };
1347
+ try {
1348
+ chunk = body.background ? await readWithin(current, BACKGROUND_STREAM_STALL_MS) : await current.read();
1349
+ } catch (err) {
1350
+ if (!body.background || aborted || isAbortError(err)) throw err;
1351
+ break;
1352
+ }
1353
+ if (chunk.done) break;
1354
+ for (const m of parser.feed(decoder.decode(chunk.value, { stream: true }))) handleMessage(m.event, m.data);
1355
+ }
1356
+ for (const m of parser.flush()) handleMessage(m.event, m.data);
1357
+ } finally {
1105
1358
  try {
1106
- await reader.cancel();
1359
+ current.releaseLock();
1107
1360
  } catch {}
1108
- throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
1109
1361
  }
1110
- const { done, value } = await reader.read();
1111
- if (done) break;
1112
- const chunk = decoder.decode(value, { stream: true });
1113
- for (const m of parser.feed(chunk)) handleMessage(m.event, m.data);
1362
+ };
1363
+
1364
+ await readAll(res);
1365
+ // Long decode: a background stream may drop; resume after the last event until the interaction ends.
1366
+ for (let misses = 0; body.background && completedBody === undefined && !aborted; ) {
1367
+ if (!responseId || ++misses > BACKGROUND_MAX_RECONNECTS) throw incompleteStreamError("google", clean);
1368
+ const before = framesSeen;
1369
+ const resume = `${baseUrl}/interactions/${responseId}?stream=true${lastEventId ? `&last_event_id=${encodeURIComponent(lastEventId)}` : ""}`;
1370
+ await readAll(await open(resume, "GET"));
1371
+ if (framesSeen > before) misses = 0;
1114
1372
  }
1115
- for (const m of parser.flush()) handleMessage(m.event, m.data);
1116
- try {
1117
- reader.releaseLock();
1118
- } catch {}
1119
1373
 
1120
1374
  if (linked.signal.aborted || eventStream.isCancelled() || aborted) {
1121
1375
  throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
1122
1376
  }
1123
1377
 
1124
1378
  const toolCalls: ToolCallRecord[] = [];
1125
- for (const c of calls.values()) {
1379
+ for (const [index, c] of calls) {
1126
1380
  const args = parseStreamedToolArguments(c.startArgs, c.deltaArgs);
1381
+ const step = steps.get(index);
1382
+ if (step) step["arguments"] = args;
1127
1383
  const record: ToolCallRecord = {
1128
1384
  id: c.id,
1129
1385
  name: c.name,
@@ -1178,6 +1434,7 @@ export class GoogleInteractionsProvider implements Provider {
1178
1434
  response: { ...responseMeta, body: completedBody, badFrames } as unknown as ProviderRawData["response"],
1179
1435
  },
1180
1436
  durationMs: Date.now() - startTime,
1437
+ providerSteps: replaySteps([...steps.entries()].sort(([a], [b]) => a - b).map(([, s]) => s)),
1181
1438
  });
1182
1439
  eventStream.push({ type: "done", delta: "", usage, finishReason, responseId });
1183
1440
  eventStream.end(finalResponse);
@@ -1188,6 +1445,7 @@ export class GoogleInteractionsProvider implements Provider {
1188
1445
  eventStream.isCancelled() ||
1189
1446
  (raw as { name?: string }).name === "AbortError";
1190
1447
  if (isAbort) {
1448
+ cancelBackground();
1191
1449
  eventStream.fail(
1192
1450
  Object.assign(new Error("Stream aborted"), { name: "AbortError" })
1193
1451
  );
@@ -90,6 +90,7 @@ import {
90
90
  mapGenerationParamsToChat,
91
91
  normalizeStructuredOutput,
92
92
  augmentSystemPromptWithStructuredOutput,
93
+ warnIgnoredMediaResolution,
93
94
  } from "../providers.ts";
94
95
 
95
96
  // ---------------------------------------------------------------------------
@@ -704,6 +705,7 @@ export class OpenAICompatibleChatProvider implements Provider {
704
705
  }
705
706
  // Strict endpoints reject unknown body fields: implicit caching only.
706
707
  resolveCache(this.prefix, options?.cache, ["implicit"], `${this.prefix}/${modelId}`);
708
+ warnIgnoredMediaResolution(this.prefix, context.messages, `${this.prefix}/${modelId}`);
707
709
 
708
710
  // Structured outputs: native `response_format` when supported, else
709
711
  // emulated via system prompt (strict endpoints would 400 on json_schema
@@ -85,6 +85,7 @@ import {
85
85
  normalizeStructuredOutput,
86
86
  augmentSystemPromptWithStructuredOutput,
87
87
  emitProviderWarning,
88
+ warnIgnoredMediaResolution,
88
89
  } from "../providers.ts";
89
90
 
90
91
  // ---------------------------------------------------------------------------
@@ -524,6 +525,7 @@ export class OpenAIResponsesProvider implements Provider {
524
525
  const serviceTier = mapServiceTierToOpenAI(options?.serviceTier);
525
526
  // `12h` maps to `prompt_cache_retention: "24h"`; everything else stays on implicit prompt_cache_key affinity.
526
527
  const cache = resolveCache("openai", options?.cache, ["implicit", "12h"], `openai/${modelId}`);
528
+ warnIgnoredMediaResolution("openai", context.messages, `openai/${modelId}`);
527
529
 
528
530
  // Structured outputs: native `text.format` when supported, else emulated
529
531
  // via instructions (catalog `structured_output: false` would 400).
@@ -77,6 +77,7 @@ import {
77
77
  normalizeStructuredOutput,
78
78
  augmentSystemPromptWithStructuredOutput,
79
79
  emitProviderWarning,
80
+ warnIgnoredMediaResolution,
80
81
  } from "../providers.ts";
81
82
 
82
83
  // ---------------------------------------------------------------------------
@@ -576,6 +577,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
576
577
  const serviceTier = mapServiceTierToOpenRouter(options?.serviceTier);
577
578
  // Anthropic models take a top-level `cache_control` TTL; every other route caches implicitly (docs: prompt-caching.md).
578
579
  const cache = resolveCache("openrouter", options?.cache, modelId.startsWith("anthropic/") ? ["implicit", "5m", "1h"] : ["implicit"], `openrouter/${modelId}`);
580
+ warnIgnoredMediaResolution("openrouter", context.messages, `openrouter/${modelId}`);
579
581
 
580
582
  // Structured outputs: native `response_format` when supported, else
581
583
  // emulated via system-prompt instruction + client validation (catalog
package/src/providers.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import type { CacheOption, ServiceTier, ThinkingLevel } from "./types/core.ts";
13
13
  import type { StructuredOutputSpec } from "./types/model.ts";
14
+ import type { Message } from "./types/message.ts";
14
15
  import { zodToJsonSchema } from "./tools/schema.ts";
15
16
  import { ValidationError } from "./types/errors.ts";
16
17
  import { TRUNCATED_ARGS_KEY } from "./types/tool.ts";
@@ -247,6 +248,21 @@ export function resolveCache(
247
248
  return undefined;
248
249
  }
249
250
 
251
+ /** Warns once per provider/model that per-media `resolution` is ignored (no such control on that wire); in: an adapter's request messages, out: telemetry bus only. */
252
+ export function warnIgnoredMediaResolution(provider: string, messages: Message[], modelRef: string): void {
253
+ const used = messages.some(
254
+ (m) => Array.isArray(m.content) && m.content.some((p) => (p.type === "image" || p.type === "video") && p.resolution)
255
+ );
256
+ if (!used) return;
257
+ emitProviderWarning({
258
+ provider,
259
+ capability: "media resolution",
260
+ requested: modelRef,
261
+ reason: "this provider has no per-media detail control (Google only).",
262
+ fallback: "the provider's default detail",
263
+ });
264
+ }
265
+
250
266
  /** OpenRouter Chat Completions `tool_choice` wire values. */
251
267
  export type OpenRouterChatToolChoice =
252
268
  | "auto"
@@ -9,6 +9,9 @@ export interface TextPart {
9
9
  thoughtSignature?: string;
10
10
  }
11
11
 
12
+ /** Detail level a provider spends on one image or video (more detail = more input tokens). Google only; other providers ignore it with a warning. */
13
+ export type MediaResolution = "low" | "medium" | "high" | "ultra_high";
14
+
12
15
  /** Image content accepted as a URL, data, path, or binary value. */
13
16
  export interface ImagePart {
14
17
  type: "image";
@@ -17,6 +20,8 @@ export interface ImagePart {
17
20
  */
18
21
  image: string | Uint8Array | ArrayBuffer;
19
22
  mimeType?: string;
23
+ /** Detail level for this image (Google: `low` 280, `medium` 560, `high` 1120, `ultra_high` 2240 tokens). */
24
+ resolution?: MediaResolution;
20
25
  }
21
26
 
22
27
  /** Audio content accepted as a URL, data, path, or binary value. */
@@ -48,6 +53,8 @@ export interface VideoPart {
48
53
  */
49
54
  video: string | Uint8Array | ArrayBuffer;
50
55
  mimeType?: string;
56
+ /** Detail level per frame (Google: `low`/`medium` 70, `high` 280 tokens). */
57
+ resolution?: MediaResolution;
51
58
  }
52
59
 
53
60
  /** Assistant-generated structured tool invocation. */
@@ -100,12 +107,20 @@ export type ContentPart =
100
107
 
101
108
  export type MessageRole = "system" | "user" | "assistant" | "tool";
102
109
 
110
+ /** A provider's raw output steps for one assistant turn, kept to replay history byte-exact (only the tagged provider reads them). */
111
+ export interface ProviderSteps {
112
+ provider: string;
113
+ steps: unknown[];
114
+ }
115
+
103
116
  /** Normalized conversation message. */
104
117
  export interface Message {
105
118
  role: MessageRole;
106
119
  content: string | ContentPart[];
107
120
  name?: string;
108
121
  thoughtSignature?: string;
122
+ /** Raw provider output steps of an assistant turn (opaque; e.g. Google `steps`). */
123
+ providerSteps?: ProviderSteps;
109
124
  }
110
125
 
111
126
  /**
@@ -1,5 +1,5 @@
1
1
  import type { ThinkingConfig, CacheOption, ServiceTier, TokenUsage } from "./core.ts";
2
- import type { ProviderContext } from "./message.ts";
2
+ import type { ProviderContext, ProviderSteps } from "./message.ts";
3
3
  import type { StandardToolDeclaration, ToolCallRecord } from "./tool.ts";
4
4
  import type { AssistantMessageEventStream } from "../streaming/event-stream.ts";
5
5
 
@@ -226,6 +226,8 @@ export interface ProviderGenerateResult {
226
226
  provider: ProviderId;
227
227
  raw: ProviderRawData;
228
228
  durationMs: number;
229
+ /** Raw output steps for exact history replay (stored on the assistant message). */
230
+ providerSteps?: ProviderSteps;
229
231
  }
230
232
 
231
233
  /** Provider contract implemented by native REST transports. */
@@ -244,4 +246,6 @@ export interface Provider {
244
246
  context: ProviderContext,
245
247
  options?: ProviderRequestOptions
246
248
  ): AssistantMessageEventStream;
249
+ /** True when this call may legitimately run far past the request timeouts (e.g. Google long decode); the loop then leaves timing to the adapter's own per-request timeouts. */
250
+ isLongRunning?(model: string | ModelSpec, options?: ProviderRequestOptions): boolean;
247
251
  }
@@ -1,6 +1,7 @@
1
1
  import type { TokenUsage } from "./core.ts";
2
2
  import type { ToolCallRecord, ToolResultRecord } from "./tool.ts";
3
3
  import type { ProviderRawData, ProviderId } from "./model.ts";
4
+ import type { ProviderSteps } from "./message.ts";
4
5
 
5
6
  /** One logged step inside a sub-agent worker run (surfaced via `agent.track(id)`). */
6
7
  export interface SubAgentStep {
@@ -142,6 +143,8 @@ export class AgentResponse {
142
143
  readonly durationMs: number;
143
144
  readonly raw: ProviderRawData;
144
145
  readonly turns: number;
146
+ /** Raw provider output steps of a single streamed turn (history replay only; not in `toJSON`). */
147
+ readonly providerSteps?: ProviderSteps;
145
148
 
146
149
  /**
147
150
  * Constructs a normalized response. Agent runs create this automatically;
@@ -167,6 +170,7 @@ export class AgentResponse {
167
170
  durationMs: number;
168
171
  raw: ProviderRawData;
169
172
  turns?: number;
173
+ providerSteps?: ProviderSteps;
170
174
  }) {
171
175
  this.text = data.text;
172
176
  if (data.parsed !== undefined) (this as { parsed?: unknown }).parsed = data.parsed;
@@ -184,6 +188,7 @@ export class AgentResponse {
184
188
  this.durationMs = data.durationMs;
185
189
  this.raw = data.raw;
186
190
  this.turns = data.turns ?? 1;
191
+ if (data.providerSteps) this.providerSteps = data.providerSteps;
187
192
  }
188
193
 
189
194
  /** Returns the final assistant text. */
@@ -23,7 +23,7 @@ export function getPackageVersion(): string {
23
23
  const v = (globalThis as any).process?.env?.npm_package_version;
24
24
  if (typeof v === "string" && v) return v;
25
25
  } catch {}
26
- return "0.5.1";
26
+ return "0.5.2";
27
27
  }
28
28
 
29
29
  function getAgentAccelUserAgent(): string {
@@ -43,7 +43,8 @@ function readRetryAfterMs(error: unknown): number | undefined {
43
43
  return Math.min(60_000, secs * 1000);
44
44
  }
45
45
 
46
- function sleep(ms: number, signal?: AbortSignal): Promise<void> {
46
+ // Waits `ms`, rejecting with an AbortError as soon as `signal` aborts; in: retry backoff and Google background polling, out: the awaiting caller.
47
+ export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
47
48
  if (ms <= 0) return Promise.resolve();
48
49
  return new Promise((resolve, reject) => {
49
50
  const timer = setTimeout(() => {