agent-accelerator 0.5.8 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +26 -5
  2. package/examples/prompts/SYSTEM_PROMPT.md +2 -0
  3. package/examples/prompts/SYSTEM_PROMPT_AGENT.md +4 -0
  4. package/examples/prompts/SYSTEM_PROMPT_TOOLS.md +1 -0
  5. package/package.json +4 -1
  6. package/src/agent/agent.ts +374 -167
  7. package/src/agent/architecture.md +14 -12
  8. package/src/agent/config-blocks.ts +39 -12
  9. package/src/agent/context.ts +8 -3
  10. package/src/agent/delegation.ts +82 -29
  11. package/src/agent/loop.ts +449 -373
  12. package/src/agent/subagent.ts +1 -3
  13. package/src/agent/subagents-tool.ts +45 -7
  14. package/src/agent/subagents.md +3 -0
  15. package/src/agent/turns.ts +3 -1
  16. package/src/agent/worker.ts +58 -28
  17. package/src/agent/workers.ts +113 -20
  18. package/src/architecture.md +16 -15
  19. package/src/index.ts +34 -10
  20. package/src/models/architecture.md +4 -3
  21. package/src/models/catalog-cache.ts +6 -5
  22. package/src/models/catalog.ts +46 -49
  23. package/src/providers/architecture.md +10 -8
  24. package/src/providers/google.ts +150 -62
  25. package/src/providers/openai-compat.ts +60 -58
  26. package/src/providers/openai.ts +57 -39
  27. package/src/providers/openrouter.ts +87 -32
  28. package/src/providers/registry.ts +12 -26
  29. package/src/providers/shared.ts +89 -39
  30. package/src/providers.ts +56 -115
  31. package/src/scheduler/architecture.md +9 -8
  32. package/src/scheduler/scheduled-actions.md +4 -0
  33. package/src/scheduler/scheduler.ts +186 -60
  34. package/src/scheduler/store.ts +23 -10
  35. package/src/scheduler/time.ts +17 -3
  36. package/src/scheduler/tool.ts +33 -7
  37. package/src/session/architecture.md +7 -6
  38. package/src/session/journal.ts +38 -12
  39. package/src/session/store.ts +122 -61
  40. package/src/session/usage.ts +58 -9
  41. package/src/streaming/architecture.md +4 -3
  42. package/src/streaming/event-stream.ts +26 -12
  43. package/src/telemetry.ts +6 -2
  44. package/src/tools/architecture.md +8 -7
  45. package/src/tools/executor.ts +50 -48
  46. package/src/tools/schema.ts +10 -6
  47. package/src/tools/subagent-result.ts +6 -1
  48. package/src/tools/tool.ts +9 -11
  49. package/src/types/agent.ts +2 -21
  50. package/src/types/architecture.md +11 -10
  51. package/src/types/core.ts +1 -9
  52. package/src/types/errors.ts +26 -4
  53. package/src/types/model.ts +0 -4
  54. package/src/types/tool.ts +12 -2
  55. package/src/update-models.ts +5 -5
  56. package/src/utils/architecture.md +16 -15
  57. package/src/utils/cache.ts +1 -3
  58. package/src/utils/durable.ts +17 -5
  59. package/src/utils/env.ts +9 -7
  60. package/src/utils/errors.ts +50 -44
  61. package/src/utils/headers.ts +3 -10
  62. package/src/utils/media.ts +23 -8
  63. package/src/utils/raw-capture.ts +9 -2
  64. package/src/utils/retry.ts +17 -31
  65. package/src/utils/safe-io.ts +29 -7
  66. package/src/utils/serialization.ts +1 -4
  67. package/src/utils/session.ts +1 -2
package/src/types/core.ts CHANGED
@@ -2,15 +2,7 @@
2
2
  * Level-based reasoning — single flag for all levels (bloatfree)
3
3
  */
4
4
  /** Public reasoning control accepted by AgentConfig. */
5
- export type ThinkingLevel =
6
- | "none"
7
- | "dynamic"
8
- | "minimal"
9
- | "low"
10
- | "medium"
11
- | "high"
12
- | "xhigh"
13
- | "max";
5
+ export type ThinkingLevel = "none" | "dynamic" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
14
6
 
15
7
  // Internal normalized config (SDK-internal, not exposed as Agent flag)
16
8
  /** Internal normalized reasoning configuration passed to providers. */
@@ -40,7 +40,11 @@ export class AgentAccelError extends Error {
40
40
  readonly code: AgentAccelErrorCode;
41
41
  readonly retryable: boolean;
42
42
  readonly context: AgentAccelErrorContext;
43
- constructor(code: AgentAccelErrorCode, message: string, opts?: { retryable?: boolean; context?: AgentAccelErrorContext; cause?: unknown }) {
43
+ constructor(
44
+ code: AgentAccelErrorCode,
45
+ message: string,
46
+ opts?: { retryable?: boolean; context?: AgentAccelErrorContext; cause?: unknown }
47
+ ) {
44
48
  super(message);
45
49
  this.name = "AgentAccelError";
46
50
  this.code = code;
@@ -59,10 +63,26 @@ export class ConfigError extends AgentAccelError {
59
63
 
60
64
  export class ProviderError extends AgentAccelError {
61
65
  readonly status?: number;
62
- constructor(message: string, opts?: { status?: number; provider?: string; model?: string; requestId?: string; retryable?: boolean; context?: AgentAccelErrorContext }) {
66
+ constructor(
67
+ message: string,
68
+ opts?: {
69
+ status?: number;
70
+ provider?: string;
71
+ model?: string;
72
+ requestId?: string;
73
+ retryable?: boolean;
74
+ context?: AgentAccelErrorContext;
75
+ }
76
+ ) {
63
77
  super("provider", message, {
64
78
  retryable: opts?.retryable ?? false,
65
- context: { ...opts?.context, provider: opts?.provider, model: opts?.model, status: opts?.status, requestId: opts?.requestId },
79
+ context: {
80
+ ...opts?.context,
81
+ provider: opts?.provider,
82
+ model: opts?.model,
83
+ status: opts?.status,
84
+ requestId: opts?.requestId,
85
+ },
66
86
  });
67
87
  this.name = "ProviderError";
68
88
  this.status = opts?.status;
@@ -124,7 +144,9 @@ export function isAbortError(error: unknown): boolean {
124
144
  /** Format without ANSI for CLI pretty-printing. */
125
145
  export function formatErrorPlain(err: unknown): string {
126
146
  if (err instanceof AgentAccelError) {
127
- const ctx = err.context?.provider ? ` [${err.context.provider}${err.context.model ? `/${err.context.model}` : ""}]` : "";
147
+ const ctx = err.context?.provider
148
+ ? ` [${err.context.provider}${err.context.model ? `/${err.context.model}` : ""}]`
149
+ : "";
128
150
  const status = err.context?.status ? ` (${err.context.status})` : "";
129
151
  return `[${err.code}]${ctx}${status}: ${err.message}`;
130
152
  }
@@ -185,10 +185,6 @@ export interface ProviderRequestOptions {
185
185
  googleTier?: "free" | "paid";
186
186
  /** Upper bound on generated tokens for one model call. */
187
187
  maxOutputTokens?: number;
188
- /** Stop sequence(s). Chat Completions only; other wires warn+drop. */
189
- stop?: string[];
190
- /** Deterministic-sampling seed. Chat Completions only; other wires warn+drop. */
191
- seed?: number;
192
188
  /** `fetch` implementation used for the provider request (defaults to global `fetch`). */
193
189
  fetch?: typeof fetch;
194
190
  /** Base backoff delay in ms for provider retries (default 250). */
package/src/types/tool.ts CHANGED
@@ -21,7 +21,13 @@ export interface ToolExecutionContext {
21
21
  /** Session identifier associated with this invocation. */
22
22
  sessionId?: string;
23
23
  /** Forwards live sub-agent deltas (used by the `subagents` tool for realtime tracking). */
24
- onSubagentEvent?: (event: { trackingId: string; delta?: string; thinkingDelta?: string; partialText?: string; partialThinking?: string }) => void;
24
+ onSubagentEvent?: (event: {
25
+ trackingId: string;
26
+ delta?: string;
27
+ thinkingDelta?: string;
28
+ partialText?: string;
29
+ partialThinking?: string;
30
+ }) => void;
25
31
  // Tool API v2 context additions (all optional, additive).
26
32
  /** Stable id for this run (`newRunId`). Mirrors SubAgentRunIds.runId. */
27
33
  runId?: string;
@@ -161,5 +167,9 @@ export const TRUNCATED_ARGS_KEY = "__truncated_raw";
161
167
  export function isTruncatedArguments(args: unknown): boolean {
162
168
  if (!args || typeof args !== "object" || Array.isArray(args)) return false;
163
169
  const keys = Object.keys(args as Record<string, unknown>);
164
- return keys.length === 1 && keys[0] === TRUNCATED_ARGS_KEY && typeof (args as Record<string, unknown>)[TRUNCATED_ARGS_KEY] === "string";
170
+ return (
171
+ keys.length === 1 &&
172
+ keys[0] === TRUNCATED_ARGS_KEY &&
173
+ typeof (args as Record<string, unknown>)[TRUNCATED_ARGS_KEY] === "string"
174
+ );
165
175
  }
@@ -28,7 +28,9 @@ for (const arg of args) {
28
28
  }
29
29
  // Reject non-numeric TTLs (e.g. --ttl=abc) instead of silently caching NaN.
30
30
  if (!Number.isFinite(customTtlMs)) {
31
- throw new ConfigError(`Invalid --ttl value "${val}": expected a number with optional h/m suffix (e.g. --ttl=24h).`);
31
+ throw new ConfigError(
32
+ `Invalid --ttl value "${val}": expected a number with optional h/m suffix (e.g. --ttl=24h).`
33
+ );
32
34
  }
33
35
  }
34
36
  }
@@ -42,13 +44,11 @@ try {
42
44
  const ttlHours = (result.ttlMs / (1000 * 60 * 60)).toFixed(1);
43
45
 
44
46
  if (result.fromCache) {
45
- console.log(
46
- `\x1b[32m✓\x1b[0m Models catalog is already fresh (within ${ttlHours}h TTL).`
47
- );
47
+ console.log(`\x1b[32m✓\x1b[0m Models catalog is already fresh (within ${ttlHours}h TTL).`);
48
48
  } else {
49
49
  console.log(
50
50
  `\x1b[32m✓\x1b[0m Successfully downloaded and cached \x1b[1m${result.modelCount.toLocaleString()}\x1b[0m models ` +
51
- `across \x1b[1m${result.providerCount}\x1b[0m providers in ${duration}s.`
51
+ `across \x1b[1m${result.providerCount}\x1b[0m providers in ${duration}s.`
52
52
  );
53
53
  }
54
54
  console.log(` \x1b[90mCache location: ${result.cachePath}\x1b[0m`);
@@ -2,23 +2,24 @@
2
2
 
3
3
  Small shared helpers. Each file has one job.
4
4
 
5
- | File | What it does |
6
- | :-- | :-- |
7
- | `durable.ts` | The ONE disk writer (`writeFileDurable`: temp, fsync, `.bak`, rename, folder fsync), `readWithBackup`, `lockFolder` (one process per folder, used for session folders; stale-lock takeover, temp sweep) and `withFileMutex` (short per-file lock for state several processes change, e.g. one scheduled action). |
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. |
5
+ | File | What it does |
6
+ | :---------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
7
+ | `durable.ts` | The ONE disk writer (`writeFileDurable`: temp, fsync, `.bak`, rename, folder fsync), `readWithBackup`, `lockFolder` (one process per folder, used for session folders; stale-lock takeover, temp sweep) and `withFileMutex` (short per-file lock for state several processes change, e.g. one scheduled action). |
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` (any raw provider failure becomes one-line `ProviderError`: `code` `"provider"`, `retryable` from status/code, the provider's code as `providerCode`; SDK errors pass through), `isTransientError`, and the pre-network modality guards (`ValidationError`). |
16
+ | `retry.ts` | `withRetries`, `isTransientError` (re-exported from `errors.ts`), `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
20
 
21
21
  ## Invariants
22
+
22
23
  - Never write state with a bare `writeFileSync`; use `writeFileDurable`.
23
24
  - `media.ts` and `safe-io.ts` import `node:*` lazily so browser bundles keep working.
24
25
  - Redaction is central (`cache.ts`); adapters must not keep their own deny-lists.
@@ -58,9 +58,7 @@ export function redactValue(value: unknown): unknown {
58
58
  * Deny-list (name contains): *key*, *token*, *secret*, cookie, authorization.
59
59
  * Value patterns (redacted regardless of name): sk-, AIza, gsk-, xox-.
60
60
  */
61
- export function redactHeadersCentral(
62
- headers: Record<string, unknown> | undefined | null
63
- ): Record<string, unknown> {
61
+ export function redactHeadersCentral(headers: Record<string, unknown> | undefined | null): Record<string, unknown> {
64
62
  if (!headers || typeof headers !== "object") return {};
65
63
  const out: Record<string, unknown> = {};
66
64
  for (const [k, v] of Object.entries(headers)) {
@@ -32,7 +32,11 @@ export function fsyncFolder(dir: string): void {
32
32
  * Durable atomic write: temp file (0600) -> fsync -> previous version kept as `<file>.bak` (hard link, no copy)
33
33
  * -> rename -> fsync of the folder. Throws on failure; the temp file is removed. `backup: false` skips the `.bak`.
34
34
  */
35
- export function writeFileDurable(filePath: string, content: string | Uint8Array, opts: { backup?: boolean } = {}): void {
35
+ export function writeFileDurable(
36
+ filePath: string,
37
+ content: string | Uint8Array,
38
+ opts: { backup?: boolean } = {}
39
+ ): void {
36
40
  const dir = path.dirname(filePath);
37
41
  try {
38
42
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -73,7 +77,10 @@ export function writeFileDurable(filePath: string, content: string | Uint8Array,
73
77
  * Reads `file`; when `isValid` rejects it, falls back to `<file>.bak`. `undefined` when the file is missing (a deleted
74
78
  * file is never resurrected from its backup). When both are invalid the primary text comes back, `fromBackup: false`.
75
79
  */
76
- export function readWithBackup(file: string, isValid: (text: string) => boolean): { text: string; fromBackup: boolean } | undefined {
80
+ export function readWithBackup(
81
+ file: string,
82
+ isValid: (text: string) => boolean
83
+ ): { text: string; fromBackup: boolean } | undefined {
77
84
  let text: string;
78
85
  try {
79
86
  text = fs.readFileSync(file, "utf8");
@@ -234,7 +241,8 @@ const heldMutexes = new Set<string>();
234
241
  */
235
242
  export function withFileMutex<T>(lockFile: string, fn: () => T): T {
236
243
  const file = path.resolve(lockFile);
237
- if (heldMutexes.has(file)) throw new ConfigError(`[Agent Accelerator] Mutex "${file}" is already held by this process (nested use).`);
244
+ if (heldMutexes.has(file))
245
+ throw new ConfigError(`[Agent Accelerator] Mutex "${file}" is already held by this process (nested use).`);
238
246
  const mine = JSON.stringify({ pid: process.pid, host: os.hostname(), at: Date.now() });
239
247
  const deadline = Date.now() + MUTEX_WAIT_MS;
240
248
  let unreadable = 0;
@@ -265,7 +273,8 @@ export function withFileMutex<T>(lockFile: string, fn: () => T): T {
265
273
  owner = undefined;
266
274
  }
267
275
  let stale = false;
268
- if (typeof owner?.pid !== "number") stale = ++unreadable > 5; // empty or garbage: another process may be mid-write
276
+ if (typeof owner?.pid !== "number")
277
+ stale = ++unreadable > 5; // empty or garbage: another process may be mid-write
269
278
  else if (owner.host === os.hostname() && !processAlive(owner.pid)) stale = true;
270
279
  else if (Date.now() - mtime > MUTEX_MAX_AGE_MS) stale = true;
271
280
  if (stale) {
@@ -276,7 +285,10 @@ export function withFileMutex<T>(lockFile: string, fn: () => T): T {
276
285
  }
277
286
  continue;
278
287
  }
279
- if (Date.now() > deadline) throw new ConfigError(`[Agent Accelerator] Timed out after ${MUTEX_WAIT_MS} ms waiting for the lock "${file}" (held by process ${String(owner?.pid)}).`);
288
+ if (Date.now() > deadline)
289
+ throw new ConfigError(
290
+ `[Agent Accelerator] Timed out after ${MUTEX_WAIT_MS} ms waiting for the lock "${file}" (held by process ${String(owner?.pid)}).`
291
+ );
280
292
  pause(typeof owner?.pid === "number" ? 2 : 40);
281
293
  }
282
294
  heldMutexes.add(file);
package/src/utils/env.ts CHANGED
@@ -11,7 +11,10 @@ export function getEnv(key: string, fallback?: string): string | undefined {
11
11
  if (typeof process !== "undefined" && process.env && process.env[key]) {
12
12
  // Trim quotes/whitespace from .env files (`sk-..."\n` -> 401 otherwise).
13
13
  const raw = String(process.env[key]);
14
- const trimmed = raw.trim().replace(/^["']|["']$/g, "").trim();
14
+ const trimmed = raw
15
+ .trim()
16
+ .replace(/^["']|["']$/g, "")
17
+ .trim();
15
18
  if (trimmed) return trimmed;
16
19
  }
17
20
  return fallback;
@@ -27,7 +30,10 @@ export type ProviderEnv = Record<string, string>;
27
30
  export function getApiKey(provider: string, explicitKey?: string, env?: ProviderEnv): string | undefined {
28
31
  const clean = (v: unknown): string | undefined => {
29
32
  if (typeof v !== "string") return undefined;
30
- const t = v.trim().replace(/^["']|["']$/g, "").trim();
33
+ const t = v
34
+ .trim()
35
+ .replace(/^["']|["']$/g, "")
36
+ .trim();
31
37
  return t || undefined;
32
38
  };
33
39
  const cleanedExplicit = clean(explicitKey);
@@ -56,11 +62,7 @@ export function getApiKey(provider: string, explicitKey?: string, env?: Provider
56
62
  switch (provider.toLowerCase()) {
57
63
  case "google":
58
64
  case "gemini":
59
- return (
60
- getEnv("GEMINI_API_KEY") ||
61
- getEnv("GOOGLE_API_KEY") ||
62
- getEnv("GOOGLE_GENAI_API_KEY")
63
- );
65
+ return getEnv("GEMINI_API_KEY") || getEnv("GOOGLE_API_KEY") || getEnv("GOOGLE_GENAI_API_KEY");
64
66
  case "openrouter":
65
67
  return getEnv("OPENROUTER_API_KEY");
66
68
  case "openai":
@@ -1,5 +1,34 @@
1
1
  import type { ProviderContext } from "../types/message.ts";
2
2
  import { getModelFromCatalog } from "../models/catalog.ts";
3
+ import { AgentAccelError, ProviderError, ValidationError, isAbortError } from "../types/errors.ts";
4
+
5
+ /**
6
+ * Checks whether a provider error is transient and safe to retry.
7
+ * Structured-data only: status / code / transient flag. Never message text,
8
+ * so a 400 mentioning "512 tokens" is not retried.
9
+ */
10
+ export function isTransientError(error: unknown): boolean {
11
+ if (!error) return false;
12
+ if (isAbortError(error)) return false;
13
+ const err = error as Record<string, unknown>;
14
+ if (err["transient"] === true) return true;
15
+ if ((err as { retryable?: unknown }).retryable === true) return true;
16
+ const status = Number(
17
+ (err["status"] as number | undefined) ??
18
+ (err["statusCode"] as number | undefined) ??
19
+ ((err["response"] as Record<string, unknown> | undefined)?.["status"] as number | undefined) ??
20
+ ((err["lastError"] as Record<string, unknown> | undefined)?.["status"] as number | undefined) ??
21
+ NaN
22
+ );
23
+ if ([408, 425, 429, 500, 502, 503, 504].includes(status)) return true;
24
+ const code = String(
25
+ (err["providerCode"] as string | undefined) ?? (err["code"] as string | undefined) ?? ""
26
+ ).toUpperCase();
27
+ if (["ECONNRESET", "ECONNREFUSED", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT", "EPIPE", "ETIMEDOUT"].includes(code)) {
28
+ return true;
29
+ }
30
+ return false;
31
+ }
3
32
 
4
33
  const CONCISE = "__agentAccelConcise";
5
34
 
@@ -49,12 +78,12 @@ function displayModel(providerId: string, modelId: string): string {
49
78
  }
50
79
 
51
80
  /**
52
- * Collapses a raw provider error (which carries the full request/response
53
- * dump as enumerable props) into a one-line actionable error. Raw details stay
54
- * available but non-enumerable, so runtime dumps stay small.
81
+ * Collapses a raw provider error (which carries the full request/response dump as enumerable props) into a one-line
82
+ * `ProviderError` with `retryable` set from its status/code; the provider's own error code is `providerCode`. Raw
83
+ * details stay available but non-enumerable, so runtime dumps stay small. An SDK error (already typed) passes through.
55
84
  */
56
85
  export function toConciseProviderError(error: unknown, providerId: string, modelId: string): Error {
57
- if (error instanceof Error && (error as any)[CONCISE]) return error;
86
+ if (error instanceof AgentAccelError || (error instanceof Error && (error as any)[CONCISE])) return error;
58
87
  const err = error as any;
59
88
  const status = readStatus(err);
60
89
  const url = readUrl(err);
@@ -70,10 +99,10 @@ export function toConciseProviderError(error: unknown, providerId: string, model
70
99
  if (rawMeta && !detail.includes(rawMeta.slice(0, 80))) {
71
100
  detail = `${detail}\n${rawMeta.slice(0, 500)}`.slice(0, 800);
72
101
  }
73
- const concise = new Error(
74
- `[${displayModel(providerId, modelId)}] request failed${status ? ` (${status})` : ""}: ${detail}`
102
+ const concise = new ProviderError(
103
+ `[${displayModel(providerId, modelId)}] request failed${status ? ` (${status})` : ""}: ${detail}`,
104
+ { status, provider: providerId, model: modelId, retryable: isTransientError(err) }
75
105
  );
76
- concise.name = "AgentAccelProviderError";
77
106
  Object.defineProperties(concise, {
78
107
  [CONCISE]: { value: true, enumerable: false },
79
108
  statusCode: { value: status, enumerable: false },
@@ -87,14 +116,11 @@ export function toConciseProviderError(error: unknown, providerId: string, model
87
116
  // Forwarded when present (e.g. OpenRouter's canonical `error_type`,
88
117
  // which disambiguates lossy native codes). Survives re-wraps so retry
89
118
  // helpers never strip it.
90
- ...(typeof err?.errorType === "string"
91
- ? { errorType: { value: err.errorType, enumerable: false } }
92
- : {}),
93
- // Forwarded when present (e.g. Google's `not_found` chain-eviction code,
94
- // which carries no HTTP status on SSE error frames). Survives re-wraps
95
- // so chain-expiry detectors never lose it.
119
+ ...(typeof err?.errorType === "string" ? { errorType: { value: err.errorType, enumerable: false } } : {}),
120
+ // The provider's own error code (e.g. Google's `not_found` chain-eviction code, which carries no HTTP status on
121
+ // SSE error frames); `code` itself is the SDK kind (`"provider"`). Survives re-wraps so chain detectors never lose it.
96
122
  ...((typeof err?.code === "string" || typeof err?.code === "number") && err.code !== ""
97
- ? { code: { value: err.code, enumerable: false } }
123
+ ? { providerCode: { value: err.code, enumerable: false } }
98
124
  : {}),
99
125
  // Server rate-limit hint in ms (from Retry-After); survives re-wraps so
100
126
  // `withRetries` can honor it instead of hammering a 429.
@@ -120,13 +146,9 @@ const PART_TO_MODALITY: Record<string, "image" | "audio" | "video" | "pdf"> = {
120
146
  * lacks a requested input modality (image/audio/video/document; files are
121
147
  * not checked on OpenRouter, which parses them for any model). Unknown
122
148
  * models (custom providers, dynamic routers) are skipped: the provider
123
- * verdict stands and surfaces via toConciseProviderError instead.
149
+ * verdict stands and surfaces via toConciseProviderError instead. Throws a `ValidationError`.
124
150
  */
125
- export function assertModalitiesSupported(
126
- context: ProviderContext,
127
- providerId: string,
128
- modelId: string
129
- ): void {
151
+ export function assertModalitiesSupported(context: ProviderContext, providerId: string, modelId: string): void {
130
152
  const needed = new Set<string>();
131
153
  for (const msg of context.messages) {
132
154
  if (!Array.isArray((msg as any)?.content)) continue;
@@ -147,16 +169,10 @@ export function assertModalitiesSupported(
147
169
  if (!supported) return;
148
170
  const missing = [...needed].filter((k) => !supported.includes(k));
149
171
  if (missing.length === 0) return;
150
- const err = new Error(
151
- `[${displayModel(providerId, modelId)}] unsupported ${missing.join("+")} input (supports: ${supported.join(", ") || "text"}). Use a capable model or drop the ${missing.join("+")} part.`
172
+ throw new ValidationError(
173
+ `[${displayModel(providerId, modelId)}] unsupported ${missing.join("+")} input (supports: ${supported.join(", ") || "text"}). Use a capable model or drop the ${missing.join("+")} part.`,
174
+ { provider: providerId, model: modelId }
152
175
  );
153
- err.name = "AgentAccelProviderError";
154
- Object.defineProperties(err, {
155
- [CONCISE]: { value: true, enumerable: false },
156
- provider: { value: providerId, enumerable: false },
157
- model: { value: modelId, enumerable: false },
158
- });
159
- throw err;
160
176
  }
161
177
 
162
178
  /**
@@ -169,13 +185,9 @@ export function assertModalitiesSupported(
169
185
  * `openrouter/stealth/space-bunny-alpha` under the old Responses skin despite
170
186
  * its catalog `video` flag, which is chat-transport oriented). Fail fast with
171
187
  * a clear one-liner instead; video stays Gemini-only, and OpenRouter Chat
172
- * Completions carries `video_url` natively (no guard there).
188
+ * Completions carries `video_url` natively (no guard there). Throws a `ValidationError`.
173
189
  */
174
- export function assertNoVideoPartsOnResponses(
175
- context: ProviderContext,
176
- providerId: string,
177
- modelId: string
178
- ): void {
190
+ export function assertNoVideoPartsOnResponses(context: ProviderContext, providerId: string, modelId: string): void {
179
191
  let hasVideo = false;
180
192
  for (const msg of context.messages) {
181
193
  if (!Array.isArray((msg as any)?.content)) continue;
@@ -197,14 +209,8 @@ export function assertNoVideoPartsOnResponses(
197
209
  // contradict the rejection).
198
210
  if (known && known.length > 0) supported = known.filter((m) => m !== "video").join(", ") || "text";
199
211
  } catch {}
200
- const err = new Error(
201
- `[${displayModel(providerId, modelId)}] unsupported video input (the Responses API accepts text/image/file only; supports: ${supported}). Use a video-capable model (e.g. google/gemini-*) or drop the video part.`
212
+ throw new ValidationError(
213
+ `[${displayModel(providerId, modelId)}] unsupported video input (the Responses API accepts text/image/file only; supports: ${supported}). Use a video-capable model (e.g. google/gemini-*) or drop the video part.`,
214
+ { provider: providerId, model: modelId }
202
215
  );
203
- err.name = "AgentAccelProviderError";
204
- Object.defineProperties(err, {
205
- [CONCISE]: { value: true, enumerable: false },
206
- provider: { value: providerId, enumerable: false },
207
- model: { value: modelId, enumerable: false },
208
- });
209
- throw err;
210
216
  }
@@ -24,7 +24,7 @@ export function getPackageVersion(): string {
24
24
  const v = (globalThis as any).process?.env?.npm_package_version;
25
25
  if (typeof v === "string" && v) return v;
26
26
  } catch {}
27
- return "0.5.8";
27
+ return "0.6.0";
28
28
  }
29
29
 
30
30
  // User-Agent string with version and platform; in: AGENT_ACCEL_USER_AGENT, out: a string.
@@ -93,11 +93,7 @@ function findHeaderKey(headers: Record<string, string>, name: string): string |
93
93
  * coexist on the wire.
94
94
  * @example `setHeaderCaseInsensitive(headers, "Authorization", "Bearer k");`
95
95
  */
96
- export function setHeaderCaseInsensitive(
97
- headers: Record<string, string>,
98
- name: string,
99
- value: string
100
- ): void {
96
+ export function setHeaderCaseInsensitive(headers: Record<string, string>, name: string, value: string): void {
101
97
  const existing = findHeaderKey(headers, name);
102
98
  if (existing !== undefined && existing !== name) delete headers[existing];
103
99
  headers[name] = value;
@@ -111,10 +107,7 @@ export function setHeaderCaseInsensitive(
111
107
  * Returns true when a stored signature may be echoed on `currentProvider`:
112
108
  * untagged (legacy) echoes everywhere, tagged echoes only on a match.
113
109
  */
114
- export function shouldEchoSignature(
115
- storedProvider: unknown,
116
- currentProvider: string
117
- ): boolean {
110
+ export function shouldEchoSignature(storedProvider: unknown, currentProvider: string): boolean {
118
111
  if (!storedProvider) return true;
119
112
  return storedProvider === currentProvider;
120
113
  }
@@ -2,6 +2,7 @@
2
2
  // paths are loaded via a lazy dynamic import so browsers never resolve it.
3
3
  import { bytesToBase64, base64ToBytes } from "./base64.ts";
4
4
  import { fetchRemoteBytes } from "./safe-io.ts";
5
+ import { ValidationError } from "../types/errors.ts";
5
6
 
6
7
  export interface NormalizedMedia {
7
8
  mimeType: string;
@@ -63,7 +64,12 @@ export function sniffMimeType(bytes: Uint8Array): string | undefined {
63
64
  if (ascii(bytes, 0, "FORM") && ascii(bytes, 8, "AIF")) return "audio/aiff";
64
65
  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)
65
66
  if (ascii(bytes, 0, "ID3") || (bytes[0] === 0xff && ((bytes[1] ?? 0) & 0xe0) === 0xe0)) return "audio/mp3";
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 (ascii(bytes, 4, "ftyp"))
68
+ return ascii(bytes, 8, "M4A") || ascii(bytes, 8, "M4B")
69
+ ? "audio/m4a"
70
+ : ascii(bytes, 8, "qt")
71
+ ? "video/quicktime"
72
+ : "video/mp4";
67
73
  if (bytes[0] === 0 && bytes[1] === 0 && bytes[2] === 1 && bytes[3] === 0xba) return "video/mpeg"; // MPEG program stream
68
74
  if (bytes[0] === 0x1a && bytes[1] === 0x45 && bytes[2] === 0xdf && bytes[3] === 0xa3) return "video/webm";
69
75
  return undefined;
@@ -72,9 +78,15 @@ export function sniffMimeType(bytes: Uint8Array): string | undefined {
72
78
  const GENERIC_MIME = new Set(["", "application/octet-stream", "binary/octet-stream"]);
73
79
 
74
80
  /** Picks the best MIME type: explicit hint, then a specific candidate, then magic bytes, then the fallback. */
75
- function resolveMime(explicit: string | undefined, candidate: string | null | undefined, bytes: Uint8Array, fallback: string): string {
81
+ function resolveMime(
82
+ explicit: string | undefined,
83
+ candidate: string | null | undefined,
84
+ bytes: Uint8Array,
85
+ fallback: string
86
+ ): string {
76
87
  if (explicit) return explicit;
77
- const specific = candidate && !GENERIC_MIME.has(candidate.split(";")[0]!.trim().toLowerCase()) ? candidate : undefined;
88
+ const specific =
89
+ candidate && !GENERIC_MIME.has(candidate.split(";")[0]!.trim().toLowerCase()) ? candidate : undefined;
78
90
  return specific || sniffMimeType(bytes) || candidate || fallback;
79
91
  }
80
92
 
@@ -130,7 +142,7 @@ export async function normalizeMediaInput(
130
142
  // but redirects are re-checked and the download is size-capped.
131
143
  const response = await fetchRemoteBytes(input, { allowPrivateNetwork: true });
132
144
  if (!response.ok) {
133
- throw new Error(`Failed to fetch media from URL: ${input} (${response.statusText})`);
145
+ throw new ValidationError(`Failed to fetch media from URL: ${input} (${response.statusText})`);
134
146
  }
135
147
  const buffer = response.bytes;
136
148
  const mimeType = resolveMime(explicitMimeType, response.contentType, buffer, inferMimeType(input));
@@ -175,10 +187,11 @@ export async function normalizeMediaInput(
175
187
  base64Data,
176
188
  dataUrl: `data:${mimeType};base64,${base64Data}`,
177
189
  };
178
- } catch (err) {
190
+ } catch {
179
191
  // S11: don't silently return path string as base64 (would 400). Re-check if it's actually base64 with "/"
180
192
  const trimmed = input.replace(/\s/g, "");
181
- const isMaybeBase64 = trimmed.length > 100 && /^[A-Za-z0-9+/=\n\r]+$/.test(trimmed.slice(0, 500)) && trimmed.length % 4 === 0;
193
+ const isMaybeBase64 =
194
+ trimmed.length > 100 && /^[A-Za-z0-9+/=\n\r]+$/.test(trimmed.slice(0, 500)) && trimmed.length % 4 === 0;
182
195
  if (isMaybeBase64) {
183
196
  const mimeType = explicitMimeType || sniffBase64(trimmed) || "image/jpeg";
184
197
  return {
@@ -187,9 +200,11 @@ export async function normalizeMediaInput(
187
200
  dataUrl: `data:${mimeType};base64,${trimmed}`,
188
201
  };
189
202
  }
190
- throw new Error(`Failed to normalize media input: not a valid file path, data URL, http URL, or base64 string: ${input.slice(0, 80)}`);
203
+ throw new ValidationError(
204
+ `Failed to normalize media input: not a valid file path, data URL, http URL, or base64 string: ${input.slice(0, 80)}`
205
+ );
191
206
  }
192
207
  }
193
208
 
194
- throw new Error(`Unsupported media input type: ${typeof input}`);
209
+ throw new ValidationError(`Unsupported media input type: ${typeof input}`);
195
210
  }
@@ -16,7 +16,11 @@ function byteLength(value: unknown): number {
16
16
  function toolNames(tools: unknown): string[] | undefined {
17
17
  if (!Array.isArray(tools)) return undefined;
18
18
  const names = tools
19
- .map((t) => (t as { name?: unknown; function?: { name?: unknown } })?.name ?? (t as { function?: { name?: unknown } })?.function?.name)
19
+ .map(
20
+ (t) =>
21
+ (t as { name?: unknown; function?: { name?: unknown } })?.name ??
22
+ (t as { function?: { name?: unknown } })?.function?.name
23
+ )
20
24
  .filter((n): n is string => typeof n === "string");
21
25
  return names.length > 0 ? names : undefined;
22
26
  }
@@ -56,7 +60,10 @@ function summarizeResponseBody(body: unknown): unknown {
56
60
  * model output never leak through `response.raw` / `toJSON()` into logs.
57
61
  * - `false`: keep only URL, method and HTTP status.
58
62
  */
59
- export function applyCaptureRaw(raw: ProviderRawData | undefined, mode: CaptureRawMode | undefined): ProviderRawData | undefined {
63
+ export function applyCaptureRaw(
64
+ raw: ProviderRawData | undefined,
65
+ mode: CaptureRawMode | undefined
66
+ ): ProviderRawData | undefined {
60
67
  if (!raw || mode === true) return raw;
61
68
  const redactedMode = mode !== false; // undefined -> default "redacted"
62
69
  const request = raw.request
@@ -1,33 +1,7 @@
1
- import { toConciseProviderError } from "./errors.ts";
1
+ import { toConciseProviderError, isTransientError } from "./errors.ts";
2
2
  import { isAbortError } from "../types/errors.ts";
3
3
 
4
- export { isAbortError };
5
-
6
- /**
7
- * Checks whether a provider error is transient and safe to retry.
8
- * Structured-data only: status / code / transient flag. Never message text,
9
- * so a 400 mentioning "512 tokens" is not retried.
10
- */
11
- export function isTransientError(error: unknown): boolean {
12
- if (!error) return false;
13
- if (isAbortError(error)) return false;
14
- const err = error as Record<string, unknown>;
15
- if (err["transient"] === true) return true;
16
- if ((err as { retryable?: unknown }).retryable === true) return true;
17
- const status = Number(
18
- (err["status"] as number | undefined) ??
19
- (err["statusCode"] as number | undefined) ??
20
- ((err["response"] as Record<string, unknown> | undefined)?.["status"] as number | undefined) ??
21
- ((err["lastError"] as Record<string, unknown> | undefined)?.["status"] as number | undefined) ??
22
- NaN
23
- );
24
- if ([408, 425, 429, 500, 502, 503, 504].includes(status)) return true;
25
- const code = String((err["code"] as string | undefined) ?? "").toUpperCase();
26
- if (["ECONNRESET", "ECONNREFUSED", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT", "EPIPE", "ETIMEDOUT"].includes(code)) {
27
- return true;
28
- }
29
- return false;
30
- }
4
+ export { isAbortError, isTransientError };
31
5
 
32
6
  /**
33
7
  * Whether a FAILED RUN (the provider layer already retried the call) is worth trying again: the failure is transient
@@ -39,7 +13,18 @@ export function isRetryableRun(error: unknown): boolean {
39
13
  if (!error || isAbortError(error)) return false;
40
14
  const name = (error as { name?: unknown }).name;
41
15
  if (name === "TimeoutError") return true; // the loop's own request / first-byte / idle timeouts
42
- if (["BudgetExceededError", "ConfigError", "ValidationError", "SecurityPolicyError", "SessionLoadError", "ThinkingLevelError", "ToolError"].includes(String(name))) return false;
16
+ if (
17
+ [
18
+ "BudgetExceededError",
19
+ "ConfigError",
20
+ "ValidationError",
21
+ "SecurityPolicyError",
22
+ "SessionLoadError",
23
+ "ThinkingLevelError",
24
+ "ToolError",
25
+ ].includes(String(name))
26
+ )
27
+ return false;
43
28
  return isTransientError(error);
44
29
  }
45
30
 
@@ -50,8 +35,9 @@ export function readRetryAfterMs(error: unknown): number | undefined {
50
35
  const direct = err["retryAfterMs"];
51
36
  if (typeof direct === "number" && Number.isFinite(direct) && direct >= 0) return Math.min(60_000, direct);
52
37
  const raw =
53
- ((err["response"] as Record<string, unknown> | undefined)?.["headers"] as Record<string, unknown> | undefined)?.["retry-after"] ??
54
- ((err["headers"] as Record<string, unknown> | undefined)?.["retry-after"]);
38
+ ((err["response"] as Record<string, unknown> | undefined)?.["headers"] as Record<string, unknown> | undefined)?.[
39
+ "retry-after"
40
+ ] ?? (err["headers"] as Record<string, unknown> | undefined)?.["retry-after"];
55
41
  if (raw === undefined) return undefined;
56
42
  const secs = Number(Array.isArray(raw) ? raw[0] : raw);
57
43
  if (!Number.isFinite(secs) || secs < 0) return undefined;