agent-accelerator 0.5.6 → 0.5.8

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 (57) hide show
  1. package/README.md +10 -17
  2. package/package.json +1 -1
  3. package/src/agent/agent.ts +92 -57
  4. package/src/agent/architecture.md +25 -0
  5. package/src/agent/context.ts +7 -3
  6. package/src/agent/delegation.ts +30 -96
  7. package/src/agent/loop.ts +54 -56
  8. package/src/agent/subagent.ts +4 -4
  9. package/src/agent/worker.ts +1 -3
  10. package/src/architecture.md +28 -0
  11. package/src/index.ts +13 -80
  12. package/src/models/architecture.md +14 -0
  13. package/src/models/catalog-cache.ts +10 -7
  14. package/src/models/catalog.ts +20 -13
  15. package/src/providers/architecture.md +24 -0
  16. package/src/providers/google.ts +24 -4
  17. package/src/providers/openai-compat.ts +29 -7
  18. package/src/providers/openai.ts +22 -6
  19. package/src/providers/openrouter.ts +14 -2
  20. package/src/providers/registry.ts +1 -0
  21. package/src/providers/shared.ts +4 -4
  22. package/src/providers.ts +9 -53
  23. package/src/scheduler/architecture.md +22 -0
  24. package/src/scheduler/scheduler.ts +131 -56
  25. package/src/scheduler/store.ts +274 -54
  26. package/src/scheduler/time.ts +2 -0
  27. package/src/session/architecture.md +19 -0
  28. package/src/session/store.ts +37 -22
  29. package/src/session/usage.ts +1 -0
  30. package/src/streaming/architecture.md +13 -0
  31. package/src/streaming/event-stream.ts +16 -11
  32. package/src/telemetry-file.ts +74 -24
  33. package/src/telemetry.ts +3 -2
  34. package/src/tools/architecture.md +19 -0
  35. package/src/tools/executor.ts +14 -1
  36. package/src/tools/schema.ts +1 -0
  37. package/src/tools/subagent-result.ts +1 -0
  38. package/src/tools/tool.ts +8 -8
  39. package/src/types/agent.ts +22 -20
  40. package/src/types/architecture.md +20 -0
  41. package/src/types/response.ts +8 -8
  42. package/src/types/scheduler.ts +2 -2
  43. package/src/types/tool.ts +5 -5
  44. package/src/update-models.ts +2 -2
  45. package/src/utils/architecture.md +25 -0
  46. package/src/utils/base64.ts +2 -0
  47. package/src/utils/cache.ts +6 -4
  48. package/src/utils/durable.ts +78 -3
  49. package/src/utils/env.ts +1 -1
  50. package/src/utils/errors.ts +4 -0
  51. package/src/utils/headers.ts +7 -4
  52. package/src/utils/home.ts +1 -1
  53. package/src/utils/media.ts +1 -0
  54. package/src/utils/raw-capture.ts +3 -0
  55. package/src/utils/retry.ts +2 -1
  56. package/src/utils/safe-io.ts +4 -0
  57. package/src/utils/session.ts +10 -166
@@ -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
  */
@@ -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}`;
@@ -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.6";
27
+ return "0.5.8";
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 {
@@ -41,7 +43,7 @@ function getAgentAccelUserAgent(): string {
41
43
  export const AGENT_ACCEL_USER_AGENT = getAgentAccelUserAgent;
42
44
 
43
45
  // ---------------------------------------------------------------------------
44
- // Attribution (Q-17): configurable, default none except OpenRouter legacy.
46
+ // Attribution: configurable, default none except OpenRouter legacy.
45
47
  // ---------------------------------------------------------------------------
46
48
 
47
49
  /** Configurable attribution headers (OpenRouter `HTTP-Referer` / `X-Title`). */
@@ -102,7 +104,7 @@ export function setHeaderCaseInsensitive(
102
104
  }
103
105
 
104
106
  // ---------------------------------------------------------------------------
105
- // Thought-signature namespacing (Q-18).
107
+ // Thought-signature namespacing.
106
108
  // ---------------------------------------------------------------------------
107
109
 
108
110
  /**
@@ -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)) {
@@ -137,7 +140,7 @@ function stripForBrowser(headers: Record<string, string>): Record<string, string
137
140
  /**
138
141
  * Builds provider-specific session, cache-affinity, and attribution headers.
139
142
  *
140
- * Q-17: attribution is configurable via `setAttribution()` (default none; no
143
+ * Attribution is configurable via `setAttribution()` (default none; no
141
144
  * referer/title is ever sent unless you set one). Custom (non-first-class)
142
145
  * prefixes receive minimal `x-session-id`/`x-client-request-id` affinity only
143
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;
@@ -38,6 +38,7 @@ export function inferMimeType(input: string, fallback = "application/octet-strea
38
38
  return fallback;
39
39
  }
40
40
 
41
+ // True when the bytes spell `text` at `start`; in: sniffMimeType, out: a boolean.
41
42
  const ascii = (bytes: Uint8Array, start: number, text: string): boolean =>
42
43
  bytes.length >= start + text.length && [...text].every((ch, i) => bytes[start + i] === ch.charCodeAt(0));
43
44
 
@@ -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>;
@@ -6,7 +6,7 @@ export { isAbortError };
6
6
  /**
7
7
  * Checks whether a provider error is transient and safe to retry.
8
8
  * Structured-data only: status / code / transient flag. Never message text,
9
- * so a 400 mentioning "512 tokens" is not retried (Q-24).
9
+ * so a 400 mentioning "512 tokens" is not retried.
10
10
  */
11
11
  export function isTransientError(error: unknown): boolean {
12
12
  if (!error) return false;
@@ -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(
@@ -1,7 +1,5 @@
1
1
  import { clampCacheKey } from "./cache.ts";
2
2
  import type { ThinkingLevel } from "../types/core.ts";
3
- import type { MidSessionMode } from "../types/agent.ts";
4
- import type { AgentConfig } from "../types/agent.ts";
5
3
  import type { ModelSpec } from "../types/model.ts";
6
4
  import type { ModelProviderInstance } from "../providers/registry.ts";
7
5
  import type { ContentPart } from "../types/message.ts";
@@ -68,7 +66,7 @@ export function createTrackingId(): string {
68
66
  }
69
67
 
70
68
  /**
71
- * Monotonic clock for measuring durations (Q-42). Uses `performance.now()`
69
+ * Monotonic clock for measuring durations. Uses `performance.now()`
72
70
  * when available, falling back to `Date.now()`. Differences on the same
73
71
  * clock are valid durations; never treat the value as wall-clock epoch.
74
72
  *
@@ -83,6 +81,7 @@ export function nowMs(): number {
83
81
  return Date.now();
84
82
  }
85
83
 
84
+ // 32 random lowercase hex characters; in: newRunId/newTurnId, out: a string.
86
85
  function random32Hex(): string {
87
86
  try {
88
87
  const c = (globalThis as any)?.crypto;
@@ -96,7 +95,7 @@ function random32Hex(): string {
96
95
  }
97
96
 
98
97
  /**
99
- * 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).
100
99
  * Opaque lineage token attached via `(metadata as any).runId`.
101
100
  */
102
101
  export function newRunId(): string {
@@ -104,21 +103,14 @@ export function newRunId(): string {
104
103
  }
105
104
 
106
105
  /**
107
- * 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).
108
107
  * Opaque lineage token attached via `(metadata as any).turnId`.
109
108
  */
110
109
  export function newTurnId(): string {
111
110
  return random32Hex();
112
111
  }
113
112
 
114
- /**
115
- * Single canonical prompt preview (Q-49): merges the former
116
- * `previewPrompt` (agent.ts) / `steerPreview` (loop.ts) duplicates.
117
- * Bounded to 500 chars, display-only. Other modules own their copies;
118
- * new code should import this one.
119
- *
120
- * @example `previewPrompt("hello")`
121
- */
113
+ /** Bounded (500 chars), display-only preview of a prompt, used in `queued`/`steer_injected` events, turn lists and `interrupted()`. */
122
114
  export function previewPrompt(prompt: string | ContentPart[]): string {
123
115
  if (typeof prompt === "string") return prompt.slice(0, 500);
124
116
  try {
@@ -132,7 +124,7 @@ export function previewPrompt(prompt: string | ContentPart[]): string {
132
124
  }
133
125
  }
134
126
 
135
- /** Parsed model reference returned by {@link parseModelRef} (Q-48). */
127
+ /** Parsed model reference returned by {@link parseModelRef}. */
136
128
  export interface ParsedModelRef {
137
129
  provider: string;
138
130
  modelId: string;
@@ -141,6 +133,7 @@ export interface ParsedModelRef {
141
133
  thinking?: ThinkingLevel;
142
134
  }
143
135
 
136
+ // Splits `provider/model` on the first slash (bare ids default to openrouter); in: parseModelRef, out: provider and model id.
144
137
  function splitModelString(raw: string): { provider: string; modelId: string } {
145
138
  const trimmed = raw.trim();
146
139
  const slash = trimmed.indexOf("/");
@@ -153,12 +146,14 @@ function splitModelString(raw: string): { provider: string; modelId: string } {
153
146
  };
154
147
  }
155
148
 
149
+ // Type guard for a `{ model: string }` helper result; in: parseModelRef, out: a boolean.
156
150
  function isModelProviderInstance(v: unknown): v is ModelProviderInstance {
157
151
  if (!v || typeof v !== "object" || Array.isArray(v)) return false;
158
152
  const r = v as Record<string, unknown>;
159
153
  return typeof r["model"] === "string" && (r["model"] as string).length > 0;
160
154
  }
161
155
 
156
+ // Type guard for a catalog spec (`provider` and `id` strings); in: parseModelRef, out: a boolean.
162
157
  function isModelSpec(v: unknown): v is ModelSpec {
163
158
  if (!v || typeof v !== "object" || Array.isArray(v)) return false;
164
159
  const r = v as Record<string, unknown>;
@@ -166,7 +161,7 @@ function isModelSpec(v: unknown): v is ModelSpec {
166
161
  }
167
162
 
168
163
  /**
169
- * Single model-ref parser (Q-48). Table-driven, no env sniffing:
164
+ * Single model-ref parser. Table-driven, no env sniffing:
170
165
  * - string `"provider/model"` → split on first `/`; bare ids default to `openrouter`.
171
166
  * - `ModelProviderInstance` (`{ model, apiKey?, baseUrl?, thinkingLevel? }`) → parse `model`, passthrough creds.
172
167
  * - `ModelSpec` (`{ provider, id }`) → direct mapping.
@@ -192,154 +187,3 @@ export function parseModelRef(input: string | ModelSpec | ModelProviderInstance)
192
187
  }
193
188
  throw new ConfigError("parseModelRef: expected string | ModelSpec | ModelProviderInstance.");
194
189
  }
195
-
196
- /** Validated, readonly agent configuration returned by {@link resolveAgentConfig} (Q-32). */
197
- export interface ResolvedAgentConfig {
198
- readonly model: NonNullable<AgentConfig["model"]>;
199
- readonly thinkingLevel?: ThinkingLevel;
200
- readonly maxTurns: number;
201
- readonly midSession: { readonly mode: MidSessionMode; readonly maxQueued: number };
202
- readonly toolNames: readonly string[];
203
- }
204
-
205
- const VALID_THINKING_LEVELS: readonly ThinkingLevel[] = [
206
- "none",
207
- "dynamic",
208
- "minimal",
209
- "low",
210
- "medium",
211
- "high",
212
- "xhigh",
213
- "max",
214
- ];
215
-
216
- const VALID_MIDSESSION_MODES: readonly MidSessionMode[] = ["steer", "queue", "auto"];
217
-
218
- /** Local name normalization mirroring `normalizeToolName` (no import cycle, Q-32). */
219
- function normalizeConfigToolName(name: string): string {
220
- return name
221
- .trim()
222
- .replace(/^(?:functions?|tools?)\./i, "")
223
- .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
224
- .replace(/[\s-]+/g, "_")
225
- .toLowerCase();
226
- }
227
-
228
- /**
229
- * Single config resolution (Q-32). Validates `model`, `thinkingLevel`,
230
- * `maxTurns`, `midSession`, and tool-name collisions, collecting EVERY issue
231
- * and throwing one `ConfigError` listing all of them. Not wired into `Agent`
232
- * (other owner) — call it from setup paths that want fail-fast validation.
233
- *
234
- * @example `const cfg = resolveAgentConfig({ model, maxTurns: 10 });`
235
- */
236
- export function resolveAgentConfig(config: AgentConfig): Readonly<ResolvedAgentConfig> {
237
- const issues: string[] = [];
238
- const cfg = (config ?? {}) as AgentConfig;
239
-
240
- let model: NonNullable<AgentConfig["model"]> | undefined;
241
- const rawModel = cfg.model;
242
- if (typeof rawModel === "string") {
243
- if (rawModel.trim()) model = rawModel;
244
- else issues.push("model: must be a non-empty string | ModelSpec | ModelProviderInstance.");
245
- } else if (rawModel && typeof rawModel === "object") {
246
- model = rawModel as NonNullable<AgentConfig["model"]>;
247
- } else {
248
- issues.push("model: is required (string | ModelSpec | ModelProviderInstance).");
249
- }
250
-
251
- let thinkingLevel: ThinkingLevel | undefined;
252
- if (cfg.thinkingLevel !== undefined) {
253
- if ((VALID_THINKING_LEVELS as readonly string[]).includes(cfg.thinkingLevel)) {
254
- thinkingLevel = cfg.thinkingLevel;
255
- } else {
256
- issues.push(
257
- `thinkingLevel: invalid '${cfg.thinkingLevel}'. Expected one of: ${VALID_THINKING_LEVELS.join(", ")}.`
258
- );
259
- }
260
- }
261
-
262
- let maxTurns = Infinity;
263
- if (cfg.maxTurns !== undefined) {
264
- const m = cfg.maxTurns;
265
- if (m === 0) {
266
- maxTurns = Infinity;
267
- } else if (m === Infinity) {
268
- maxTurns = Infinity;
269
- } else if (typeof m === "number" && Number.isFinite(m) && m >= 1) {
270
- maxTurns = Math.floor(m);
271
- } else {
272
- issues.push(`maxTurns: must be finite >= 1 or Infinity (0/omitted means unlimited). Received: ${String(m)}.`);
273
- }
274
- }
275
-
276
- let mode: MidSessionMode = "auto";
277
- if (cfg.midSession?.mode !== undefined) {
278
- if ((VALID_MIDSESSION_MODES as readonly string[]).includes(cfg.midSession.mode as string)) {
279
- mode = cfg.midSession.mode as MidSessionMode;
280
- } else {
281
- issues.push(
282
- `midSession.mode: invalid '${String(cfg.midSession.mode)}'. Expected one of: ${VALID_MIDSESSION_MODES.join(", ")}.`
283
- );
284
- }
285
- }
286
- let maxQueued = 20;
287
- if (cfg.midSession?.maxQueued !== undefined) {
288
- const q = cfg.midSession.maxQueued;
289
- if (typeof q === "number" && Number.isFinite(q) && Math.floor(q) === q && q >= 0) {
290
- maxQueued = q;
291
- } else {
292
- issues.push(`midSession.maxQueued: must be an integer >= 0. Received: ${String(q)}.`);
293
- }
294
- }
295
-
296
- const toolNames: string[] = [];
297
- const tools = cfg.tools;
298
- if (tools !== undefined) {
299
- const entries: Array<[string, string]> = [];
300
- if (Array.isArray(tools)) {
301
- for (const def of tools) {
302
- const declared = (def as { name?: unknown })?.name;
303
- if (typeof declared === "string" && declared.trim()) {
304
- toolNames.push(declared);
305
- entries.push([declared, declared]);
306
- } else {
307
- issues.push("tools: array entries must declare a non-empty `name`.");
308
- }
309
- }
310
- } else if (tools && typeof tools === "object") {
311
- for (const [key, def] of Object.entries(tools)) {
312
- toolNames.push(key);
313
- const declared = (def as { name?: unknown })?.name;
314
- entries.push([key, typeof declared === "string" && declared ? declared : key]);
315
- }
316
- } else {
317
- issues.push("tools: must be a record or array of ToolDefinitions.");
318
- }
319
- const seen = new Map<string, string>();
320
- for (const [key, declared] of entries) {
321
- for (const candidate of new Set([key, declared])) {
322
- const normalized = normalizeConfigToolName(String(candidate));
323
- const prev = seen.get(normalized);
324
- if (prev !== undefined && prev !== key) {
325
- issues.push(
326
- `tools: name collision: '${prev}' and '${key}' both normalize to '${normalized}'. Rename one tool.`
327
- );
328
- } else if (prev === undefined) {
329
- seen.set(normalized, key);
330
- }
331
- }
332
- }
333
- }
334
-
335
- if (issues.length > 0) {
336
- throw new ConfigError(`Invalid Agent config (${issues.length} issue${issues.length === 1 ? "" : "s"}): ${issues.join(" ")}`);
337
- }
338
- return {
339
- model: model as NonNullable<AgentConfig["model"]>,
340
- ...(thinkingLevel !== undefined ? { thinkingLevel } : {}),
341
- maxTurns,
342
- midSession: { mode, maxQueued },
343
- toolNames,
344
- };
345
- }