agent-accelerator 0.5.7 → 0.5.9

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 (44) hide show
  1. package/README.md +27 -8
  2. package/package.json +1 -1
  3. package/src/agent/agent.ts +84 -45
  4. package/src/agent/architecture.md +1 -0
  5. package/src/agent/context.ts +3 -3
  6. package/src/agent/delegation.ts +12 -12
  7. package/src/agent/loop.ts +50 -53
  8. package/src/agent/subagent.ts +4 -4
  9. package/src/agent/worker.ts +2 -1
  10. package/src/architecture.md +1 -1
  11. package/src/index.ts +7 -7
  12. package/src/models/architecture.md +1 -1
  13. package/src/models/catalog-cache.ts +7 -7
  14. package/src/models/catalog.ts +22 -34
  15. package/src/providers/google.ts +7 -7
  16. package/src/providers/openai-compat.ts +10 -16
  17. package/src/providers/openai.ts +8 -7
  18. package/src/providers/openrouter.ts +4 -4
  19. package/src/providers/registry.ts +6 -5
  20. package/src/providers/shared.ts +6 -10
  21. package/src/scheduler/architecture.md +6 -3
  22. package/src/scheduler/scheduler.ts +111 -57
  23. package/src/scheduler/store.ts +273 -59
  24. package/src/session/architecture.md +1 -1
  25. package/src/session/store.ts +22 -22
  26. package/src/streaming/event-stream.ts +11 -11
  27. package/src/telemetry-file.ts +74 -24
  28. package/src/telemetry.ts +2 -2
  29. package/src/tools/executor.ts +1 -1
  30. package/src/tools/tool.ts +8 -8
  31. package/src/types/agent.ts +22 -20
  32. package/src/types/response.ts +8 -8
  33. package/src/types/scheduler.ts +2 -2
  34. package/src/types/tool.ts +5 -5
  35. package/src/update-models.ts +2 -2
  36. package/src/utils/architecture.md +3 -3
  37. package/src/utils/cache.ts +4 -4
  38. package/src/utils/durable.ts +78 -3
  39. package/src/utils/env.ts +1 -1
  40. package/src/utils/errors.ts +45 -31
  41. package/src/utils/headers.ts +4 -4
  42. package/src/utils/home.ts +1 -1
  43. package/src/utils/retry.ts +2 -28
  44. package/src/utils/session.ts +5 -5
@@ -11,7 +11,7 @@ export function clampCacheKey(key?: string): string | undefined {
11
11
  }
12
12
 
13
13
  // ---------------------------------------------------------------------------
14
- // Central redaction helpers (Q-11, temporary home to avoid owner conflicts).
14
+ // Central redaction helpers (temporary home to avoid owner conflicts).
15
15
  // Providers must call these instead of maintaining local deny-lists.
16
16
  // ---------------------------------------------------------------------------
17
17
 
@@ -45,7 +45,7 @@ function valueLooksSensitive(value: unknown): boolean {
45
45
  }
46
46
 
47
47
  /**
48
- * Redacts a single value: sensitive strings become "[REDACTED]" (Q-11).
48
+ * Redacts a single value: sensitive strings become "[REDACTED]".
49
49
  */
50
50
  export function redactValue(value: unknown): unknown {
51
51
  if (typeof value !== "string") return value;
@@ -54,7 +54,7 @@ export function redactValue(value: unknown): unknown {
54
54
  }
55
55
 
56
56
  /**
57
- * Redacts sensitive header values (Q-11). Returns a shallow copy; input untouched.
57
+ * Redacts sensitive header values. Returns a shallow copy; input untouched.
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
  */
@@ -71,7 +71,7 @@ export function redactHeadersCentral(
71
71
  }
72
72
 
73
73
  /**
74
- * Redacts secret-looking substrings inside free text (Q-11).
74
+ * Redacts secret-looking substrings inside free text.
75
75
  * Replaces `sk-...`, `AIza...`, `gsk-...`, `xox-...` tokens with "[REDACTED]".
76
76
  */
77
77
  export function redactText(text: string): string {
@@ -91,7 +91,7 @@ export function readWithBackup(file: string, isValid: (text: string) => boolean)
91
91
  }
92
92
 
93
93
  // ---------------------------------------------------------------------------
94
- // One process per folder
94
+ // One process per folder (used for session folders)
95
95
  // ---------------------------------------------------------------------------
96
96
 
97
97
  /** Folders this process already holds (the lock is re-entrant: many Agents share one home). */
@@ -103,8 +103,8 @@ function pause(ms: number): void {
103
103
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
104
104
  }
105
105
 
106
- // True while process `pid` exists (EPERM means it exists but belongs to someone else); in: a pid from a lock file, out: lockFolder's stale check.
107
- function processAlive(pid: number): boolean {
106
+ /** True while process `pid` exists (EPERM means it exists but belongs to someone else); in: a pid from a lock or heartbeat file, out: the stale checks. */
107
+ export function processAlive(pid: number): boolean {
108
108
  try {
109
109
  process.kill(pid, 0);
110
110
  return true;
@@ -216,3 +216,78 @@ export function lockFolder(folder: string): void {
216
216
  }
217
217
  }
218
218
  }
219
+
220
+ /** Longest a short mutex may be held before it counts as stale even though its owner process is alive (a hung holder). */
221
+ const MUTEX_MAX_AGE_MS = 30_000;
222
+ /** Longest a caller waits for a short mutex before getting a ConfigError. */
223
+ const MUTEX_WAIT_MS = 10_000;
224
+ /** Mutex files this process holds right now; a second `withFileMutex` on the same file inside `fn` would deadlock, so it throws. */
225
+ const heldMutexes = new Set<string>();
226
+
227
+ /**
228
+ * Runs `fn` while holding a short mutex file (`lockFile`, created with O_EXCL, content = pid, host, time), so several
229
+ * processes can change one shared file without losing updates. Hold it for milliseconds only: `fn` must be synchronous and
230
+ * quick. A waiter retries every few ms; a holder whose pid is gone (same host) or whose lock is older than 30 s is taken
231
+ * over. Not re-entrant (a nested call on the same file throws). The folder of `lockFile` must exist: if it is removed meanwhile
232
+ * (its owner deleted it) the call throws the plain `ENOENT` error, and the caller decides what that means. Throws
233
+ * `ConfigError` after 10 s of waiting. Local disks only.
234
+ */
235
+ export function withFileMutex<T>(lockFile: string, fn: () => T): T {
236
+ 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).`);
238
+ const mine = JSON.stringify({ pid: process.pid, host: os.hostname(), at: Date.now() });
239
+ const deadline = Date.now() + MUTEX_WAIT_MS;
240
+ let unreadable = 0;
241
+ for (;;) {
242
+ try {
243
+ const fd = fs.openSync(file, "wx", 0o600);
244
+ try {
245
+ fs.writeFileSync(fd, mine);
246
+ } finally {
247
+ fs.closeSync(fd);
248
+ }
249
+ break;
250
+ } catch (err) {
251
+ if ((err as { code?: string }).code !== "EEXIST") throw err;
252
+ }
253
+ let text = "";
254
+ let mtime = 0;
255
+ try {
256
+ text = fs.readFileSync(file, "utf8");
257
+ mtime = fs.statSync(file).mtimeMs;
258
+ } catch {
259
+ continue; // released between the open and the read: try again
260
+ }
261
+ let owner: { pid?: unknown; host?: unknown } | undefined;
262
+ try {
263
+ owner = JSON.parse(text);
264
+ } catch {
265
+ owner = undefined;
266
+ }
267
+ let stale = false;
268
+ if (typeof owner?.pid !== "number") stale = ++unreadable > 5; // empty or garbage: another process may be mid-write
269
+ else if (owner.host === os.hostname() && !processAlive(owner.pid)) stale = true;
270
+ else if (Date.now() - mtime > MUTEX_MAX_AGE_MS) stale = true;
271
+ if (stale) {
272
+ try {
273
+ if (fs.readFileSync(file, "utf8") === text) fs.rmSync(file, { force: true });
274
+ } catch {
275
+ // why: it vanished: the next loop turn creates it.
276
+ }
277
+ continue;
278
+ }
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)}).`);
280
+ pause(typeof owner?.pid === "number" ? 2 : 40);
281
+ }
282
+ heldMutexes.add(file);
283
+ try {
284
+ return fn();
285
+ } finally {
286
+ heldMutexes.delete(file);
287
+ try {
288
+ fs.rmSync(file, { force: true });
289
+ } catch {
290
+ // why: a leftover mutex is detected as stale by the next caller.
291
+ }
292
+ }
293
+ }
package/src/utils/env.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  */
4
4
  /**
5
5
  * Reads an environment value. Process env only. No globalThis fallback:
6
- * DOM-clobberable in browsers (Q-05).
6
+ * DOM-clobberable in browsers.
7
7
  *
8
8
  * @example `const apiKey = getEnv("OPENAI_API_KEY");`
9
9
  */
@@ -1,5 +1,32 @@
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((err["providerCode"] as string | undefined) ?? (err["code"] as string | undefined) ?? "").toUpperCase();
25
+ if (["ECONNRESET", "ECONNREFUSED", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT", "EPIPE", "ETIMEDOUT"].includes(code)) {
26
+ return true;
27
+ }
28
+ return false;
29
+ }
3
30
 
4
31
  const CONCISE = "__agentAccelConcise";
5
32
 
@@ -49,12 +76,12 @@ function displayModel(providerId: string, modelId: string): string {
49
76
  }
50
77
 
51
78
  /**
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.
79
+ * Collapses a raw provider error (which carries the full request/response dump as enumerable props) into a one-line
80
+ * `ProviderError` with `retryable` set from its status/code; the provider's own error code is `providerCode`. Raw
81
+ * details stay available but non-enumerable, so runtime dumps stay small. An SDK error (already typed) passes through.
55
82
  */
56
83
  export function toConciseProviderError(error: unknown, providerId: string, modelId: string): Error {
57
- if (error instanceof Error && (error as any)[CONCISE]) return error;
84
+ if (error instanceof AgentAccelError || (error instanceof Error && (error as any)[CONCISE])) return error;
58
85
  const err = error as any;
59
86
  const status = readStatus(err);
60
87
  const url = readUrl(err);
@@ -70,10 +97,10 @@ export function toConciseProviderError(error: unknown, providerId: string, model
70
97
  if (rawMeta && !detail.includes(rawMeta.slice(0, 80))) {
71
98
  detail = `${detail}\n${rawMeta.slice(0, 500)}`.slice(0, 800);
72
99
  }
73
- const concise = new Error(
74
- `[${displayModel(providerId, modelId)}] request failed${status ? ` (${status})` : ""}: ${detail}`
100
+ const concise = new ProviderError(
101
+ `[${displayModel(providerId, modelId)}] request failed${status ? ` (${status})` : ""}: ${detail}`,
102
+ { status, provider: providerId, model: modelId, retryable: isTransientError(err) }
75
103
  );
76
- concise.name = "AgentAccelProviderError";
77
104
  Object.defineProperties(concise, {
78
105
  [CONCISE]: { value: true, enumerable: false },
79
106
  statusCode: { value: status, enumerable: false },
@@ -90,11 +117,10 @@ export function toConciseProviderError(error: unknown, providerId: string, model
90
117
  ...(typeof err?.errorType === "string"
91
118
  ? { errorType: { value: err.errorType, enumerable: false } }
92
119
  : {}),
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.
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,7 +146,7 @@ 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
151
  export function assertModalitiesSupported(
126
152
  context: ProviderContext,
@@ -147,16 +173,10 @@ export function assertModalitiesSupported(
147
173
  if (!supported) return;
148
174
  const missing = [...needed].filter((k) => !supported.includes(k));
149
175
  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.`
176
+ throw new ValidationError(
177
+ `[${displayModel(providerId, modelId)}] unsupported ${missing.join("+")} input (supports: ${supported.join(", ") || "text"}). Use a capable model or drop the ${missing.join("+")} part.`,
178
+ { provider: providerId, model: modelId }
152
179
  );
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
180
  }
161
181
 
162
182
  /**
@@ -169,7 +189,7 @@ export function assertModalitiesSupported(
169
189
  * `openrouter/stealth/space-bunny-alpha` under the old Responses skin despite
170
190
  * its catalog `video` flag, which is chat-transport oriented). Fail fast with
171
191
  * a clear one-liner instead; video stays Gemini-only, and OpenRouter Chat
172
- * Completions carries `video_url` natively (no guard there).
192
+ * Completions carries `video_url` natively (no guard there). Throws a `ValidationError`.
173
193
  */
174
194
  export function assertNoVideoPartsOnResponses(
175
195
  context: ProviderContext,
@@ -197,14 +217,8 @@ export function assertNoVideoPartsOnResponses(
197
217
  // contradict the rejection).
198
218
  if (known && known.length > 0) supported = known.filter((m) => m !== "video").join(", ") || "text";
199
219
  } 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.`
220
+ throw new ValidationError(
221
+ `[${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.`,
222
+ { provider: providerId, model: modelId }
202
223
  );
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
224
  }
@@ -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.7";
27
+ return "0.5.9";
28
28
  }
29
29
 
30
30
  // User-Agent string with version and platform; in: AGENT_ACCEL_USER_AGENT, out: a string.
@@ -43,7 +43,7 @@ function getAgentAccelUserAgent(): string {
43
43
  export const AGENT_ACCEL_USER_AGENT = getAgentAccelUserAgent;
44
44
 
45
45
  // ---------------------------------------------------------------------------
46
- // Attribution (Q-17): configurable, default none except OpenRouter legacy.
46
+ // Attribution: configurable, default none except OpenRouter legacy.
47
47
  // ---------------------------------------------------------------------------
48
48
 
49
49
  /** Configurable attribution headers (OpenRouter `HTTP-Referer` / `X-Title`). */
@@ -104,7 +104,7 @@ export function setHeaderCaseInsensitive(
104
104
  }
105
105
 
106
106
  // ---------------------------------------------------------------------------
107
- // Thought-signature namespacing (Q-18).
107
+ // Thought-signature namespacing.
108
108
  // ---------------------------------------------------------------------------
109
109
 
110
110
  /**
@@ -140,7 +140,7 @@ function stripForBrowser(headers: Record<string, string>): Record<string, string
140
140
  /**
141
141
  * Builds provider-specific session, cache-affinity, and attribution headers.
142
142
  *
143
- * Q-17: attribution is configurable via `setAttribution()` (default none; no
143
+ * Attribution is configurable via `setAttribution()` (default none; no
144
144
  * referer/title is ever sent unless you set one). Custom (non-first-class)
145
145
  * prefixes receive minimal `x-session-id`/`x-client-request-id` affinity only
146
146
  * (no `session_id` alias, no attribution) when a session id is present.
package/src/utils/home.ts CHANGED
@@ -3,7 +3,7 @@ import { getEnv } from "./env.ts";
3
3
 
4
4
  /**
5
5
  * The Agent Accelerator home: ONE folder that holds everything the SDK stores
6
- * (`sessions/`, `scheduled_actions/`, `telemetry/`, `catalog/`), so nothing is
6
+ * (`sessions/`, `scheduled/`, `telemetry/`, `catalog/`), so nothing is
7
7
  * scattered across the project or the package.
8
8
  */
9
9
  let defaultHome: string | undefined;
@@ -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 (Q-24).
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
@@ -66,7 +66,7 @@ export function createTrackingId(): string {
66
66
  }
67
67
 
68
68
  /**
69
- * Monotonic clock for measuring durations (Q-42). Uses `performance.now()`
69
+ * Monotonic clock for measuring durations. Uses `performance.now()`
70
70
  * when available, falling back to `Date.now()`. Differences on the same
71
71
  * clock are valid durations; never treat the value as wall-clock epoch.
72
72
  *
@@ -95,7 +95,7 @@ function random32Hex(): string {
95
95
  }
96
96
 
97
97
  /**
98
- * New stable run id: 32 lowercase hex chars (crypto.randomUUID, no dashes) (Q-42).
98
+ * New stable run id: 32 lowercase hex chars (crypto.randomUUID, no dashes).
99
99
  * Opaque lineage token attached via `(metadata as any).runId`.
100
100
  */
101
101
  export function newRunId(): string {
@@ -103,7 +103,7 @@ export function newRunId(): string {
103
103
  }
104
104
 
105
105
  /**
106
- * New stable turn id: 32 lowercase hex chars (crypto.randomUUID, no dashes) (Q-42).
106
+ * New stable turn id: 32 lowercase hex chars (crypto.randomUUID, no dashes).
107
107
  * Opaque lineage token attached via `(metadata as any).turnId`.
108
108
  */
109
109
  export function newTurnId(): string {
@@ -124,7 +124,7 @@ export function previewPrompt(prompt: string | ContentPart[]): string {
124
124
  }
125
125
  }
126
126
 
127
- /** Parsed model reference returned by {@link parseModelRef} (Q-48). */
127
+ /** Parsed model reference returned by {@link parseModelRef}. */
128
128
  export interface ParsedModelRef {
129
129
  provider: string;
130
130
  modelId: string;
@@ -161,7 +161,7 @@ function isModelSpec(v: unknown): v is ModelSpec {
161
161
  }
162
162
 
163
163
  /**
164
- * Single model-ref parser (Q-48). Table-driven, no env sniffing:
164
+ * Single model-ref parser. Table-driven, no env sniffing:
165
165
  * - string `"provider/model"` → split on first `/`; bare ids default to `openrouter`.
166
166
  * - `ModelProviderInstance` (`{ model, apiKey?, baseUrl?, thinkingLevel? }`) → parse `model`, passthrough creds.
167
167
  * - `ModelSpec` (`{ provider, id }`) → direct mapping.