agent-accelerator 0.4.0 → 0.5.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.
Files changed (46) hide show
  1. package/README.md +149 -57
  2. package/bunfig.toml +1 -0
  3. package/package.json +2 -3
  4. package/src/agent/agent.ts +414 -118
  5. package/src/agent/context.ts +11 -16
  6. package/src/agent/delegation.ts +111 -274
  7. package/src/agent/loop.ts +327 -99
  8. package/src/agent/subagent.ts +4 -12
  9. package/src/agent/worker.ts +435 -0
  10. package/src/index.ts +58 -5
  11. package/src/models/catalog-cache.ts +65 -25
  12. package/src/models/catalog.ts +47 -2
  13. package/src/providers/google.ts +338 -81
  14. package/src/providers/openai-compat.ts +171 -61
  15. package/src/providers/openai.ts +174 -44
  16. package/src/providers/openrouter.ts +129 -44
  17. package/src/providers/shared.ts +169 -17
  18. package/src/providers.ts +375 -11
  19. package/src/scheduler/scheduled-actions.md +41 -0
  20. package/src/scheduler/scheduler.ts +814 -0
  21. package/src/scheduler/store.ts +263 -0
  22. package/src/scheduler/time.ts +148 -0
  23. package/src/scheduler/tool.ts +151 -0
  24. package/src/session/store.ts +229 -113
  25. package/src/streaming/event-stream.ts +6 -4
  26. package/src/telemetry-file.ts +31 -0
  27. package/src/telemetry.ts +65 -0
  28. package/src/tools/executor.ts +163 -38
  29. package/src/tools/subagent-result.ts +60 -0
  30. package/src/types/agent.ts +74 -22
  31. package/src/types/model.ts +39 -0
  32. package/src/types/response.ts +9 -0
  33. package/src/types/scheduler.ts +81 -0
  34. package/src/types/tool.ts +15 -0
  35. package/src/utils/cache.ts +14 -5
  36. package/src/utils/documents.ts +112 -52
  37. package/src/utils/env.ts +21 -7
  38. package/src/utils/errors.ts +26 -1
  39. package/src/utils/headers.ts +15 -15
  40. package/src/utils/home.ts +30 -0
  41. package/src/utils/media.ts +57 -12
  42. package/src/utils/raw-capture.ts +76 -0
  43. package/src/utils/retry.ts +11 -3
  44. package/src/utils/safe-io.ts +252 -0
  45. package/src/utils/session.ts +7 -8
  46. package/src/data/README.md +0 -84
@@ -1,6 +1,7 @@
1
1
  // NOTE: no static node:fs import — it breaks browser bundles. Local file
2
2
  // paths are loaded via a lazy dynamic import so browsers never resolve it.
3
- import { bytesToBase64 } from "./base64.ts";
3
+ import { bytesToBase64, base64ToBytes } from "./base64.ts";
4
+ import { fetchRemoteBytes } from "./safe-io.ts";
4
5
 
5
6
  export interface NormalizedMedia {
6
7
  mimeType: string;
@@ -32,6 +33,51 @@ export function inferMimeType(input: string, fallback = "application/octet-strea
32
33
  return fallback;
33
34
  }
34
35
 
36
+ const ascii = (bytes: Uint8Array, start: number, text: string): boolean =>
37
+ bytes.length >= start + text.length && [...text].every((ch, i) => bytes[start + i] === ch.charCodeAt(0));
38
+
39
+ /**
40
+ * Identifies common image/audio/video/document formats from their magic bytes.
41
+ * Used when no MIME type was supplied (raw bytes, base64, extension-less or
42
+ * `application/octet-stream` downloads) so providers receive a real type
43
+ * instead of a generic one they would reject.
44
+ *
45
+ * @example `sniffMimeType(pngBytes) // "image/png"`
46
+ */
47
+ export function sniffMimeType(bytes: Uint8Array): string | undefined {
48
+ if (bytes.length < 4) return undefined;
49
+ if (bytes[0] === 0x89 && ascii(bytes, 1, "PNG")) return "image/png";
50
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
51
+ if (ascii(bytes, 0, "GIF8")) return "image/gif";
52
+ if (ascii(bytes, 0, "RIFF") && ascii(bytes, 8, "WEBP")) return "image/webp";
53
+ if (ascii(bytes, 0, "RIFF") && ascii(bytes, 8, "WAVE")) return "audio/wav";
54
+ if (ascii(bytes, 0, "%PDF")) return "application/pdf";
55
+ if (ascii(bytes, 0, "OggS")) return "audio/ogg";
56
+ if (ascii(bytes, 0, "ID3") || (bytes[0] === 0xff && ((bytes[1] ?? 0) & 0xe0) === 0xe0)) return "audio/mp3";
57
+ if (ascii(bytes, 4, "ftyp")) return ascii(bytes, 8, "qt") ? "video/quicktime" : "video/mp4";
58
+ if (bytes[0] === 0x1a && bytes[1] === 0x45 && bytes[2] === 0xdf && bytes[3] === 0xa3) return "video/webm";
59
+ return undefined;
60
+ }
61
+
62
+ const GENERIC_MIME = new Set(["", "application/octet-stream", "binary/octet-stream"]);
63
+
64
+ /** Picks the best MIME type: explicit hint, then a specific candidate, then magic bytes, then the fallback. */
65
+ function resolveMime(explicit: string | undefined, candidate: string | null | undefined, bytes: Uint8Array, fallback: string): string {
66
+ if (explicit) return explicit;
67
+ const specific = candidate && !GENERIC_MIME.has(candidate.split(";")[0]!.trim().toLowerCase()) ? candidate : undefined;
68
+ return specific || sniffMimeType(bytes) || candidate || fallback;
69
+ }
70
+
71
+ /** Magic-byte sniff of a base64 payload from its first bytes only (no full decode). */
72
+ function sniffBase64(b64: string): string | undefined {
73
+ try {
74
+ const head = b64.replace(/\s/g, "").slice(0, 32);
75
+ return sniffMimeType(base64ToBytes(head.slice(0, head.length - (head.length % 4))));
76
+ } catch {
77
+ return undefined;
78
+ }
79
+ }
80
+
35
81
  /**
36
82
  * Normalizes an image, audio, or video input into raw base64 and data URL
37
83
  */
@@ -47,7 +93,7 @@ export async function normalizeMediaInput(
47
93
  // 1. If input is ArrayBuffer or Uint8Array
48
94
  if (input instanceof Uint8Array || input instanceof ArrayBuffer) {
49
95
  const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
50
- const mimeType = explicitMimeType || "application/octet-stream";
96
+ const mimeType = resolveMime(explicitMimeType, undefined, bytes, "application/octet-stream");
51
97
  const base64Data = bytesToBase64(bytes);
52
98
  return {
53
99
  mimeType,
@@ -70,16 +116,14 @@ export async function normalizeMediaInput(
70
116
 
71
117
  // 3. If input is a remote HTTP URL (check before base64 to avoid misclassifying URL as base64)
72
118
  if (typeof input === "string" && (input.startsWith("http://") || input.startsWith("https://"))) {
73
- const response = await fetch(input);
119
+ // Developer-supplied URL: private hosts are allowed (intranet assets),
120
+ // but redirects are re-checked and the download is size-capped.
121
+ const response = await fetchRemoteBytes(input, { allowPrivateNetwork: true });
74
122
  if (!response.ok) {
75
123
  throw new Error(`Failed to fetch media from URL: ${input} (${response.statusText})`);
76
124
  }
77
- const arrayBuffer = await response.arrayBuffer();
78
- const buffer = new Uint8Array(arrayBuffer);
79
- const mimeType =
80
- explicitMimeType ||
81
- response.headers.get("content-type") ||
82
- inferMimeType(input);
125
+ const buffer = response.bytes;
126
+ const mimeType = resolveMime(explicitMimeType, response.contentType, buffer, inferMimeType(input));
83
127
  const base64Data = bytesToBase64(buffer);
84
128
  return {
85
129
  mimeType,
@@ -100,7 +144,7 @@ export async function normalizeMediaInput(
100
144
  /^[A-Za-z0-9+/=\n\r]+$/.test(input.slice(0, 200)) &&
101
145
  input.length % 4 === 0
102
146
  ) {
103
- const mimeType = explicitMimeType || "image/jpeg";
147
+ const mimeType = explicitMimeType || sniffBase64(input) || "image/jpeg";
104
148
  return {
105
149
  mimeType,
106
150
  base64Data: input.replace(/\s/g, ""),
@@ -113,7 +157,8 @@ export async function normalizeMediaInput(
113
157
  try {
114
158
  const { promises: fs } = await import("node:fs");
115
159
  const buffer = await fs.readFile(input);
116
- const mimeType = explicitMimeType || inferMimeType(input);
160
+ const byExtension = inferMimeType(input, "");
161
+ const mimeType = resolveMime(explicitMimeType, byExtension, buffer, "application/octet-stream");
117
162
  const base64Data = bytesToBase64(buffer);
118
163
  return {
119
164
  mimeType,
@@ -125,7 +170,7 @@ export async function normalizeMediaInput(
125
170
  const trimmed = input.replace(/\s/g, "");
126
171
  const isMaybeBase64 = trimmed.length > 100 && /^[A-Za-z0-9+/=\n\r]+$/.test(trimmed.slice(0, 500)) && trimmed.length % 4 === 0;
127
172
  if (isMaybeBase64) {
128
- const mimeType = explicitMimeType || "image/jpeg";
173
+ const mimeType = explicitMimeType || sniffBase64(trimmed) || "image/jpeg";
129
174
  return {
130
175
  mimeType,
131
176
  base64Data: trimmed,
@@ -0,0 +1,76 @@
1
+ import type { ProviderRawData } from "../types/model.ts";
2
+
3
+ /** How much of the wire exchange `AgentResponse.raw` keeps (see `AgentConfig.captureRaw`). */
4
+ export type CaptureRawMode = false | "redacted" | true;
5
+
6
+ function byteLength(value: unknown): number {
7
+ try {
8
+ return JSON.stringify(value)?.length ?? 0;
9
+ } catch {
10
+ return 0;
11
+ }
12
+ }
13
+
14
+ function toolNames(tools: unknown): string[] | undefined {
15
+ if (!Array.isArray(tools)) return undefined;
16
+ const names = tools
17
+ .map((t) => (t as { name?: unknown; function?: { name?: unknown } })?.name ?? (t as { function?: { name?: unknown } })?.function?.name)
18
+ .filter((n): n is string => typeof n === "string");
19
+ return names.length > 0 ? names : undefined;
20
+ }
21
+
22
+ /** Body summary that keeps the audit-useful shape and none of the content. */
23
+ function summarizeRequestBody(body: unknown): unknown {
24
+ if (body === null || body === undefined) return body;
25
+ const b = (typeof body === "object" ? body : {}) as Record<string, unknown>;
26
+ const items = Array.isArray(b["messages"]) ? b["messages"] : Array.isArray(b["input"]) ? b["input"] : undefined;
27
+ return {
28
+ redacted: true,
29
+ bytes: byteLength(body),
30
+ ...(typeof b["model"] === "string" ? { model: b["model"] } : {}),
31
+ ...(items ? { items: items.length } : {}),
32
+ ...(toolNames(b["tools"]) ? { tools: toolNames(b["tools"]) } : {}),
33
+ ...(b["stream"] === true ? { stream: true } : {}),
34
+ };
35
+ }
36
+
37
+ function summarizeResponseBody(body: unknown): unknown {
38
+ if (body === null || body === undefined) return body;
39
+ const b = (typeof body === "object" ? body : {}) as Record<string, unknown>;
40
+ return {
41
+ redacted: true,
42
+ bytes: byteLength(body),
43
+ ...(typeof b["id"] === "string" ? { id: b["id"] } : {}),
44
+ ...(typeof b["status"] === "string" ? { status: b["status"] } : {}),
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Applies the `captureRaw` policy to a provider audit record.
50
+ * - `true`: keep everything (headers are always redacted by the adapters).
51
+ * - `"redacted"` (default): keep URL, method, headers and status, but replace
52
+ * request/response BODIES with a content-free summary. Prompts, files and
53
+ * model output never leak through `response.raw` / `toJSON()` into logs.
54
+ * - `false`: keep only URL, method and HTTP status.
55
+ */
56
+ export function applyCaptureRaw(raw: ProviderRawData | undefined, mode: CaptureRawMode | undefined): ProviderRawData | undefined {
57
+ if (!raw || mode === true) return raw;
58
+ const redactedMode = mode !== false; // undefined -> default "redacted"
59
+ const request = raw.request
60
+ ? {
61
+ url: raw.request.url,
62
+ method: raw.request.method,
63
+ headers: redactedMode ? raw.request.headers : {},
64
+ body: redactedMode ? summarizeRequestBody(raw.request.body) : null,
65
+ }
66
+ : raw.request;
67
+ const response = raw.response
68
+ ? {
69
+ status: raw.response.status,
70
+ statusText: raw.response.statusText,
71
+ headers: redactedMode ? raw.response.headers : {},
72
+ body: redactedMode ? summarizeResponseBody(raw.response.body) : null,
73
+ }
74
+ : undefined;
75
+ return { request, ...(response ? { response } : {}) } as ProviderRawData;
76
+ }
@@ -31,8 +31,10 @@ export function isTransientError(error: unknown): boolean {
31
31
 
32
32
  function readRetryAfterMs(error: unknown): number | undefined {
33
33
  const err = error as Record<string, unknown>;
34
+ // Preferred: milliseconds attached by the adapter from the Retry-After header.
35
+ const direct = err["retryAfterMs"];
36
+ if (typeof direct === "number" && Number.isFinite(direct) && direct >= 0) return Math.min(60_000, direct);
34
37
  const raw =
35
- (err["retryAfterMs"] as unknown) ??
36
38
  ((err["response"] as Record<string, unknown> | undefined)?.["headers"] as Record<string, unknown> | undefined)?.["retry-after"] ??
37
39
  ((err["headers"] as Record<string, unknown> | undefined)?.["retry-after"]);
38
40
  if (raw === undefined) return undefined;
@@ -73,11 +75,13 @@ export async function withRetries<T>(
73
75
  signal?: AbortSignal;
74
76
  label?: { providerId?: string; modelId?: string };
75
77
  retryBudgetMs?: number;
78
+ baseDelayMs?: number;
76
79
  }
77
80
  ): Promise<T> {
78
81
  const maxRetries = options?.maxRetries ?? 2;
79
82
  const maxDelay = options?.maxRetryDelayMs ?? 5000;
80
83
  const budgetMs = options?.retryBudgetMs ?? 30_000;
84
+ const baseDelay = options?.baseDelayMs ?? 250;
81
85
  const started = Date.now();
82
86
  const fail = (error: unknown): never => {
83
87
  if (options?.label?.providerId && options?.label?.modelId) {
@@ -94,8 +98,12 @@ export async function withRetries<T>(
94
98
  if (attempt >= maxRetries || !isTransientError(error)) fail(error);
95
99
  if (Date.now() - started >= budgetMs) fail(error);
96
100
  const retryAfter = readRetryAfterMs(error);
97
- const backoff = Math.min(maxDelay, 250 * 2 ** attempt + Math.random() * 100);
98
- const delay = retryAfter !== undefined ? Math.min(maxDelay, Math.max(backoff, retryAfter)) : backoff;
101
+ const backoff = Math.min(maxDelay, baseDelay * 2 ** attempt + Math.random() * 100);
102
+ // A server-provided Retry-After is honored in full (it is the provider's
103
+ // own instruction), but never past the retry budget: waiting that long is
104
+ // pointless, so surface the 429 now instead of sleeping and failing anyway.
105
+ const delay = retryAfter !== undefined ? Math.max(backoff, retryAfter) : backoff;
106
+ if (retryAfter !== undefined && Date.now() - started + delay > budgetMs) fail(error);
99
107
  attempt += 1;
100
108
  await sleep(delay, options?.signal);
101
109
  }
@@ -0,0 +1,252 @@
1
+ // NOTE: no static node:fs / node:path / node:dns imports: they break browser
2
+ // bundles. Everything Node-specific is loaded lazily inside the helpers.
3
+ import type { Message } from "../types/message.ts";
4
+ import { SecurityPolicyError } from "../types/errors.ts";
5
+
6
+ /** Default cap for one remote media/document download (bytes). */
7
+ export const MAX_REMOTE_FETCH_BYTES = 200_000_000;
8
+
9
+ /**
10
+ * True when a media/document string would be read from the local filesystem
11
+ * by `normalizeMediaInput` (i.e. it is not a data URL, an http(s) URL, or an
12
+ * embedded base64 payload). Mirrors the classification order used there.
13
+ */
14
+ export function isLocalPathInput(input: unknown): input is string {
15
+ if (typeof input !== "string" || !input) return false;
16
+ if (input.startsWith("data:")) return false;
17
+ if (input.startsWith("http://") || input.startsWith("https://")) return false;
18
+ if (
19
+ !input.includes("\\") &&
20
+ input.length > 100 &&
21
+ /^[A-Za-z0-9+/=\n\r]+$/.test(input.slice(0, 200)) &&
22
+ input.length % 4 === 0
23
+ ) {
24
+ return false;
25
+ }
26
+ return true;
27
+ }
28
+
29
+ const MAX_REDIRECTS = 5;
30
+
31
+ /** Raised when a remote fetch is blocked by policy or exceeds its size cap. */
32
+ export class RemoteFetchError extends SecurityPolicyError {
33
+ readonly reason: "blocked" | "too_large";
34
+ constructor(reason: "blocked" | "too_large", message: string, url?: string) {
35
+ super(message, url ? { url } : undefined);
36
+ this.name = "RemoteFetchError";
37
+ this.reason = reason;
38
+ }
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Local path containment (`fileAccess.roots`)
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /**
46
+ * Throws {@link SecurityPolicyError} unless `target` resolves (after symlinks)
47
+ * inside one of `roots`. An empty `roots` list denies every local path. URL
48
+ * schemes other than plain paths (`file:`, `ftp:`, …) are always rejected.
49
+ */
50
+ export async function assertPathAllowed(target: string, roots: readonly string[]): Promise<void> {
51
+ if (/^[a-z][a-z0-9+.-]+:/i.test(target)) {
52
+ throw new SecurityPolicyError(
53
+ `[Agent Accelerator] Path "${target.slice(0, 80)}" uses a URL scheme; only plain local paths or http(s) URLs are allowed.`
54
+ );
55
+ }
56
+ const path = await import("node:path");
57
+ const fs = await import("node:fs");
58
+ const real = (p: string): string => {
59
+ try {
60
+ return fs.realpathSync(p);
61
+ } catch {
62
+ return path.resolve(p);
63
+ }
64
+ };
65
+ const resolved = real(path.resolve(target));
66
+ for (const root of roots) {
67
+ const base = real(path.resolve(root));
68
+ const rel = path.relative(base, resolved);
69
+ if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))) return;
70
+ }
71
+ throw new SecurityPolicyError(
72
+ `[Agent Accelerator] Path "${target.slice(0, 120)}" is outside the allowed fileAccess roots` +
73
+ `${roots.length === 0 ? " (no local paths are allowed)" : ""}.`
74
+ );
75
+ }
76
+
77
+ const MEDIA_KEYS = ["image", "audio", "video", "file"] as const;
78
+
79
+ /**
80
+ * Validates every local-path media part in `messages` against `roots`. Used by
81
+ * the loop each turn so developer-supplied parts, steered prompts and
82
+ * imported sessions all obey `fileAccess.roots` the same way.
83
+ */
84
+ export async function enforceFileAccess(messages: Message[], roots: readonly string[]): Promise<void> {
85
+ for (const m of messages) {
86
+ if (!Array.isArray(m.content)) continue;
87
+ for (const part of m.content) {
88
+ const p = part as unknown as Record<string, unknown>;
89
+ const key = MEDIA_KEYS.find((k) => k === p?.type);
90
+ if (!key) continue;
91
+ const value = p[key];
92
+ if (isLocalPathInput(value)) await assertPathAllowed(value, roots);
93
+ }
94
+ }
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Remote fetch guard (SSRF + size cap)
99
+ // ---------------------------------------------------------------------------
100
+
101
+ function ipv4Private(a: number, b: number): boolean {
102
+ return (
103
+ a === 0 ||
104
+ a === 10 ||
105
+ a === 127 ||
106
+ (a === 100 && b >= 64 && b <= 127) ||
107
+ (a === 169 && b === 254) ||
108
+ (a === 172 && b >= 16 && b <= 31) ||
109
+ (a === 192 && b === 168)
110
+ );
111
+ }
112
+
113
+ /** True for loopback, link-local, private, CGNAT and unique-local addresses. */
114
+ export function isPrivateAddress(address: string): boolean {
115
+ const host = address.replace(/^\[|\]$/g, "").toLowerCase();
116
+ const v4 = host.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
117
+ if (v4) return ipv4Private(Number(v4[1]), Number(v4[2]));
118
+ if (host.includes(":")) {
119
+ if (host === "::" || host === "::1") return true;
120
+ const mapped = host.match(/^::ffff:(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
121
+ if (mapped) return ipv4Private(Number(mapped[1]), Number(mapped[2]));
122
+ return /^f[cd]/.test(host) || /^fe[89ab]/.test(host);
123
+ }
124
+ return false;
125
+ }
126
+
127
+ function isPrivateHostname(host: string): boolean {
128
+ return (
129
+ host === "localhost" ||
130
+ host.endsWith(".localhost") ||
131
+ host.endsWith(".local") ||
132
+ host.endsWith(".internal") ||
133
+ isPrivateAddress(host)
134
+ );
135
+ }
136
+
137
+ async function assertRemoteUrlAllowed(url: URL, allowPrivateNetwork: boolean): Promise<void> {
138
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
139
+ throw new RemoteFetchError("blocked", `[Agent Accelerator] Only http(s) URLs are allowed (got ${url.protocol}).`, url.href);
140
+ }
141
+ if (allowPrivateNetwork) return;
142
+ const host = url.hostname.replace(/^\[|\]$/g, "").toLowerCase();
143
+ if (isPrivateHostname(host)) {
144
+ throw new RemoteFetchError("blocked", `[Agent Accelerator] Refusing to fetch private/internal host "${host}".`, url.href);
145
+ }
146
+ if (/^[\d.]+$/.test(host) || host.includes(":")) return; // IP literal already checked
147
+ try {
148
+ const dns = await import("node:dns");
149
+ const addrs = await dns.promises.lookup(host, { all: true });
150
+ if (addrs.some((a) => isPrivateAddress(a.address))) {
151
+ throw new RemoteFetchError("blocked", `[Agent Accelerator] Host "${host}" resolves to a private/internal address.`, url.href);
152
+ }
153
+ } catch (err) {
154
+ if (err instanceof RemoteFetchError) throw err;
155
+ // Resolution failed (or non-Node runtime): fetch will surface its own error.
156
+ }
157
+ }
158
+
159
+ async function readCapped(res: Response, maxBytes: number, url: string): Promise<Uint8Array> {
160
+ const tooLarge = () =>
161
+ new RemoteFetchError(
162
+ "too_large",
163
+ `[Agent Accelerator] Remote resource exceeds the ${(maxBytes / 1_000_000).toFixed(0)} MB fetch cap.`,
164
+ url
165
+ );
166
+ const declared = Number(res.headers.get("content-length"));
167
+ if (Number.isFinite(declared) && declared > maxBytes) {
168
+ try { await res.body?.cancel(); } catch {}
169
+ throw tooLarge();
170
+ }
171
+ if (!res.body) {
172
+ const buf = new Uint8Array(await res.arrayBuffer());
173
+ if (buf.byteLength > maxBytes) throw tooLarge();
174
+ return buf;
175
+ }
176
+ const reader = res.body.getReader();
177
+ const chunks: Uint8Array[] = [];
178
+ let total = 0;
179
+ for (;;) {
180
+ const { done, value } = await reader.read();
181
+ if (done) break;
182
+ total += value.byteLength;
183
+ if (total > maxBytes) {
184
+ try { await reader.cancel(); } catch {}
185
+ throw tooLarge();
186
+ }
187
+ chunks.push(value);
188
+ }
189
+ const out = new Uint8Array(total);
190
+ let offset = 0;
191
+ for (const c of chunks) {
192
+ out.set(c, offset);
193
+ offset += c.byteLength;
194
+ }
195
+ return out;
196
+ }
197
+
198
+ /** Result of {@link fetchRemoteBytes}; `bytes` is empty when `ok` is false. */
199
+ export interface RemoteBytes {
200
+ ok: boolean;
201
+ status: number;
202
+ statusText: string;
203
+ contentType: string | null;
204
+ bytes: Uint8Array;
205
+ }
206
+
207
+ /**
208
+ * Fetches a URL with redirect re-validation and a hard byte cap. Redirects
209
+ * are followed manually (max 5) so every hop is checked. With
210
+ * `allowPrivateNetwork: false` loopback/LAN/link-local/cloud-metadata hosts
211
+ * (including DNS names that resolve to them) are refused.
212
+ */
213
+ export async function fetchRemoteBytes(
214
+ input: string,
215
+ opts: { signal?: AbortSignal; maxBytes?: number; allowPrivateNetwork?: boolean } = {}
216
+ ): Promise<RemoteBytes> {
217
+ const maxBytes = opts.maxBytes ?? MAX_REMOTE_FETCH_BYTES;
218
+ const allowPrivate = opts.allowPrivateNetwork ?? false;
219
+ let current = new URL(input);
220
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
221
+ await assertRemoteUrlAllowed(current, allowPrivate);
222
+ const res = await fetch(current.href, { signal: opts.signal, redirect: "manual" });
223
+ if ((res as { type?: string }).type === "opaqueredirect") {
224
+ // Browser runtimes hide the Location header: only trusted callers may follow blindly.
225
+ if (!allowPrivate) {
226
+ throw new RemoteFetchError("blocked", "[Agent Accelerator] Redirects cannot be validated in this runtime.", current.href);
227
+ }
228
+ const followed = await fetch(current.href, { signal: opts.signal });
229
+ return {
230
+ ok: followed.ok,
231
+ status: followed.status,
232
+ statusText: followed.statusText,
233
+ contentType: followed.headers.get("content-type"),
234
+ bytes: followed.ok ? await readCapped(followed, maxBytes, current.href) : new Uint8Array(0),
235
+ };
236
+ }
237
+ const location = res.status >= 300 && res.status < 400 ? res.headers.get("location") : null;
238
+ if (location) {
239
+ try { await res.body?.cancel(); } catch {}
240
+ current = new URL(location, current);
241
+ continue;
242
+ }
243
+ return {
244
+ ok: res.ok,
245
+ status: res.status,
246
+ statusText: res.statusText,
247
+ contentType: res.headers.get("content-type"),
248
+ bytes: res.ok ? await readCapped(res, maxBytes, current.href) : new Uint8Array(0),
249
+ };
250
+ }
251
+ throw new RemoteFetchError("blocked", `[Agent Accelerator] Too many redirects (> ${MAX_REDIRECTS}).`, input);
252
+ }
@@ -8,26 +8,25 @@ import type { ContentPart } from "../types/message.ts";
8
8
  import { ConfigError } from "../types/errors.ts";
9
9
 
10
10
  /**
11
- * Creates or formats a unique session ID for prompt caching affinity (clamped to 64 chars like agent-accel)
12
- */
13
- /**
14
- * Creates a unique, provider-safe session ID for cache affinity.
11
+ * Creates a unique, provider-safe session ID for cache affinity: a bare random
12
+ * UUID by default, or `${prefix}-<uuid>` when a prefix is given (clamped to 64 chars).
15
13
  *
16
- * @example `const sessionId = createSessionId("checkout");`
14
+ * @example `const sessionId = createSessionId();`
17
15
  */
18
- export function createSessionId(prefix = "accel"): string {
16
+ export function createSessionId(prefix?: string): string {
17
+ const head = prefix ? `${prefix}-` : "";
19
18
  let id: string;
20
19
  try {
21
20
  const webCrypto =
22
21
  (globalThis as any)?.crypto ?? (typeof crypto !== "undefined" ? crypto : undefined);
23
22
  if (webCrypto && typeof webCrypto.randomUUID === "function") {
24
- id = `${prefix}-${webCrypto.randomUUID()}`;
23
+ id = `${head}${webCrypto.randomUUID()}`;
25
24
  } else {
26
25
  throw new Error("no randomUUID");
27
26
  }
28
27
  } catch {
29
28
  // Fallback — still clamp
30
- id = `${prefix}-${Math.random().toString(36).slice(2, 11)}-${Date.now().toString(36)}`;
29
+ id = `${head}${Math.random().toString(36).slice(2, 11)}-${Date.now().toString(36)}`;
31
30
  }
32
31
  return clampCacheKey(id) || id;
33
32
  }
@@ -1,84 +0,0 @@
1
- # Model Catalog Cache (`src/data/`)
2
-
3
- This directory is the local destination for the dynamic model catalog cache used by **Agent Accelerator**.
4
-
5
- Agent Accelerator references a catalog of **7,500+ models across 200+ providers** for token limits, context windows, modality support, reasoning configurations, and per-token pricing (input, output, cache-read, cache-write).
6
-
7
- ---
8
-
9
- ## Zero-Bloat Distribution Architecture
10
-
11
- To keep the repository and published npm/Bun package lightweight and blazing fast:
12
-
13
- 1. **Excluded from Production Package & Git**:
14
- * The ~4.5 MB full catalog snapshot (`models.dev.json`) is **never** committed to git and is **excluded** from the npm/Bun distribution bundle.
15
- * The file is tracked in `.gitignore` and `.npmignore`.
16
-
17
- 2. **Dynamic 12-Hour Automated TTL Cache**:
18
- * On agent execution (`runAgentLoop` / `streamAgentLoop`), Agent Accelerator checks the status of `src/data/models.dev.json`.
19
- * **Cache Hit (< 12h)**: Instant memory/disk load with zero network overhead.
20
- * **Cache Miss / Expired (≥ 12h)**: Automatically downloads the latest catalog directly from `https://models.dev/api.json`, caches it to `src/data/models.dev.json`, and loads the catalog into memory.
21
- * **Resilience**: If an existing cache exists on disk, temporary upstream network issues will safely continue using the local cached copy.
22
-
23
- ---
24
-
25
- ## Developer Controls & Management
26
-
27
- ### 1. CLI Script
28
-
29
- You can manually inspect or refresh the model catalog anytime via the developer CLI script:
30
-
31
- ```bash
32
- # Refresh catalog if expired (or download if missing)
33
- bun run update-models
34
-
35
- # Force download regardless of age
36
- bun run update-models --force
37
-
38
- # Custom TTL (e.g., 24 hours)
39
- bun src/update-models.ts --ttl=24h
40
- ```
41
-
42
- ---
43
-
44
- ### 2. Programmatic API
45
-
46
- All catalog cache lifecycle controls are exported from the root package:
47
-
48
- ```typescript
49
- import {
50
- refreshModelCatalog,
51
- ensureModelCatalogFresh,
52
- getCatalogStatus,
53
- setCatalogTTL,
54
- getModelFromCatalog,
55
- } from "agent-accelerator";
56
-
57
- // 1. Check current cache status
58
- const status = getCatalogStatus();
59
- console.log(status);
60
- // {
61
- // isExpired: false,
62
- // cachedAt: 1773400000000,
63
- // ttlMs: 43200000,
64
- // modelCount: 7696,
65
- // providerCount: 213,
66
- // source: "cache"
67
- // }
68
-
69
- // 2. Refresh on-demand (e.g. in a cron job or startup hook)
70
- await refreshModelCatalog({ force: true });
71
-
72
- // 3. Customize runtime TTL (e.g., 6 hours)
73
- setCatalogTTL(6 * 60 * 60 * 1000);
74
- ```
75
-
76
- ---
77
-
78
- ## Structure
79
-
80
- ```text
81
- src/data/
82
- ├── README.md # This documentation
83
- └── models.dev.json # Auto-generated on first run (gitignored, excluded from bundle)
84
- ```