agent-accelerator 0.3.0 → 0.4.5

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,
@@ -125,6 +210,15 @@ export {
125
210
  mapToolChoiceToOpenAI,
126
211
  normalizeToolChoice,
127
212
  parseStreamedToolArguments,
213
+ normalizeStructuredOutput,
214
+ extractOutputValidator,
215
+ parseStructuredOutput,
216
+ mapOutputToOpenAI,
217
+ mapOutputToGoogle,
218
+ mapOutputToOpenRouterChat,
219
+ mapOutputToCompatChat,
220
+ buildStructuredOutputInstruction,
221
+ augmentSystemPromptWithStructuredOutput,
128
222
  } from "./providers.ts";
129
223
  export type {
130
224
  ProviderCapabilityStatus,
@@ -132,13 +226,21 @@ export type {
132
226
  OpenRouterToolChoice,
133
227
  OpenRouterChatToolChoice,
134
228
  OpenAIToolChoice,
229
+ ChatCompletionsResponseFormat,
135
230
  } from "./providers.ts";
136
231
 
137
232
  export {
138
233
  GOOGLE_MODELS,
139
234
  OPENAI_MODELS,
140
235
  OPENROUTER_MODELS,
236
+ modelSupportsStructuredOutput,
141
237
  } from "./models/catalog.ts";
238
+ export {
239
+ hasAuthHeader,
240
+ clampToolCallId,
241
+ hasTruncatedToolArguments,
242
+ shortHashId,
243
+ } from "./providers/shared.ts";
142
244
 
143
245
  export {
144
246
  isValidThoughtSignature,
@@ -161,11 +263,34 @@ export type {
161
263
 
162
264
  // Streaming & Events
163
265
  export { AssistantMessageEventStream } from "./streaming/event-stream.ts";
266
+ /**
267
+ * @deprecated Internal SSE plumbing. Prefer `AssistantMessageEventStream`;
268
+ * direct parser use moves to the future `/internal` entry (Q-50).
269
+ */
164
270
  export { SSEParser } from "./streaming/sse-parser.ts";
271
+ export type { SteerEntry } from "./agent/loop.ts";
165
272
 
166
273
  // Responses
167
274
  export { AgentResponse } from "./types/response.ts";
168
275
 
276
+ // Core error taxonomy (Q-50 core surface)
277
+ export {
278
+ AgentAccelError,
279
+ ConfigError,
280
+ ProviderError,
281
+ TimeoutError,
282
+ BudgetExceededError,
283
+ ToolError,
284
+ ValidationError,
285
+ SessionLoadError,
286
+ SecurityPolicyError,
287
+ isAbortError,
288
+ formatErrorPlain,
289
+ } from "./types/errors.ts";
290
+ export type { AgentAccelErrorCode, AgentAccelErrorContext } from "./types/errors.ts";
291
+ // Normalized run limits (Q-22; sub-agents inherit unless overridden)
292
+ export type { AgentRunLimits } from "./agent/agent.ts";
293
+
169
294
  // Session persistence & telemetry (conversation history + cost rollup)
170
295
  export {
171
296
  SessionTelemetry,
@@ -191,14 +316,29 @@ export {
191
316
  export type { PersistedAgentSession, SessionTotals, SessionAgentLike, LoadedSessionDir, PersistedSubAgentTrace } from "./session/store.ts";
192
317
 
193
318
  // Utilities
194
- export { createSessionId, hashSessionPart, createTrackingId } from "./utils/session.ts";
319
+ /**
320
+ * @deprecated Session-routing internals. Prefer agent-level `sessionId`;
321
+ * direct use moves to the future `/internal` entry (Q-50).
322
+ */
323
+ export { createSessionId, hashSessionPart, createTrackingId, nowMs, newRunId, newTurnId, previewPrompt, parseModelRef, resolveAgentConfig } from "./utils/session.ts";
324
+ export type { ParsedModelRef, ResolvedAgentConfig } from "./utils/session.ts";
325
+ /**
326
+ * @deprecated Raw env access. Prefer explicit `Agent` config (which wins over
327
+ * env) and `Agent.fromEnv()`/`agentFromEnv()`; direct use moves to the future
328
+ * `/internal` entry (Q-50).
329
+ */
195
330
  export { getApiKey, getEnv } from "./utils/env.ts";
196
331
  export { buildSessionHeaders } from "./utils/headers.ts";
197
332
  export { normalizeMediaInput, inferMimeType } from "./utils/media.ts";
198
333
  export { base64ToBytes, bytesToBase64 } from "./utils/base64.ts";
199
334
  export { toJsonSafe, safeStringify, escapeXml } from "./utils/serialization.ts";
200
335
 
201
- // Re-export Zod
336
+ // Re-export Zod (compat only)
337
+ /**
338
+ * @deprecated Compat re-export: prefer adding `zod` as a direct (peer)
339
+ * dependency and importing from `"zod"` (Q-50). Kept so existing
340
+ * `import { z } from "agent-accelerator"` code keeps working.
341
+ */
202
342
  export { z };
203
343
 
204
344
  // TypeScript Type Exports
@@ -233,6 +373,8 @@ export type {
233
373
  ToolCallRecord,
234
374
  ToolResultRecord,
235
375
  StandardToolDeclaration,
376
+ ToolLogger,
377
+ ToolEmitFn,
236
378
  } from "./types/tool.ts";
237
379
 
238
380
  export type {
@@ -243,6 +385,7 @@ export type {
243
385
  ProviderRequestOptions,
244
386
  ProviderGenerateResult,
245
387
  ProviderRawData,
388
+ StructuredOutputSpec,
246
389
  } from "./types/model.ts";
247
390
 
248
391
  export type {
@@ -258,50 +401,21 @@ export type {
258
401
  AgentRunOptions,
259
402
  AgentTurnEvent,
260
403
  DynamicSubagentsConfig,
404
+ MidSessionConfig,
405
+ MidSessionMode,
406
+ WhenBusyMode,
407
+ ModelRef,
408
+ ThinkingOption,
409
+ StructuredOutputOption,
410
+ SamplingConfig,
411
+ RetryPolicyConfig,
412
+ AgentLogger,
413
+ ToolChoiceOption,
414
+ StopOption,
415
+ Budget,
416
+ BudgetState,
417
+ SubAgentRunIds,
261
418
  } from "./types/agent.ts";
262
419
 
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";
420
+ // Minimalist core: harness modules (RLM/RSI, OTel, testing, CLI doctor, hooks,
421
+ // 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();