agent-accelerator 0.4.5 → 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 (45) 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 +401 -120
  5. package/src/agent/context.ts +11 -16
  6. package/src/agent/delegation.ts +111 -274
  7. package/src/agent/loop.ts +253 -98
  8. package/src/agent/subagent.ts +4 -12
  9. package/src/agent/worker.ts +435 -0
  10. package/src/index.ts +39 -5
  11. package/src/models/catalog-cache.ts +65 -25
  12. package/src/models/catalog.ts +21 -2
  13. package/src/providers/google.ts +282 -74
  14. package/src/providers/openai-compat.ts +61 -41
  15. package/src/providers/openai.ts +46 -31
  16. package/src/providers/openrouter.ts +42 -31
  17. package/src/providers/shared.ts +110 -16
  18. package/src/providers.ts +119 -6
  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 +211 -104
  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 +57 -20
  31. package/src/types/model.ts +18 -0
  32. package/src/types/response.ts +2 -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/errors.ts +11 -0
  38. package/src/utils/headers.ts +15 -15
  39. package/src/utils/home.ts +30 -0
  40. package/src/utils/media.ts +57 -12
  41. package/src/utils/raw-capture.ts +76 -0
  42. package/src/utils/retry.ts +11 -3
  43. package/src/utils/safe-io.ts +252 -0
  44. package/src/utils/session.ts +7 -8
  45. package/src/data/README.md +0 -84
@@ -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
- ```