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.
- package/README.md +149 -57
- package/bunfig.toml +1 -0
- package/package.json +2 -3
- package/src/agent/agent.ts +401 -120
- package/src/agent/context.ts +11 -16
- package/src/agent/delegation.ts +111 -274
- package/src/agent/loop.ts +253 -98
- package/src/agent/subagent.ts +4 -12
- package/src/agent/worker.ts +435 -0
- package/src/index.ts +39 -5
- package/src/models/catalog-cache.ts +65 -25
- package/src/models/catalog.ts +21 -2
- package/src/providers/google.ts +282 -74
- package/src/providers/openai-compat.ts +61 -41
- package/src/providers/openai.ts +46 -31
- package/src/providers/openrouter.ts +42 -31
- package/src/providers/shared.ts +110 -16
- package/src/providers.ts +119 -6
- package/src/scheduler/scheduled-actions.md +41 -0
- package/src/scheduler/scheduler.ts +814 -0
- package/src/scheduler/store.ts +263 -0
- package/src/scheduler/time.ts +148 -0
- package/src/scheduler/tool.ts +151 -0
- package/src/session/store.ts +211 -104
- package/src/streaming/event-stream.ts +6 -4
- package/src/telemetry-file.ts +31 -0
- package/src/telemetry.ts +65 -0
- package/src/tools/executor.ts +163 -38
- package/src/tools/subagent-result.ts +60 -0
- package/src/types/agent.ts +57 -20
- package/src/types/model.ts +18 -0
- package/src/types/response.ts +2 -0
- package/src/types/scheduler.ts +81 -0
- package/src/types/tool.ts +15 -0
- package/src/utils/cache.ts +14 -5
- package/src/utils/documents.ts +112 -52
- package/src/utils/errors.ts +11 -0
- package/src/utils/headers.ts +15 -15
- package/src/utils/home.ts +30 -0
- package/src/utils/media.ts +57 -12
- package/src/utils/raw-capture.ts +76 -0
- package/src/utils/retry.ts +11 -3
- package/src/utils/safe-io.ts +252 -0
- package/src/utils/session.ts +7 -8
- package/src/data/README.md +0 -84
package/src/utils/cache.ts
CHANGED
|
@@ -71,7 +71,7 @@ export interface CreateExplicitCacheOptions {
|
|
|
71
71
|
|
|
72
72
|
/**
|
|
73
73
|
* Creates an explicit cached content object using Google Gemini's cachedContents API.
|
|
74
|
-
* REST: POST https://generativelanguage.googleapis.com/v1beta/cachedContents
|
|
74
|
+
* REST: POST https://generativelanguage.googleapis.com/v1beta/cachedContents (key in the `x-goog-api-key` header)
|
|
75
75
|
*/
|
|
76
76
|
/**
|
|
77
77
|
* Creates a Google Gemini cachedContents resource through the REST API.
|
|
@@ -125,11 +125,13 @@ export async function createExplicitCache(
|
|
|
125
125
|
(payload as any).tool_config = options.toolConfig;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
|
|
128
|
+
// The key travels in a header, never in the URL (URLs end up in proxy and access logs).
|
|
129
|
+
const url = `${baseUrl}/cachedContents`;
|
|
129
130
|
const response = await fetch(url, {
|
|
130
131
|
method: "POST",
|
|
131
132
|
headers: {
|
|
132
133
|
"Content-Type": "application/json",
|
|
134
|
+
"x-goog-api-key": apiKey,
|
|
133
135
|
},
|
|
134
136
|
body: JSON.stringify(payload),
|
|
135
137
|
});
|
|
@@ -152,8 +154,15 @@ export async function createExplicitCache(
|
|
|
152
154
|
/** Header-name deny-list (case-insensitive substring match). */
|
|
153
155
|
const REDACT_HEADER_SUBSTRINGS = ["key", "token", "secret", "cookie", "authorization"];
|
|
154
156
|
|
|
155
|
-
/**
|
|
156
|
-
|
|
157
|
+
/**
|
|
158
|
+
* Credential-looking values. Anchored on a non-alphanumeric boundary so plain
|
|
159
|
+
* words that merely contain "sk-" (e.g. `Task-Manager`, `Disk-Cache`) are NOT
|
|
160
|
+
* redacted, while `sk-…`, `gsk_…`, `xoxb-…` and `AIza…` tokens are.
|
|
161
|
+
*/
|
|
162
|
+
const SENSITIVE_VALUE_PATTERNS: RegExp[] = [
|
|
163
|
+
/(^|[^A-Za-z0-9])(sk|gsk|xox[a-z]?)[-_][A-Za-z0-9_-]{8,}/,
|
|
164
|
+
/(^|[^A-Za-z0-9])AIza[A-Za-z0-9_-]{10,}/,
|
|
165
|
+
];
|
|
157
166
|
|
|
158
167
|
function headerNameIsSensitive(name: string): boolean {
|
|
159
168
|
const lower = String(name ?? "").toLowerCase();
|
|
@@ -163,7 +172,7 @@ function headerNameIsSensitive(name: string): boolean {
|
|
|
163
172
|
function valueLooksSensitive(value: unknown): boolean {
|
|
164
173
|
if (typeof value !== "string") return false;
|
|
165
174
|
const v = value.trim();
|
|
166
|
-
if (
|
|
175
|
+
if (SENSITIVE_VALUE_PATTERNS.some((re) => re.test(v))) return true;
|
|
167
176
|
// Bearer-style long tokens: "Bearer <20+ chars>"
|
|
168
177
|
if (/^bearer\s+\S{20,}/i.test(v)) return true;
|
|
169
178
|
return false;
|
package/src/utils/documents.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { normalizeMediaInput } from "./media.ts";
|
|
|
6
6
|
import { base64ToBytes } from "./base64.ts";
|
|
7
7
|
import { escapeXml } from "./serialization.ts";
|
|
8
8
|
import { getModelFromCatalog } from "../models/catalog.ts";
|
|
9
|
+
import { assertPathAllowed, fetchRemoteBytes, RemoteFetchError } from "./safe-io.ts";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Client-side document conversion (anydoc engine).
|
|
@@ -104,6 +105,16 @@ export interface ConvertDocumentOptions {
|
|
|
104
105
|
maxChars?: number;
|
|
105
106
|
/** Abort signal honored by the remote-fetch leg. */
|
|
106
107
|
signal?: AbortSignal;
|
|
108
|
+
/**
|
|
109
|
+
* Allowed local roots for path inputs (see `AgentConfig.fileAccess`).
|
|
110
|
+
* Omitted = unrestricted (developer-supplied inputs); `[]` denies every path.
|
|
111
|
+
*/
|
|
112
|
+
roots?: readonly string[];
|
|
113
|
+
/**
|
|
114
|
+
* Input is model/user-controlled: private/internal hosts are refused for
|
|
115
|
+
* remote URLs. The built-in tool always sets this.
|
|
116
|
+
*/
|
|
117
|
+
untrusted?: boolean;
|
|
107
118
|
}
|
|
108
119
|
|
|
109
120
|
/** Normalized conversion result (pre-XML-envelope). */
|
|
@@ -296,7 +307,23 @@ export async function convertDocumentInput(
|
|
|
296
307
|
try {
|
|
297
308
|
// Remote URL leg: fetch bytes first (anydoc reads files, not URLs).
|
|
298
309
|
if (typeof input === "string" && isHttpUrl(input)) {
|
|
299
|
-
|
|
310
|
+
let res;
|
|
311
|
+
try {
|
|
312
|
+
res = await fetchRemoteBytes(input, {
|
|
313
|
+
signal: opts.signal,
|
|
314
|
+
maxBytes: MAX_DOCUMENT_FETCH_BYTES,
|
|
315
|
+
allowPrivateNetwork: !opts.untrusted,
|
|
316
|
+
});
|
|
317
|
+
} catch (err) {
|
|
318
|
+
if (err instanceof RemoteFetchError) {
|
|
319
|
+
throw new DocumentConversionError(
|
|
320
|
+
err.reason === "too_large" ? "resourceLimit" : "blocked",
|
|
321
|
+
`[Agent Accelerator] Could not download document "${input}": ${err.message}`,
|
|
322
|
+
input
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
throw err;
|
|
326
|
+
}
|
|
300
327
|
if (!res.ok) {
|
|
301
328
|
throw new DocumentConversionError(
|
|
302
329
|
"io",
|
|
@@ -304,19 +331,12 @@ export async function convertDocumentInput(
|
|
|
304
331
|
input
|
|
305
332
|
);
|
|
306
333
|
}
|
|
307
|
-
const buf =
|
|
308
|
-
if (buf.byteLength > MAX_DOCUMENT_FETCH_BYTES) {
|
|
309
|
-
throw new DocumentConversionError(
|
|
310
|
-
"resourceLimit",
|
|
311
|
-
`[Agent Accelerator] Remote document "${input}" is ${(buf.byteLength / 1_000_000).toFixed(1)} MB, over the ${MAX_DOCUMENT_FETCH_BYTES / 1_000_000} MB fetch cap.`,
|
|
312
|
-
input
|
|
313
|
-
);
|
|
314
|
-
}
|
|
334
|
+
const buf = res.bytes;
|
|
315
335
|
const name = filenameOpt || basenameOf(input);
|
|
316
336
|
const inferred = resolveDocumentFormat({
|
|
317
337
|
filename: name,
|
|
318
338
|
format: opts.format,
|
|
319
|
-
mimeType: res.
|
|
339
|
+
mimeType: res.contentType || opts.mimeType,
|
|
320
340
|
});
|
|
321
341
|
let wire = canonicalizeFormat(anydoc, inferred);
|
|
322
342
|
if (!wire) {
|
|
@@ -340,6 +360,17 @@ export async function convertDocumentInput(
|
|
|
340
360
|
|
|
341
361
|
// Local path leg: the engine reads + detects from the path itself.
|
|
342
362
|
if (typeof input === "string" && looksLikePath(input)) {
|
|
363
|
+
if (opts.roots) {
|
|
364
|
+
try {
|
|
365
|
+
await assertPathAllowed(input, opts.roots);
|
|
366
|
+
} catch (err) {
|
|
367
|
+
throw new DocumentConversionError(
|
|
368
|
+
"blocked",
|
|
369
|
+
`[Agent Accelerator] Could not read document "${input.slice(0, 120)}": ${err instanceof Error ? err.message : String(err)}`,
|
|
370
|
+
input
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
343
374
|
const name = filenameOpt || basenameOf(input);
|
|
344
375
|
const markdown = await anydoc.toMarkdown(input);
|
|
345
376
|
const inferred = resolveDocumentFormat({ filename: name, format: opts.format });
|
|
@@ -424,7 +455,7 @@ export function modelSupportsFileInput(providerId: string, modelId: string): boo
|
|
|
424
455
|
*/
|
|
425
456
|
export async function preprocessFilePartsForBypass(
|
|
426
457
|
messages: Message[],
|
|
427
|
-
opts: { providerId: string; modelId: string; signal?: AbortSignal; maxChars?: number }
|
|
458
|
+
opts: { providerId: string; modelId: string; signal?: AbortSignal; maxChars?: number; roots?: readonly string[] }
|
|
428
459
|
): Promise<void> {
|
|
429
460
|
if (modelSupportsFileInput(opts.providerId, opts.modelId)) return;
|
|
430
461
|
for (const m of messages) {
|
|
@@ -451,6 +482,7 @@ export async function preprocessFilePartsForBypass(
|
|
|
451
482
|
mimeType,
|
|
452
483
|
maxChars: opts.maxChars,
|
|
453
484
|
signal: opts.signal,
|
|
485
|
+
roots: opts.roots,
|
|
454
486
|
});
|
|
455
487
|
const destination =
|
|
456
488
|
filename ||
|
|
@@ -466,8 +498,75 @@ export async function preprocessFilePartsForBypass(
|
|
|
466
498
|
}
|
|
467
499
|
}
|
|
468
500
|
|
|
501
|
+
/** Options for {@link createConvertDocumentTool}. */
|
|
502
|
+
export interface ConvertDocumentToolOptions {
|
|
503
|
+
/**
|
|
504
|
+
* Local roots the model may read from. Defaults to `[process.cwd()]`; pass
|
|
505
|
+
* `[]` to deny every local path (URLs only).
|
|
506
|
+
*/
|
|
507
|
+
roots?: readonly string[];
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Builds the model-callable document converter. The `destination` is chosen
|
|
512
|
+
* by the model, so it is always treated as untrusted: local paths must sit
|
|
513
|
+
* inside `roots`, and remote URLs may not target private/internal hosts.
|
|
514
|
+
*
|
|
515
|
+
* @example `const tool = createConvertDocumentTool({ roots: ["./docs"] });`
|
|
516
|
+
*/
|
|
517
|
+
export function createConvertDocumentTool(options: ConvertDocumentToolOptions = {}): ToolDefinition {
|
|
518
|
+
return tool({
|
|
519
|
+
name: "convert_document_to_markdown",
|
|
520
|
+
description:
|
|
521
|
+
"Convert a document file to Markdown text. Call this when the user provides a document file path or URL, or asks about the contents of a document. " +
|
|
522
|
+
"Supported formats: PDF (.pdf), Word (.doc, .docx, .docm), PowerPoint (.ppt, .pps, .pot, .pptx, .pptm, .ppsx, .ppsm), " +
|
|
523
|
+
"Excel (.xls, .xlsx, .xlsm, .xlsb), OpenDocument (.odt, .ods, .odp), RTF (.rtf), EPUB (.epub), CSV (.csv). " +
|
|
524
|
+
"Returns the content wrapped in <Document name destination> tags. Do not use for images, audio, or video.",
|
|
525
|
+
input: z.object({
|
|
526
|
+
destination: z
|
|
527
|
+
.string()
|
|
528
|
+
.min(1)
|
|
529
|
+
.describe("Local file path or http(s) URL of the document to convert."),
|
|
530
|
+
format: z
|
|
531
|
+
.string()
|
|
532
|
+
.optional()
|
|
533
|
+
.describe(
|
|
534
|
+
"Explicit format override, e.g. 'pdf', 'docx', 'xlsx', 'csv'. Inferred from the destination when omitted."
|
|
535
|
+
),
|
|
536
|
+
maxChars: z
|
|
537
|
+
.number()
|
|
538
|
+
.int()
|
|
539
|
+
.positive()
|
|
540
|
+
.optional()
|
|
541
|
+
.describe("Maximum Markdown characters to return (default 100000). Excess is truncated with a marker."),
|
|
542
|
+
}),
|
|
543
|
+
timeoutMs: 60_000,
|
|
544
|
+
execute: async ({ destination, format, maxChars }, ctx) => {
|
|
545
|
+
const dest = String(destination ?? "").trim();
|
|
546
|
+
if (!dest) {
|
|
547
|
+
return "Error: convert_document_to_markdown needs a non-empty destination (local file path or http(s) URL).";
|
|
548
|
+
}
|
|
549
|
+
try {
|
|
550
|
+
const doc = await convertDocumentInput(dest, {
|
|
551
|
+
format,
|
|
552
|
+
maxChars,
|
|
553
|
+
signal: ctx?.signal,
|
|
554
|
+
untrusted: true,
|
|
555
|
+
roots: options.roots ?? [process.cwd()],
|
|
556
|
+
});
|
|
557
|
+
return buildDocumentXml(doc.name, dest, doc.markdown);
|
|
558
|
+
} catch (err) {
|
|
559
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
560
|
+
return `Error: ${detail}`;
|
|
561
|
+
}
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
469
566
|
/**
|
|
470
|
-
* Built-in model-callable document converter
|
|
567
|
+
* Built-in model-callable document converter with the default policy (local
|
|
568
|
+
* paths limited to the working directory, no private-network URLs). Use
|
|
569
|
+
* {@link createConvertDocumentTool} to choose other roots.
|
|
471
570
|
*
|
|
472
571
|
* @example
|
|
473
572
|
* ```ts
|
|
@@ -475,43 +574,4 @@ export async function preprocessFilePartsForBypass(
|
|
|
475
574
|
* const agent = new Agent({ model, tools: { convert_document_to_markdown } });
|
|
476
575
|
* ```
|
|
477
576
|
*/
|
|
478
|
-
export const convert_document_to_markdown: ToolDefinition =
|
|
479
|
-
name: "convert_document_to_markdown",
|
|
480
|
-
description:
|
|
481
|
-
"Convert a document file to Markdown text. Call this when the user provides a document file path or URL, or asks about the contents of a document. " +
|
|
482
|
-
"Supported formats: PDF (.pdf), Word (.doc, .docx, .docm), PowerPoint (.ppt, .pps, .pot, .pptx, .pptm, .ppsx, .ppsm), " +
|
|
483
|
-
"Excel (.xls, .xlsx, .xlsm, .xlsb), OpenDocument (.odt, .ods, .odp), RTF (.rtf), EPUB (.epub), CSV (.csv). " +
|
|
484
|
-
"Returns the content wrapped in <Document name destination> tags. Do not use for images, audio, or video.",
|
|
485
|
-
input: z.object({
|
|
486
|
-
destination: z
|
|
487
|
-
.string()
|
|
488
|
-
.min(1)
|
|
489
|
-
.describe("Local file path or http(s) URL of the document to convert."),
|
|
490
|
-
format: z
|
|
491
|
-
.string()
|
|
492
|
-
.optional()
|
|
493
|
-
.describe(
|
|
494
|
-
"Explicit format override, e.g. 'pdf', 'docx', 'xlsx', 'csv'. Inferred from the destination when omitted."
|
|
495
|
-
),
|
|
496
|
-
maxChars: z
|
|
497
|
-
.number()
|
|
498
|
-
.int()
|
|
499
|
-
.positive()
|
|
500
|
-
.optional()
|
|
501
|
-
.describe("Maximum Markdown characters to return (default 100000). Excess is truncated with a marker."),
|
|
502
|
-
}),
|
|
503
|
-
timeoutMs: 60_000,
|
|
504
|
-
execute: async ({ destination, format, maxChars }, ctx) => {
|
|
505
|
-
const dest = String(destination ?? "").trim();
|
|
506
|
-
if (!dest) {
|
|
507
|
-
return "Error: convert_document_to_markdown needs a non-empty destination (local file path or http(s) URL).";
|
|
508
|
-
}
|
|
509
|
-
try {
|
|
510
|
-
const doc = await convertDocumentInput(dest, { format, maxChars, signal: ctx?.signal });
|
|
511
|
-
return buildDocumentXml(doc.name, dest, doc.markdown);
|
|
512
|
-
} catch (err) {
|
|
513
|
-
const detail = err instanceof Error ? err.message : String(err);
|
|
514
|
-
return `Error: ${detail}`;
|
|
515
|
-
}
|
|
516
|
-
},
|
|
517
|
-
});
|
|
577
|
+
export const convert_document_to_markdown: ToolDefinition = createConvertDocumentTool();
|
package/src/utils/errors.ts
CHANGED
|
@@ -86,6 +86,17 @@ export function toConciseProviderError(error: unknown, providerId: string, model
|
|
|
86
86
|
...(typeof err?.errorType === "string"
|
|
87
87
|
? { errorType: { value: err.errorType, enumerable: false } }
|
|
88
88
|
: {}),
|
|
89
|
+
// Forwarded when present (e.g. Google's `not_found` chain-eviction code,
|
|
90
|
+
// which carries no HTTP status on SSE error frames). Survives re-wraps
|
|
91
|
+
// so chain-expiry detectors never lose it.
|
|
92
|
+
...((typeof err?.code === "string" || typeof err?.code === "number") && err.code !== ""
|
|
93
|
+
? { code: { value: err.code, enumerable: false } }
|
|
94
|
+
: {}),
|
|
95
|
+
// Server rate-limit hint in ms (from Retry-After); survives re-wraps so
|
|
96
|
+
// `withRetries` can honor it instead of hammering a 429.
|
|
97
|
+
...(typeof err?.retryAfterMs === "number" && Number.isFinite(err.retryAfterMs)
|
|
98
|
+
? { retryAfterMs: { value: err.retryAfterMs, enumerable: false } }
|
|
99
|
+
: {}),
|
|
89
100
|
...(typeof (err as { raw?: unknown })?.raw === "string"
|
|
90
101
|
? { raw: { value: (err as { raw: string }).raw.slice(0, 500), enumerable: false } }
|
|
91
102
|
: {}),
|
package/src/utils/headers.ts
CHANGED
|
@@ -24,7 +24,7 @@ export function getPackageVersion(): string {
|
|
|
24
24
|
const v = (globalThis as any).process?.env?.npm_package_version;
|
|
25
25
|
if (typeof v === "string" && v) return v;
|
|
26
26
|
} catch {}
|
|
27
|
-
return "0.
|
|
27
|
+
return "0.5.0";
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
function getAgentAccelUserAgent(): string {
|
|
@@ -55,9 +55,9 @@ export interface AttributionConfig {
|
|
|
55
55
|
let attribution: AttributionConfig = {};
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
|
-
* Configures global attribution headers
|
|
59
|
-
*
|
|
60
|
-
*
|
|
58
|
+
* Configures global attribution headers (OpenRouter `HTTP-Referer` / `X-Title`).
|
|
59
|
+
* Defaults to none: nothing is sent unless you call this, so your traffic is
|
|
60
|
+
* never credited to a site or app you did not choose.
|
|
61
61
|
* @example `setAttribution({ referer: "https://example.com", title: "My App" });`
|
|
62
62
|
*/
|
|
63
63
|
export function setAttribution(cfg: AttributionConfig): void {
|
|
@@ -151,9 +151,8 @@ function stripForBrowser(headers: Record<string, string>): Record<string, string
|
|
|
151
151
|
/**
|
|
152
152
|
* Builds provider-specific session, cache-affinity, and attribution headers.
|
|
153
153
|
*
|
|
154
|
-
* Q-17: attribution is configurable via `setAttribution()` (default none
|
|
155
|
-
*
|
|
156
|
-
* fallback when no custom attribution is set. Custom (non-first-class)
|
|
154
|
+
* Q-17: attribution is configurable via `setAttribution()` (default none; no
|
|
155
|
+
* referer/title is ever sent unless you set one). Custom (non-first-class)
|
|
157
156
|
* prefixes receive minimal `x-session-id`/`x-client-request-id` affinity only
|
|
158
157
|
* (no `session_id` alias, no attribution) when a session id is present.
|
|
159
158
|
* `customHeaders` merge case-insensitively against canonical names.
|
|
@@ -193,14 +192,18 @@ export function buildSessionHeaders(
|
|
|
193
192
|
return key !== undefined ? headers[key] : undefined;
|
|
194
193
|
};
|
|
195
194
|
|
|
195
|
+
// OpenRouter app attribution is strictly opt-in (`setAttribution`): by default
|
|
196
|
+
// a user's traffic is not credited to any third-party app or site.
|
|
197
|
+
const applyOpenRouterAttribution = (): void => {
|
|
198
|
+
if (attr.referer !== undefined && getHeader("HTTP-Referer") === undefined) headers["HTTP-Referer"] = attr.referer;
|
|
199
|
+
if (attr.title !== undefined && getHeader("X-Title") === undefined) headers["X-Title"] = attr.title;
|
|
200
|
+
};
|
|
201
|
+
|
|
196
202
|
if (sessionId && !browser) {
|
|
197
203
|
if (provider === "openrouter") {
|
|
198
204
|
setHeaderCaseInsensitive(headers, "x-session-id", sessionId);
|
|
199
205
|
setHeaderCaseInsensitive(headers, "x-client-request-id", sessionId);
|
|
200
|
-
|
|
201
|
-
const title = attr.title ?? "Agent Accelerator";
|
|
202
|
-
if (getHeader("HTTP-Referer") === undefined) headers["HTTP-Referer"] = referer;
|
|
203
|
-
if (getHeader("X-Title") === undefined) headers["X-Title"] = title;
|
|
206
|
+
applyOpenRouterAttribution();
|
|
204
207
|
} else if (provider === "google") {
|
|
205
208
|
if (getHeader("x-goog-api-client") === undefined) {
|
|
206
209
|
headers["x-goog-api-client"] = "agent-accel/1.0";
|
|
@@ -219,10 +222,7 @@ export function buildSessionHeaders(
|
|
|
219
222
|
setHeaderCaseInsensitive(headers, "x-client-request-id", sessionId);
|
|
220
223
|
}
|
|
221
224
|
} else if (provider === "openrouter") {
|
|
222
|
-
|
|
223
|
-
const title = attr.title ?? "Agent Accelerator";
|
|
224
|
-
if (getHeader("HTTP-Referer") === undefined) headers["HTTP-Referer"] = referer;
|
|
225
|
-
if (getHeader("X-Title") === undefined) headers["X-Title"] = title;
|
|
225
|
+
applyOpenRouterAttribution();
|
|
226
226
|
}
|
|
227
227
|
|
|
228
228
|
if (provider === "google" && !browser && getHeader("x-goog-api-client") === undefined) {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { getEnv } from "./env.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The Agent Accelerator home: ONE folder that holds everything the SDK stores
|
|
6
|
+
* (`sessions/`, `scheduled_actions/`, `telemetry/`, `catalog/`), so nothing is
|
|
7
|
+
* scattered across the project or the package.
|
|
8
|
+
*/
|
|
9
|
+
let defaultHome: string | undefined;
|
|
10
|
+
const listeners = new Set<() => void>();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Sets where the home lives for every Agent created afterwards, so nothing lands
|
|
14
|
+
* in the working directory unless you want it there. Call once at startup;
|
|
15
|
+
* `setHome()` clears it.
|
|
16
|
+
*/
|
|
17
|
+
export function setHome(dir?: string): void {
|
|
18
|
+
defaultHome = dir ? path.resolve(dir) : undefined;
|
|
19
|
+
for (const fn of listeners) fn();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The home folder: explicit `home`, else `setHome()`, else `AGENT_ACCEL_HOME`, else `.agent-accel/` in the working directory. */
|
|
23
|
+
export function resolveHome(home?: string): string {
|
|
24
|
+
return path.resolve(home || defaultHome || getEnv("AGENT_ACCEL_HOME") || ".agent-accel");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Runs `fn` whenever `setHome` changes the default (the catalog cache reloads from the new place). */
|
|
28
|
+
export function onHomeChange(fn: () => void): void {
|
|
29
|
+
listeners.add(fn);
|
|
30
|
+
}
|
package/src/utils/media.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
78
|
-
const
|
|
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
|
|
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
|
+
}
|
package/src/utils/retry.ts
CHANGED
|
@@ -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,
|
|
98
|
-
|
|
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
|
}
|