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
@@ -9,6 +9,8 @@ import { toConciseProviderError } from "../utils/errors.ts";
9
9
  import { withRetries } from "../utils/retry.ts";
10
10
  import { redactHeadersCentral } from "../utils/cache.ts";
11
11
  import { TRUNCATED_ARGS_KEY, isTruncatedArguments } from "../types/tool.ts";
12
+ import { SSEParser } from "../streaming/sse-parser.ts";
13
+ import type { AssistantMessageEventStream } from "../streaming/event-stream.ts";
12
14
 
13
15
  /** Parses a JSON tool-argument string; non-objects degrade to the truncated-arguments marker. */
14
16
  export function parseArguments(raw: string | undefined): Record<string, unknown> {
@@ -43,17 +45,17 @@ export function redactedHeaders(headers: Record<string, string>): Record<string,
43
45
  }
44
46
 
45
47
  /**
46
- * Extracts `{ message, code, errorType, raw }` from a non-2xx JSON body. Handles
47
- * OpenAI `type`, OpenRouter `metadata.error_type` + `metadata.raw`, and Google
48
+ * Extracts `{ message, code, errorType, raw, limitSource }` from a non-2xx JSON body. Handles
49
+ * OpenAI `type`, OpenRouter `metadata.error_type` + `metadata.raw` + `metadata.limit_source`, and Google
48
50
  * `status` code shapes; unknown bodies degrade to a 300-char slice.
49
51
  */
50
52
  export function readErrorPayload(
51
53
  bodyText: string
52
- ): { message: string; code?: string | number; errorType?: string; raw?: string } {
54
+ ): { message: string; code?: string | number; errorType?: string; raw?: string; limitSource?: string } {
53
55
  try {
54
56
  const parsed: unknown = JSON.parse(bodyText);
55
57
  const first = Array.isArray(parsed) ? parsed[0] : parsed;
56
- const err = (first as { error?: { message?: string; code?: string | number; type?: string; status?: string | number; metadata?: { error_type?: string; raw?: unknown } } })?.error;
58
+ const err = (first as { error?: { message?: string; code?: string | number; type?: string; status?: string | number; metadata?: { error_type?: string; raw?: unknown; limit_source?: unknown } } })?.error;
57
59
  if (err && typeof err.message === "string") {
58
60
  const code = err.code ?? err.status;
59
61
  const errorType =
@@ -67,6 +69,7 @@ export function readErrorPayload(
67
69
  ...(code !== undefined ? { code } : {}),
68
70
  ...(typeof errorType === "string" ? { errorType } : {}),
69
71
  ...(raw ? { raw } : {}),
72
+ ...(typeof err.metadata?.limit_source === "string" ? { limitSource: err.metadata.limit_source } : {}),
70
73
  };
71
74
  }
72
75
  return { message: bodyText.slice(0, 300) };
@@ -180,6 +183,12 @@ export function hasTruncatedToolArguments(args: unknown): boolean {
180
183
  return isTruncatedArguments(args);
181
184
  }
182
185
 
186
+ // True when a stream with no terminal event left a tool call whose arguments never fully arrived (none at all, or cut mid-JSON); in: the adapter's assembled calls and their records, out: its incomplete_stream guard.
187
+ export function hasUnfinishedToolCall(calls: Iterable<{ startArgs: string; deltaArgs: string }>, toolCalls: Array<{ arguments: unknown }>): boolean {
188
+ for (const c of calls) if (!c.startArgs.trim() && !c.deltaArgs.trim()) return true;
189
+ return toolCalls.some((c) => isTruncatedArguments(c.arguments));
190
+ }
191
+
183
192
  /** Short hash for tool-ID clamping (FNV-1a, 8 hex chars, no deps). */
184
193
  export function shortHashId(value: string): string {
185
194
  let h = 0x811c9dc5;
@@ -198,7 +207,10 @@ export function shortHashId(value: string): string {
198
207
  export function clampToolCallId(id: string): string {
199
208
  const clean = String(id ?? "").replace(/[^a-zA-Z0-9_-]/g, "_");
200
209
  if (!clean.includes("|")) {
201
- return clean.length > 40 ? clean.slice(0, 40) : clean || "call_unknown";
210
+ if (clean.length <= 40) return clean || "call_unknown";
211
+ // A hard cut would give two long ids with the same first 40 characters the same id; a hash of the whole id keeps them apart.
212
+ const hash = shortHashId(id);
213
+ return `${clean.slice(0, 40 - hash.length - 1)}_${hash}`;
202
214
  }
203
215
  const sep = clean.indexOf("|");
204
216
  const callId = clean.slice(0, sep).replace(/_+$/, "") || "call";
@@ -210,6 +222,16 @@ export function clampToolCallId(id: string): string {
210
222
  return `${prefix}_${hash}`;
211
223
  }
212
224
 
225
+ // The finish reason of a turn that produced tool calls: `tool_calls`, except that a turn cut off by the output limit (`length`) keeps saying so when one of its calls was cut mid-JSON; in: the wire finish reason + the assembled calls, out: each streaming adapter.
226
+ export function toolTurnFinishReason(finishReason: string, toolCalls: Array<{ arguments: unknown }>): string {
227
+ return finishReason === "length" && toolCalls.some((c) => isTruncatedArguments(c.arguments)) ? "length" : "tool_calls";
228
+ }
229
+
230
+ // The JSON text of a tool call's arguments for a history message: `{}` for a call whose streamed arguments were cut off (the internal `__truncated_raw` marker was never the model's own output and is never resent); in: a stored tool call, out: each adapter's history builder.
231
+ export function toolArgumentsJson(args: unknown): string {
232
+ return isTruncatedArguments(args) ? "{}" : JSON.stringify(args || {});
233
+ }
234
+
213
235
  /** Plain lower-cased header record from a fetch `Headers` object. */
214
236
  export function headersToRecord(headers: Headers): Record<string, string> {
215
237
  const out: Record<string, string> = {};
@@ -282,3 +304,110 @@ export function retryOptionsFrom(options: {
282
304
  retryBudgetMs: options?.retryBudgetMs,
283
305
  };
284
306
  }
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // Streaming scaffold shared by the SSE adapters (abort linking, request timer, read loop, end-of-stream check, failure)
310
+ // ---------------------------------------------------------------------------
311
+
312
+ // Ties a provider stream to the caller's signal and to the stream's own cancel: one controller that aborts when either does; in: an adapter's stream(), out: the controller its request uses and the `release` its finally block calls.
313
+ export function linkStreamAbort(eventStream: AssistantMessageEventStream, signal: AbortSignal | undefined): { linked: AbortController; release: () => void } {
314
+ const linked = new AbortController();
315
+ const forwardUserAbort = (): void => {
316
+ try {
317
+ linked.abort((signal as { reason?: unknown } | undefined)?.reason);
318
+ } catch {
319
+ try {
320
+ linked.abort();
321
+ } catch {}
322
+ }
323
+ };
324
+ if (signal?.aborted) forwardUserAbort();
325
+ else signal?.addEventListener("abort", forwardUserAbort, { once: true });
326
+ const removeStreamCancel = eventStream.onCancel(() => {
327
+ try {
328
+ linked.abort();
329
+ } catch {}
330
+ });
331
+ return {
332
+ linked,
333
+ release: () => {
334
+ try {
335
+ signal?.removeEventListener("abort", forwardUserAbort);
336
+ } catch {}
337
+ try {
338
+ removeStreamCancel();
339
+ } catch {}
340
+ },
341
+ };
342
+ }
343
+
344
+ // Runs one request attempt under a timer that aborts `controller` after `ms` (none when unset); in: an adapter's fetchOnce, out: its Response.
345
+ export async function withAbortTimer<T>(controller: AbortController, ms: number | undefined, run: () => Promise<T>): Promise<T> {
346
+ const timer =
347
+ ms && ms > 0
348
+ ? setTimeout(() => {
349
+ try {
350
+ controller.abort();
351
+ } catch {}
352
+ }, ms)
353
+ : undefined;
354
+ try {
355
+ return await run();
356
+ } finally {
357
+ if (timer) clearTimeout(timer);
358
+ }
359
+ }
360
+
361
+ // Reads an SSE response body to its end, handing each `data:` payload to `onData`; stops with an AbortError as soon as the stream is aborted or cancelled (cancelling the body, whose rejection is absorbed); in: the opened Response body of an SSE adapter, out: calls to onData.
362
+ export async function readSseData(
363
+ body: ReadableStream<Uint8Array>,
364
+ linked: AbortController,
365
+ eventStream: AssistantMessageEventStream,
366
+ onData: (data: string) => void
367
+ ): Promise<void> {
368
+ const parser = new SSEParser();
369
+ const reader = body.getReader();
370
+ const decoder = new TextDecoder();
371
+ const abort = (): Error => Object.assign(new Error("Stream aborted"), { name: "AbortError" });
372
+ linked.signal.addEventListener("abort", () => void reader.cancel().catch(() => {}), { once: true }); // an aborted body is already errored: its cancel() rejects, and nobody else listens
373
+ while (true) {
374
+ if (linked.signal.aborted || eventStream.isCancelled()) {
375
+ try {
376
+ await reader.cancel();
377
+ } catch {}
378
+ throw abort();
379
+ }
380
+ const { done, value } = await reader.read();
381
+ if (done) break;
382
+ for (const m of parser.feed(decoder.decode(value, { stream: true }))) onData(m.data);
383
+ }
384
+ for (const m of parser.flush()) onData(m.data);
385
+ try {
386
+ reader.releaseLock();
387
+ } catch {}
388
+ if (linked.signal.aborted || eventStream.isCancelled()) throw abort();
389
+ }
390
+
391
+ // The error to fail with when an SSE stream ended without its final event while carrying no content or an unfinished tool call (checked only for real SSE payloads, not JSON mocks); in: the adapter's state after reading, out: `eventStream.fail`, else undefined.
392
+ export function incompleteStreamCheck(a: {
393
+ provider: string;
394
+ modelId: string;
395
+ hasTerminal: boolean;
396
+ empty: boolean;
397
+ unfinishedCall: boolean;
398
+ responseHeaders: Record<string, string>;
399
+ framesSeen: number;
400
+ badFrames: number;
401
+ }): Error | undefined {
402
+ if (a.hasTerminal || !(a.empty || a.unfinishedCall)) return undefined;
403
+ const contentType = Object.entries(a.responseHeaders).find(([k]) => k.toLowerCase() === "content-type")?.[1];
404
+ const isSSEPayload = (typeof contentType === "string" && (contentType.includes("event-stream") || contentType.startsWith("text/"))) || a.framesSeen > 0 || a.badFrames > 0;
405
+ return isSSEPayload ? incompleteStreamError(a.provider, a.modelId) : undefined;
406
+ }
407
+
408
+ // Fails a stream with the right error: an abort (the signal, the stream's cancel, or an AbortError) becomes the plain `AbortError`, anything else passes through; in: an adapter's catch block, out: the stream's result() rejection.
409
+ export function failStream(eventStream: AssistantMessageEventStream, linked: AbortController, err: unknown): void {
410
+ const raw = err instanceof Error ? err : new Error(String(err));
411
+ const isAbort = linked.signal.aborted || eventStream.isCancelled() || (raw as { name?: string }).name === "AbortError";
412
+ eventStream.fail(isAbort ? Object.assign(new Error("Stream aborted"), { name: "AbortError" }) : raw);
413
+ }
package/src/providers.ts CHANGED
@@ -42,6 +42,7 @@ export type ProviderCapabilityStatus =
42
42
  */
43
43
  const emittedWarnings = new Set<string>();
44
44
 
45
+ /** Warns once per provider, capability and request about something that cannot be applied as asked: a `provider_warning` bus event, the legacy global sink and console; in: the mappers and adapters, out: those channels. */
45
46
  export function emitProviderWarning(options: {
46
47
  provider: string;
47
48
  capability: string;
@@ -190,40 +191,6 @@ export function mapThinkingLevelToOpenRouterChat(
190
191
  return {};
191
192
  }
192
193
 
193
- /**
194
- * Maps a canonical ThinkingLevel onto OpenRouter Responses `reasoning.effort`.
195
- * Documented efforts: minimal|low|medium|high (server default medium).
196
- * Wire-validated extras: `none` disables reasoning output; `xhigh`/`max` are
197
- * echoed but are NOT documented levels, so they clamp to `high` with a warning.
198
- *
199
- * @deprecated The Responses skin is discontinued for OpenRouter
200
- * (removed from core). Use
201
- * {@link mapThinkingLevelToOpenRouterChat} with the stable Chat Completions
202
- * transport instead.
203
- */
204
- export function mapThinkingLevelToOpenRouter(
205
- level: ThinkingLevel | string | undefined
206
- ): { effort?: string } {
207
- if (!level) return {};
208
- const norm = String(level).toLowerCase().trim();
209
- if (norm === "dynamic") return {}; // server default (medium)
210
- if (norm === "none") return { effort: "none" };
211
- if (norm === "xhigh" || norm === "max") {
212
- emitProviderWarning({
213
- provider: "openrouter",
214
- capability: "thinking level",
215
- requested: String(level),
216
- reason: "OpenRouter documents reasoning efforts up to high.",
217
- fallback: "reasoning effort high",
218
- });
219
- return { effort: "high" };
220
- }
221
- if (norm === "minimal" || norm === "low" || norm === "medium" || norm === "high") {
222
- return { effort: norm };
223
- }
224
- return {};
225
- }
226
-
227
194
  /** Maps canonical ServiceTier onto OpenRouter `service_tier` (omit = auto). */
228
195
  export function mapServiceTierToOpenRouter(tier: ServiceTier | undefined): "flex" | "priority" | undefined {
229
196
  if (tier === "flex" || tier === "priority") return tier;
@@ -248,17 +215,17 @@ export function resolveCache(
248
215
  return undefined;
249
216
  }
250
217
 
251
- /** Warns once per provider/model that per-media `resolution` is ignored (no such control on that wire); in: an adapter's request messages, out: telemetry bus only. */
252
- export function warnIgnoredMediaResolution(provider: string, messages: Message[], modelRef: string): void {
218
+ /** Warns once per provider/model that per-media `resolution` is ignored (no such control on that wire for the given kinds of media); in: an adapter's request messages, out: telemetry bus only. */
219
+ export function warnIgnoredMediaResolution(provider: string, messages: Message[], modelRef: string, kinds: Array<"image" | "video"> = ["image", "video"]): void {
253
220
  const used = messages.some(
254
- (m) => Array.isArray(m.content) && m.content.some((p) => (p.type === "image" || p.type === "video") && p.resolution)
221
+ (m) => Array.isArray(m.content) && m.content.some((p) => (p.type === "image" || p.type === "video") && kinds.includes(p.type) && p.resolution)
255
222
  );
256
223
  if (!used) return;
257
224
  emitProviderWarning({
258
225
  provider,
259
226
  capability: "media resolution",
260
227
  requested: modelRef,
261
- reason: "this provider has no per-media detail control (Google only).",
228
+ reason: "this provider has no per-media detail control for this kind of media.",
262
229
  fallback: "the provider's default detail",
263
230
  });
264
231
  }
@@ -287,25 +254,6 @@ export function mapToolChoiceToOpenRouterChat(
287
254
  return undefined;
288
255
  }
289
256
 
290
- /** OpenRouter Responses `tool_choice` wire values.
291
- * @deprecated Discontinued skin; see {@link OpenRouterChatToolChoice}. */
292
- export type OpenRouterToolChoice = "auto" | "none" | "required" | { type: "function"; name: string };
293
-
294
- /**
295
- * Maps canonical toolChoice onto OpenRouter Responses `tool_choice`.
296
- * `auto` is the server default (omitted); `required` is natively supported
297
- * (validated); a function pin passes through verbatim.
298
- */
299
- export function mapToolChoiceToOpenRouter(
300
- choice: "auto" | "none" | "required" | { type: "function"; function: { name: string } } | undefined
301
- ): OpenRouterToolChoice | undefined {
302
- if (!choice || choice === "auto") return undefined;
303
- if (choice === "none" || choice === "required") return choice;
304
- const name = choice.function?.name;
305
- if (name) return { type: "function", name };
306
- return undefined;
307
- }
308
-
309
257
  /**
310
258
  * Maps a canonical ThinkingLevel onto OpenAI Responses `reasoning.effort`.
311
259
  * Documented efforts: none|minimal|low|medium|high|xhigh|max (server default
@@ -383,6 +331,7 @@ export function normalizeToolChoice(
383
331
  return { mode: "any" };
384
332
  }
385
333
 
334
+ // True for a non-array object; in: the argument and schema helpers, out: a type guard.
386
335
  function isPlainObject(value: unknown): value is Record<string, unknown> {
387
336
  return !!value && typeof value === "object" && !Array.isArray(value);
388
337
  }
@@ -399,6 +348,7 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
399
348
  * @example `parseStreamedToolArguments("{}", '{"location":"Paris"}')`
400
349
  */
401
350
  export function parseStreamedToolArguments(startText: string, deltaText: string): Record<string, unknown> {
351
+ if (!startText.trim() && !deltaText.trim()) return {}; // no argument text at all is a call that takes none
402
352
  const candidates = [startText + deltaText, deltaText, startText].filter((c) => c && c.trim());
403
353
  for (const candidate of candidates) {
404
354
  try {
@@ -422,6 +372,7 @@ export function parseStreamedToolArguments(startText: string, deltaText: string)
422
372
  * @example `selectRawArguments("{}", '{"location":"Paris"}') // '{"location":"Paris"}'`
423
373
  */
424
374
  export function selectRawArguments(startText: string, deltaText: string): string {
375
+ if (!startText.trim() && !deltaText.trim()) return "{}";
425
376
  const candidates = [startText + deltaText, deltaText, startText].filter((c) => c && c.trim());
426
377
  for (const candidate of candidates) {
427
378
  try {
@@ -445,6 +396,9 @@ export interface GenerationParams {
445
396
  seed?: number;
446
397
  }
447
398
 
399
+ /** Most stop sequences a Chat Completions request may carry (OpenRouter chat-completion api-ref `maxItems`, OpenAI the same). */
400
+ const MAX_CHAT_STOP = 4;
401
+ // True for a finite number; in: the generation-param mappers, out: a type guard.
448
402
  const finiteNumber = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
449
403
 
450
404
  /** Normalizes the public `stop` option (`string | string[]`) to a clean array, or undefined. */
@@ -460,15 +414,27 @@ export function normalizeStop(stop: string | string[] | undefined): string[] | u
460
414
  * `max_tokens`, `stop`, `seed`. Only set values are
461
415
  * emitted, so strict endpoints never see anything the caller did not ask for.
462
416
  */
463
- export function mapGenerationParamsToChat(params: GenerationParams | undefined): Record<string, unknown> {
417
+ export function mapGenerationParamsToChat(params: GenerationParams | undefined, provider = "chat", modelRef = ""): Record<string, unknown> {
464
418
  const out: Record<string, unknown> = {};
465
419
  if (!params) return out;
466
420
  if (finiteNumber(params.maxOutputTokens) && params.maxOutputTokens > 0) out["max_tokens"] = Math.floor(params.maxOutputTokens);
467
- if (params.stop && params.stop.length > 0) out["stop"] = params.stop;
421
+ if (params.stop && params.stop.length > 0) {
422
+ out["stop"] = params.stop.slice(0, MAX_CHAT_STOP);
423
+ if (params.stop.length > MAX_CHAT_STOP) {
424
+ emitProviderWarning({
425
+ provider,
426
+ capability: "stop sequences",
427
+ requested: `${params.stop.length} sequences (${modelRef})`,
428
+ reason: `Chat Completions allows at most ${MAX_CHAT_STOP} stop sequences.`,
429
+ fallback: `the first ${MAX_CHAT_STOP}`,
430
+ });
431
+ }
432
+ }
468
433
  if (finiteNumber(params.seed)) out["seed"] = Math.floor(params.seed);
469
434
  return out;
470
435
  }
471
436
 
437
+ // Warns that `stop`/`seed` have no parameter on this wire and are dropped; in: the OpenAI and Google mappers, out: provider_warning events.
472
438
  function warnUnsupportedStopSeed(provider: string, modelRef: string, params: GenerationParams, reasonSuffix: string): void {
473
439
  if (params.stop && params.stop.length > 0) {
474
440
  emitProviderWarning({
@@ -518,6 +484,7 @@ export function mapGenerationParamsToGoogle(
518
484
  // Structured outputs (canonical, pure, no I/O)
519
485
  // ---------------------------------------------------------------------------
520
486
 
487
+ // True when a value looks like a Zod schema (parse/safeParse/_def/~standard); in: structured-output normalization, out: a boolean.
521
488
  function isZodLike(value: unknown): boolean {
522
489
  if (!value || typeof value !== "object") return false;
523
490
  const v = value as Record<string, unknown>;
@@ -529,6 +496,7 @@ function isZodLike(value: unknown): boolean {
529
496
  );
530
497
  }
531
498
 
499
+ // True when a plain object has JSON Schema keywords; in: structured-output normalization, out: a boolean.
532
500
  function looksLikeJsonSchema(value: unknown): boolean {
533
501
  if (!isPlainObject(value)) return false;
534
502
  const v = value as Record<string, unknown>;
@@ -544,12 +512,14 @@ function looksLikeJsonSchema(value: unknown): boolean {
544
512
  );
545
513
  }
546
514
 
515
+ // A structured-output name limited to letters, digits, `_` and `-` (64 chars), default `structured_output`; in: normalizeStructuredOutput, out: the name.
547
516
  function sanitizeOutputName(raw: unknown): string {
548
517
  const base = typeof raw === "string" && raw.trim() ? raw.trim() : "structured_output";
549
518
  const cleaned = base.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "structured_output";
550
519
  return cleaned;
551
520
  }
552
521
 
522
+ // A Zod schema or JSON Schema object as a JSON Schema object, else a ValidationError; in: normalizeStructuredOutput, out: the schema.
553
523
  function schemaToJsonSchema(schema: unknown, what: string): Record<string, unknown> {
554
524
  if (isZodLike(schema)) {
555
525
  try {
@@ -619,6 +589,7 @@ export function extractOutputValidator(output: unknown): { safeParse: (v: unknow
619
589
  return undefined;
620
590
  }
621
591
 
592
+ // Removes a surrounding ```json fence from a model answer; in: parseStructuredOutput, out: the bare text.
622
593
  function stripCodeFences(text: string): string {
623
594
  const trimmed = text.trim();
624
595
  const fenced = trimmed.match(/^```(?:json)?\s*\n([\s\S]*?)\n```\s*$/i);
@@ -0,0 +1,19 @@
1
+ # src/scheduler/
2
+
3
+ Scheduled actions: a worker that runs later (once or repeating) and saves its answer to a file. Off unless `dynamicSubagents.allowScheduledActions` is set.
4
+
5
+ | File | What it is |
6
+ | :-- | :-- |
7
+ | `scheduler.ts` | One `Scheduler` per home folder: a 1 s unref'd clock that runs only while something is pending, startup sweep, cook/deliver state machine, and the public functions (`createScheduledAction`, `list/update/run/skip/deleteScheduledAction`). |
8
+ | `store.ts` | Disk layer under `<home>/scheduled_actions/`: `tasks.json`, `deliveries.json`, run files; validation of records. |
9
+ | `time.ts` | Pure `DD:HH:MM` and local-clock maths. No timezone, DST or `Intl`; `now` is always passed in. |
10
+ | `tool.ts` + `scheduled-actions.md` | The model-facing `scheduled_actions` tool and `get_datetime`; the `.md` is the tool description. |
11
+
12
+ ## Invariants
13
+ - The files are the truth: every write re-reads first and goes through the durable writer. The file format is a stable public contract: see `docs/scheduled-action-files.md` and add a changelog row for any change.
14
+ - Write order when a run ends: run file, then `deliveries.json`, then `tasks.json`. The startup sweep repairs a crash in between.
15
+ - `startCook` advances the next times BEFORE the run starts; a run still cooking when the next is due is skipped, never stacked.
16
+ - Runs are fresh stateless workers built by `buildDynamicWorkerConfig`; their spending guardrail is `workerBudget` only.
17
+ - The Agent registry holds `WeakRef`s by name: keep every Agent that owns actions referenced, or its runs become `missed`.
18
+ - Times come from `Date`, local clock only. Tests inject `scheduler.now` / `tickMs` and call `tick()`.
19
+ - Events: `scheduled_action.{created,updated,started,ready,failed,delivered,missed,skipped,deleted,invalid,store_corrupt,error}`.
@@ -81,6 +81,7 @@ interface RunningEntry {
81
81
  missed: boolean;
82
82
  }
83
83
 
84
+ // The message of any thrown value; in: catch blocks here, out: event and error text.
84
85
  const errText = (err: unknown): string => (err instanceof Error ? err.message : String(err));
85
86
 
86
87
  // A run's exact spending as run-header fields, the numbers `usageReport()` keeps for workers (tokens split, cache hit rate, cost and where the price came from); in: the one-shot worker's totals over all attempts, out: finish's RunHeader.
@@ -106,10 +107,12 @@ function usageFields(totals: UsageTotals | undefined): Partial<RunHeader> {
106
107
  // Whole seconds since the epoch: stored times have no milliseconds, so lateness is compared at this precision; in: a clock reading or stored time, out: finish and deliver.
107
108
  const secondOf = (d: Date): number => Math.floor(d.getTime() / 1000);
108
109
 
110
+ // Emits one `scheduled_action.<code>` event on the telemetry bus; in: the scheduler and public functions, out: telemetryBus.
109
111
  function emit(code: string, message: string, data: Record<string, unknown>, level: "info" | "warn" = "info"): void {
110
112
  telemetryBus.emit({ code: `scheduled_action.${code}`, message: `[Agent Accelerator] ${message}`, level, data });
111
113
  }
112
114
 
115
+ // The ValidationError for an unknown action id; in: lookups by id, out: a thrown error.
113
116
  function notFound(id: string): ValidationError {
114
117
  return new ValidationError(`No scheduled action with id "${id}".`);
115
118
  }
@@ -118,6 +121,7 @@ function notFound(id: string): ValidationError {
118
121
  // Validation (collects EVERY problem so the model can fix a call in one go)
119
122
  // ---------------------------------------------------------------------------
120
123
 
124
+ // Runs one validation step and records its message instead of throwing, so one call can report every problem; in: validateInput/checkSchedule, out: the problems list.
121
125
  function collect(problems: string[], fn: () => void): void {
122
126
  try {
123
127
  fn();
@@ -126,10 +130,12 @@ function collect(problems: string[], fn: () => void): void {
126
130
  }
127
131
  }
128
132
 
133
+ // Throws unless the value is a non-empty string; in: field validation, out: collect.
129
134
  function requireText(field: string, value: unknown): void {
130
135
  if (typeof value !== "string" || !value.trim()) throw new ValidationError(`${field}: required, a non-empty string.`);
131
136
  }
132
137
 
138
+ // Throws unless every requested worker tool name exists in the developer's pool; in: create/update validation, out: collect.
133
139
  function checkTools(value: unknown, pool: PoolTools): void {
134
140
  if (!Array.isArray(value) || value.some((n) => typeof n !== "string")) {
135
141
  throw new ValidationError("tools: must be an array of worker tool names, e.g. [\"recent_news\"].");
@@ -174,6 +180,7 @@ function checkSchedule(
174
180
  }
175
181
  }
176
182
 
183
+ // Validates role, tools and timeoutMs of a create or update; in: validateInput/updateScheduledAction, out: the problems list.
177
184
  function checkOptionalFields(input: Pick<ScheduledActionPatch, "role" | "tools" | "timeoutMs">, pool: PoolTools | undefined, problems: string[]): void {
178
185
  if (input.role !== undefined && typeof input.role !== "string") problems.push("role: must be a string.");
179
186
  if (input.tools !== undefined && pool) collect(problems, () => checkTools(input.tools, pool));
@@ -208,9 +215,11 @@ export function validateInput(
208
215
  return problems;
209
216
  }
210
217
 
218
+ // Joins validation problems into one numbered ValidationError message; in: create/update failures, out: the error text.
211
219
  const joinProblems = (what: string, problems: string[]): string =>
212
220
  `${what} (${problems.length} problem${problems.length === 1 ? "" : "s"}): ${problems.map((p, i) => `(${i + 1}) ${p}`).join(" ")}`;
213
221
 
222
+ // Six random lowercase/digit characters appended to an action id; in: createScheduledAction, out: the unique id suffix.
214
223
  function randomSuffix(): string {
215
224
  const bytes = new Uint8Array(6);
216
225
  globalThis.crypto.getRandomValues(bytes);
@@ -244,6 +253,7 @@ export class Scheduler {
244
253
  (this.timer as { unref?: () => void }).unref?.();
245
254
  }
246
255
 
256
+ // Stops the clock's interval; in: tick when nothing is pending and resetSchedulers, out: no more ticks.
247
257
  stop(): void {
248
258
  if (this.timer) clearInterval(this.timer);
249
259
  this.timer = undefined;
@@ -266,6 +276,7 @@ export class Scheduler {
266
276
  }
267
277
  }
268
278
 
279
+ // Reports an invalid record or delivery once per file/id/problem and leaves the file untouched; in: snapshot/startCook, out: a scheduled_action.invalid warning.
269
280
  private warnInvalid(id: string, problem: string, file = "tasks.json"): void {
270
281
  const key = `${file}:${id}:${problem}`;
271
282
  if (this.warned.has(key)) return;
@@ -292,6 +303,7 @@ export class Scheduler {
292
303
  return { live, pending };
293
304
  }
294
305
 
306
+ // True while anything keeps the clock useful (a run in flight, a saved delivery, a waiting or cooking action); in: ensureRunning and tick, out: start/stop of the interval.
295
307
  private hasPending(): boolean {
296
308
  const { live, pending } = this.snapshot();
297
309
  return this.running.size > 0 || pending.length > 0 || live.some((t) => t.state === "waiting" || t.state === "cooking");
@@ -359,6 +371,7 @@ export class Scheduler {
359
371
 
360
372
  // ----- delivery ----------------------------------------------------------
361
373
 
374
+ // Delivers every saved run whose delivery time has come; in: tick, out: deliver calls.
362
375
  private deliverDue(now: Date): void {
363
376
  for (const entry of this.snapshot().pending) {
364
377
  if (parseLocalIso(entry.deliverAt).getTime() <= now.getTime()) this.deliver(entry, now);
@@ -394,6 +407,7 @@ export class Scheduler {
394
407
 
395
408
  // ----- starting runs -----------------------------------------------------
396
409
 
410
+ // Starts waiting actions whose cook time has come and skips the overlap of those still cooking; in: tick, out: startCook/skipOverlap calls.
397
411
  private startDue(now: Date): void {
398
412
  for (const t of this.snapshot().live) {
399
413
  if (!t.nextCookAt || parseLocalIso(t.nextCookAt).getTime() > now.getTime()) continue;
@@ -468,6 +482,7 @@ export class Scheduler {
468
482
  return started;
469
483
  }
470
484
 
485
+ // The display name of an action from tasks.json, else its id; in: startCook's no-agent message, out: event text.
471
486
  private nameOf(id: string): string {
472
487
  return readTasks(this.home).find((t) => t.id === id)?.name ?? id;
473
488
  }
@@ -511,6 +526,7 @@ export class Scheduler {
511
526
 
512
527
  // ----- the run -----------------------------------------------------------
513
528
 
529
+ // Runs one action to its outcome and writes the result unless the action was deleted meanwhile; in: launch, out: finish.
514
530
  private async runWorker(task: ScheduledActionRecord, agent: Agent, entry: RunningEntry): Promise<void> {
515
531
  const deliverAt = parseLocalIso(task.current!.deliverAt);
516
532
  const outcome = await this.execute(task, agent, entry, deliverAt);
@@ -556,6 +572,7 @@ export class Scheduler {
556
572
  }
557
573
  }
558
574
 
575
+ // One worker run with a per-attempt timeout and the delete signal, raced so a hung provider cannot hold it; in: execute, out: the worker's AgentResponse.
559
576
  private async attempt(worker: Agent, task: ScheduledActionRecord, agent: Agent, entry: RunningEntry): Promise<AgentResponse> {
560
577
  const ms = scheduledRunTimeoutMs(agent.dynamicSubagents?.workerTimeoutMs ?? Infinity, task.timeoutMs);
561
578
  // One controller per attempt (the timeout is per attempt), fed by the entry's delete controller.
@@ -613,6 +630,7 @@ export class Scheduler {
613
630
 
614
631
  // ----- commands ----------------------------------------------------------
615
632
 
633
+ // Starts an action immediately outside its schedule (also resolves a missed one); in: runScheduledAction, out: the cooking record.
616
634
  runNow(id: string): ScheduledActionRecord {
617
635
  const rec = readTasks(this.home).find((t) => t.id === id);
618
636
  if (!rec) throw notFound(id);
@@ -629,6 +647,7 @@ export class Scheduler {
629
647
  return started;
630
648
  }
631
649
 
650
+ // Ignores a missed occurrence and schedules the next one (a one-shot becomes done); in: skipScheduledAction and onMissed: skip, out: the updated record.
632
651
  skip(id: string): ScheduledActionRecord {
633
652
  const now = this.now();
634
653
  const rec = updateTasks(this.home, (tasks) => {
@@ -647,6 +666,7 @@ export class Scheduler {
647
666
  return rec;
648
667
  }
649
668
 
669
+ // Deletes an action (aborting a cooking run only with force) and optionally purges its run files; in: deleteScheduledAction, out: a DeleteResult.
650
670
  async delete(id: string, opts: { force?: boolean; purge?: boolean }): Promise<DeleteResult> {
651
671
  const rec = readTasks(this.home).find((t) => t.id === id);
652
672
  if (!rec) return { ok: false, reason: "not_found" };
@@ -699,6 +719,7 @@ export function resetSchedulers(): void {
699
719
  schedulers.clear();
700
720
  }
701
721
 
722
+ // The scheduler of a resolved home (option, setHome, env or default); in: the public functions, out: a Scheduler.
702
723
  const schedulerFor = (opts?: HomeOptions): Scheduler => getScheduler(resolveHome(opts?.home));
703
724
 
704
725
  /** Makes `agent` the live runner for actions created under its name, and starts the clock if work is pending. */
@@ -72,8 +72,11 @@ export interface RunHeader {
72
72
  error?: string;
73
73
  }
74
74
 
75
+ // The `scheduled_actions` folder of a home; in: the path helpers, out: its path.
75
76
  const baseDir = (home: string): string => path.join(home, "scheduled_actions");
77
+ // Path of tasks.json; in: readTasks/updateTasks, out: its path.
76
78
  const tasksFile = (home: string): string => path.join(baseDir(home), "tasks.json");
79
+ // Path of deliveries.json; in: readDeliveries/updateDeliveries, out: its path.
77
80
  const deliveriesFile = (home: string): string => path.join(baseDir(home), "deliveries.json");
78
81
 
79
82
  /** Absolute path of a run file given its `scheduled_actions/`-relative path. */
@@ -134,6 +137,7 @@ function readList(file: string, key: "tasks" | "pending"): Raw[] {
134
137
  return list ?? [];
135
138
  }
136
139
 
140
+ // Writes `{ version, <key>: [...] }` durably (with a .bak); in: updateTasks/updateDeliveries, out: the file.
137
141
  function writeList(file: string, key: "tasks" | "pending", list: Raw[]): void {
138
142
  writeFileDurable(file, JSON.stringify({ version: 1, [key]: list }, null, 2) + "\n");
139
143
  }
@@ -151,6 +155,7 @@ export function updateTasks<T>(home: string, fn: (tasks: ScheduledActionRecord[]
151
155
  return result;
152
156
  }
153
157
 
158
+ /** Every saved delivery as stored (entries may be invalid: check with validateDelivery); in: the scheduler, out: PendingDelivery[]. */
154
159
  export function readDeliveries(home: string): PendingDelivery[] {
155
160
  return readList(deliveriesFile(home), "pending") as unknown as PendingDelivery[];
156
161
  }
@@ -229,6 +234,7 @@ export function validateDelivery(raw: unknown): string | undefined {
229
234
  // Run files
230
235
  // ---------------------------------------------------------------------------
231
236
 
237
+ // Renders a run file: the `---` header lines, a blank line, the result text; in: writeRunFile, out: the file text.
232
238
  function renderRun(header: RunHeader, body: string): string {
233
239
  const lines = ["---"];
234
240
  for (const [key, value] of Object.entries(header)) {
@@ -15,7 +15,9 @@ const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday",
15
15
  const DDHHMM = /^(\d{2,}):(\d{2}):(\d{2})$/;
16
16
  const LOCAL_ISO = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/;
17
17
 
18
+ // Two-digit zero padding; in: date formatting here, out: a string.
18
19
  const pad2 = (n: number): string => String(n).padStart(2, "0");
20
+ // JSON text of a value for error messages; in: the validators, out: a string.
19
21
  const show = (v: unknown): string => JSON.stringify(v) ?? String(v);
20
22
 
21
23
  /** Parses `DD:HH:MM` (DD >= 2 digits, MM < 60). HH is range-checked by the caller. */
@@ -0,0 +1,19 @@
1
+ # src/session/
2
+
3
+ Saving a conversation, its spending and its tool logs.
4
+
5
+ | File | What it is |
6
+ | :-- | :-- |
7
+ | `store.ts` | Snapshot format (`session.json`, one line per field and per turn), load validation, `SessionTelemetry`, media extraction, journal replay (`replayJournal`), and `priceUsage` (the one cost model, integer micro-USD). |
8
+ | `journal.ts` | `SessionJournal`: after each step appends one fsynced line to `journal.ndjson`; writes a fresh snapshot (new `epoch`) when history changed any other way or the journal passed 500 lines / 4 MB. |
9
+ | `usage.ts` | Pure spending accounting: `UsageTotals`, `usageLine`, `buildUsageReport`. No Agent import. |
10
+ | `tool-log.ts` | `ToolLogs`: one fsynced NDJSON file per tool call under `<session>/tool-logs/`. |
11
+
12
+ ## Invariants
13
+ - Layout: `<home>/sessions/<id>/{session.json, journal.ndjson, media/, tool-logs/, workers/}`.
14
+ - A journal whose header epoch differs from the snapshot's is ignored on load.
15
+ - Journal record types: `base`, `msg`, `meta`, `run`, `end`, `tool`, `pending`, `usage`. Replay ignores unknown types, drops a torn last line, and stops at earlier damage with `session.journal_corrupt`.
16
+ - Media is content-addressed (`media/<kind>-h<hash>.<ext>`), written once, never deleted.
17
+ - Valid sessions over the load limits throw `SessionLoadError` and are never quarantined; only corrupt files are renamed `*.corrupt-<ts>`.
18
+ - Pricing: a provider-reported cost wins and is never rescaled; unknown pricing stays unknown, never $0; cache reads and writes are subsets of input.
19
+ - Never call `saveSessionDir` on a folder an Agent persists to.