agent-accelerator 0.3.0 → 0.4.0

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.
@@ -7,13 +7,44 @@ import { getSubModel, getModel } from "../utils/env.ts";
7
7
  /** AgentConfig alias for a delegated worker agent. */
8
8
  export interface SubAgentConfig extends AgentConfig {}
9
9
 
10
+ /**
11
+ * Canonical env-based construction (Q-56): builds an `Agent` reading
12
+ * `MODEL`/`SUB_AGENT_MODEL` only for the fields the caller omits, via
13
+ * `getModel()`/`getSubModel()`. Explicit config always wins; constructing an
14
+ * `Agent` with an explicit `model` reads no env for routing. The static
15
+ * `Agent.fromEnv()` (attached below without touching `agent.ts`, owned by
16
+ * another audit lane) delegates here.
17
+ */
18
+ export function agentFromEnv(
19
+ config?: Omit<AgentConfig, "model"> & { model?: AgentConfig["model"] }
20
+ ): Agent {
21
+ return new Agent({
22
+ ...config,
23
+ model: config?.model ?? getModel() ?? getSubModel(),
24
+ } as AgentConfig);
25
+ }
26
+
10
27
  /**
11
28
  * SubAgent is a specialized Agent designed for modular delegation,
12
29
  * evaluation, reviewing, or parallel task execution.
13
30
  *
31
+ * @deprecated Prefer composing a plain `Agent` and exposing it via
32
+ * `agentToTool()`/`buildAgentTools()` (or `subagents: [...]`) instead of
33
+ * subclassing. `SubAgent` stays for compat and receives no new features.
34
+ *
14
35
  * It inherits 100% of the capabilities of Agent, defaults to SUB_AGENT_MODEL,
15
36
  * and provides .asTool() and .toTool() for seamless registration into parent agents.
16
37
  *
38
+ * Model fallback chain (Q-56, canonical): explicit `config.model` ??
39
+ * `SUB_AGENT_MODEL` (`getSubModel()`) ?? `MODEL` (`getModel()`). Missing
40
+ * everything throws. Constructing any `Agent`/`SubAgent` with an explicit
41
+ * `model` reads no env for routing.
42
+ *
43
+ * Q-47 decomposition boundary: this class stays a thin wrapper. The run/stream
44
+ * loops live in `agent.ts`/`loop.ts`, spawning in `delegation.ts`
45
+ * (`agentToTool`), and model parsing in `utils/session.ts` (`parseModelRef`).
46
+ * No loop, transport, or formatting logic is duplicated here.
47
+ *
17
48
  * @example
18
49
  * ```ts
19
50
  * const researcher = new SubAgent({
@@ -81,3 +112,31 @@ export class SubAgent extends Agent {
81
112
  return this.asTool(nameOverride, descriptionOverride);
82
113
  }
83
114
  }
115
+
116
+ // Q-56: `Agent.fromEnv()` static without touching `agent.ts` (owned by
117
+ // another lane). Wired at module load; typed via the declaration below so
118
+ // `Agent.fromEnv({...})` typechecks wherever this module is imported.
119
+ declare module "./agent.ts" {
120
+ namespace Agent {
121
+ /**
122
+ * Builds an `Agent` from env (`MODEL`/`SUB_AGENT_MODEL`) for omitted
123
+ * fields. Explicit config always wins. See `agentFromEnv`.
124
+ */
125
+ function fromEnv(
126
+ config?: Omit<import("../types/agent.ts").AgentConfig, "model"> & {
127
+ model?: import("../types/agent.ts").AgentConfig["model"];
128
+ }
129
+ ): Agent;
130
+ }
131
+ }
132
+
133
+ try {
134
+ const Ctor = Agent as unknown as {
135
+ fromEnv?: (config?: SubAgentConfig) => Agent;
136
+ };
137
+ if (typeof Ctor.fromEnv !== "function") {
138
+ Ctor.fromEnv = (config?: SubAgentConfig) => agentFromEnv(config);
139
+ }
140
+ } catch {
141
+ // why: static attachment must never break module load (host may freeze Agent).
142
+ }
package/src/index.ts CHANGED
@@ -1,12 +1,59 @@
1
1
  import { z } from "zod";
2
+ import type { AssistantMessageEventStream as AssistantMessageEventStreamT } from "./streaming/event-stream.ts";
3
+ import type { AgentResponse as AgentResponseT } from "./types/response.ts";
4
+
5
+ // Q-50 public API budget: ~25 core runtime exports + compat/deprecation shims.
6
+ // Core = Agent, tool, SubAgent (deprecated alias), AgentResponse,
7
+ // AssistantMessageEventStream, document conversion, delegation primitives,
8
+ // provider registry, catalog, errors, and option/normalization helpers.
9
+ // Everything else below is a shim kept for compat and marked @deprecated
10
+ // toward a future `/internal` entry. Nothing is deleted (would break tests).
11
+ //
12
+ // Q-27 node split (deferred): this root entry pulls `session/store.ts`, which
13
+ // imports `node:fs` for session persistence. A future `/node` subpath will
14
+ // host the fs-backed persistence/telemetry surface; the root entry stays
15
+ // runtime-agnostic. Until then, browser/bundler consumers should import the
16
+ // documented core shims only.
17
+ //
18
+ // Q-51 one way to run: `run()` is canonical. `ask()` and `run/stream: true`
19
+ // remain for compat but are deprecated — prefer `run()` for single-shot and
20
+ // `stream()` for event iteration. `Run` (below) is the typed streaming handle.
21
+
22
+ /**
23
+ * Typed streaming run handle (Q-51): the `AssistantMessageEventStream`
24
+ * returned by `stream()`/`run(stream:true)`, with its terminal `result()` and
25
+ * `cancel()` surfaced on the type for callers that only need the handle.
26
+ */
27
+ export type Run = AssistantMessageEventStreamT & {
28
+ result: () => Promise<AgentResponseT>;
29
+ cancel: (reason?: unknown) => void;
30
+ };
2
31
 
3
32
  // Core Agent & Tool Classes
4
33
  export { Agent, SubAgentModelError } from "./agent/agent.ts";
5
- export { SubAgent } from "./agent/subagent.ts";
34
+ /**
35
+ * @deprecated Compat alias: prefer composing a plain `Agent` and exposing it
36
+ * via `agentToTool()`/`buildAgentTools()` (or `subagents: [...]`). `SubAgent`
37
+ * stays for compat and receives no new features (Q-56).
38
+ */
39
+ export { SubAgent, agentFromEnv } from "./agent/subagent.ts";
6
40
  export type { SubAgentConfig } from "./agent/subagent.ts";
7
- export { tool, toStandardToolDeclarations } from "./tools/tool.ts";
41
+ export {
42
+ tool,
43
+ toStandardToolDeclarations,
44
+ assertValidToolName,
45
+ TOOL_NAME_PATTERN,
46
+ normalizeThinkingV2,
47
+ resolveWhenBusy,
48
+ normalizeWorkerTimeout,
49
+ normalizeMaxAttempts,
50
+ } from "./tools/tool.ts";
8
51
  export type { CreateToolOptions } from "./tools/tool.ts";
9
52
  export { zodToJsonSchema, cleanJsonSchema } from "./tools/schema.ts";
53
+ /**
54
+ * @deprecated Internal executor surface. Prefer `Agent` runs; for direct
55
+ * execution import from the future `/internal` entry (Q-50).
56
+ */
10
57
  export { executeToolCalls } from "./tools/executor.ts";
11
58
 
12
59
  // Client-side document conversion (optional anydoc peer — see utils/documents.ts)
@@ -35,10 +82,23 @@ export {
35
82
  createFixedChildSessionId,
36
83
  isSessionDescendant,
37
84
  getSubAgentTrace,
85
+ getSubAgentTraceScoped,
38
86
  listSubAgentTraceIds,
39
87
  subscribeToSubAgent,
88
+ appendSubAgentStep,
89
+ pruneTraces,
90
+ trimTraceSteps,
91
+ checkBudget,
92
+ chargeBudgetUsage,
93
+ reserveSpawn,
94
+ isStreamingLoop,
95
+ escapeXml as escapeXmlFromDelegation,
96
+ MAX_SUBAGENT_TRACES,
97
+ SUBAGENT_TRACE_TTL_MS,
98
+ MAX_SUBAGENT_STEPS_PER_TRACE,
99
+ SUBAGENT_SUBSCRIBE_TTL_MS,
40
100
  } from "./agent/delegation.ts";
41
- export type { DynamicSubagentTask, AgentAsToolTarget, SubAgentTrace } from "./agent/delegation.ts";
101
+ export type { DynamicSubagentTask, AgentAsToolTarget, SubAgentTrace, StreamingLoopHandle } from "./agent/delegation.ts";
42
102
 
43
103
  // Providers & Registry — unified multi-provider layer, all native REST
44
104
  export {
@@ -57,6 +117,9 @@ export {
57
117
  getModelThinkingInfo,
58
118
  validateModelThinking,
59
119
  getModelsForProvider,
120
+ registerModel,
121
+ clearCustomModels,
122
+ getModelSource,
60
123
  ThinkingLevelError,
61
124
  refreshModelCatalog,
62
125
  ensureModelCatalogFresh,
@@ -74,34 +137,56 @@ export {
74
137
  export { resolveEffectiveThinking } from "./agent/agent.ts";
75
138
  export { withRetries, isTransientError } from "./utils/retry.ts";
76
139
  export { toConciseProviderError, assertModalitiesSupported, assertNoVideoPartsOnResponses } from "./utils/errors.ts";
77
- // Native provider implementations. `openrouter-responses.ts` (discontinued
78
- // beta-Responses transport) stays importable directly but is wired nowhere.
140
+ // Native provider implementations.
141
+ /** @deprecated The discontinued beta-Responses transport was removed
142
+ * directly for frozen wire-contract evidence only. It is wired nowhere and
143
+ * must not be extended or re-exported here (Q-46). */
79
144
  export {
80
145
  OpenAICompatibleChatProvider,
81
- OpenAICompatibleChatProvider as OpenAICompatibleProvider,
82
146
  createOpenAICompatibleProvider,
83
147
  createCustomProvider,
84
148
  CustomProvider,
85
149
  } from "./providers/openai-compat.ts";
150
+ /**
151
+ * @deprecated Compat alias of `OpenAICompatibleChatProvider` (canonical).
152
+ * Kept for back-compat only; new code uses the canonical name (Q-50).
153
+ */
154
+ export { OpenAICompatibleChatProvider as OpenAICompatibleProvider } from "./providers/openai-compat.ts";
86
155
  export type { CustomProviderOptions } from "./providers/openai-compat.ts";
87
156
 
88
157
  // Canonical provider contract (provider-agnostic) + native adapters.
89
158
  export {
90
159
  GoogleInteractionsProvider,
91
- GoogleInteractionsProvider as GoogleAIStudioProvider,
92
160
  clearInteractionChains,
93
161
  } from "./providers/google.ts";
162
+ /**
163
+ * @deprecated Compat alias of `GoogleInteractionsProvider` (canonical).
164
+ * Kept for back-compat only; new code uses the canonical name (Q-50).
165
+ */
166
+ export { GoogleInteractionsProvider as GoogleAIStudioProvider } from "./providers/google.ts";
94
167
  export {
95
168
  OpenRouterChatCompletionsProvider,
96
- OpenRouterChatCompletionsProvider as OpenRouterProvider,
97
169
  } from "./providers/openrouter.ts";
98
- // Discontinued beta-Responses transport, retained frozen as migration
99
- // evidence (importable via `./providers/openrouter-responses.ts`, not wired
100
- // anywhere): OpenRouterResponsesProvider.
170
+ /**
171
+ * @deprecated Compat alias of `OpenRouterChatCompletionsProvider`
172
+ * (canonical). Kept for back-compat only (Q-50).
173
+ */
174
+ export { OpenRouterChatCompletionsProvider as OpenRouterProvider } from "./providers/openrouter.ts";
175
+ // Discontinued beta-Responses transport, removed from core
176
+ // (was frozen migration evidence, wired nowhere): OpenRouterResponsesProvider.
101
177
  export {
102
178
  OpenAIResponsesProvider,
103
- OpenAIResponsesProvider as OpenAIProvider,
104
179
  } from "./providers/openai.ts";
180
+ /**
181
+ * @deprecated Compat alias of `OpenAIResponsesProvider` (canonical).
182
+ * Kept for back-compat only; new code uses the canonical name (Q-50).
183
+ */
184
+ export { OpenAIResponsesProvider as OpenAIProvider } from "./providers/openai.ts";
185
+ /**
186
+ * @deprecated Internal provider mappers/routing helpers. Prefer the
187
+ * provider-agnostic registry (`resolveModel`/`getProvider`); direct mapper
188
+ * use moves to the future `/internal` entry (Q-50).
189
+ */
105
190
  export {
106
191
  emitProviderWarning,
107
192
  clearEmittedWarnings,
@@ -161,11 +246,34 @@ export type {
161
246
 
162
247
  // Streaming & Events
163
248
  export { AssistantMessageEventStream } from "./streaming/event-stream.ts";
249
+ /**
250
+ * @deprecated Internal SSE plumbing. Prefer `AssistantMessageEventStream`;
251
+ * direct parser use moves to the future `/internal` entry (Q-50).
252
+ */
164
253
  export { SSEParser } from "./streaming/sse-parser.ts";
254
+ export type { SteerEntry } from "./agent/loop.ts";
165
255
 
166
256
  // Responses
167
257
  export { AgentResponse } from "./types/response.ts";
168
258
 
259
+ // Core error taxonomy (Q-50 core surface)
260
+ export {
261
+ AgentAccelError,
262
+ ConfigError,
263
+ ProviderError,
264
+ TimeoutError,
265
+ BudgetExceededError,
266
+ ToolError,
267
+ ValidationError,
268
+ SessionLoadError,
269
+ SecurityPolicyError,
270
+ isAbortError,
271
+ formatErrorPlain,
272
+ } from "./types/errors.ts";
273
+ export type { AgentAccelErrorCode, AgentAccelErrorContext } from "./types/errors.ts";
274
+ // Normalized run limits (Q-22; sub-agents inherit unless overridden)
275
+ export type { AgentRunLimits } from "./agent/agent.ts";
276
+
169
277
  // Session persistence & telemetry (conversation history + cost rollup)
170
278
  export {
171
279
  SessionTelemetry,
@@ -191,14 +299,29 @@ export {
191
299
  export type { PersistedAgentSession, SessionTotals, SessionAgentLike, LoadedSessionDir, PersistedSubAgentTrace } from "./session/store.ts";
192
300
 
193
301
  // Utilities
194
- export { createSessionId, hashSessionPart, createTrackingId } from "./utils/session.ts";
302
+ /**
303
+ * @deprecated Session-routing internals. Prefer agent-level `sessionId`;
304
+ * direct use moves to the future `/internal` entry (Q-50).
305
+ */
306
+ export { createSessionId, hashSessionPart, createTrackingId, nowMs, newRunId, newTurnId, previewPrompt, parseModelRef, resolveAgentConfig } from "./utils/session.ts";
307
+ export type { ParsedModelRef, ResolvedAgentConfig } from "./utils/session.ts";
308
+ /**
309
+ * @deprecated Raw env access. Prefer explicit `Agent` config (which wins over
310
+ * env) and `Agent.fromEnv()`/`agentFromEnv()`; direct use moves to the future
311
+ * `/internal` entry (Q-50).
312
+ */
195
313
  export { getApiKey, getEnv } from "./utils/env.ts";
196
314
  export { buildSessionHeaders } from "./utils/headers.ts";
197
315
  export { normalizeMediaInput, inferMimeType } from "./utils/media.ts";
198
316
  export { base64ToBytes, bytesToBase64 } from "./utils/base64.ts";
199
317
  export { toJsonSafe, safeStringify, escapeXml } from "./utils/serialization.ts";
200
318
 
201
- // Re-export Zod
319
+ // Re-export Zod (compat only)
320
+ /**
321
+ * @deprecated Compat re-export: prefer adding `zod` as a direct (peer)
322
+ * dependency and importing from `"zod"` (Q-50). Kept so existing
323
+ * `import { z } from "agent-accelerator"` code keeps working.
324
+ */
202
325
  export { z };
203
326
 
204
327
  // TypeScript Type Exports
@@ -233,6 +356,8 @@ export type {
233
356
  ToolCallRecord,
234
357
  ToolResultRecord,
235
358
  StandardToolDeclaration,
359
+ ToolLogger,
360
+ ToolEmitFn,
236
361
  } from "./types/tool.ts";
237
362
 
238
363
  export type {
@@ -258,50 +383,20 @@ export type {
258
383
  AgentRunOptions,
259
384
  AgentTurnEvent,
260
385
  DynamicSubagentsConfig,
386
+ MidSessionConfig,
387
+ MidSessionMode,
388
+ WhenBusyMode,
389
+ ModelRef,
390
+ ThinkingOption,
391
+ SamplingConfig,
392
+ RetryPolicyConfig,
393
+ AgentLogger,
394
+ ToolChoiceOption,
395
+ StopOption,
396
+ Budget,
397
+ BudgetState,
398
+ SubAgentRunIds,
261
399
  } from "./types/agent.ts";
262
400
 
263
- // Provider wire payload types (back-compat inspection of raw requests/responses,
264
- // e.g. `res.raw.request.body as OpenAIChatCompletionRequest`).
265
- // NOTE: wire `OpenAIToolChoice` intentionally omitted — it duplicates the
266
- // canonical `OpenAIToolChoice` exported above (same shape); use that one.
267
- export type {
268
- GoogleThinkingLevel,
269
- GoogleThinkingConfig,
270
- GoogleFunctionCallingMode,
271
- GoogleFunctionCallingConfig,
272
- GoogleToolConfig,
273
- GoogleFunctionDeclaration,
274
- GoogleTool,
275
- GoogleBlob,
276
- GooglePart,
277
- GoogleContent,
278
- GoogleGenerationConfig,
279
- GoogleCandidate,
280
- GoogleUsageMetadata,
281
- GoogleGenerateContentRequest,
282
- GoogleGenerateContentResponse,
283
- OpenRouterProviderRouting,
284
- OpenRouterReasoning,
285
- OpenRouterParameters,
286
- OpenRouterUsage,
287
- OpenRouterChatRequest,
288
- OpenRouterResponse,
289
- OpenAIMessageRole,
290
- OpenAIReasoningEffort,
291
- OpenAIServiceTier,
292
- OpenAITextPart,
293
- OpenAIImageUrlPart,
294
- OpenAIInputAudioPart,
295
- OpenAIVideoUrlPart,
296
- OpenAIContentPart,
297
- OpenAIToolCall,
298
- OpenAIMessage,
299
- OpenAITool,
300
- OpenAIUsage,
301
- OpenAIChoice,
302
- OpenAIDelta,
303
- OpenAIChunkChoice,
304
- OpenAIChatCompletionRequest,
305
- OpenAIChatCompletionResponse,
306
- OpenAIChatCompletionChunk,
307
- } from "./types/provider-payloads.ts";
401
+ // Minimalist core: harness modules (RLM/RSI, OTel, testing, CLI doctor, hooks,
402
+ // context-ops, run-tree/record, session-store, legacy wire types) live in userland.
@@ -1,13 +1,21 @@
1
1
  import * as path from "node:path";
2
2
  import * as fs from "node:fs";
3
+ import { createHash } from "node:crypto";
3
4
  import { getEnv } from "../utils/env.ts";
4
5
 
5
6
  /** Default TTL: 12 Hours (in milliseconds). */
6
7
  export const DEFAULT_CATALOG_TTL_MS = 12 * 60 * 60 * 1000;
7
8
 
9
+ /** Max accepted models.dev payload size: 15MB (Q-16a). Larger payloads are rejected before parse. */
10
+ export const MAX_CATALOG_PAYLOAD_BYTES = 15 * 1024 * 1024;
11
+
8
12
  let globalCatalogTtlMs = DEFAULT_CATALOG_TTL_MS;
9
13
 
10
- /** Sets the global model catalog cache TTL in milliseconds. */
14
+ /** Sets the global model catalog cache TTL in milliseconds.
15
+ * Note (Q-16a): cache files carry their own `ttlMs`. A global `setCatalogTTL`
16
+ * does not rewrite in-flight file TTLs; it takes effect on the next refresh
17
+ * (fresh download) or when the cached file has no `ttlMs` of its own.
18
+ */
11
19
  export function setCatalogTTL(ttlMs: number): void {
12
20
  if (Number.isFinite(ttlMs) && ttlMs > 0) {
13
21
  globalCatalogTtlMs = ttlMs;
@@ -77,9 +85,9 @@ export function getCacheDir(): string {
77
85
  return path.resolve(process.cwd(), "src/data");
78
86
  }
79
87
 
80
- /** Non-configurable cache file path: src/data/models.dev.json */
88
+ /** Cache file path: `<cacheDir>/models.dev.json`, honoring AGENT_CACHE_DIR (Q-16a/Q-34). */
81
89
  export function getCacheFilePath(): string {
82
- return path.resolve(process.cwd(), "src/data/models.dev.json");
90
+ return path.join(getCacheDir(), "models.dev.json");
83
91
  }
84
92
 
85
93
  function readJsonFileSync(filePath: string): any {
@@ -97,7 +105,20 @@ function writeJsonFileSync(filePath: string, data: any): void {
97
105
  if (!fs.existsSync(dir)) {
98
106
  fs.mkdirSync(dir, { recursive: true });
99
107
  }
100
- fs.writeFileSync(filePath, JSON.stringify(data), "utf-8");
108
+ // Atomic tmp+rename with 0600 so a crash never leaves half-written JSON (Q-16a).
109
+ const rand = Math.random().toString(36).slice(2, 10);
110
+ const tmp = `${filePath}.tmp-${process.pid}-${rand}`;
111
+ fs.writeFileSync(tmp, JSON.stringify(data), { encoding: "utf-8", mode: 0o600 });
112
+ try { fs.chmodSync(tmp, 0o600); } catch {}
113
+ fs.renameSync(tmp, filePath);
114
+ }
115
+
116
+ /** Verifies optional sha256 pin from MODELS_DEV_SHA256 (Q-16a). Returns false on mismatch. */
117
+ export function verifyCatalogSha256(text: string): boolean {
118
+ const pin = getEnv("MODELS_DEV_SHA256")?.trim().toLowerCase();
119
+ if (!pin) return true;
120
+ const hex = createHash("sha256").update(text, "utf8").digest("hex").toLowerCase();
121
+ return hex === pin;
101
122
  }
102
123
 
103
124
  // In-memory catalog state
@@ -225,6 +246,17 @@ export async function refreshModelCatalog(options: RefreshCatalogOptions = {}):
225
246
  const timer = setTimeout(() => controller.abort(), timeoutMs);
226
247
  let freshData: Record<string, any>;
227
248
 
249
+ const fallbackToDiskCache = (): void => {
250
+ const existing = readJsonFileSync(cachePath);
251
+ if (existing && existing.data) {
252
+ activeCatalog = existing.data;
253
+ activeFetchedAt = existing.fetchedAt;
254
+ activeTtlMs = existing.ttlMs || effectiveTtl;
255
+ activeFromCache = true;
256
+ notifyUpdateListeners();
257
+ }
258
+ };
259
+
228
260
  try {
229
261
  const res = await fetch(apiUrl, {
230
262
  headers: {
@@ -238,33 +270,40 @@ export async function refreshModelCatalog(options: RefreshCatalogOptions = {}):
238
270
  throw new Error(`HTTP ${res.status} ${res.statusText}`);
239
271
  }
240
272
 
241
- freshData = (await res.json()) as Record<string, any>;
273
+ // Q-16a: size-cap before parse (check text length, reject >15MB).
274
+ const text = await res.text();
275
+ if (text.length > MAX_CATALOG_PAYLOAD_BYTES) {
276
+ clearTimeout(timer);
277
+ fallbackToDiskCache();
278
+ return getCatalogStatus();
279
+ }
280
+ // Q-16a: optional sha256 pin via MODELS_DEV_SHA256.
281
+ if (!verifyCatalogSha256(text)) {
282
+ clearTimeout(timer);
283
+ fallbackToDiskCache();
284
+ return getCatalogStatus();
285
+ }
286
+ let parsed: unknown;
287
+ try {
288
+ parsed = JSON.parse(text);
289
+ } catch {
290
+ clearTimeout(timer);
291
+ fallbackToDiskCache();
292
+ return getCatalogStatus();
293
+ }
294
+ freshData = parsed as Record<string, any>;
242
295
 
243
296
  // Never let a non-catalog payload (mocked fetch, proxy error page,
244
297
  // chat-completion stub) overwrite the good on-disk cache.
245
298
  if (!isValidCatalogPayload(freshData)) {
246
299
  clearTimeout(timer);
247
- const existing = readJsonFileSync(cachePath);
248
- if (existing && existing.data) {
249
- activeCatalog = existing.data;
250
- activeFetchedAt = existing.fetchedAt;
251
- activeTtlMs = existing.ttlMs || effectiveTtl;
252
- activeFromCache = true;
253
- notifyUpdateListeners();
254
- }
300
+ fallbackToDiskCache();
255
301
  return getCatalogStatus();
256
302
  }
257
303
  } catch (err: any) {
258
304
  clearTimeout(timer);
259
305
  // If download fails, retain disk cache if available
260
- const existing = readJsonFileSync(cachePath);
261
- if (existing && existing.data) {
262
- activeCatalog = existing.data;
263
- activeFetchedAt = existing.fetchedAt;
264
- activeTtlMs = existing.ttlMs || effectiveTtl;
265
- activeFromCache = true;
266
- notifyUpdateListeners();
267
- }
306
+ fallbackToDiskCache();
268
307
  return getCatalogStatus();
269
308
  } finally {
270
309
  clearTimeout(timer);
@@ -281,19 +320,23 @@ export async function refreshModelCatalog(options: RefreshCatalogOptions = {}):
281
320
 
282
321
  // Update in-memory state FIRST so a read-only filesystem still serves the
283
322
  // fresh catalog for this process; disk persistence below is best-effort.
323
+ // Q-34: a real download is NOT from cache.
284
324
  activeCatalog = freshData;
285
325
  activeFetchedAt = fetchedAt;
286
326
  activeTtlMs = effectiveTtl;
287
- activeFromCache = true;
327
+ activeFromCache = false;
288
328
 
289
329
  // Save to src/data/models.dev.json (or the AGENT_CACHE_DIR override)
290
330
  try {
291
331
  writeJsonFileSync(cachePath, cachePayload);
292
332
  } catch (err) {
293
333
  activeFromCache = false;
294
- console.warn(
295
- `[Agent Accelerator] Could not write model catalog cache to ${cachePath} (${err instanceof Error ? err.message : String(err)}). Continuing with the in-memory catalog.`
296
- );
334
+ try {
335
+ (globalThis as any).__agentAccelTelemetry?.emitWarning?.({
336
+ code: "catalog_cache_write_failed",
337
+ message: `[Agent Accelerator] Could not write model catalog cache (${err instanceof Error ? err.message : String(err)}). Continuing with the in-memory catalog.`,
338
+ });
339
+ } catch {}
297
340
  }
298
341
 
299
342
  notifyUpdateListeners();