agent-accelerator 0.5.9 → 0.6.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 +3 -1
- package/examples/prompts/SYSTEM_PROMPT.md +2 -0
- package/examples/prompts/SYSTEM_PROMPT_AGENT.md +4 -0
- package/examples/prompts/SYSTEM_PROMPT_TOOLS.md +1 -0
- package/package.json +4 -1
- package/src/agent/agent.ts +366 -161
- package/src/agent/architecture.md +13 -12
- package/src/agent/config-blocks.ts +39 -12
- package/src/agent/context.ts +8 -3
- package/src/agent/delegation.ts +82 -29
- package/src/agent/loop.ts +444 -365
- package/src/agent/subagent.ts +1 -3
- package/src/agent/subagents-tool.ts +45 -7
- package/src/agent/subagents.md +3 -0
- package/src/agent/turns.ts +3 -1
- package/src/agent/worker.ts +56 -27
- package/src/agent/workers.ts +113 -20
- package/src/architecture.md +16 -15
- package/src/index.ts +34 -10
- package/src/models/architecture.md +4 -3
- package/src/models/catalog-cache.ts +6 -5
- package/src/models/catalog.ts +39 -30
- package/src/providers/architecture.md +10 -8
- package/src/providers/google.ts +147 -59
- package/src/providers/openai-compat.ts +57 -49
- package/src/providers/openai.ts +54 -37
- package/src/providers/openrouter.ts +85 -30
- package/src/providers/registry.ts +6 -21
- package/src/providers/shared.ts +87 -33
- package/src/providers.ts +56 -115
- package/src/scheduler/architecture.md +7 -6
- package/src/scheduler/scheduled-actions.md +4 -0
- package/src/scheduler/scheduler.ts +186 -60
- package/src/scheduler/store.ts +23 -10
- package/src/scheduler/time.ts +17 -3
- package/src/scheduler/tool.ts +33 -7
- package/src/session/architecture.md +7 -6
- package/src/session/journal.ts +38 -12
- package/src/session/store.ts +122 -61
- package/src/session/usage.ts +58 -9
- package/src/streaming/architecture.md +4 -3
- package/src/streaming/event-stream.ts +26 -12
- package/src/telemetry.ts +6 -2
- package/src/tools/architecture.md +8 -7
- package/src/tools/executor.ts +50 -48
- package/src/tools/schema.ts +10 -6
- package/src/tools/subagent-result.ts +6 -1
- package/src/tools/tool.ts +9 -11
- package/src/types/agent.ts +2 -21
- package/src/types/architecture.md +11 -10
- package/src/types/core.ts +1 -9
- package/src/types/errors.ts +26 -4
- package/src/types/model.ts +0 -4
- package/src/types/tool.ts +12 -2
- package/src/update-models.ts +5 -5
- package/src/utils/architecture.md +16 -15
- package/src/utils/cache.ts +1 -3
- package/src/utils/durable.ts +17 -5
- package/src/utils/env.ts +9 -7
- package/src/utils/errors.ts +6 -14
- package/src/utils/headers.ts +3 -10
- package/src/utils/media.ts +23 -8
- package/src/utils/raw-capture.ts +9 -2
- package/src/utils/retry.ts +15 -3
- package/src/utils/safe-io.ts +29 -7
- package/src/utils/serialization.ts +1 -4
- package/src/utils/session.ts +1 -2
package/src/types/core.ts
CHANGED
|
@@ -2,15 +2,7 @@
|
|
|
2
2
|
* Level-based reasoning — single flag for all levels (bloatfree)
|
|
3
3
|
*/
|
|
4
4
|
/** Public reasoning control accepted by AgentConfig. */
|
|
5
|
-
export type ThinkingLevel =
|
|
6
|
-
| "none"
|
|
7
|
-
| "dynamic"
|
|
8
|
-
| "minimal"
|
|
9
|
-
| "low"
|
|
10
|
-
| "medium"
|
|
11
|
-
| "high"
|
|
12
|
-
| "xhigh"
|
|
13
|
-
| "max";
|
|
5
|
+
export type ThinkingLevel = "none" | "dynamic" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
14
6
|
|
|
15
7
|
// Internal normalized config (SDK-internal, not exposed as Agent flag)
|
|
16
8
|
/** Internal normalized reasoning configuration passed to providers. */
|
package/src/types/errors.ts
CHANGED
|
@@ -40,7 +40,11 @@ export class AgentAccelError extends Error {
|
|
|
40
40
|
readonly code: AgentAccelErrorCode;
|
|
41
41
|
readonly retryable: boolean;
|
|
42
42
|
readonly context: AgentAccelErrorContext;
|
|
43
|
-
constructor(
|
|
43
|
+
constructor(
|
|
44
|
+
code: AgentAccelErrorCode,
|
|
45
|
+
message: string,
|
|
46
|
+
opts?: { retryable?: boolean; context?: AgentAccelErrorContext; cause?: unknown }
|
|
47
|
+
) {
|
|
44
48
|
super(message);
|
|
45
49
|
this.name = "AgentAccelError";
|
|
46
50
|
this.code = code;
|
|
@@ -59,10 +63,26 @@ export class ConfigError extends AgentAccelError {
|
|
|
59
63
|
|
|
60
64
|
export class ProviderError extends AgentAccelError {
|
|
61
65
|
readonly status?: number;
|
|
62
|
-
constructor(
|
|
66
|
+
constructor(
|
|
67
|
+
message: string,
|
|
68
|
+
opts?: {
|
|
69
|
+
status?: number;
|
|
70
|
+
provider?: string;
|
|
71
|
+
model?: string;
|
|
72
|
+
requestId?: string;
|
|
73
|
+
retryable?: boolean;
|
|
74
|
+
context?: AgentAccelErrorContext;
|
|
75
|
+
}
|
|
76
|
+
) {
|
|
63
77
|
super("provider", message, {
|
|
64
78
|
retryable: opts?.retryable ?? false,
|
|
65
|
-
context: {
|
|
79
|
+
context: {
|
|
80
|
+
...opts?.context,
|
|
81
|
+
provider: opts?.provider,
|
|
82
|
+
model: opts?.model,
|
|
83
|
+
status: opts?.status,
|
|
84
|
+
requestId: opts?.requestId,
|
|
85
|
+
},
|
|
66
86
|
});
|
|
67
87
|
this.name = "ProviderError";
|
|
68
88
|
this.status = opts?.status;
|
|
@@ -124,7 +144,9 @@ export function isAbortError(error: unknown): boolean {
|
|
|
124
144
|
/** Format without ANSI for CLI pretty-printing. */
|
|
125
145
|
export function formatErrorPlain(err: unknown): string {
|
|
126
146
|
if (err instanceof AgentAccelError) {
|
|
127
|
-
const ctx = err.context?.provider
|
|
147
|
+
const ctx = err.context?.provider
|
|
148
|
+
? ` [${err.context.provider}${err.context.model ? `/${err.context.model}` : ""}]`
|
|
149
|
+
: "";
|
|
128
150
|
const status = err.context?.status ? ` (${err.context.status})` : "";
|
|
129
151
|
return `[${err.code}]${ctx}${status}: ${err.message}`;
|
|
130
152
|
}
|
package/src/types/model.ts
CHANGED
|
@@ -185,10 +185,6 @@ export interface ProviderRequestOptions {
|
|
|
185
185
|
googleTier?: "free" | "paid";
|
|
186
186
|
/** Upper bound on generated tokens for one model call. */
|
|
187
187
|
maxOutputTokens?: number;
|
|
188
|
-
/** Stop sequence(s). Chat Completions only; other wires warn+drop. */
|
|
189
|
-
stop?: string[];
|
|
190
|
-
/** Deterministic-sampling seed. Chat Completions only; other wires warn+drop. */
|
|
191
|
-
seed?: number;
|
|
192
188
|
/** `fetch` implementation used for the provider request (defaults to global `fetch`). */
|
|
193
189
|
fetch?: typeof fetch;
|
|
194
190
|
/** Base backoff delay in ms for provider retries (default 250). */
|
package/src/types/tool.ts
CHANGED
|
@@ -21,7 +21,13 @@ export interface ToolExecutionContext {
|
|
|
21
21
|
/** Session identifier associated with this invocation. */
|
|
22
22
|
sessionId?: string;
|
|
23
23
|
/** Forwards live sub-agent deltas (used by the `subagents` tool for realtime tracking). */
|
|
24
|
-
onSubagentEvent?: (event: {
|
|
24
|
+
onSubagentEvent?: (event: {
|
|
25
|
+
trackingId: string;
|
|
26
|
+
delta?: string;
|
|
27
|
+
thinkingDelta?: string;
|
|
28
|
+
partialText?: string;
|
|
29
|
+
partialThinking?: string;
|
|
30
|
+
}) => void;
|
|
25
31
|
// Tool API v2 context additions (all optional, additive).
|
|
26
32
|
/** Stable id for this run (`newRunId`). Mirrors SubAgentRunIds.runId. */
|
|
27
33
|
runId?: string;
|
|
@@ -161,5 +167,9 @@ export const TRUNCATED_ARGS_KEY = "__truncated_raw";
|
|
|
161
167
|
export function isTruncatedArguments(args: unknown): boolean {
|
|
162
168
|
if (!args || typeof args !== "object" || Array.isArray(args)) return false;
|
|
163
169
|
const keys = Object.keys(args as Record<string, unknown>);
|
|
164
|
-
return
|
|
170
|
+
return (
|
|
171
|
+
keys.length === 1 &&
|
|
172
|
+
keys[0] === TRUNCATED_ARGS_KEY &&
|
|
173
|
+
typeof (args as Record<string, unknown>)[TRUNCATED_ARGS_KEY] === "string"
|
|
174
|
+
);
|
|
165
175
|
}
|
package/src/update-models.ts
CHANGED
|
@@ -28,7 +28,9 @@ for (const arg of args) {
|
|
|
28
28
|
}
|
|
29
29
|
// Reject non-numeric TTLs (e.g. --ttl=abc) instead of silently caching NaN.
|
|
30
30
|
if (!Number.isFinite(customTtlMs)) {
|
|
31
|
-
throw new ConfigError(
|
|
31
|
+
throw new ConfigError(
|
|
32
|
+
`Invalid --ttl value "${val}": expected a number with optional h/m suffix (e.g. --ttl=24h).`
|
|
33
|
+
);
|
|
32
34
|
}
|
|
33
35
|
}
|
|
34
36
|
}
|
|
@@ -42,13 +44,11 @@ try {
|
|
|
42
44
|
const ttlHours = (result.ttlMs / (1000 * 60 * 60)).toFixed(1);
|
|
43
45
|
|
|
44
46
|
if (result.fromCache) {
|
|
45
|
-
console.log(
|
|
46
|
-
`\x1b[32m✓\x1b[0m Models catalog is already fresh (within ${ttlHours}h TTL).`
|
|
47
|
-
);
|
|
47
|
+
console.log(`\x1b[32m✓\x1b[0m Models catalog is already fresh (within ${ttlHours}h TTL).`);
|
|
48
48
|
} else {
|
|
49
49
|
console.log(
|
|
50
50
|
`\x1b[32m✓\x1b[0m Successfully downloaded and cached \x1b[1m${result.modelCount.toLocaleString()}\x1b[0m models ` +
|
|
51
|
-
|
|
51
|
+
`across \x1b[1m${result.providerCount}\x1b[0m providers in ${duration}s.`
|
|
52
52
|
);
|
|
53
53
|
}
|
|
54
54
|
console.log(` \x1b[90mCache location: ${result.cachePath}\x1b[0m`);
|
|
@@ -2,23 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
Small shared helpers. Each file has one job.
|
|
4
4
|
|
|
5
|
-
| File
|
|
6
|
-
|
|
|
7
|
-
| `durable.ts`
|
|
8
|
-
| `home.ts`
|
|
9
|
-
| `env.ts`
|
|
10
|
-
| `headers.ts`
|
|
11
|
-
| `media.ts`
|
|
12
|
-
| `safe-io.ts`
|
|
13
|
-
| `raw-capture.ts`
|
|
14
|
-
| `cache.ts`
|
|
15
|
-
| `errors.ts`
|
|
16
|
-
| `retry.ts`
|
|
17
|
-
| `session.ts`
|
|
18
|
-
| `serialization.ts`
|
|
19
|
-
| `base64.ts`, `thought-signature.ts` | Runtime-agnostic base64; Gemini thought-signature helpers.
|
|
5
|
+
| File | What it does |
|
|
6
|
+
| :---------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
7
|
+
| `durable.ts` | The ONE disk writer (`writeFileDurable`: temp, fsync, `.bak`, rename, folder fsync), `readWithBackup`, `lockFolder` (one process per folder, used for session folders; stale-lock takeover, temp sweep) and `withFileMutex` (short per-file lock for state several processes change, e.g. one scheduled action). |
|
|
8
|
+
| `home.ts` | `resolveHome` / `setHome`: the single folder everything is stored in. |
|
|
9
|
+
| `env.ts` | Process-env lookups and API key resolution. No `globalThis` fallback. |
|
|
10
|
+
| `headers.ts` | Session-affinity headers, opt-in attribution, browser stripping, package version fallback. |
|
|
11
|
+
| `media.ts` | Normalizes a path, URL, data URL, base64 or bytes to base64 and a MIME type; magic-byte sniffing. |
|
|
12
|
+
| `safe-io.ts` | `fileAccess.roots` containment and the guarded remote fetch (private hosts refused, redirects re-checked, size cap). |
|
|
13
|
+
| `raw-capture.ts` | The `captureRaw` policy for `AgentResponse.raw`. |
|
|
14
|
+
| `cache.ts` | Cache-key clamp (64 chars) and the central secret redaction. |
|
|
15
|
+
| `errors.ts` | `toConciseProviderError` (any raw provider failure becomes one-line `ProviderError`: `code` `"provider"`, `retryable` from status/code, the provider's code as `providerCode`; SDK errors pass through), `isTransientError`, and the pre-network modality guards (`ValidationError`). |
|
|
16
|
+
| `retry.ts` | `withRetries`, `isTransientError` (re-exported from `errors.ts`), `isRetryableRun`, `sleep`. Structured data only (status, code, flags), never message text. |
|
|
17
|
+
| `session.ts` | Id helpers (`createSessionId`, `hashSessionPart`, `createTrackingId`, run/turn ids), `parseModelRef`, `previewPrompt`. |
|
|
18
|
+
| `serialization.ts` | `toJsonSafe`, `safeStringify`, `escapeXml`. |
|
|
19
|
+
| `base64.ts`, `thought-signature.ts` | Runtime-agnostic base64; Gemini thought-signature helpers. |
|
|
20
20
|
|
|
21
21
|
## Invariants
|
|
22
|
+
|
|
22
23
|
- Never write state with a bare `writeFileSync`; use `writeFileDurable`.
|
|
23
24
|
- `media.ts` and `safe-io.ts` import `node:*` lazily so browser bundles keep working.
|
|
24
25
|
- Redaction is central (`cache.ts`); adapters must not keep their own deny-lists.
|
package/src/utils/cache.ts
CHANGED
|
@@ -58,9 +58,7 @@ export function redactValue(value: unknown): unknown {
|
|
|
58
58
|
* Deny-list (name contains): *key*, *token*, *secret*, cookie, authorization.
|
|
59
59
|
* Value patterns (redacted regardless of name): sk-, AIza, gsk-, xox-.
|
|
60
60
|
*/
|
|
61
|
-
export function redactHeadersCentral(
|
|
62
|
-
headers: Record<string, unknown> | undefined | null
|
|
63
|
-
): Record<string, unknown> {
|
|
61
|
+
export function redactHeadersCentral(headers: Record<string, unknown> | undefined | null): Record<string, unknown> {
|
|
64
62
|
if (!headers || typeof headers !== "object") return {};
|
|
65
63
|
const out: Record<string, unknown> = {};
|
|
66
64
|
for (const [k, v] of Object.entries(headers)) {
|
package/src/utils/durable.ts
CHANGED
|
@@ -32,7 +32,11 @@ export function fsyncFolder(dir: string): void {
|
|
|
32
32
|
* Durable atomic write: temp file (0600) -> fsync -> previous version kept as `<file>.bak` (hard link, no copy)
|
|
33
33
|
* -> rename -> fsync of the folder. Throws on failure; the temp file is removed. `backup: false` skips the `.bak`.
|
|
34
34
|
*/
|
|
35
|
-
export function writeFileDurable(
|
|
35
|
+
export function writeFileDurable(
|
|
36
|
+
filePath: string,
|
|
37
|
+
content: string | Uint8Array,
|
|
38
|
+
opts: { backup?: boolean } = {}
|
|
39
|
+
): void {
|
|
36
40
|
const dir = path.dirname(filePath);
|
|
37
41
|
try {
|
|
38
42
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -73,7 +77,10 @@ export function writeFileDurable(filePath: string, content: string | Uint8Array,
|
|
|
73
77
|
* Reads `file`; when `isValid` rejects it, falls back to `<file>.bak`. `undefined` when the file is missing (a deleted
|
|
74
78
|
* file is never resurrected from its backup). When both are invalid the primary text comes back, `fromBackup: false`.
|
|
75
79
|
*/
|
|
76
|
-
export function readWithBackup(
|
|
80
|
+
export function readWithBackup(
|
|
81
|
+
file: string,
|
|
82
|
+
isValid: (text: string) => boolean
|
|
83
|
+
): { text: string; fromBackup: boolean } | undefined {
|
|
77
84
|
let text: string;
|
|
78
85
|
try {
|
|
79
86
|
text = fs.readFileSync(file, "utf8");
|
|
@@ -234,7 +241,8 @@ const heldMutexes = new Set<string>();
|
|
|
234
241
|
*/
|
|
235
242
|
export function withFileMutex<T>(lockFile: string, fn: () => T): T {
|
|
236
243
|
const file = path.resolve(lockFile);
|
|
237
|
-
if (heldMutexes.has(file))
|
|
244
|
+
if (heldMutexes.has(file))
|
|
245
|
+
throw new ConfigError(`[Agent Accelerator] Mutex "${file}" is already held by this process (nested use).`);
|
|
238
246
|
const mine = JSON.stringify({ pid: process.pid, host: os.hostname(), at: Date.now() });
|
|
239
247
|
const deadline = Date.now() + MUTEX_WAIT_MS;
|
|
240
248
|
let unreadable = 0;
|
|
@@ -265,7 +273,8 @@ export function withFileMutex<T>(lockFile: string, fn: () => T): T {
|
|
|
265
273
|
owner = undefined;
|
|
266
274
|
}
|
|
267
275
|
let stale = false;
|
|
268
|
-
if (typeof owner?.pid !== "number")
|
|
276
|
+
if (typeof owner?.pid !== "number")
|
|
277
|
+
stale = ++unreadable > 5; // empty or garbage: another process may be mid-write
|
|
269
278
|
else if (owner.host === os.hostname() && !processAlive(owner.pid)) stale = true;
|
|
270
279
|
else if (Date.now() - mtime > MUTEX_MAX_AGE_MS) stale = true;
|
|
271
280
|
if (stale) {
|
|
@@ -276,7 +285,10 @@ export function withFileMutex<T>(lockFile: string, fn: () => T): T {
|
|
|
276
285
|
}
|
|
277
286
|
continue;
|
|
278
287
|
}
|
|
279
|
-
if (Date.now() > deadline)
|
|
288
|
+
if (Date.now() > deadline)
|
|
289
|
+
throw new ConfigError(
|
|
290
|
+
`[Agent Accelerator] Timed out after ${MUTEX_WAIT_MS} ms waiting for the lock "${file}" (held by process ${String(owner?.pid)}).`
|
|
291
|
+
);
|
|
280
292
|
pause(typeof owner?.pid === "number" ? 2 : 40);
|
|
281
293
|
}
|
|
282
294
|
heldMutexes.add(file);
|
package/src/utils/env.ts
CHANGED
|
@@ -11,7 +11,10 @@ export function getEnv(key: string, fallback?: string): string | undefined {
|
|
|
11
11
|
if (typeof process !== "undefined" && process.env && process.env[key]) {
|
|
12
12
|
// Trim quotes/whitespace from .env files (`sk-..."\n` -> 401 otherwise).
|
|
13
13
|
const raw = String(process.env[key]);
|
|
14
|
-
const trimmed = raw
|
|
14
|
+
const trimmed = raw
|
|
15
|
+
.trim()
|
|
16
|
+
.replace(/^["']|["']$/g, "")
|
|
17
|
+
.trim();
|
|
15
18
|
if (trimmed) return trimmed;
|
|
16
19
|
}
|
|
17
20
|
return fallback;
|
|
@@ -27,7 +30,10 @@ export type ProviderEnv = Record<string, string>;
|
|
|
27
30
|
export function getApiKey(provider: string, explicitKey?: string, env?: ProviderEnv): string | undefined {
|
|
28
31
|
const clean = (v: unknown): string | undefined => {
|
|
29
32
|
if (typeof v !== "string") return undefined;
|
|
30
|
-
const t = v
|
|
33
|
+
const t = v
|
|
34
|
+
.trim()
|
|
35
|
+
.replace(/^["']|["']$/g, "")
|
|
36
|
+
.trim();
|
|
31
37
|
return t || undefined;
|
|
32
38
|
};
|
|
33
39
|
const cleanedExplicit = clean(explicitKey);
|
|
@@ -56,11 +62,7 @@ export function getApiKey(provider: string, explicitKey?: string, env?: Provider
|
|
|
56
62
|
switch (provider.toLowerCase()) {
|
|
57
63
|
case "google":
|
|
58
64
|
case "gemini":
|
|
59
|
-
return (
|
|
60
|
-
getEnv("GEMINI_API_KEY") ||
|
|
61
|
-
getEnv("GOOGLE_API_KEY") ||
|
|
62
|
-
getEnv("GOOGLE_GENAI_API_KEY")
|
|
63
|
-
);
|
|
65
|
+
return getEnv("GEMINI_API_KEY") || getEnv("GOOGLE_API_KEY") || getEnv("GOOGLE_GENAI_API_KEY");
|
|
64
66
|
case "openrouter":
|
|
65
67
|
return getEnv("OPENROUTER_API_KEY");
|
|
66
68
|
case "openai":
|
package/src/utils/errors.ts
CHANGED
|
@@ -21,7 +21,9 @@ export function isTransientError(error: unknown): boolean {
|
|
|
21
21
|
NaN
|
|
22
22
|
);
|
|
23
23
|
if ([408, 425, 429, 500, 502, 503, 504].includes(status)) return true;
|
|
24
|
-
const code = String(
|
|
24
|
+
const code = String(
|
|
25
|
+
(err["providerCode"] as string | undefined) ?? (err["code"] as string | undefined) ?? ""
|
|
26
|
+
).toUpperCase();
|
|
25
27
|
if (["ECONNRESET", "ECONNREFUSED", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT", "EPIPE", "ETIMEDOUT"].includes(code)) {
|
|
26
28
|
return true;
|
|
27
29
|
}
|
|
@@ -114,9 +116,7 @@ export function toConciseProviderError(error: unknown, providerId: string, model
|
|
|
114
116
|
// Forwarded when present (e.g. OpenRouter's canonical `error_type`,
|
|
115
117
|
// which disambiguates lossy native codes). Survives re-wraps so retry
|
|
116
118
|
// helpers never strip it.
|
|
117
|
-
...(typeof err?.errorType === "string"
|
|
118
|
-
? { errorType: { value: err.errorType, enumerable: false } }
|
|
119
|
-
: {}),
|
|
119
|
+
...(typeof err?.errorType === "string" ? { errorType: { value: err.errorType, enumerable: false } } : {}),
|
|
120
120
|
// The provider's own error code (e.g. Google's `not_found` chain-eviction code, which carries no HTTP status on
|
|
121
121
|
// SSE error frames); `code` itself is the SDK kind (`"provider"`). Survives re-wraps so chain detectors never lose it.
|
|
122
122
|
...((typeof err?.code === "string" || typeof err?.code === "number") && err.code !== ""
|
|
@@ -148,11 +148,7 @@ const PART_TO_MODALITY: Record<string, "image" | "audio" | "video" | "pdf"> = {
|
|
|
148
148
|
* models (custom providers, dynamic routers) are skipped: the provider
|
|
149
149
|
* verdict stands and surfaces via toConciseProviderError instead. Throws a `ValidationError`.
|
|
150
150
|
*/
|
|
151
|
-
export function assertModalitiesSupported(
|
|
152
|
-
context: ProviderContext,
|
|
153
|
-
providerId: string,
|
|
154
|
-
modelId: string
|
|
155
|
-
): void {
|
|
151
|
+
export function assertModalitiesSupported(context: ProviderContext, providerId: string, modelId: string): void {
|
|
156
152
|
const needed = new Set<string>();
|
|
157
153
|
for (const msg of context.messages) {
|
|
158
154
|
if (!Array.isArray((msg as any)?.content)) continue;
|
|
@@ -191,11 +187,7 @@ export function assertModalitiesSupported(
|
|
|
191
187
|
* a clear one-liner instead; video stays Gemini-only, and OpenRouter Chat
|
|
192
188
|
* Completions carries `video_url` natively (no guard there). Throws a `ValidationError`.
|
|
193
189
|
*/
|
|
194
|
-
export function assertNoVideoPartsOnResponses(
|
|
195
|
-
context: ProviderContext,
|
|
196
|
-
providerId: string,
|
|
197
|
-
modelId: string
|
|
198
|
-
): void {
|
|
190
|
+
export function assertNoVideoPartsOnResponses(context: ProviderContext, providerId: string, modelId: string): void {
|
|
199
191
|
let hasVideo = false;
|
|
200
192
|
for (const msg of context.messages) {
|
|
201
193
|
if (!Array.isArray((msg as any)?.content)) continue;
|
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.6.0";
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
// User-Agent string with version and platform; in: AGENT_ACCEL_USER_AGENT, out: a string.
|
|
@@ -93,11 +93,7 @@ function findHeaderKey(headers: Record<string, string>, name: string): string |
|
|
|
93
93
|
* coexist on the wire.
|
|
94
94
|
* @example `setHeaderCaseInsensitive(headers, "Authorization", "Bearer k");`
|
|
95
95
|
*/
|
|
96
|
-
export function setHeaderCaseInsensitive(
|
|
97
|
-
headers: Record<string, string>,
|
|
98
|
-
name: string,
|
|
99
|
-
value: string
|
|
100
|
-
): void {
|
|
96
|
+
export function setHeaderCaseInsensitive(headers: Record<string, string>, name: string, value: string): void {
|
|
101
97
|
const existing = findHeaderKey(headers, name);
|
|
102
98
|
if (existing !== undefined && existing !== name) delete headers[existing];
|
|
103
99
|
headers[name] = value;
|
|
@@ -111,10 +107,7 @@ export function setHeaderCaseInsensitive(
|
|
|
111
107
|
* Returns true when a stored signature may be echoed on `currentProvider`:
|
|
112
108
|
* untagged (legacy) echoes everywhere, tagged echoes only on a match.
|
|
113
109
|
*/
|
|
114
|
-
export function shouldEchoSignature(
|
|
115
|
-
storedProvider: unknown,
|
|
116
|
-
currentProvider: string
|
|
117
|
-
): boolean {
|
|
110
|
+
export function shouldEchoSignature(storedProvider: unknown, currentProvider: string): boolean {
|
|
118
111
|
if (!storedProvider) return true;
|
|
119
112
|
return storedProvider === currentProvider;
|
|
120
113
|
}
|
package/src/utils/media.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// paths are loaded via a lazy dynamic import so browsers never resolve it.
|
|
3
3
|
import { bytesToBase64, base64ToBytes } from "./base64.ts";
|
|
4
4
|
import { fetchRemoteBytes } from "./safe-io.ts";
|
|
5
|
+
import { ValidationError } from "../types/errors.ts";
|
|
5
6
|
|
|
6
7
|
export interface NormalizedMedia {
|
|
7
8
|
mimeType: string;
|
|
@@ -63,7 +64,12 @@ export function sniffMimeType(bytes: Uint8Array): string | undefined {
|
|
|
63
64
|
if (ascii(bytes, 0, "FORM") && ascii(bytes, 8, "AIF")) return "audio/aiff";
|
|
64
65
|
if (bytes[0] === 0xff && ((bytes[1] ?? 0) & 0xf6) === 0xf0) return "audio/aac"; // ADTS: sync bits plus layer 00 (MP3 frames have a non-zero layer)
|
|
65
66
|
if (ascii(bytes, 0, "ID3") || (bytes[0] === 0xff && ((bytes[1] ?? 0) & 0xe0) === 0xe0)) return "audio/mp3";
|
|
66
|
-
if (ascii(bytes, 4, "ftyp"))
|
|
67
|
+
if (ascii(bytes, 4, "ftyp"))
|
|
68
|
+
return ascii(bytes, 8, "M4A") || ascii(bytes, 8, "M4B")
|
|
69
|
+
? "audio/m4a"
|
|
70
|
+
: ascii(bytes, 8, "qt")
|
|
71
|
+
? "video/quicktime"
|
|
72
|
+
: "video/mp4";
|
|
67
73
|
if (bytes[0] === 0 && bytes[1] === 0 && bytes[2] === 1 && bytes[3] === 0xba) return "video/mpeg"; // MPEG program stream
|
|
68
74
|
if (bytes[0] === 0x1a && bytes[1] === 0x45 && bytes[2] === 0xdf && bytes[3] === 0xa3) return "video/webm";
|
|
69
75
|
return undefined;
|
|
@@ -72,9 +78,15 @@ export function sniffMimeType(bytes: Uint8Array): string | undefined {
|
|
|
72
78
|
const GENERIC_MIME = new Set(["", "application/octet-stream", "binary/octet-stream"]);
|
|
73
79
|
|
|
74
80
|
/** Picks the best MIME type: explicit hint, then a specific candidate, then magic bytes, then the fallback. */
|
|
75
|
-
function resolveMime(
|
|
81
|
+
function resolveMime(
|
|
82
|
+
explicit: string | undefined,
|
|
83
|
+
candidate: string | null | undefined,
|
|
84
|
+
bytes: Uint8Array,
|
|
85
|
+
fallback: string
|
|
86
|
+
): string {
|
|
76
87
|
if (explicit) return explicit;
|
|
77
|
-
const specific =
|
|
88
|
+
const specific =
|
|
89
|
+
candidate && !GENERIC_MIME.has(candidate.split(";")[0]!.trim().toLowerCase()) ? candidate : undefined;
|
|
78
90
|
return specific || sniffMimeType(bytes) || candidate || fallback;
|
|
79
91
|
}
|
|
80
92
|
|
|
@@ -130,7 +142,7 @@ export async function normalizeMediaInput(
|
|
|
130
142
|
// but redirects are re-checked and the download is size-capped.
|
|
131
143
|
const response = await fetchRemoteBytes(input, { allowPrivateNetwork: true });
|
|
132
144
|
if (!response.ok) {
|
|
133
|
-
throw new
|
|
145
|
+
throw new ValidationError(`Failed to fetch media from URL: ${input} (${response.statusText})`);
|
|
134
146
|
}
|
|
135
147
|
const buffer = response.bytes;
|
|
136
148
|
const mimeType = resolveMime(explicitMimeType, response.contentType, buffer, inferMimeType(input));
|
|
@@ -175,10 +187,11 @@ export async function normalizeMediaInput(
|
|
|
175
187
|
base64Data,
|
|
176
188
|
dataUrl: `data:${mimeType};base64,${base64Data}`,
|
|
177
189
|
};
|
|
178
|
-
} catch
|
|
190
|
+
} catch {
|
|
179
191
|
// S11: don't silently return path string as base64 (would 400). Re-check if it's actually base64 with "/"
|
|
180
192
|
const trimmed = input.replace(/\s/g, "");
|
|
181
|
-
const isMaybeBase64 =
|
|
193
|
+
const isMaybeBase64 =
|
|
194
|
+
trimmed.length > 100 && /^[A-Za-z0-9+/=\n\r]+$/.test(trimmed.slice(0, 500)) && trimmed.length % 4 === 0;
|
|
182
195
|
if (isMaybeBase64) {
|
|
183
196
|
const mimeType = explicitMimeType || sniffBase64(trimmed) || "image/jpeg";
|
|
184
197
|
return {
|
|
@@ -187,9 +200,11 @@ export async function normalizeMediaInput(
|
|
|
187
200
|
dataUrl: `data:${mimeType};base64,${trimmed}`,
|
|
188
201
|
};
|
|
189
202
|
}
|
|
190
|
-
throw new
|
|
203
|
+
throw new ValidationError(
|
|
204
|
+
`Failed to normalize media input: not a valid file path, data URL, http URL, or base64 string: ${input.slice(0, 80)}`
|
|
205
|
+
);
|
|
191
206
|
}
|
|
192
207
|
}
|
|
193
208
|
|
|
194
|
-
throw new
|
|
209
|
+
throw new ValidationError(`Unsupported media input type: ${typeof input}`);
|
|
195
210
|
}
|
package/src/utils/raw-capture.ts
CHANGED
|
@@ -16,7 +16,11 @@ function byteLength(value: unknown): number {
|
|
|
16
16
|
function toolNames(tools: unknown): string[] | undefined {
|
|
17
17
|
if (!Array.isArray(tools)) return undefined;
|
|
18
18
|
const names = tools
|
|
19
|
-
.map(
|
|
19
|
+
.map(
|
|
20
|
+
(t) =>
|
|
21
|
+
(t as { name?: unknown; function?: { name?: unknown } })?.name ??
|
|
22
|
+
(t as { function?: { name?: unknown } })?.function?.name
|
|
23
|
+
)
|
|
20
24
|
.filter((n): n is string => typeof n === "string");
|
|
21
25
|
return names.length > 0 ? names : undefined;
|
|
22
26
|
}
|
|
@@ -56,7 +60,10 @@ function summarizeResponseBody(body: unknown): unknown {
|
|
|
56
60
|
* model output never leak through `response.raw` / `toJSON()` into logs.
|
|
57
61
|
* - `false`: keep only URL, method and HTTP status.
|
|
58
62
|
*/
|
|
59
|
-
export function applyCaptureRaw(
|
|
63
|
+
export function applyCaptureRaw(
|
|
64
|
+
raw: ProviderRawData | undefined,
|
|
65
|
+
mode: CaptureRawMode | undefined
|
|
66
|
+
): ProviderRawData | undefined {
|
|
60
67
|
if (!raw || mode === true) return raw;
|
|
61
68
|
const redactedMode = mode !== false; // undefined -> default "redacted"
|
|
62
69
|
const request = raw.request
|
package/src/utils/retry.ts
CHANGED
|
@@ -13,7 +13,18 @@ export function isRetryableRun(error: unknown): boolean {
|
|
|
13
13
|
if (!error || isAbortError(error)) return false;
|
|
14
14
|
const name = (error as { name?: unknown }).name;
|
|
15
15
|
if (name === "TimeoutError") return true; // the loop's own request / first-byte / idle timeouts
|
|
16
|
-
if (
|
|
16
|
+
if (
|
|
17
|
+
[
|
|
18
|
+
"BudgetExceededError",
|
|
19
|
+
"ConfigError",
|
|
20
|
+
"ValidationError",
|
|
21
|
+
"SecurityPolicyError",
|
|
22
|
+
"SessionLoadError",
|
|
23
|
+
"ThinkingLevelError",
|
|
24
|
+
"ToolError",
|
|
25
|
+
].includes(String(name))
|
|
26
|
+
)
|
|
27
|
+
return false;
|
|
17
28
|
return isTransientError(error);
|
|
18
29
|
}
|
|
19
30
|
|
|
@@ -24,8 +35,9 @@ export function readRetryAfterMs(error: unknown): number | undefined {
|
|
|
24
35
|
const direct = err["retryAfterMs"];
|
|
25
36
|
if (typeof direct === "number" && Number.isFinite(direct) && direct >= 0) return Math.min(60_000, direct);
|
|
26
37
|
const raw =
|
|
27
|
-
((err["response"] as Record<string, unknown> | undefined)?.["headers"] as Record<string, unknown> | undefined)?.[
|
|
28
|
-
|
|
38
|
+
((err["response"] as Record<string, unknown> | undefined)?.["headers"] as Record<string, unknown> | undefined)?.[
|
|
39
|
+
"retry-after"
|
|
40
|
+
] ?? (err["headers"] as Record<string, unknown> | undefined)?.["retry-after"];
|
|
29
41
|
if (raw === undefined) return undefined;
|
|
30
42
|
const secs = Number(Array.isArray(raw) ? raw[0] : raw);
|
|
31
43
|
if (!Number.isFinite(secs) || secs < 0) return undefined;
|
package/src/utils/safe-io.ts
CHANGED
|
@@ -139,19 +139,31 @@ function isPrivateHostname(host: string): boolean {
|
|
|
139
139
|
// Throws RemoteFetchError for a non-http(s) URL or, unless private hosts are allowed, a private host (also by DNS result); in: fetchRemoteBytes on every hop, out: nothing or a thrown error.
|
|
140
140
|
async function assertRemoteUrlAllowed(url: URL, allowPrivateNetwork: boolean): Promise<void> {
|
|
141
141
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
142
|
-
throw new RemoteFetchError(
|
|
142
|
+
throw new RemoteFetchError(
|
|
143
|
+
"blocked",
|
|
144
|
+
`[Agent Accelerator] Only http(s) URLs are allowed (got ${url.protocol}).`,
|
|
145
|
+
url.href
|
|
146
|
+
);
|
|
143
147
|
}
|
|
144
148
|
if (allowPrivateNetwork) return;
|
|
145
149
|
const host = url.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
146
150
|
if (isPrivateHostname(host)) {
|
|
147
|
-
throw new RemoteFetchError(
|
|
151
|
+
throw new RemoteFetchError(
|
|
152
|
+
"blocked",
|
|
153
|
+
`[Agent Accelerator] Refusing to fetch private/internal host "${host}".`,
|
|
154
|
+
url.href
|
|
155
|
+
);
|
|
148
156
|
}
|
|
149
157
|
if (/^[\d.]+$/.test(host) || host.includes(":")) return; // IP literal already checked
|
|
150
158
|
try {
|
|
151
159
|
const dns = await import("node:dns");
|
|
152
160
|
const addrs = await dns.promises.lookup(host, { all: true });
|
|
153
161
|
if (addrs.some((a) => isPrivateAddress(a.address))) {
|
|
154
|
-
throw new RemoteFetchError(
|
|
162
|
+
throw new RemoteFetchError(
|
|
163
|
+
"blocked",
|
|
164
|
+
`[Agent Accelerator] Host "${host}" resolves to a private/internal address.`,
|
|
165
|
+
url.href
|
|
166
|
+
);
|
|
155
167
|
}
|
|
156
168
|
} catch (err) {
|
|
157
169
|
if (err instanceof RemoteFetchError) throw err;
|
|
@@ -169,7 +181,9 @@ async function readCapped(res: Response, maxBytes: number, url: string): Promise
|
|
|
169
181
|
);
|
|
170
182
|
const declared = Number(res.headers.get("content-length"));
|
|
171
183
|
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
172
|
-
try {
|
|
184
|
+
try {
|
|
185
|
+
await res.body?.cancel();
|
|
186
|
+
} catch {}
|
|
173
187
|
throw tooLarge();
|
|
174
188
|
}
|
|
175
189
|
if (!res.body) {
|
|
@@ -185,7 +199,9 @@ async function readCapped(res: Response, maxBytes: number, url: string): Promise
|
|
|
185
199
|
if (done) break;
|
|
186
200
|
total += value.byteLength;
|
|
187
201
|
if (total > maxBytes) {
|
|
188
|
-
try {
|
|
202
|
+
try {
|
|
203
|
+
await reader.cancel();
|
|
204
|
+
} catch {}
|
|
189
205
|
throw tooLarge();
|
|
190
206
|
}
|
|
191
207
|
chunks.push(value);
|
|
@@ -227,7 +243,11 @@ export async function fetchRemoteBytes(
|
|
|
227
243
|
if ((res as { type?: string }).type === "opaqueredirect") {
|
|
228
244
|
// Browser runtimes hide the Location header: only trusted callers may follow blindly.
|
|
229
245
|
if (!allowPrivate) {
|
|
230
|
-
throw new RemoteFetchError(
|
|
246
|
+
throw new RemoteFetchError(
|
|
247
|
+
"blocked",
|
|
248
|
+
"[Agent Accelerator] Redirects cannot be validated in this runtime.",
|
|
249
|
+
current.href
|
|
250
|
+
);
|
|
231
251
|
}
|
|
232
252
|
const followed = await fetch(current.href, { signal: opts.signal });
|
|
233
253
|
return {
|
|
@@ -240,7 +260,9 @@ export async function fetchRemoteBytes(
|
|
|
240
260
|
}
|
|
241
261
|
const location = res.status >= 300 && res.status < 400 ? res.headers.get("location") : null;
|
|
242
262
|
if (location) {
|
|
243
|
-
try {
|
|
263
|
+
try {
|
|
264
|
+
await res.body?.cancel();
|
|
265
|
+
} catch {}
|
|
244
266
|
current = new URL(location, current);
|
|
245
267
|
continue;
|
|
246
268
|
}
|
|
@@ -53,10 +53,7 @@ export function toJsonSafe(value: unknown, seen = new WeakSet<object>()): unknow
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
if (value instanceof Map) {
|
|
56
|
-
return Array.from(value.entries()).map(([key, entry]) => [
|
|
57
|
-
toJsonSafe(key, seen),
|
|
58
|
-
toJsonSafe(entry, seen),
|
|
59
|
-
]);
|
|
56
|
+
return Array.from(value.entries()).map(([key, entry]) => [toJsonSafe(key, seen), toJsonSafe(entry, seen)]);
|
|
60
57
|
}
|
|
61
58
|
|
|
62
59
|
if (value instanceof Set) {
|
package/src/utils/session.ts
CHANGED
|
@@ -15,8 +15,7 @@ export function createSessionId(prefix?: string): string {
|
|
|
15
15
|
const head = prefix ? `${prefix}-` : "";
|
|
16
16
|
let id: string;
|
|
17
17
|
try {
|
|
18
|
-
const webCrypto =
|
|
19
|
-
(globalThis as any)?.crypto ?? (typeof crypto !== "undefined" ? crypto : undefined);
|
|
18
|
+
const webCrypto = (globalThis as any)?.crypto ?? (typeof crypto !== "undefined" ? crypto : undefined);
|
|
20
19
|
if (webCrypto && typeof webCrypto.randomUUID === "function") {
|
|
21
20
|
id = `${head}${webCrypto.randomUUID()}`;
|
|
22
21
|
} else {
|