agent-accelerator 0.5.7 → 0.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +27 -8
  2. package/package.json +1 -1
  3. package/src/agent/agent.ts +84 -45
  4. package/src/agent/architecture.md +1 -0
  5. package/src/agent/context.ts +3 -3
  6. package/src/agent/delegation.ts +12 -12
  7. package/src/agent/loop.ts +50 -53
  8. package/src/agent/subagent.ts +4 -4
  9. package/src/agent/worker.ts +2 -1
  10. package/src/architecture.md +1 -1
  11. package/src/index.ts +7 -7
  12. package/src/models/architecture.md +1 -1
  13. package/src/models/catalog-cache.ts +7 -7
  14. package/src/models/catalog.ts +22 -34
  15. package/src/providers/google.ts +7 -7
  16. package/src/providers/openai-compat.ts +10 -16
  17. package/src/providers/openai.ts +8 -7
  18. package/src/providers/openrouter.ts +4 -4
  19. package/src/providers/registry.ts +6 -5
  20. package/src/providers/shared.ts +6 -10
  21. package/src/scheduler/architecture.md +6 -3
  22. package/src/scheduler/scheduler.ts +111 -57
  23. package/src/scheduler/store.ts +273 -59
  24. package/src/session/architecture.md +1 -1
  25. package/src/session/store.ts +22 -22
  26. package/src/streaming/event-stream.ts +11 -11
  27. package/src/telemetry-file.ts +74 -24
  28. package/src/telemetry.ts +2 -2
  29. package/src/tools/executor.ts +1 -1
  30. package/src/tools/tool.ts +8 -8
  31. package/src/types/agent.ts +22 -20
  32. package/src/types/response.ts +8 -8
  33. package/src/types/scheduler.ts +2 -2
  34. package/src/types/tool.ts +5 -5
  35. package/src/update-models.ts +2 -2
  36. package/src/utils/architecture.md +3 -3
  37. package/src/utils/cache.ts +4 -4
  38. package/src/utils/durable.ts +78 -3
  39. package/src/utils/env.ts +1 -1
  40. package/src/utils/errors.ts +45 -31
  41. package/src/utils/headers.ts +4 -4
  42. package/src/utils/home.ts +1 -1
  43. package/src/utils/retry.ts +2 -28
  44. package/src/utils/session.ts +5 -5
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Model catalog — battle-tested single source of truth (models.dev)
3
- * Dynamically loaded and synchronized via ~/.cache with ~12h TTL.
4
- * All context length, pricing, limits, reasoning, modalities come from here.
5
- * Providers' hardcoded GOOGLE_MODELS etc. are DEPRECATED fallbacks only.
2
+ * Model catalog: the single source of truth (models.dev) for context limits, prices, modalities and reasoning options.
3
+ * The data is cached in `<home>/catalog/models.dev.json` (or `AGENT_CACHE_DIR`) with a 12 hour TTL and refreshed in the
4
+ * background (see `catalog-cache.ts`). `GOOGLE_MODELS`, `OPENAI_MODELS` and `OPENROUTER_MODELS` are views of it,
5
+ * rebuilt in place whenever it changes.
6
6
  */
7
7
 
8
8
  import {
@@ -19,6 +19,7 @@ import {
19
19
  } from "./catalog-cache.ts";
20
20
  import type { ModelSpec, ProviderId, ModelLimit, ModelCost, ModelModalities } from "../types/model.ts";
21
21
  import type { ThinkingLevel } from "../types/core.ts";
22
+ import { ConfigError } from "../types/errors.ts";
22
23
 
23
24
  // ---------------------------------------------------------------------------
24
25
  // Provider aliases — maps our internal ids to models.dev keys
@@ -77,12 +78,12 @@ function normalizeModelIdForLookup(modelId: string): string {
77
78
 
78
79
  // ---------------------------------------------------------------------------
79
80
  // Internal cache — battle-tested: avoid repeated global scans (7487 models)
80
- // Bound lookupCache to 1000 entries LRU (Q-33): evict oldest on overflow.
81
+ // Bound lookupCache to 1000 entries LRU: evict oldest on overflow.
81
82
  // ---------------------------------------------------------------------------
82
83
  const lookupCache = new Map<string, ModelSpec | undefined>();
83
84
  const providerModelsCache = new Map<string, ModelSpec[]>();
84
85
 
85
- /** Bounded LRU set for lookupCache (Q-33): keeps at most 1000 entries. */
86
+ /** Bounded LRU set for lookupCache: keeps at most 1000 entries. */
86
87
  function setLookupCache(key: string, value: ModelSpec | undefined): void {
87
88
  if (lookupCache.has(key)) lookupCache.delete(key);
88
89
  else if (lookupCache.size >= 1000) {
@@ -92,7 +93,7 @@ function setLookupCache(key: string, value: ModelSpec | undefined): void {
92
93
  lookupCache.set(key, value);
93
94
  }
94
95
 
95
- // Custom-registered models (Q-33): checked first in getModelFromCatalog.
96
+ // Custom-registered models: checked first in getModelFromCatalog.
96
97
  const customModelRegistry = new Map<string, ModelSpec>();
97
98
 
98
99
  // Case-insensitive key of a registered custom model; in: registerModel and lookups, out: a string.
@@ -101,7 +102,7 @@ function customRegistryKey(provider: string, modelId: string): string {
101
102
  }
102
103
 
103
104
  /**
104
- * Registers a custom model spec that takes precedence over the catalog (Q-33).
105
+ * Registers a custom model spec that takes precedence over the catalog.
105
106
  * Stored in a separate map, checked first in `getModelFromCatalog`.
106
107
  */
107
108
  export function registerModel(spec: ModelSpec): void {
@@ -124,7 +125,7 @@ export function clearCustomModels(): void {
124
125
  }
125
126
 
126
127
  /**
127
- * Returns where a model resolves from without touching frozen types (Q-33):
128
+ * Returns where a model resolves from without touching frozen types:
128
129
  * `"override"` for custom-registered or MODALITY_OVERRIDES-covered models,
129
130
  * `"catalog"` for plain catalog hits, `"unknown"` when unresolvable.
130
131
  */
@@ -256,7 +257,7 @@ export function getModelFromCatalog(providerInput: string, modelIdInput: string)
256
257
  const modelId = modelIdInput.trim();
257
258
  let result: ModelSpec | undefined;
258
259
 
259
- // Q-33: custom-registered models win over the catalog.
260
+ // Custom-registered models win over the catalog.
260
261
  const customHit =
261
262
  customModelRegistry.get(customRegistryKey(provider, modelId)) ??
262
263
  customModelRegistry.get(customRegistryKey(provider, normalizeModelIdForLookup(modelId)));
@@ -287,7 +288,7 @@ export function getModelFromCatalog(providerInput: string, modelIdInput: string)
287
288
  return result;
288
289
  }
289
290
  }
290
- // Q-33: tightened fuzzy match — exact, `endsWith("/"+modelId)` boundary,
291
+ // Tightened fuzzy match — exact, `endsWith("/"+modelId)` boundary,
291
292
  // or normalized equality. The old loose `modelId.endsWith(key)` is removed
292
293
  // (e.g. "gpt-4o-mini-extra" must not match "mini").
293
294
  for (const [key, raw] of Object.entries(providerData.models as Record<string, any>)) {
@@ -421,8 +422,8 @@ export function getModelThinkingInfo(provider: string, modelId: string): ModelTh
421
422
  };
422
423
  }
423
424
 
424
- /** Thrown when a requested ThinkingLevel is unsupported by the selected model. */
425
- export class ThinkingLevelError extends Error {
425
+ /** Thrown when a requested ThinkingLevel is unsupported by the selected model: a `ConfigError` (one line, never retried) with `allowedLevels`. */
426
+ export class ThinkingLevelError extends ConfigError {
426
427
  readonly provider: string;
427
428
  readonly modelId: string;
428
429
  readonly requestedLevel: string;
@@ -443,31 +444,18 @@ export class ThinkingLevelError extends Error {
443
444
  reason: string;
444
445
  remedy?: string;
445
446
  }) {
446
- const remedyLines = opts.remedy
447
- ? `\n\n How to fix:\n ${opts.remedy}`
448
- : "";
449
- const allowedLine = opts.allowedLevels.length > 0
450
- ? `\n Allowed Levels: [${opts.allowedLevels.map((l) => `"${l}"`).join(", ")}]`
451
- : "";
452
-
453
- const formattedMessage =
454
- `[Agent Accelerator] ThinkingLevel Mismatch for "${opts.provider}/${opts.modelId}":\n` +
455
- ` Requested Level: "${opts.requestedLevel}"\n` +
456
- ` Issue: ${opts.reason}` +
457
- allowedLine +
458
- remedyLines;
459
-
460
- super(formattedMessage);
447
+ const allowed = opts.allowedLevels.length > 0 ? ` Allowed levels: ${opts.allowedLevels.map((l) => `"${l}"`).join(", ")}.` : "";
448
+ const remedy = opts.remedy ? ` How to fix: ${opts.remedy}` : "";
449
+ super(
450
+ `[Agent Accelerator] Thinking level "${opts.requestedLevel}" does not fit "${opts.provider}/${opts.modelId}": ${opts.reason}${allowed}${remedy}`,
451
+ { provider: opts.provider, model: opts.modelId }
452
+ );
461
453
  this.name = "ThinkingLevelError";
462
454
  this.provider = opts.provider;
463
455
  this.modelId = opts.modelId;
464
456
  this.requestedLevel = opts.requestedLevel;
465
457
  this.allowedLevels = opts.allowedLevels;
466
458
  this.supportsThinking = opts.supportsThinking;
467
-
468
- if (Error.captureStackTrace) {
469
- Error.captureStackTrace(this, ThinkingLevelError);
470
- }
471
459
  }
472
460
  }
473
461
 
@@ -563,7 +551,7 @@ export function getModelsForProvider(provider: string): ModelSpec[] {
563
551
  out.push(mapModelSpec(alias as ProviderId, key, raw));
564
552
  }
565
553
  }
566
- // Q-33: include custom-registered models for this provider/alias set.
554
+ // Include custom-registered models for this provider/alias set.
567
555
  for (const spec of customModelRegistry.values()) {
568
556
  if (aliases.includes(String(spec.provider)) || String(spec.provider) === provider) {
569
557
  if (!seen.has(spec.id)) {
@@ -573,7 +561,7 @@ export function getModelsForProvider(provider: string): ModelSpec[] {
573
561
  }
574
562
  }
575
563
  // Also include our internal provider id mapping
576
- // Q-33: return copies — cache the canonical array, hand out a fresh copy.
564
+ // Return copies — cache the canonical array, hand out a fresh copy.
577
565
  providerModelsCache.set(provider, out);
578
566
  return [...out];
579
567
  }
@@ -383,7 +383,7 @@ function cleanModelId(model: string | ModelSpec): string {
383
383
  function assertSupportedGeneration(clean: string): void {
384
384
  const match = clean.match(/^gemini-(\d+)(?:[.\-]|$)/i);
385
385
  if (match && Number(match[1]) < 3) {
386
- throw new Error(
386
+ throw new ConfigError(
387
387
  `[Agent Accelerator] [google/${clean}] Unsupported generation: Gemini ${match[1]}.x and older are strictly rejected by this SDK. ` +
388
388
  `Use a Gemini 3 or later model for full support and feature coverage.`
389
389
  );
@@ -609,7 +609,7 @@ async function fullHistorySteps(context: ProviderContext): Promise<InteractionIn
609
609
  const turnStart = steps.length;
610
610
  for (const part of m.content) {
611
611
  if (part.type === "thinking" && part.thinking) {
612
- // Q-18: preserve namespaced signatures only — foreign-provider
612
+ // Preserve namespaced signatures only — foreign-provider
613
613
  // signatures never echo here; untagged legacy echoes (compat).
614
614
  // Absent signatures are omitted (never sent as empty strings).
615
615
  const partProvider = (part as { thoughtSignatureProvider?: unknown }).thoughtSignatureProvider;
@@ -642,7 +642,7 @@ async function fullHistorySteps(context: ProviderContext): Promise<InteractionIn
642
642
  } else if (m.thoughtSignature && !steps.slice(turnStart).some((s) => s.type === "thought")) {
643
643
  // Preserve a signature-only thought so stateless chaining keeps
644
644
  // reasoning continuity even when no summary text was retained.
645
- // Q-18: only for google/untagged signatures.
645
+ // Only for google/untagged signatures.
646
646
  const msgProvider = (m as { thoughtSignatureProvider?: unknown }).thoughtSignatureProvider;
647
647
  if (shouldEchoSignature(msgProvider, "google")) {
648
648
  steps.push({ type: "thought", signature: m.thoughtSignature });
@@ -910,7 +910,7 @@ export class GoogleInteractionsProvider implements Provider {
910
910
  signal?: AbortSignal
911
911
  ): RequestInit {
912
912
  const headers: Record<string, string> = { "Content-Type": "application/json", ...options?.headers };
913
- // Q-17: key travels as the `x-goog-api-key` header (never `?key=` in the
913
+ // Key travels as the `x-goog-api-key` header (never `?key=` in the
914
914
  // URL, which would leak into logs). Case-insensitive merge so a custom
915
915
  // `X-Goog-Api-Key` never duplicates the canonical header. Proxy flows
916
916
  // (`apiKey === "unused"`) preserve custom auth instead of overwriting.
@@ -1022,7 +1022,7 @@ export class GoogleInteractionsProvider implements Provider {
1022
1022
  assertSupportedGeneration(clean);
1023
1023
  const apiKey = resolveApiKey(options);
1024
1024
  if (!apiKey) {
1025
- throw new Error(
1025
+ throw new ConfigError(
1026
1026
  "[Agent Accelerator] Missing Google API key. Set GEMINI_API_KEY (or GOOGLE_API_KEY) or pass apiKey."
1027
1027
  );
1028
1028
  }
@@ -1133,7 +1133,7 @@ export class GoogleInteractionsProvider implements Provider {
1133
1133
  assertSupportedGeneration(clean);
1134
1134
  const apiKey = resolveApiKey(options);
1135
1135
  if (!apiKey) {
1136
- throw new Error(
1136
+ throw new ConfigError(
1137
1137
  "[Agent Accelerator] Missing Google API key. Set GEMINI_API_KEY (or GOOGLE_API_KEY) or pass apiKey."
1138
1138
  );
1139
1139
  }
@@ -1392,7 +1392,7 @@ export class GoogleInteractionsProvider implements Provider {
1392
1392
  noteProviderTurn(sessionId, "google");
1393
1393
  checkTurnTokens(sessionId, usage);
1394
1394
 
1395
- // Q-23: terminal-state guard — fail `incomplete_stream` (retryable)
1395
+ // Terminal-state guard — fail `incomplete_stream` (retryable)
1396
1396
  // instead of resolving empty success. Skipped for non-SSE payloads
1397
1397
  // (JSON mocks) where no SSE frames were ever seen.
1398
1398
  const hasTerminal = completedBody !== undefined || sawFinishReason || sawUsage;
@@ -59,7 +59,7 @@ import { normalizeMediaInput } from "../utils/media.ts";
59
59
  import { safeStringify } from "../utils/serialization.ts";
60
60
  import { toConciseProviderError, assertModalitiesSupported } from "../utils/errors.ts";
61
61
  import { withRetries } from "../utils/retry.ts";
62
- import { ConfigError } from "../types/errors.ts";
62
+ import { ConfigError, ValidationError } from "../types/errors.ts";
63
63
  import {
64
64
  parseArguments,
65
65
  normalizeThinkingParts,
@@ -350,13 +350,7 @@ function assertNoVideoOrFileParts(
350
350
  kind === "video"
351
351
  ? `[${prefix}/${modelId}] unsupported video input (generic OpenAI-compatible endpoints carry text/image/audio only). Use a video-capable model (e.g. google/gemini-*) or drop the video part.`
352
352
  : `[${prefix}/${modelId}] unsupported file input (generic OpenAI-compatible endpoints carry text/image/audio only). Use a file-capable model (e.g. google/gemini-*) or convert the document to text before sending it.`;
353
- const err = new Error(message);
354
- err.name = "AgentAccelProviderError";
355
- Object.defineProperties(err, {
356
- provider: { value: prefix, enumerable: false },
357
- model: { value: modelId, enumerable: false },
358
- });
359
- throw err;
353
+ throw new ValidationError(message, { provider: prefix, model: modelId });
360
354
  }
361
355
 
362
356
  // Text, image (link or data URL) and audio parts as Chat content blocks; in: a user message's parts, out: fullHistoryMessages.
@@ -406,7 +400,7 @@ function resultToOutput(result: unknown): string {
406
400
  * resent — except echoed Gemini thought signatures, which strict
407
401
  * OpenAI-compatible endpoints require on tool calls they generated.
408
402
  *
409
- * Q-18: signatures are namespaced — only echo when the stored provider is
403
+ * Signatures are namespaced — only echo when the stored provider is
410
404
  * `google` (or untagged legacy). Signatures minted by other providers never
411
405
  * leak onto this transport.
412
406
  */
@@ -468,7 +462,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
468
462
  // Echo provider-issued thought signatures verbatim so endpoints
469
463
  // that minted them (Gemini behind a compat proxy) keep working.
470
464
  // Only present when a previous turn captured one — other endpoints
471
- // never see this field. Q-18: echo only google/untagged signatures.
465
+ // never see this field. Echo only google/untagged signatures.
472
466
  const storedProvider = (part as { thoughtSignatureProvider?: unknown }).thoughtSignatureProvider;
473
467
  if (part.thoughtSignature && shouldEchoSignature(storedProvider, "google")) {
474
468
  call.extra_content = { google: { thought_signature: part.thoughtSignature } };
@@ -540,7 +534,7 @@ function extractSignature(rawTc: {
540
534
  return typeof sig === "string" && sig ? sig : undefined;
541
535
  }
542
536
 
543
- /** Tags a captured compat signature with its issuing provider (`google`, Q-18). */
537
+ /** Tags a captured compat signature with its issuing provider (`google`). */
544
538
  function withCompatSignature(sig: string | undefined): { thoughtSignature?: string; thoughtSignatureProvider?: string } {
545
539
  if (!sig) return {};
546
540
  return { thoughtSignature: sig, thoughtSignatureProvider: "google" };
@@ -575,7 +569,7 @@ function parseResponse(
575
569
 
576
570
  const choice = response.choices?.[0];
577
571
  const message = choice?.message;
578
- // Q-23: refusal content surfaces as text with a `refusal` finish reason.
572
+ // Refusal content surfaces as text with a `refusal` finish reason.
579
573
  const refusal = typeof message?.refusal === "string" && message.refusal ? message.refusal : "";
580
574
  const text = typeof message?.content === "string" && message.content ? message.content : refusal;
581
575
  // Thinking tolerance: plain `reasoning` plus per-block texts (deepseek-style
@@ -701,7 +695,7 @@ export class OpenAICompatibleChatProvider implements Provider {
701
695
  private requireApiKey(baseUrl: string, options?: ProviderRequestOptions): string | undefined {
702
696
  const key = this.apiKey(options);
703
697
  if (!key && !isLocalEndpoint(baseUrl)) {
704
- throw new Error(
698
+ throw new ConfigError(
705
699
  `[Agent Accelerator] Missing API key for "${this.prefix}". Set ${envPrefixOf(this.prefix)}_API_KEY or pass apiKey. (Local endpoints may omit the key.)`
706
700
  );
707
701
  }
@@ -789,7 +783,7 @@ export class OpenAICompatibleChatProvider implements Provider {
789
783
  // why: invalid output shapes fail fast at Agent construction/loop validation.
790
784
  }
791
785
  }
792
- // Q-38: request usage on streams where supported so terminal accounting
786
+ // Request usage on streams where supported so terminal accounting
793
787
  // arrives on the final chunk. Strict servers that reject unknown fields
794
788
  // opt out via `COMPAT_NO_STREAM_OPTIONS=1`; an explicit per-call
795
789
  // `(options as any).streamOptions === false` also skips it.
@@ -1068,7 +1062,7 @@ export class OpenAICompatibleChatProvider implements Provider {
1068
1062
  const choice = (Array.isArray(msg["choices"]) ? (msg["choices"] as Record<string, unknown>[])[0] : undefined) ?? {};
1069
1063
  const delta = (choice["delta"] as Record<string, unknown>) ?? {};
1070
1064
  // Text delta (content-free accounting frames carry "" — ignored).
1071
- // Q-23: refusal deltas surface as text with a `refusal` finish.
1065
+ // Refusal deltas surface as text with a `refusal` finish.
1072
1066
  const deltaRefusal = delta["refusal"];
1073
1067
  if (typeof deltaRefusal === "string" && deltaRefusal) {
1074
1068
  text += deltaRefusal;
@@ -1187,7 +1181,7 @@ export class OpenAICompatibleChatProvider implements Provider {
1187
1181
  sawFinishReason = true;
1188
1182
  }
1189
1183
 
1190
- // Q-23: terminal-state guard — fail `incomplete_stream` (retryable)
1184
+ // Terminal-state guard — fail `incomplete_stream` (retryable)
1191
1185
  // instead of resolving empty success. Skipped for non-SSE payloads
1192
1186
  // (JSON mocks) where no SSE frames were ever seen.
1193
1187
  const cleanThinking = thinking.replace(/\n{3,}/g, "\n\n").trim();
@@ -51,6 +51,7 @@ import { clampCacheKey } from "../utils/cache.ts";
51
51
  import { normalizeMediaInput } from "../utils/media.ts";
52
52
  import { safeStringify } from "../utils/serialization.ts";
53
53
  import { toConciseProviderError, assertModalitiesSupported, assertNoVideoPartsOnResponses } from "../utils/errors.ts";
54
+ import { ConfigError } from "../types/errors.ts";
54
55
  import { withRetries } from "../utils/retry.ts";
55
56
  import {
56
57
  parseArguments,
@@ -472,7 +473,7 @@ function parseResponse(
472
473
  }
473
474
  }
474
475
 
475
- // Q-23: `incomplete` -> length (content-filter truncations surface as
476
+ // `incomplete` -> length (content-filter truncations surface as
476
477
  // refusal when refusal text is present).
477
478
  const incompleteReason = (response as { incomplete_details?: { reason?: string } }).incomplete_details?.reason;
478
479
  const finishReason =
@@ -716,7 +717,7 @@ export class OpenAIResponsesProvider implements Provider {
716
717
  const clean = cleanModelId(model);
717
718
  const apiKey = resolveApiKey(options);
718
719
  if (!apiKey) {
719
- throw new Error(
720
+ throw new ConfigError(
720
721
  "[Agent Accelerator] Missing OpenAI API key. Set OPENAI_API_KEY (or OPENAI_BASE_API_KEY) or pass apiKey."
721
722
  );
722
723
  }
@@ -804,7 +805,7 @@ export class OpenAIResponsesProvider implements Provider {
804
805
  try {
805
806
  const apiKey = resolveApiKey(options);
806
807
  if (!apiKey) {
807
- throw new Error(
808
+ throw new ConfigError(
808
809
  "[Agent Accelerator] Missing OpenAI API key. Set OPENAI_API_KEY (or OPENAI_BASE_API_KEY) or pass apiKey."
809
810
  );
810
811
  }
@@ -828,7 +829,7 @@ export class OpenAIResponsesProvider implements Provider {
828
829
  body,
829
830
  };
830
831
 
831
- // Q-17: optional per-call timeout races the linked abort signal.
832
+ // Optional per-call timeout races the linked abort signal.
832
833
  const streamTimeoutMs = timeoutFor(options as { timeoutMs?: number } | undefined);
833
834
  // Transient failures (429/5xx/network) are retried here, before the
834
835
  // first byte; once the stream is live it is never retried.
@@ -895,7 +896,7 @@ export class OpenAIResponsesProvider implements Provider {
895
896
  type === "response.refusal.delta" ||
896
897
  type === "response.output_refusal.delta"
897
898
  ) {
898
- // Q-23: refusal deltas carry the filter text; surface as text and
899
+ // Refusal deltas carry the filter text; surface as text and
899
900
  // remember for a `refusal` finish reason.
900
901
  if (typeof msg["delta"] === "string" && msg["delta"]) {
901
902
  text += msg["delta"] as string;
@@ -955,7 +956,7 @@ export class OpenAIResponsesProvider implements Provider {
955
956
  }
956
957
  // Non-streaming maps `incomplete` → `length` (max tokens). Streaming
957
958
  // must do the same — otherwise truncated turns misreport `stop`.
958
- // Q-23: content-filter incompletes surface as `refusal`.
959
+ // Content-filter incompletes surface as `refusal`.
959
960
  const incompleteReason = (response as { incomplete_details?: { reason?: string } }).incomplete_details?.reason;
960
961
  if (response.status === "incomplete") {
961
962
  finishReason = incompleteReason === "content_filter" ? "refusal" : "length";
@@ -1039,7 +1040,7 @@ export class OpenAIResponsesProvider implements Provider {
1039
1040
  sawFinishReason = true;
1040
1041
  }
1041
1042
 
1042
- // Q-23: terminal-state guard. Loops ending with no completed body, no
1043
+ // Terminal-state guard. Loops ending with no completed body, no
1043
1044
  // finish_reason, and no usage while carrying no content must fail as
1044
1045
  // `incomplete_stream` (retryable) instead of resolving empty success.
1045
1046
  // Skipped for non-SSE payloads (e.g. JSON mocks in delegation tests)
@@ -46,7 +46,7 @@ import { buildSessionHeaders, setHeaderCaseInsensitive } from "../utils/headers.
46
46
  import { normalizeMediaInput } from "../utils/media.ts";
47
47
  import { safeStringify } from "../utils/serialization.ts";
48
48
  import { toConciseProviderError, assertModalitiesSupported } from "../utils/errors.ts";
49
- import { isAbortError } from "../types/errors.ts";
49
+ import { ConfigError, isAbortError } from "../types/errors.ts";
50
50
  import {
51
51
  newToolCallId,
52
52
  redactedHeaders,
@@ -648,7 +648,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
648
648
  try {
649
649
  const apiKey = resolveApiKey(options);
650
650
  if (!apiKey) {
651
- throw new Error(
651
+ throw new ConfigError(
652
652
  "[Agent Accelerator] Missing OpenRouter API key. Set OPENROUTER_API_KEY or pass apiKey."
653
653
  );
654
654
  }
@@ -741,7 +741,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
741
741
  const choice = (Array.isArray(msg["choices"]) ? (msg["choices"] as Record<string, unknown>[])[0] : undefined) ?? {};
742
742
  const delta = (choice["delta"] as Record<string, unknown>) ?? {};
743
743
  // Text delta (content-free accounting frames carry "" — ignored).
744
- // Q-23: refusal deltas surface as text with a `refusal` finish.
744
+ // Refusal deltas surface as text with a `refusal` finish.
745
745
  const deltaRefusal = delta["refusal"];
746
746
  if (typeof deltaRefusal === "string" && deltaRefusal) {
747
747
  text += deltaRefusal;
@@ -854,7 +854,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
854
854
  sawFinishReason = true;
855
855
  }
856
856
 
857
- // Q-23: terminal-state guard — fail `incomplete_stream` (retryable)
857
+ // Terminal-state guard — fail `incomplete_stream` (retryable)
858
858
  // instead of resolving empty success. Skipped for non-SSE payloads
859
859
  // (JSON mocks) where no SSE frames were ever seen.
860
860
  const cleanThinking = thinking.replace(/\n{3,}/g, "\n\n").trim();
@@ -1,3 +1,4 @@
1
+ import { ConfigError } from "../types/errors.ts";
1
2
  import type { Provider, ProviderId, ModelSpec } from "../types/model.ts";
2
3
  import type { ThinkingLevel } from "../types/core.ts";
3
4
  import { OpenAICompatibleChatProvider } from "./openai-compat.ts";
@@ -54,7 +55,7 @@ function removedProviderMessage(id: string): string {
54
55
  /** Throws for removed provider prefixes. */
55
56
  function assertProviderNotRemoved(id: string): void {
56
57
  if (REMOVED_PROVIDERS.has(id.trim().toLowerCase())) {
57
- throw new Error(removedProviderMessage(id.trim()));
58
+ throw new ConfigError(removedProviderMessage(id.trim()));
58
59
  }
59
60
  }
60
61
 
@@ -83,7 +84,7 @@ export function ensureCustomProvider(
83
84
  const id = normalizeProviderPrefix(prefix) as ProviderId;
84
85
  if (FIRST_CLASS.has(id.toLowerCase())) {
85
86
  if (opts?.baseUrl !== undefined || opts?.apiKey !== undefined) {
86
- throw new Error(
87
+ throw new ConfigError(
87
88
  `[Agent Accelerator] Provider "${id}" is first-class and cannot be replaced with custom baseUrl/apiKey. Pass baseUrl/apiKey per-Agent instead.`
88
89
  );
89
90
  }
@@ -93,7 +94,7 @@ export function ensureCustomProvider(
93
94
  if (existing) {
94
95
  const prevBase = (existing as { configuredBaseUrl?: string }).configuredBaseUrl;
95
96
  if (opts?.baseUrl !== undefined && prevBase !== undefined && prevBase !== opts.baseUrl) {
96
- throw new Error(
97
+ throw new ConfigError(
97
98
  `[Agent Accelerator] Conflicting re-registration for provider "${id}". Registry is immutable after creation.`
98
99
  );
99
100
  }
@@ -120,7 +121,7 @@ export function getProvider(id: ProviderId | string): Provider {
120
121
  const provider = providerRegistry.get(key);
121
122
  if (provider) return provider;
122
123
  if (!key) {
123
- throw new Error(
124
+ throw new ConfigError(
124
125
  `Provider '' is not supported or not registered. Available first-support providers: google, openrouter, openai — or any custom prefix via {PREFIX}_API_KEY + {PREFIX}_BASE_URL.`
125
126
  );
126
127
  }
@@ -146,7 +147,7 @@ export interface ResolvedModel {
146
147
  */
147
148
  export function resolveModel(model: string | ModelSpec): ResolvedModel {
148
149
  if (!model || (typeof model === "string" && !model.trim())) {
149
- throw new Error(
150
+ throw new ConfigError(
150
151
  "[Agent Accelerator] No model specified. Please specify 'model' in Agent configuration or set the MODEL environment variable."
151
152
  );
152
153
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Shared provider helpers (Q-45-partial).
2
+ * Shared provider helpers.
3
3
  *
4
4
  * Single home for the `redactedHeaders` / `readErrorPayload` /
5
5
  * `parseArguments` / `normalizeThinkingParts` helpers previously duplicated
@@ -78,7 +78,7 @@ export function readErrorPayload(
78
78
  }
79
79
  }
80
80
 
81
- /** Retryable incomplete-stream error for truncated SSE (Q-23). */
81
+ /** Retryable incomplete-stream error for truncated SSE: a `ProviderError` whose `code` is `incomplete_stream`. */
82
82
  export function incompleteStreamError(provider: string, modelId: string): Error {
83
83
  const err = toConciseProviderError(
84
84
  Object.assign(new Error("Stream ended without terminal event"), {
@@ -110,15 +110,11 @@ export function isNotFoundChainError(error: unknown): boolean {
110
110
  NaN
111
111
  );
112
112
  if (status === 404) return true;
113
- const code = String(
114
- (err["code"] as string | undefined) ??
115
- (err["errorType"] as string | undefined) ??
116
- ""
117
- ).toLowerCase();
118
- return code === "not_found";
113
+ // `providerCode` on a wrapped ProviderError (its `code` is the SDK kind), `code` on a raw error.
114
+ return [err["providerCode"], err["code"], err["errorType"]].some((c) => String(c ?? "").toLowerCase() === "not_found");
119
115
  }
120
116
 
121
- /** Per-request timeout ms from provider options (Q-22). */
117
+ /** Per-request timeout ms from provider options. */
122
118
  export function timeoutFor(options: { timeoutMs?: number } | undefined): number | undefined {
123
119
  const t = (options as { timeoutMs?: unknown } | undefined)?.timeoutMs;
124
120
  return typeof t === "number" && Number.isFinite(t) && t > 0 ? Math.floor(t) : undefined;
@@ -149,7 +145,7 @@ export function combinedSignal(signal?: AbortSignal, timeoutMs?: number): { sign
149
145
  return { signal: ctrl.signal, cleanup };
150
146
  }
151
147
 
152
- /** Q-34: crypto-backed tool-call id, no Math.random primary. */
148
+ /** Crypto-backed tool-call id, no Math.random primary. */
153
149
  export function newToolCallId(prefix = "call"): string {
154
150
  try {
155
151
  const c = (globalThis as any)?.crypto;
@@ -4,16 +4,19 @@ Scheduled actions: a worker that runs later (once or repeating) and saves its an
4
4
 
5
5
  | File | What it is |
6
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. |
7
+ | `scheduler.ts` | One `Scheduler` per home folder: a 1 s unref'd clock that runs only while something is pending, startup sweep, dead-owner recovery, heartbeats, cook/deliver state machine, and the public functions (`createScheduledAction`, `list/update/run/skip/deleteScheduledAction`). |
8
+ | `store.ts` | Disk layer under `<home>/scheduled/`: `<id>/task.json`, `<id>/delivery.json`, run files, one short mutex per action; validation of records. |
9
9
  | `time.ts` | Pure `DD:HH:MM` and local-clock maths. No timezone, DST or `Intl`; `now` is always passed in. |
10
10
  | `tool.ts` + `scheduled-actions.md` | The model-facing `scheduled_actions` tool and `get_datetime`; the `.md` is the tool description. |
11
11
 
12
12
  ## Invariants
13
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.
14
+ - Write order when a run ends: run file, then `<id>/delivery.json`, then `<id>/task.json`. A crash in between is repaired by the dead-owner recovery (`recoverCooking`, at start and every 15 s): the delivery is rebuilt from the run file, or the run is marked `missed` when no run file exists.
15
15
  - `startCook` advances the next times BEFORE the run starts; a run still cooking when the next is due is skipped, never stacked.
16
16
  - Runs are fresh stateless workers built by `buildDynamicWorkerConfig`; their spending guardrail is `workerBudget` only.
17
17
  - The Agent registry holds `WeakRef`s by name: keep every Agent that owns actions referenced, or its runs become `missed`.
18
+ - Several processes may share a home: scheduled actions take no home lock, only the short per-action mutex (`<id>/.mutex`); the one-process lock is on session folders only. Ownership: a process only starts, delivers or recovers actions whose Agent it registered. An action nobody here owns is left alone until its delivery time passed with no run started, then marked `missed` (`no_agent`).
19
+ - Exactly once: a scheduled start re-checks under the action's mutex that it is still `waiting` and due; `markMissed` only changes the state the caller saw; a delivery is emitted only by the process that removed its entry from `delivery.json`.
20
+ - A run in flight keeps `<id>/.heartbeat` (pid, host, token; mtime refreshed every 5 s, stale after 60 s), written before the record shows `cooking`. A cooking action whose owner is dead (pid gone, stale, or none) is recovered by a live process that owns its Agent, at start and every 15 s. `delete` of a run another live process is cooking is refused, even with `force`.
18
21
  - Times come from `Date`, local clock only. Tests inject `scheduler.now` / `tickMs` and call `tick()`.
19
22
  - Events: `scheduled_action.{created,updated,started,ready,failed,delivered,missed,skipped,deleted,invalid,store_corrupt,error}`.