agent-accelerator 0.5.6 → 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 (45) hide show
  1. package/README.md +6 -13
  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 +13 -15
  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 +20 -0
  16. package/src/providers/openai-compat.ts +22 -0
  17. package/src/providers/openai.ts +17 -1
  18. package/src/providers/openrouter.ts +12 -0
  19. package/src/providers/registry.ts +1 -0
  20. package/src/providers.ts +9 -53
  21. package/src/scheduler/architecture.md +19 -0
  22. package/src/scheduler/scheduler.ts +21 -0
  23. package/src/scheduler/store.ts +6 -0
  24. package/src/scheduler/time.ts +2 -0
  25. package/src/session/architecture.md +19 -0
  26. package/src/session/store.ts +15 -0
  27. package/src/session/usage.ts +1 -0
  28. package/src/streaming/architecture.md +13 -0
  29. package/src/streaming/event-stream.ts +5 -0
  30. package/src/telemetry.ts +1 -0
  31. package/src/tools/architecture.md +19 -0
  32. package/src/tools/executor.ts +13 -0
  33. package/src/tools/schema.ts +1 -0
  34. package/src/tools/subagent-result.ts +1 -0
  35. package/src/types/architecture.md +20 -0
  36. package/src/utils/architecture.md +25 -0
  37. package/src/utils/base64.ts +2 -0
  38. package/src/utils/cache.ts +2 -0
  39. package/src/utils/errors.ts +4 -0
  40. package/src/utils/headers.ts +4 -1
  41. package/src/utils/media.ts +1 -0
  42. package/src/utils/raw-capture.ts +3 -0
  43. package/src/utils/retry.ts +1 -0
  44. package/src/utils/safe-io.ts +4 -0
  45. package/src/utils/session.ts +5 -161
@@ -167,6 +167,7 @@ const DEFAULT_BASE_URL = "https://api.openai.com/v1";
167
167
  // Request building (canonical -> Responses)
168
168
  // ---------------------------------------------------------------------------
169
169
 
170
+ // Endpoint from the option, OPENAI_BASE_URL/OPENAI_API_BASE, else api.openai.com; in: each request, out: the base URL without a trailing slash.
170
171
  function resolveBaseUrl(options?: ProviderRequestOptions): string {
171
172
  return (
172
173
  options?.baseUrl ||
@@ -178,6 +179,7 @@ function resolveBaseUrl(options?: ProviderRequestOptions): string {
178
179
  ).replace(/\/+$/, "");
179
180
  }
180
181
 
182
+ // Key from the option or env, or `unused` when custom headers carry the auth; in: each request, out: the key or undefined.
181
183
  function resolveApiKey(options?: ProviderRequestOptions): string | undefined {
182
184
  const key = getApiKey("openai", options?.apiKey, options?.env);
183
185
  if (key) return key;
@@ -191,6 +193,7 @@ function cleanModelId(model: string | ModelSpec): string {
191
193
  return rawId.replace(/^openai\//i, "");
192
194
  }
193
195
 
196
+ // Canonical tool declarations as Responses function tools; in: options.tools, out: request `tools`.
194
197
  function toOpenAITools(tools?: StandardToolDeclaration[]): Array<Record<string, unknown>> | undefined {
195
198
  if (!tools || tools.length === 0) return undefined;
196
199
  return tools.map((t) => ({
@@ -202,6 +205,7 @@ function toOpenAITools(tools?: StandardToolDeclaration[]): Array<Record<string,
202
205
  }));
203
206
  }
204
207
 
208
+ // Text and media parts as input_text, input_image and input_file blocks; web links pass through, the rest is inlined as data URLs; in: a user message's parts, out: fullHistoryInput.
205
209
  async function contentPartsToBlocks(parts: ContentPart[]): Promise<ResponseContentPart[]> {
206
210
  const blocks: ResponseContentPart[] = [];
207
211
  for (const part of parts) {
@@ -258,17 +262,20 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<ResponseConte
258
262
  return blocks;
259
263
  }
260
264
 
265
+ // A tool result as the text of a function_call_output item; in: fullHistoryInput, out: a string.
261
266
  function resultToOutput(result: unknown): string {
262
267
  if (typeof result === "string") return result;
263
268
  return safeStringify(result);
264
269
  }
265
270
 
271
+ // Makes an id safe for the Responses wire (allowed characters, at most 64, no trailing underscores); in: fullHistoryInput, out: the id.
266
272
  function normalizeResponsesIdPart(part: string): string {
267
273
  const sanitized = String(part ?? "").replace(/[^a-zA-Z0-9_-]/g, "_");
268
274
  const truncated = sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized;
269
275
  return truncated.replace(/_+$/, "") || "fc_unknown";
270
276
  }
271
277
 
278
+ // Gives a function-call item id the required `fc_` prefix; in: fullHistoryInput, out: the id.
272
279
  function normalizeResponsesItemId(itemId: string): string {
273
280
  let norm = normalizeResponsesIdPart(itemId);
274
281
  if (!norm.startsWith("fc_")) {
@@ -390,6 +397,7 @@ async function fullHistoryInput(context: ProviderContext): Promise<ResponseInput
390
397
  // Response mapping (Responses -> canonical)
391
398
  // ---------------------------------------------------------------------------
392
399
 
400
+ // Responses usage as TokenUsage, clamping cache reads to input; in: a response or the completed event, out: TokenUsage.
393
401
  function mapUsage(raw?: ResponsesObject["usage"]): TokenUsage {
394
402
  const input = raw?.input_tokens ?? 0;
395
403
  const output = raw?.output_tokens ?? 0;
@@ -406,6 +414,7 @@ function mapUsage(raw?: ResponsesObject["usage"]): TokenUsage {
406
414
  };
407
415
  }
408
416
 
417
+ // Turns a Responses object into text, reasoning, tool calls and usage, mapping refusals and incomplete to finish reasons and throwing on failed; in: generate, out: ProviderGenerateResult.
409
418
  function parseResponse(
410
419
  response: ResponsesObject,
411
420
  modelId: string,
@@ -509,6 +518,7 @@ export class OpenAIResponsesProvider implements Provider {
509
518
  return getModelsForProvider("openai");
510
519
  }
511
520
 
521
+ // The catalog spec of a model, else a permissive generic one; in: the registry, out: a ModelSpec.
512
522
  getModel(modelId: string): ModelSpec | undefined {
513
523
  const clean = cleanModelId(modelId);
514
524
  return (
@@ -519,6 +529,7 @@ export class OpenAIResponsesProvider implements Provider {
519
529
  );
520
530
  }
521
531
 
532
+ // Builds the stateless Responses request (full history, store:false, tools, reasoning, tier, text.format, cache key and retention); in: generate/stream, out: the body.
522
533
  private async buildBody(
523
534
  modelId: string,
524
535
  context: ProviderContext,
@@ -620,6 +631,7 @@ export class OpenAIResponsesProvider implements Provider {
620
631
  return body;
621
632
  }
622
633
 
634
+ // Fetch options with session-affinity headers, JSON content type and the Bearer key; in: doFetch and stream, out: RequestInit.
623
635
  private requestInit(
624
636
  body: ResponsesRequestBody,
625
637
  options: ProviderRequestOptions | undefined,
@@ -640,6 +652,7 @@ export class OpenAIResponsesProvider implements Provider {
640
652
  return { method: "POST", headers, body: JSON.stringify(body), signal };
641
653
  }
642
654
 
655
+ // One HTTP call under the request timeout, returning status, headers and body text; in: generate, out: the raw reply.
643
656
  private async doFetch(
644
657
  url: string,
645
658
  body: ResponsesRequestBody,
@@ -662,6 +675,7 @@ export class OpenAIResponsesProvider implements Provider {
662
675
  }
663
676
  }
664
677
 
678
+ // Throws a concise provider error (status, code, Retry-After, key hint) for a non-2xx reply; in: generate and stream, out: nothing or a thrown error.
665
679
  private throwIfError(
666
680
  status: number,
667
681
  url: string,
@@ -692,6 +706,7 @@ export class OpenAIResponsesProvider implements Provider {
692
706
  throw toConciseProviderError(err, "openai", modelId);
693
707
  }
694
708
 
709
+ // Non-streaming turn as one retried JSON request; in: runAgentLoop, out: ProviderGenerateResult.
695
710
  async generate(
696
711
  model: string | ModelSpec,
697
712
  context: ProviderContext,
@@ -773,6 +788,7 @@ export class OpenAIResponsesProvider implements Provider {
773
788
  }
774
789
  }
775
790
 
791
+ // Streaming turn over SSE: assembles text, reasoning, tool calls and usage, retrying only before the first byte; in: streamAgentLoop, out: an AssistantMessageEventStream.
776
792
  stream(
777
793
  model: string | ModelSpec,
778
794
  context: ProviderContext,
@@ -793,7 +809,7 @@ export class OpenAIResponsesProvider implements Provider {
793
809
  );
794
810
  }
795
811
  assertModalitiesSupported(context, "openai", clean);
796
- assertNoVideoPartsOnResponses(context, "openai", clean);
812
+ assertNoVideoPartsOnResponses(context, "openai", clean);
797
813
  const baseUrl = resolveBaseUrl(options);
798
814
  const url = `${baseUrl}/responses`;
799
815
  const sessionId = options?.sessionId;
@@ -145,6 +145,7 @@ const DEFAULT_BASE_URL = "https://openrouter.ai/api/v1";
145
145
  // Request building (canonical -> Chat Completions)
146
146
  // ---------------------------------------------------------------------------
147
147
 
148
+ // Endpoint from the option, OPENROUTER_BASE_URL, else openrouter.ai; in: each request, out: the base URL without a trailing slash.
148
149
  function resolveBaseUrl(options?: ProviderRequestOptions): string {
149
150
  return (
150
151
  options?.baseUrl ||
@@ -154,6 +155,7 @@ function resolveBaseUrl(options?: ProviderRequestOptions): string {
154
155
  ).replace(/\/+$/, "");
155
156
  }
156
157
 
158
+ // Key from the option or env, or `unused` when custom headers carry the auth; in: each request, out: the key or undefined.
157
159
  function resolveApiKey(options?: ProviderRequestOptions): string | undefined {
158
160
  const key = getApiKey("openrouter", options?.apiKey, options?.env);
159
161
  if (key) return key;
@@ -172,11 +174,13 @@ function cleanModelId(model: string | ModelSpec): string {
172
174
 
173
175
  // The HTTP status of an error chunk inside a stream (the HTTP status itself is 200): its numeric `code`, else the status of its retryable `error_type` (errors-and-debugging.md, "Typed Error Codes"); in: the chunk's error, out: streamInto, where it makes the error retryable.
174
176
  const RETRYABLE_ERROR_TYPE_STATUS: Record<string, number> = { rate_limit_exceeded: 429, provider_overloaded: 503, provider_unavailable: 502, timeout: 504, server: 500 };
177
+ // HTTP-like status of an error chunk inside a stream, or undefined when unknown; in: streamInto's error branch, out: the status that makes the error retryable.
175
178
  function midStreamStatus(code: unknown, errorType: unknown): number | undefined {
176
179
  if (typeof code === "number" && Number.isInteger(code) && code >= 100 && code <= 599) return code;
177
180
  return typeof errorType === "string" ? RETRYABLE_ERROR_TYPE_STATUS[errorType] : undefined;
178
181
  }
179
182
 
183
+ // Canonical tool declarations as Chat Completions function tools; in: options.tools, out: request `tools`.
180
184
  function toOpenRouterTools(tools?: StandardToolDeclaration[]): Array<Record<string, unknown>> | undefined {
181
185
  if (!tools || tools.length === 0) return undefined;
182
186
  return tools.map((t) => ({
@@ -192,6 +196,7 @@ function toOpenRouterTools(tools?: StandardToolDeclaration[]): Array<Record<stri
192
196
 
193
197
  // The `input_audio.format` for a MIME type: Chat Completions takes any format string the provider supports (audio.md: wav, mp3, aiff, aac, ogg, flac, m4a, pcm16 ...), so the real format is sent, never a guess; in: an audio part's MIME type, out: contentPartsToBlocks.
194
198
  const AUDIO_FORMAT: Record<string, string> = { wav: "wav", wave: "wav", mpeg: "mp3", mp3: "mp3", mp4: "m4a", m4a: "m4a", aac: "aac", ogg: "ogg", flac: "flac", aiff: "aiff" };
199
+ // The `input_audio.format` for an audio MIME type (see AUDIO_FORMAT above); in: contentPartsToBlocks, out: the format string.
195
200
  function audioFormatFor(mimeType?: string): string {
196
201
  const subtype = (mimeType || "").toLowerCase().split(";")[0]!.split("/")[1]?.replace(/^x-/, "") ?? "";
197
202
  return AUDIO_FORMAT[subtype] ?? (subtype || "mp3");
@@ -278,6 +283,7 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<ChatContentPa
278
283
  return blocks;
279
284
  }
280
285
 
286
+ // A tool result as the text of a tool message; in: fullHistoryMessages, out: a string.
281
287
  function resultToOutput(result: unknown): string {
282
288
  if (typeof result === "string") return result;
283
289
  return safeStringify(result);
@@ -392,6 +398,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
392
398
  // Response mapping (Chat Completions -> canonical)
393
399
  // ---------------------------------------------------------------------------
394
400
 
401
+ // Chat usage as TokenUsage with cache reads clamped to input, cache writes, reasoning tokens and OpenRouter's reported cost (0 counts); in: the last stream chunk, out: TokenUsage.
395
402
  function mapUsage(raw?: ChatUsage): TokenUsage {
396
403
  const input = raw?.prompt_tokens ?? 0;
397
404
  const output = raw?.completion_tokens ?? 0;
@@ -430,6 +437,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
430
437
  return getModelsForProvider("openrouter");
431
438
  }
432
439
 
440
+ // The catalog spec of a model, else a permissive generic one; in: the registry, out: a ModelSpec.
433
441
  getModel(modelId: string): ModelSpec | undefined {
434
442
  const clean = cleanModelId(modelId);
435
443
  return (
@@ -440,6 +448,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
440
448
  );
441
449
  }
442
450
 
451
+ // Builds the Chat request (messages, tools, tool_choice, reasoning, tier, cache_control, generation controls, response_format, session_id); in: streamInto, out: the body.
443
452
  private async buildBody(
444
453
  modelId: string,
445
454
  context: ProviderContext,
@@ -518,6 +527,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
518
527
  return body;
519
528
  }
520
529
 
530
+ // Fetch options with session and opt-in attribution headers, JSON content type and the Bearer key; in: streamInto, out: RequestInit.
521
531
  private requestInit(
522
532
  body: ChatRequestBody,
523
533
  options: ProviderRequestOptions | undefined,
@@ -537,6 +547,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
537
547
  return { method: "POST", headers, body: JSON.stringify(body), signal };
538
548
  }
539
549
 
550
+ // Throws a concise provider error (status, code, Retry-After, key and media-link hints) for a non-2xx reply, marking the in-flight-budget 402 retryable; in: streamInto, out: nothing or a thrown error.
540
551
  private throwIfError(
541
552
  status: number,
542
553
  url: string,
@@ -612,6 +623,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
612
623
  }
613
624
  }
614
625
 
626
+ // Streaming turn: a normal event stream from streamInto; in: streamAgentLoop, out: an AssistantMessageEventStream.
615
627
  stream(
616
628
  model: string | ModelSpec,
617
629
  context: ProviderContext,
@@ -42,6 +42,7 @@ const FIRST_CLASS = new Set([
42
42
  // resolve anywhere.
43
43
  const REMOVED_PROVIDERS = new Set(["opencode", "opencode-go"]);
44
44
 
45
+ // The error text for a removed provider prefix; in: assertProviderNotRemoved, out: a message.
45
46
  function removedProviderMessage(id: string): string {
46
47
  return (
47
48
  `[Agent Accelerator] Provider "${id}" has been removed: OpenCode free models are ` +
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;
@@ -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
  }
@@ -449,6 +398,7 @@ export interface GenerationParams {
449
398
 
450
399
  /** Most stop sequences a Chat Completions request may carry (OpenRouter chat-completion api-ref `maxItems`, OpenAI the same). */
451
400
  const MAX_CHAT_STOP = 4;
401
+ // True for a finite number; in: the generation-param mappers, out: a type guard.
452
402
  const finiteNumber = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
453
403
 
454
404
  /** Normalizes the public `stop` option (`string | string[]`) to a clean array, or undefined. */
@@ -484,6 +434,7 @@ export function mapGenerationParamsToChat(params: GenerationParams | undefined,
484
434
  return out;
485
435
  }
486
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.
487
438
  function warnUnsupportedStopSeed(provider: string, modelRef: string, params: GenerationParams, reasonSuffix: string): void {
488
439
  if (params.stop && params.stop.length > 0) {
489
440
  emitProviderWarning({
@@ -533,6 +484,7 @@ export function mapGenerationParamsToGoogle(
533
484
  // Structured outputs (canonical, pure, no I/O)
534
485
  // ---------------------------------------------------------------------------
535
486
 
487
+ // True when a value looks like a Zod schema (parse/safeParse/_def/~standard); in: structured-output normalization, out: a boolean.
536
488
  function isZodLike(value: unknown): boolean {
537
489
  if (!value || typeof value !== "object") return false;
538
490
  const v = value as Record<string, unknown>;
@@ -544,6 +496,7 @@ function isZodLike(value: unknown): boolean {
544
496
  );
545
497
  }
546
498
 
499
+ // True when a plain object has JSON Schema keywords; in: structured-output normalization, out: a boolean.
547
500
  function looksLikeJsonSchema(value: unknown): boolean {
548
501
  if (!isPlainObject(value)) return false;
549
502
  const v = value as Record<string, unknown>;
@@ -559,12 +512,14 @@ function looksLikeJsonSchema(value: unknown): boolean {
559
512
  );
560
513
  }
561
514
 
515
+ // A structured-output name limited to letters, digits, `_` and `-` (64 chars), default `structured_output`; in: normalizeStructuredOutput, out: the name.
562
516
  function sanitizeOutputName(raw: unknown): string {
563
517
  const base = typeof raw === "string" && raw.trim() ? raw.trim() : "structured_output";
564
518
  const cleaned = base.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "structured_output";
565
519
  return cleaned;
566
520
  }
567
521
 
522
+ // A Zod schema or JSON Schema object as a JSON Schema object, else a ValidationError; in: normalizeStructuredOutput, out: the schema.
568
523
  function schemaToJsonSchema(schema: unknown, what: string): Record<string, unknown> {
569
524
  if (isZodLike(schema)) {
570
525
  try {
@@ -634,6 +589,7 @@ export function extractOutputValidator(output: unknown): { safeParse: (v: unknow
634
589
  return undefined;
635
590
  }
636
591
 
592
+ // Removes a surrounding ```json fence from a model answer; in: parseStructuredOutput, out: the bare text.
637
593
  function stripCodeFences(text: string): string {
638
594
  const trimmed = text.trim();
639
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.