agent-accelerator 0.5.5 → 0.5.7

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 (47) hide show
  1. package/README.md +16 -17
  2. package/package.json +1 -1
  3. package/src/agent/agent.ts +16 -18
  4. package/src/agent/architecture.md +25 -0
  5. package/src/agent/context.ts +4 -0
  6. package/src/agent/delegation.ts +18 -84
  7. package/src/agent/loop.ts +14 -16
  8. package/src/agent/worker.ts +1 -3
  9. package/src/architecture.md +28 -0
  10. package/src/index.ts +6 -73
  11. package/src/models/architecture.md +14 -0
  12. package/src/models/catalog-cache.ts +3 -0
  13. package/src/models/catalog.ts +7 -0
  14. package/src/providers/architecture.md +24 -0
  15. package/src/providers/google.ts +26 -39
  16. package/src/providers/openai-compat.ts +53 -104
  17. package/src/providers/openai.ts +47 -104
  18. package/src/providers/openrouter.ts +114 -175
  19. package/src/providers/registry.ts +1 -0
  20. package/src/providers/shared.ts +134 -5
  21. package/src/providers.ts +30 -59
  22. package/src/scheduler/architecture.md +19 -0
  23. package/src/scheduler/scheduler.ts +21 -0
  24. package/src/scheduler/store.ts +6 -0
  25. package/src/scheduler/time.ts +2 -0
  26. package/src/session/architecture.md +19 -0
  27. package/src/session/store.ts +18 -3
  28. package/src/session/usage.ts +1 -0
  29. package/src/streaming/architecture.md +13 -0
  30. package/src/streaming/event-stream.ts +5 -0
  31. package/src/telemetry.ts +1 -0
  32. package/src/tools/architecture.md +19 -0
  33. package/src/tools/executor.ts +13 -0
  34. package/src/tools/schema.ts +1 -0
  35. package/src/tools/subagent-result.ts +1 -0
  36. package/src/types/architecture.md +20 -0
  37. package/src/types/message.ts +1 -1
  38. package/src/utils/architecture.md +25 -0
  39. package/src/utils/base64.ts +2 -0
  40. package/src/utils/cache.ts +2 -0
  41. package/src/utils/errors.ts +8 -1
  42. package/src/utils/headers.ts +4 -1
  43. package/src/utils/media.ts +11 -1
  44. package/src/utils/raw-capture.ts +3 -0
  45. package/src/utils/retry.ts +1 -0
  46. package/src/utils/safe-io.ts +4 -0
  47. package/src/utils/session.ts +5 -161
@@ -127,10 +127,12 @@ export interface PersistedAgentSession {
127
127
  tools?: SessionToolRecord[];
128
128
  }
129
129
 
130
+ /** Zeroed cumulative session totals; in: SessionTelemetry, out: a SessionTotals. */
130
131
  export function emptyTotals(): SessionTotals {
131
132
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: 0 };
132
133
  }
133
134
 
135
+ // A finite non-negative number, else 0; in: telemetry and formatting, out: a safe number.
134
136
  function toNumber(n: unknown): number {
135
137
  const v = typeof n === "number" ? n : Number(n);
136
138
  return Number.isFinite(v) && v >= 0 ? v : 0;
@@ -191,6 +193,7 @@ export function computeSessionTurnCost(usage: TokenUsage, modelStr: string): num
191
193
  }
192
194
  }
193
195
 
196
+ /** Compact token count (`1.2k`, `3.4M`); in: the status bar, out: a string. */
194
197
  export function formatSessionTokens(n: number): string {
195
198
  const v = toNumber(n);
196
199
  if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`;
@@ -198,6 +201,7 @@ export function formatSessionTokens(n: number): string {
198
201
  return `${Math.floor(v)}`;
199
202
  }
200
203
 
204
+ /** Cost with precision that fits its size (`$0.0042`); in: the status bar and banner, out: a string. */
201
205
  export function formatSessionCost(c?: number): string {
202
206
  if (!c || c <= 0) return "$0.00";
203
207
  if (c < 0.0001) return `$${c.toFixed(6)}`;
@@ -246,10 +250,12 @@ export class SessionTelemetry {
246
250
  return turnCost;
247
251
  }
248
252
 
253
+ // Zeroes the totals; in: callers starting a fresh session, out: this.totals.
249
254
  reset(): void {
250
255
  this.totals = emptyTotals();
251
256
  }
252
257
 
258
+ // A copy of the totals for saving; in: buildSessionData/JSON.stringify, out: a SessionTotals.
253
259
  toJSON(): SessionTotals {
254
260
  return { ...this.totals };
255
261
  }
@@ -343,6 +349,7 @@ export interface SessionAgentLike {
343
349
  context: { systemPrompt?: string; messages: Message[] };
344
350
  }
345
351
 
352
+ // Slims worker traces for the snapshot (one key per tracking id, repeated final text dropped, reasoning kept); in: buildSessionData, out: the `subagents` field.
346
353
  function snapshotSubagents(traces: Record<string, PersistedSubAgentTrace> | undefined): Record<string, PersistedSubAgentTrace> | undefined {
347
354
  if (!traces) return undefined;
348
355
  const keys = Object.keys(traces);
@@ -562,6 +569,7 @@ function toCacheOption(raw: unknown): CacheOption | undefined {
562
569
  return typeof retention === "string" ? legacy[retention] : undefined;
563
570
  }
564
571
 
572
+ // Parses any supported snapshot shape (current, legacy pretty JSON, JSONL, ancient) or flags it over-limit; in: raw file text, out: a session, null or OVER_LIMIT.
565
573
  function parseSession(raw: string, limits?: SessionLimits): PersistedAgentSession | null | typeof OVER_LIMIT {
566
574
  const maxMessages = limits?.maxMessages ?? MAX_SESSION_MESSAGES;
567
575
  const maxBytes = limits?.maxBytes ?? MAX_SESSION_JSON_BYTES;
@@ -780,10 +788,12 @@ export function saveSessionFile(
780
788
 
781
789
  const MEDIA_PART_KEYS = ["image", "audio", "video", "file"] as const;
782
790
 
791
+ // True for an http(s) link, which stays a reference instead of being saved as media; in: embeddedMedia, out: a boolean.
783
792
  function isHttpRef(value: string): boolean {
784
793
  return value.startsWith("http://") || value.startsWith("https://");
785
794
  }
786
795
 
796
+ // Splits a `data:<mime>;base64,<payload>` string; in: embeddedMedia, out: its mime type and payload or undefined.
787
797
  function parseDataUrl(value: string): { mimeType: string; base64: string } | undefined {
788
798
  const m = value.match(/^data:([^;,]+)?(?:;[^,]*)?;base64,(.+)$/s);
789
799
  if (m && m[2]) return { mimeType: m[1] || "application/octet-stream", base64: m[2] };
@@ -797,6 +807,7 @@ function looksLikeEmbeddedBase64(value: string): boolean {
797
807
  return /^[A-Za-z0-9+/=\n\r]+$/.test(value.slice(0, 500));
798
808
  }
799
809
 
810
+ // File extension for saved media from its mime type, else its filename, else `bin`; in: embeddedMedia, out: the extension.
800
811
  function extForMedia(mimeType?: string, filename?: string): string {
801
812
  const mime = (mimeType || "").toLowerCase().split(";")[0]!.trim();
802
813
  const known: Record<string, string> = {
@@ -850,6 +861,7 @@ export function embeddedMedia(part: ContentPart): { key: (typeof MEDIA_PART_KEYS
850
861
  return { key, bytes, ...(mimeType ? { mimeType } : {}), ext: extForMedia(mimeType, typeof p["filename"] === "string" ? (p["filename"] as string) : undefined) };
851
862
  }
852
863
 
864
+ // Copy of the messages that keeps media bytes (structuredClone, else JSON); in: extractMediaToDir/resolveMediaPaths, out: a new array.
853
865
  function cloneMessages(messages: Message[]): Message[] {
854
866
  try {
855
867
  const g: any = globalThis as any;
@@ -1136,6 +1148,7 @@ export const MAX_SESSION_MESSAGES = 50_000;
1136
1148
  /** Default max raw JSON size accepted when loading a session snapshot (override via `SessionLimits`). */
1137
1149
  export const MAX_SESSION_JSON_BYTES = 100 * 1024 * 1024;
1138
1150
 
1151
+ // A SessionLoadError-named Error with the SDK prefix; in: validateSessionData, out: the error.
1139
1152
  function sessionLoadError(message: string): Error {
1140
1153
  const e = new Error(`[Agent Accelerator] Invalid session: ${message}`);
1141
1154
  e.name = "SessionLoadError";
@@ -1314,6 +1327,7 @@ const SERVICE_TIER_MULTIPLIERS: Record<string, number> = {
1314
1327
  fast: 2,
1315
1328
  };
1316
1329
 
1330
+ // Price multiplier for a service tier (flex 0.5, priority 2, 2.5 for gpt-5.5; estimates); in: priceUsage, out: the multiplier and tier name.
1317
1331
  function serviceTierMultiplier(serviceTier?: string, modelId?: string): { multiplier: number; tier?: string } {
1318
1332
  if (!serviceTier) return { multiplier: 1 };
1319
1333
  const lower = serviceTier.toLowerCase();
@@ -1327,6 +1341,7 @@ function serviceTierMultiplier(serviceTier?: string, modelId?: string): { multip
1327
1341
  return m !== undefined ? { multiplier: m, tier: serviceTier } : { multiplier: 1, tier: serviceTier };
1328
1342
  }
1329
1343
 
1344
+ // Integer micro-USD to dollars; in: priceUsage, out: a number.
1330
1345
  function toDollars(microUSD: number): number {
1331
1346
  return microUSD / 1_000_000;
1332
1347
  }
@@ -1391,7 +1406,7 @@ function resolveEffectivePricing(
1391
1406
  /**
1392
1407
  * Prices one usage snapshot with integer micro-USD accumulation (Q-36).
1393
1408
  *
1394
- * - Provider-reported `usage.cost.totalCost > 0` wins (source `provider`).
1409
+ * - A provider-reported `usage.cost.totalCost` (a number, 0 included: free or cached) wins (source `provider`).
1395
1410
  * - Else catalog pricing via `resolveEffectivePricing` (source `catalog`).
1396
1411
  * - Else returns `undefined` (source `unknown` is implicit) — unknown stays
1397
1412
  * unknown, never a fabricated 0 object.
@@ -1408,7 +1423,7 @@ export function priceUsage(
1408
1423
  opts?: { serviceTier?: ServiceTier | string; modelId?: string }
1409
1424
  ): PricedCost | undefined {
1410
1425
  const reported = usage?.cost;
1411
- if (reported && typeof reported.totalCost === "number" && reported.totalCost > 0) {
1426
+ if (reported && typeof reported.totalCost === "number" && reported.totalCost >= 0) {
1412
1427
  const micro = (d?: number): number => (typeof d === "number" && d > 0 ? Math.round(d * 1_000_000) : 0);
1413
1428
  const inputMicroUSD = micro(reported.inputCost);
1414
1429
  const outputMicroUSD = micro(reported.outputCost);
@@ -1492,7 +1507,7 @@ export function addCostToUsage(
1492
1507
  spec?: ModelSpec,
1493
1508
  opts?: { serviceTier?: ServiceTier | string }
1494
1509
  ): TokenUsage["cost"] | undefined {
1495
- if (usage?.cost && typeof usage.cost.totalCost === "number" && usage.cost.totalCost > 0) {
1510
+ if (usage?.cost && typeof usage.cost.totalCost === "number" && usage.cost.totalCost >= 0) {
1496
1511
  return usage.cost;
1497
1512
  }
1498
1513
  const priced = priceUsage(usage, spec, opts);
@@ -39,6 +39,7 @@ export function emptyUsage(): UsageTotals {
39
39
  };
40
40
  }
41
41
 
42
+ // A finite whole number >= 0, else 0; in: the usage maths and readUsage, out: a safe count.
42
43
  const count = (n: unknown): number => (typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.floor(n) : 0);
43
44
 
44
45
  /** A model reference (string or catalog spec) as a plain string for a report line. */
@@ -0,0 +1,13 @@
1
+ # src/streaming/
2
+
3
+ | File | What it is |
4
+ | :-- | :-- |
5
+ | `event-stream.ts` | `AssistantMessageEventStream`: an async-iterable event stream with listeners (`on`/`off`), a `result()` promise, `cancel()` and bounded buffering. Providers produce it; the loop and `Agent` consume it. |
6
+ | `sse-parser.ts` | `SSEParser`: incremental Server-Sent Events framing that survives events split across network chunks. Internal (not exported from the barrel). |
7
+
8
+ ## Invariants
9
+ - The buffer holds at most 1000 events (oldest dropped, reported on the bus); an event over 1 MB is dropped. `{ buffer: false }` keeps none (for a caller that only wants `result()`).
10
+ - `end()` without a response fails the stream instead of leaving `result()` hanging.
11
+ - A cancel is recognised by the `AbortError` name only, never by message text.
12
+ - Breaking out of `for await` cancels the stream, which tears down the HTTP request.
13
+ - A throwing listener never breaks the stream; the first error is kept in `getListenerError()`.
@@ -30,6 +30,7 @@ export const MAX_BUFFERED_EVENTS = 1000;
30
30
  /** Default max approx bytes per delta event (Q-29). */
31
31
  export const MAX_EVENT_BYTES = 1_048_576;
32
32
 
33
+ // Approximate size of an event's text fields; in: push's oversize guard, out: a character count.
33
34
  function approxEventBytes(event: StreamEvent): number {
34
35
  let n = 0;
35
36
  if (typeof event.delta === "string") n += event.delta.length;
@@ -39,6 +40,7 @@ function approxEventBytes(event: StreamEvent): number {
39
40
  return n;
40
41
  }
41
42
 
43
+ // Reports a dropped or overflowing event on the bus and the legacy hook, never throwing; in: push/enqueueBounded, out: telemetryBus.
42
44
  function reportStreamAnomaly(detail: { type: string; [k: string]: unknown }): void {
43
45
  // why: bus + hook only, never console — streams are hot paths and logging
44
46
  // per-drop would spam and break no-console policy (Q-29/Q-41).
@@ -161,6 +163,7 @@ export class AssistantMessageEventStream implements AsyncIterable<StreamEvent> {
161
163
  }
162
164
  }
163
165
 
166
+ // Calls the listeners of the event's type and `*`, remembering the first listener error instead of throwing; in: push, out: those callbacks.
164
167
  private deliverToListeners(event: StreamEvent): void {
165
168
  const specificListeners = this.listeners.get(event.type);
166
169
  if (specificListeners) {
@@ -187,6 +190,7 @@ export class AssistantMessageEventStream implements AsyncIterable<StreamEvent> {
187
190
  }
188
191
  }
189
192
 
193
+ // Buffers an event for the iterator, dropping the oldest non-error events beyond the cap; in: push, out: this.queue.
190
194
  private enqueueBounded(event: StreamEvent): void {
191
195
  this.queue.push(event);
192
196
  while (this.queue.length > this.maxBufferedEvents) {
@@ -289,6 +293,7 @@ export class AssistantMessageEventStream implements AsyncIterable<StreamEvent> {
289
293
  return this.finalResultPromise;
290
294
  }
291
295
 
296
+ // Iterator over the stream: buffered events first, then waits for new ones; breaking out cancels the stream and a failed stream rejects after delivering its error event; in: for-await consumers, out: StreamEvents.
292
297
  [Symbol.asyncIterator](): AsyncIterator<StreamEvent> {
293
298
  let iteratorDone = false;
294
299
  return {
package/src/telemetry.ts CHANGED
@@ -30,6 +30,7 @@ interface TelemetryBus {
30
30
  clear(): void;
31
31
  }
32
32
 
33
+ /** The in-process telemetry bus: listeners subscribe with `onEvent`; emission never throws and does nothing without listeners. */
33
34
  export const telemetryBus: TelemetryBus = (() => {
34
35
  const listeners = new Set<TelemetryListener>();
35
36
  const deliver = (event: TelemetryEvent): void => {
@@ -0,0 +1,19 @@
1
+ # src/tools/
2
+
3
+ Defining tools and running them.
4
+
5
+ | File | What it is |
6
+ | :-- | :-- |
7
+ | `tool.ts` | `tool()` factory, name validation, `toStandardToolDeclarations`, option normalizers. |
8
+ | `schema.ts` | Zod to JSON Schema, `$ref` resolution, `stripSchemaForGoogle`. |
9
+ | `executor.ts` | `executeToolCalls`: validation, concurrency (8 global + per-tool), attempts, timeout, result cap, error scrubbing, `ctx.stop`, `tool.*` events, per-call logs. |
10
+ | `subagent-result.ts` | The private-Symbol brand that marks a result as made by the SDK's delegation tools. |
11
+
12
+ ## Invariants
13
+ - Tool names match `^[a-zA-Z0-9_-]{1,64}$` and must not collide after normalization (checked in the `Agent` constructor).
14
+ - One attempt by default; only idempotent tools retry transient failures; the timeout is best effort (`ctx.signal` is cooperative), and an uncooperative tool no longer holds an aborted run.
15
+ - Results are capped by serialized size (default 20000 chars); nested error stacks and absolute paths are scrubbed. The telemetry bus gets full detail, the model gets the scrubbed text.
16
+ - A truncated call (`{ __truncated_raw }`) never executes.
17
+ - `ctx.stop(reason)` throws a private signal; the batch finishes, then the loop ends the run as `stopped`.
18
+ - Only branded envelopes get their `_subagentMetadata` trusted; the executor passes them through untouched.
19
+ - Failures return `Error executing <name>: <msg>` plus a JSON block (attempts, timedOut, last progress, hint).
@@ -28,20 +28,24 @@ export interface ExecuteToolsOptions {
28
28
 
29
29
  const DEFAULT_TOOL_CONCURRENCY = 8;
30
30
 
31
+ // Wall-clock milliseconds; in: the executor's timing, out: a number.
31
32
  function nowMs(): number {
32
33
  return Date.now();
33
34
  }
34
35
 
36
+ // Milliseconds since `start`, at least 1 so a result never reports 0; in: result records, out: durationMs.
35
37
  function elapsedMs(start: number): number {
36
38
  return Math.max(1, nowMs() - start);
37
39
  }
38
40
 
41
+ // A finite number >= 0 from a number or numeric string option, else undefined; in: tool options, out: timeout and limits.
39
42
  function numericOption(value: number | string | undefined): number | undefined {
40
43
  if (value === undefined || value === "") return undefined;
41
44
  const parsed = typeof value === "number" ? value : Number(value);
42
45
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
43
46
  }
44
47
 
48
+ // numericOption rounded down to a whole number; in: tool options, out: attempts, concurrency and char caps.
45
49
  function integerOption(value: number | string | undefined): number | undefined {
46
50
  const parsed = numericOption(value);
47
51
  return parsed === undefined ? undefined : Math.floor(parsed);
@@ -128,11 +132,13 @@ export function sanitizeErrorMessage(message: string): string {
128
132
  return out;
129
133
  }
130
134
 
135
+ // The tool's `maxResultChars` or the 20000 default; in: runSingle, out: the cap on a result kept for the model.
131
136
  function resultCharLimit(toolDef: ToolDefinition): number {
132
137
  const configured = integerOption((toolDef as any).maxResultChars);
133
138
  return configured && configured > 0 ? configured : DEFAULT_MAX_RESULT_CHARS;
134
139
  }
135
140
 
141
+ // Cuts a string to the limit and appends a `[Truncated: ...]` note; in: runSingle, out: the capped result.
136
142
  function truncateResultString(value: string, limit: number): string {
137
143
  if (value.length <= limit) return value;
138
144
  return value.slice(0, limit) + `\n[Truncated: showing ${limit} of ${value.length} chars]`;
@@ -275,12 +281,14 @@ export function traceToolEvent(
275
281
  });
276
282
  }
277
283
 
284
+ // Scrubbed, JSON-safe value capped to `max` serialized characters for logs and events; in: progress and event tracing, out: a value or a truncated string.
278
285
  function capJson(value: unknown, max = 2000): unknown {
279
286
  const safe = scrubErrors(toJsonSafe(value));
280
287
  const json = JSON.stringify(safe);
281
288
  return typeof json === "string" && json.length > max ? `${json.slice(0, max)}…[truncated]` : safe;
282
289
  }
283
290
 
291
+ // Edit distance between two strings; in: resolveTool's did-you-mean, out: a number.
284
292
  function levenshtein(a: string, b: string): number {
285
293
  const row = Array.from({ length: b.length + 1 }, (_, i) => i);
286
294
  for (let i = 1; i <= a.length; i++) {
@@ -303,6 +311,7 @@ interface ResolvedTool {
303
311
  suggestion?: string;
304
312
  }
305
313
 
314
+ // Finds the tool a call names (exact, then case/format-insensitive), else suggests the closest name; in: runSingle and findToolDefinition, out: ResolvedTool.
306
315
  function resolveTool(tools: Record<string, ToolDefinition>, requestedName: string): ResolvedTool {
307
316
  const direct = tools[requestedName];
308
317
  if (direct) return { definition: direct, name: direct.name || requestedName };
@@ -348,6 +357,7 @@ class Semaphore {
348
357
 
349
358
  constructor(private readonly limit: number) {}
350
359
 
360
+ // Takes a slot (immediately if free, else waits in line), rejecting if the run aborts first; in: runSingle, out: a release function.
351
361
  acquire(signal?: AbortSignal): Promise<() => void> {
352
362
  if (signal?.aborted) return Promise.reject(new Error("Tool execution aborted"));
353
363
  if (this.limit === Number.POSITIVE_INFINITY || this.active < this.limit) {
@@ -372,6 +382,7 @@ class Semaphore {
372
382
  });
373
383
  }
374
384
 
385
+ // Frees a slot and hands it to the next live waiter, dropping aborted ones; in: the function acquire returned, out: the next waiter's grant.
375
386
  private release(): void {
376
387
  this.active = Math.max(0, this.active - 1);
377
388
  while (this.waiters.length > 0) {
@@ -405,6 +416,7 @@ function isTransientError(error: any): boolean {
405
416
  return false;
406
417
  }
407
418
 
419
+ // Waits the retry backoff, rejecting `Tool execution aborted` as soon as the run aborts; in: runSingle's retry loop, out: nothing.
408
420
  function waitForRetry(ms: number, signal?: AbortSignal): Promise<void> {
409
421
  if (ms <= 0) return Promise.resolve();
410
422
  return new Promise((resolve, reject) => {
@@ -437,6 +449,7 @@ function raceAbort<T>(work: Promise<T>, signal: AbortSignal | undefined): Promis
437
449
  });
438
450
  }
439
451
 
452
+ // One attempt of a tool: runs it, raced against the run's abort and the per-attempt timeout (which also aborts the tool's signal); in: runSingle, out: the tool's result or a thrown timeout/abort.
440
453
  async function executeAttempt(
441
454
  toolDef: ToolDefinition,
442
455
  parsedInput: unknown,
@@ -160,6 +160,7 @@ function extractZodObjectSchema(zodSchema: any): Record<string, unknown> {
160
160
  };
161
161
  }
162
162
 
163
+ // Best-effort JSON Schema for one Zod property when toJSONSchema is unavailable; in: extractZodObjectSchema, out: a schema fragment.
163
164
  function inferZodPropertyType(prop: any): Record<string, unknown> {
164
165
  const unwrapped = prop.unwrap?.() || prop._def?.innerType || prop;
165
166
  const description = prop.description || unwrapped.description || prop._def?.description;
@@ -34,6 +34,7 @@ export function isSubagentResult(value: unknown): value is SubagentResult {
34
34
  return !!value && typeof value === "object" && (value as Record<symbol, unknown>)[SUBAGENT_RESULT_BRAND] === true;
35
35
  }
36
36
 
37
+ // A finite number >= 0, else 0; in: normalizeSubagentMetadata, out: a safe usage count.
37
38
  const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0);
38
39
 
39
40
  /**
@@ -0,0 +1,20 @@
1
+ # src/types/
2
+
3
+ Shared contracts. Types only, plus a few tiny helpers that belong to a type.
4
+
5
+ | File | What it defines |
6
+ | :-- | :-- |
7
+ | `agent.ts` | `AgentConfig`, `AgentRunOptions`, `DynamicSubagentsConfig`, `Budget`, `EditableAgentConfig`, run/turn event shapes. |
8
+ | `core.ts` | `ThinkingLevel`, `CacheOption` (+ `CACHE_OPTIONS`), `ServiceTier`, `TokenUsage`. |
9
+ | `message.ts` | `Message` and the content parts (text, image, audio, video, file, tool call/result, thinking), `ProviderSteps`. |
10
+ | `model.ts` | `ModelSpec`, `Provider`, `ProviderRequestOptions`, `ProviderGenerateResult`, structured-output spec. |
11
+ | `response.ts` | `AgentResponse`, `FinishReason` + `normalizeFinishReason`, `StreamEvent`, worker metadata. |
12
+ | `tool.ts` | `ToolDefinition`, `ToolExecutionContext` (`log`, `stop`, `emit`), call/result records, the truncated-arguments marker. |
13
+ | `scheduler.ts` | Scheduled-action input, record and delivery types. |
14
+ | `errors.ts` | The typed error family (`AgentAccelError` and subclasses), `isAbortError`. |
15
+
16
+ ## Invariants
17
+ - Adding a field to a public type is a public API change: update `README.md` and `docs/`.
18
+ - Abort means `signal.aborted` or `name === "AbortError"`, never a message match.
19
+ - Error messages are one line and ANSI-free.
20
+ - No logic here beyond `normalizeFinishReason`, `isTruncatedArguments` and the error classes.
@@ -9,7 +9,7 @@ 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. */
12
+ /** Detail level a provider spends on one image or video (more detail = more input tokens). Google (images and videos) and OpenRouter (images, as `image_url.detail`) honour it; other providers ignore it with a warning. */
13
13
  export type MediaResolution = "low" | "medium" | "high" | "ultra_high";
14
14
 
15
15
  /** Image content accepted as a URL, data, path, or binary value. */
@@ -0,0 +1,25 @@
1
+ # src/utils/
2
+
3
+ Small shared helpers. Each file has one job.
4
+
5
+ | File | What it does |
6
+ | :-- | :-- |
7
+ | `durable.ts` | The ONE disk writer (`writeFileDurable`: temp, fsync, `.bak`, rename, folder fsync), `readWithBackup`, and `lockFolder` (one process per folder, stale-lock takeover, temp sweep). |
8
+ | `home.ts` | `resolveHome` / `setHome`: the single folder everything is stored in. |
9
+ | `env.ts` | Process-env lookups and API key resolution. No `globalThis` fallback. |
10
+ | `headers.ts` | Session-affinity headers, opt-in attribution, browser stripping, package version fallback. |
11
+ | `media.ts` | Normalizes a path, URL, data URL, base64 or bytes to base64 and a MIME type; magic-byte sniffing. |
12
+ | `safe-io.ts` | `fileAccess.roots` containment and the guarded remote fetch (private hosts refused, redirects re-checked, size cap). |
13
+ | `raw-capture.ts` | The `captureRaw` policy for `AgentResponse.raw`. |
14
+ | `cache.ts` | Cache-key clamp (64 chars) and the central secret redaction. |
15
+ | `errors.ts` | `toConciseProviderError` and pre-network modality guards. |
16
+ | `retry.ts` | `withRetries`, `isTransientError`, `isRetryableRun`, `sleep`. Structured data only (status, code, flags), never message text. |
17
+ | `session.ts` | Id helpers (`createSessionId`, `hashSessionPart`, `createTrackingId`, run/turn ids), `parseModelRef`, `previewPrompt`. |
18
+ | `serialization.ts` | `toJsonSafe`, `safeStringify`, `escapeXml`. |
19
+ | `base64.ts`, `thought-signature.ts` | Runtime-agnostic base64; Gemini thought-signature helpers. |
20
+
21
+ ## Invariants
22
+ - Never write state with a bare `writeFileSync`; use `writeFileDurable`.
23
+ - `media.ts` and `safe-io.ts` import `node:*` lazily so browser bundles keep working.
24
+ - Redaction is central (`cache.ts`); adapters must not keep their own deny-lists.
25
+ - `AGENT_ACCEL_HOME` is read at import time; `setHome()` after import relies on the catalog reload hook.
@@ -1,5 +1,6 @@
1
1
  /** Runtime-agnostic base64 helpers — no Node Buffer, safe in browsers. */
2
2
 
3
+ /** Decodes base64 (whitespace ignored) to bytes, with Buffer when present, else atob; in: media and session code, out: a Uint8Array. */
3
4
  export function base64ToBytes(b64: string): Uint8Array {
4
5
  const clean = b64.replace(/\s/g, "");
5
6
  const nodeBuffer = (globalThis as any)?.Buffer;
@@ -13,6 +14,7 @@ export function base64ToBytes(b64: string): Uint8Array {
13
14
  return out;
14
15
  }
15
16
 
17
+ /** Encodes bytes as base64, with Buffer when present, else btoa in chunks; in: media and session code, out: a string. */
16
18
  export function bytesToBase64(bytes: Uint8Array): string {
17
19
  const nodeBuffer = (globalThis as any)?.Buffer;
18
20
  if (nodeBuffer && typeof nodeBuffer.from === "function") {
@@ -28,11 +28,13 @@ const SENSITIVE_VALUE_PATTERNS: RegExp[] = [
28
28
  /(^|[^A-Za-z0-9])AIza[A-Za-z0-9_-]{10,}/,
29
29
  ];
30
30
 
31
+ // True when a header name contains key, token, secret, cookie or authorization; in: redactHeadersCentral, out: a boolean.
31
32
  function headerNameIsSensitive(name: string): boolean {
32
33
  const lower = String(name ?? "").toLowerCase();
33
34
  return REDACT_HEADER_SUBSTRINGS.some((s) => lower.includes(s));
34
35
  }
35
36
 
37
+ // True when a string looks like a credential (sk-, gsk_, xox, AIza, long Bearer token); in: the redactors, out: a boolean.
36
38
  function valueLooksSensitive(value: unknown): boolean {
37
39
  if (typeof value !== "string") return false;
38
40
  const v = value.trim();
@@ -3,6 +3,7 @@ import { getModelFromCatalog } from "../models/catalog.ts";
3
3
 
4
4
  const CONCISE = "__agentAccelConcise";
5
5
 
6
+ // HTTP status from the shapes provider errors carry, else undefined; in: toConciseProviderError, out: a status.
6
7
  function readStatus(error: any): number | undefined {
7
8
  const s =
8
9
  error?.statusCode ??
@@ -14,6 +15,7 @@ function readStatus(error: any): number | undefined {
14
15
  return Number.isFinite(n) && n > 0 ? n : undefined;
15
16
  }
16
17
 
18
+ // The request URL of an error without its query string; in: toConciseProviderError, out: a string.
17
19
  function readUrl(error: any): string {
18
20
  const raw =
19
21
  (typeof error?.url === "string" && error.url) ||
@@ -22,6 +24,7 @@ function readUrl(error: any): string {
22
24
  return raw.split("?")[0];
23
25
  }
24
26
 
27
+ // The provider's own message from the error's data, body or message; in: toConciseProviderError, out: one trimmed line.
25
28
  function readProviderMessage(error: any): string {
26
29
  const data = error?.data;
27
30
  if (typeof data?.error?.message === "string" && data.error.message.trim()) return data.error.message.trim();
@@ -39,6 +42,7 @@ function readProviderMessage(error: any): string {
39
42
  return String(error?.message ?? error).slice(0, 300);
40
43
  }
41
44
 
45
+ // `provider/model` without doubling an existing prefix; in: the error messages, out: a label.
42
46
  function displayModel(providerId: string, modelId: string): string {
43
47
  if (modelId.toLowerCase().startsWith(`${providerId.toLowerCase()}/`)) return modelId;
44
48
  return `${providerId}/${modelId}`;
@@ -113,7 +117,8 @@ const PART_TO_MODALITY: Record<string, "image" | "audio" | "video" | "pdf"> = {
113
117
 
114
118
  /**
115
119
  * Fails fast — before any network call — when the catalog knows the model
116
- * lacks a requested input modality (image/audio/video/document). Unknown
120
+ * lacks a requested input modality (image/audio/video/document; files are
121
+ * not checked on OpenRouter, which parses them for any model). Unknown
117
122
  * models (custom providers, dynamic routers) are skipped: the provider
118
123
  * verdict stands and surfaces via toConciseProviderError instead.
119
124
  */
@@ -130,6 +135,8 @@ export function assertModalitiesSupported(
130
135
  if (mapped) needed.add(mapped);
131
136
  }
132
137
  }
138
+ // OpenRouter reads a file with the model's native support or its own parser, so the catalog's "pdf" flag never gates a file there.
139
+ if (providerId === "openrouter") needed.delete("pdf");
133
140
  if (needed.size === 0) return;
134
141
  let supported: readonly string[] | undefined;
135
142
  try {
@@ -1,6 +1,7 @@
1
1
  import type { ProviderId } from "../types/model.ts";
2
2
  import { clampCacheKey } from "./cache.ts";
3
3
 
4
+ /** True in a browser, worker or React Native scope, where custom headers cause CORS preflights; in: header building, out: a boolean. */
4
5
  export function isBrowserRuntime(): boolean {
5
6
  try {
6
7
  const g = globalThis as any;
@@ -23,9 +24,10 @@ export function getPackageVersion(): string {
23
24
  const v = (globalThis as any).process?.env?.npm_package_version;
24
25
  if (typeof v === "string" && v) return v;
25
26
  } catch {}
26
- return "0.5.5";
27
+ return "0.5.7";
27
28
  }
28
29
 
30
+ // User-Agent string with version and platform; in: AGENT_ACCEL_USER_AGENT, out: a string.
29
31
  function getAgentAccelUserAgent(): string {
30
32
  const version = getPackageVersion();
31
33
  try {
@@ -125,6 +127,7 @@ const BROWSER_DROPPED = new Set([
125
127
  "x-goog-api-client",
126
128
  ]);
127
129
 
130
+ // Drops the headers a browser may not or should not send; in: buildSessionHeaders, out: the safe headers.
128
131
  function stripForBrowser(headers: Record<string, string>): Record<string, string> {
129
132
  const out: Record<string, string> = {};
130
133
  for (const [k, v] of Object.entries(headers)) {
@@ -26,6 +26,11 @@ export function inferMimeType(input: string, fallback = "application/octet-strea
26
26
  if (clean.endsWith(".mp3")) return "audio/mp3";
27
27
  if (clean.endsWith(".wav")) return "audio/wav";
28
28
  if (clean.endsWith(".ogg")) return "audio/ogg";
29
+ if (clean.endsWith(".flac")) return "audio/flac";
30
+ if (clean.endsWith(".m4a")) return "audio/m4a";
31
+ if (clean.endsWith(".aac")) return "audio/aac";
32
+ if (clean.endsWith(".aiff") || clean.endsWith(".aif")) return "audio/aiff";
33
+ if (clean.endsWith(".mpeg") || clean.endsWith(".mpg")) return "video/mpeg";
29
34
  if (clean.endsWith(".mp4")) return "video/mp4";
30
35
  if (clean.endsWith(".webm")) return "video/webm";
31
36
  if (clean.endsWith(".mov")) return "video/quicktime";
@@ -33,6 +38,7 @@ export function inferMimeType(input: string, fallback = "application/octet-strea
33
38
  return fallback;
34
39
  }
35
40
 
41
+ // True when the bytes spell `text` at `start`; in: sniffMimeType, out: a boolean.
36
42
  const ascii = (bytes: Uint8Array, start: number, text: string): boolean =>
37
43
  bytes.length >= start + text.length && [...text].every((ch, i) => bytes[start + i] === ch.charCodeAt(0));
38
44
 
@@ -53,8 +59,12 @@ export function sniffMimeType(bytes: Uint8Array): string | undefined {
53
59
  if (ascii(bytes, 0, "RIFF") && ascii(bytes, 8, "WAVE")) return "audio/wav";
54
60
  if (ascii(bytes, 0, "%PDF")) return "application/pdf";
55
61
  if (ascii(bytes, 0, "OggS")) return "audio/ogg";
62
+ if (ascii(bytes, 0, "fLaC")) return "audio/flac";
63
+ if (ascii(bytes, 0, "FORM") && ascii(bytes, 8, "AIF")) return "audio/aiff";
64
+ if (bytes[0] === 0xff && ((bytes[1] ?? 0) & 0xf6) === 0xf0) return "audio/aac"; // ADTS: sync bits plus layer 00 (MP3 frames have a non-zero layer)
56
65
  if (ascii(bytes, 0, "ID3") || (bytes[0] === 0xff && ((bytes[1] ?? 0) & 0xe0) === 0xe0)) return "audio/mp3";
57
- if (ascii(bytes, 4, "ftyp")) return ascii(bytes, 8, "qt") ? "video/quicktime" : "video/mp4";
66
+ if (ascii(bytes, 4, "ftyp")) return ascii(bytes, 8, "M4A") || ascii(bytes, 8, "M4B") ? "audio/m4a" : ascii(bytes, 8, "qt") ? "video/quicktime" : "video/mp4";
67
+ if (bytes[0] === 0 && bytes[1] === 0 && bytes[2] === 1 && bytes[3] === 0xba) return "video/mpeg"; // MPEG program stream
58
68
  if (bytes[0] === 0x1a && bytes[1] === 0x45 && bytes[2] === 0xdf && bytes[3] === 0xa3) return "video/webm";
59
69
  return undefined;
60
70
  }
@@ -3,6 +3,7 @@ import type { ProviderRawData } from "../types/model.ts";
3
3
  /** How much of the wire exchange `AgentResponse.raw` keeps (see `AgentConfig.captureRaw`). */
4
4
  export type CaptureRawMode = false | "redacted" | true;
5
5
 
6
+ // Serialized length of a value, 0 if it cannot be serialized; in: the body summaries, out: a number.
6
7
  function byteLength(value: unknown): number {
7
8
  try {
8
9
  return JSON.stringify(value)?.length ?? 0;
@@ -11,6 +12,7 @@ function byteLength(value: unknown): number {
11
12
  }
12
13
  }
13
14
 
15
+ // Tool names from a request body's `tools`, if any; in: summarizeRequestBody, out: names or undefined.
14
16
  function toolNames(tools: unknown): string[] | undefined {
15
17
  if (!Array.isArray(tools)) return undefined;
16
18
  const names = tools
@@ -34,6 +36,7 @@ function summarizeRequestBody(body: unknown): unknown {
34
36
  };
35
37
  }
36
38
 
39
+ // Content-free summary of a response body (size, id, status); in: applyCaptureRaw, out: the summary.
37
40
  function summarizeResponseBody(body: unknown): unknown {
38
41
  if (body === null || body === undefined) return body;
39
42
  const b = (typeof body === "object" ? body : {}) as Record<string, unknown>;
@@ -43,6 +43,7 @@ export function isRetryableRun(error: unknown): boolean {
43
43
  return isTransientError(error);
44
44
  }
45
45
 
46
+ /** The server's wait hint in ms from `retryAfterMs` or a Retry-After header, capped at 60 s; in: withRetries, out: ms or undefined. */
46
47
  export function readRetryAfterMs(error: unknown): number | undefined {
47
48
  const err = error as Record<string, unknown>;
48
49
  // Preferred: milliseconds attached by the adapter from the Retry-After header.
@@ -98,6 +98,7 @@ export async function enforceFileAccess(messages: Message[], roots: readonly str
98
98
  // Remote fetch guard (SSRF + size cap)
99
99
  // ---------------------------------------------------------------------------
100
100
 
101
+ // True for the private, loopback, link-local and CGNAT IPv4 ranges; in: isPrivateAddress, out: a boolean.
101
102
  function ipv4Private(a: number, b: number): boolean {
102
103
  return (
103
104
  a === 0 ||
@@ -124,6 +125,7 @@ export function isPrivateAddress(address: string): boolean {
124
125
  return false;
125
126
  }
126
127
 
128
+ // True for localhost, .local/.internal names and private addresses; in: assertRemoteUrlAllowed, out: a boolean.
127
129
  function isPrivateHostname(host: string): boolean {
128
130
  return (
129
131
  host === "localhost" ||
@@ -134,6 +136,7 @@ function isPrivateHostname(host: string): boolean {
134
136
  );
135
137
  }
136
138
 
139
+ // Throws RemoteFetchError for a non-http(s) URL or, unless private hosts are allowed, a private host (also by DNS result); in: fetchRemoteBytes on every hop, out: nothing or a thrown error.
137
140
  async function assertRemoteUrlAllowed(url: URL, allowPrivateNetwork: boolean): Promise<void> {
138
141
  if (url.protocol !== "http:" && url.protocol !== "https:") {
139
142
  throw new RemoteFetchError("blocked", `[Agent Accelerator] Only http(s) URLs are allowed (got ${url.protocol}).`, url.href);
@@ -156,6 +159,7 @@ async function assertRemoteUrlAllowed(url: URL, allowPrivateNetwork: boolean): P
156
159
  }
157
160
  }
158
161
 
162
+ // Reads a response body up to `maxBytes`, throwing too_large as soon as the declared or streamed size passes it; in: fetchRemoteBytes, out: the bytes.
159
163
  async function readCapped(res: Response, maxBytes: number, url: string): Promise<Uint8Array> {
160
164
  const tooLarge = () =>
161
165
  new RemoteFetchError(