agent-accelerator 0.5.6 → 0.5.8
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 +10 -17
- package/package.json +1 -1
- package/src/agent/agent.ts +92 -57
- package/src/agent/architecture.md +25 -0
- package/src/agent/context.ts +7 -3
- package/src/agent/delegation.ts +30 -96
- package/src/agent/loop.ts +54 -56
- package/src/agent/subagent.ts +4 -4
- package/src/agent/worker.ts +1 -3
- package/src/architecture.md +28 -0
- package/src/index.ts +13 -80
- package/src/models/architecture.md +14 -0
- package/src/models/catalog-cache.ts +10 -7
- package/src/models/catalog.ts +20 -13
- package/src/providers/architecture.md +24 -0
- package/src/providers/google.ts +24 -4
- package/src/providers/openai-compat.ts +29 -7
- package/src/providers/openai.ts +22 -6
- package/src/providers/openrouter.ts +14 -2
- package/src/providers/registry.ts +1 -0
- package/src/providers/shared.ts +4 -4
- package/src/providers.ts +9 -53
- package/src/scheduler/architecture.md +22 -0
- package/src/scheduler/scheduler.ts +131 -56
- package/src/scheduler/store.ts +274 -54
- package/src/scheduler/time.ts +2 -0
- package/src/session/architecture.md +19 -0
- package/src/session/store.ts +37 -22
- package/src/session/usage.ts +1 -0
- package/src/streaming/architecture.md +13 -0
- package/src/streaming/event-stream.ts +16 -11
- package/src/telemetry-file.ts +74 -24
- package/src/telemetry.ts +3 -2
- package/src/tools/architecture.md +19 -0
- package/src/tools/executor.ts +14 -1
- package/src/tools/schema.ts +1 -0
- package/src/tools/subagent-result.ts +1 -0
- package/src/tools/tool.ts +8 -8
- package/src/types/agent.ts +22 -20
- package/src/types/architecture.md +20 -0
- package/src/types/response.ts +8 -8
- package/src/types/scheduler.ts +2 -2
- package/src/types/tool.ts +5 -5
- package/src/update-models.ts +2 -2
- package/src/utils/architecture.md +25 -0
- package/src/utils/base64.ts +2 -0
- package/src/utils/cache.ts +6 -4
- package/src/utils/durable.ts +78 -3
- package/src/utils/env.ts +1 -1
- package/src/utils/errors.ts +4 -0
- package/src/utils/headers.ts +7 -4
- package/src/utils/home.ts +1 -1
- package/src/utils/media.ts +1 -0
- package/src/utils/raw-capture.ts +3 -0
- package/src/utils/retry.ts +2 -1
- package/src/utils/safe-io.ts +4 -0
- package/src/utils/session.ts +10 -166
package/src/utils/durable.ts
CHANGED
|
@@ -91,7 +91,7 @@ export function readWithBackup(file: string, isValid: (text: string) => boolean)
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
// ---------------------------------------------------------------------------
|
|
94
|
-
// One process per folder
|
|
94
|
+
// One process per folder (used for session folders)
|
|
95
95
|
// ---------------------------------------------------------------------------
|
|
96
96
|
|
|
97
97
|
/** Folders this process already holds (the lock is re-entrant: many Agents share one home). */
|
|
@@ -103,8 +103,8 @@ function pause(ms: number): void {
|
|
|
103
103
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
|
|
107
|
-
function processAlive(pid: number): boolean {
|
|
106
|
+
/** True while process `pid` exists (EPERM means it exists but belongs to someone else); in: a pid from a lock or heartbeat file, out: the stale checks. */
|
|
107
|
+
export function processAlive(pid: number): boolean {
|
|
108
108
|
try {
|
|
109
109
|
process.kill(pid, 0);
|
|
110
110
|
return true;
|
|
@@ -216,3 +216,78 @@ export function lockFolder(folder: string): void {
|
|
|
216
216
|
}
|
|
217
217
|
}
|
|
218
218
|
}
|
|
219
|
+
|
|
220
|
+
/** Longest a short mutex may be held before it counts as stale even though its owner process is alive (a hung holder). */
|
|
221
|
+
const MUTEX_MAX_AGE_MS = 30_000;
|
|
222
|
+
/** Longest a caller waits for a short mutex before getting a ConfigError. */
|
|
223
|
+
const MUTEX_WAIT_MS = 10_000;
|
|
224
|
+
/** Mutex files this process holds right now; a second `withFileMutex` on the same file inside `fn` would deadlock, so it throws. */
|
|
225
|
+
const heldMutexes = new Set<string>();
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Runs `fn` while holding a short mutex file (`lockFile`, created with O_EXCL, content = pid, host, time), so several
|
|
229
|
+
* processes can change one shared file without losing updates. Hold it for milliseconds only: `fn` must be synchronous and
|
|
230
|
+
* quick. A waiter retries every few ms; a holder whose pid is gone (same host) or whose lock is older than 30 s is taken
|
|
231
|
+
* over. Not re-entrant (a nested call on the same file throws). The folder of `lockFile` must exist: if it is removed meanwhile
|
|
232
|
+
* (its owner deleted it) the call throws the plain `ENOENT` error, and the caller decides what that means. Throws
|
|
233
|
+
* `ConfigError` after 10 s of waiting. Local disks only.
|
|
234
|
+
*/
|
|
235
|
+
export function withFileMutex<T>(lockFile: string, fn: () => T): T {
|
|
236
|
+
const file = path.resolve(lockFile);
|
|
237
|
+
if (heldMutexes.has(file)) throw new ConfigError(`[Agent Accelerator] Mutex "${file}" is already held by this process (nested use).`);
|
|
238
|
+
const mine = JSON.stringify({ pid: process.pid, host: os.hostname(), at: Date.now() });
|
|
239
|
+
const deadline = Date.now() + MUTEX_WAIT_MS;
|
|
240
|
+
let unreadable = 0;
|
|
241
|
+
for (;;) {
|
|
242
|
+
try {
|
|
243
|
+
const fd = fs.openSync(file, "wx", 0o600);
|
|
244
|
+
try {
|
|
245
|
+
fs.writeFileSync(fd, mine);
|
|
246
|
+
} finally {
|
|
247
|
+
fs.closeSync(fd);
|
|
248
|
+
}
|
|
249
|
+
break;
|
|
250
|
+
} catch (err) {
|
|
251
|
+
if ((err as { code?: string }).code !== "EEXIST") throw err;
|
|
252
|
+
}
|
|
253
|
+
let text = "";
|
|
254
|
+
let mtime = 0;
|
|
255
|
+
try {
|
|
256
|
+
text = fs.readFileSync(file, "utf8");
|
|
257
|
+
mtime = fs.statSync(file).mtimeMs;
|
|
258
|
+
} catch {
|
|
259
|
+
continue; // released between the open and the read: try again
|
|
260
|
+
}
|
|
261
|
+
let owner: { pid?: unknown; host?: unknown } | undefined;
|
|
262
|
+
try {
|
|
263
|
+
owner = JSON.parse(text);
|
|
264
|
+
} catch {
|
|
265
|
+
owner = undefined;
|
|
266
|
+
}
|
|
267
|
+
let stale = false;
|
|
268
|
+
if (typeof owner?.pid !== "number") stale = ++unreadable > 5; // empty or garbage: another process may be mid-write
|
|
269
|
+
else if (owner.host === os.hostname() && !processAlive(owner.pid)) stale = true;
|
|
270
|
+
else if (Date.now() - mtime > MUTEX_MAX_AGE_MS) stale = true;
|
|
271
|
+
if (stale) {
|
|
272
|
+
try {
|
|
273
|
+
if (fs.readFileSync(file, "utf8") === text) fs.rmSync(file, { force: true });
|
|
274
|
+
} catch {
|
|
275
|
+
// why: it vanished: the next loop turn creates it.
|
|
276
|
+
}
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (Date.now() > deadline) throw new ConfigError(`[Agent Accelerator] Timed out after ${MUTEX_WAIT_MS} ms waiting for the lock "${file}" (held by process ${String(owner?.pid)}).`);
|
|
280
|
+
pause(typeof owner?.pid === "number" ? 2 : 40);
|
|
281
|
+
}
|
|
282
|
+
heldMutexes.add(file);
|
|
283
|
+
try {
|
|
284
|
+
return fn();
|
|
285
|
+
} finally {
|
|
286
|
+
heldMutexes.delete(file);
|
|
287
|
+
try {
|
|
288
|
+
fs.rmSync(file, { force: true });
|
|
289
|
+
} catch {
|
|
290
|
+
// why: a leftover mutex is detected as stale by the next caller.
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
package/src/utils/env.ts
CHANGED
package/src/utils/errors.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { getModelFromCatalog } from "../models/catalog.ts";
|
|
|
3
3
|
|
|
4
4
|
const CONCISE = "__agentAccelConcise";
|
|
5
5
|
|
|
6
|
+
// HTTP status from the shapes provider errors carry, else undefined; in: toConciseProviderError, out: a status.
|
|
6
7
|
function readStatus(error: any): number | undefined {
|
|
7
8
|
const s =
|
|
8
9
|
error?.statusCode ??
|
|
@@ -14,6 +15,7 @@ function readStatus(error: any): number | undefined {
|
|
|
14
15
|
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
// The request URL of an error without its query string; in: toConciseProviderError, out: a string.
|
|
17
19
|
function readUrl(error: any): string {
|
|
18
20
|
const raw =
|
|
19
21
|
(typeof error?.url === "string" && error.url) ||
|
|
@@ -22,6 +24,7 @@ function readUrl(error: any): string {
|
|
|
22
24
|
return raw.split("?")[0];
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
// The provider's own message from the error's data, body or message; in: toConciseProviderError, out: one trimmed line.
|
|
25
28
|
function readProviderMessage(error: any): string {
|
|
26
29
|
const data = error?.data;
|
|
27
30
|
if (typeof data?.error?.message === "string" && data.error.message.trim()) return data.error.message.trim();
|
|
@@ -39,6 +42,7 @@ function readProviderMessage(error: any): string {
|
|
|
39
42
|
return String(error?.message ?? error).slice(0, 300);
|
|
40
43
|
}
|
|
41
44
|
|
|
45
|
+
// `provider/model` without doubling an existing prefix; in: the error messages, out: a label.
|
|
42
46
|
function displayModel(providerId: string, modelId: string): string {
|
|
43
47
|
if (modelId.toLowerCase().startsWith(`${providerId.toLowerCase()}/`)) return modelId;
|
|
44
48
|
return `${providerId}/${modelId}`;
|
package/src/utils/headers.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ProviderId } from "../types/model.ts";
|
|
2
2
|
import { clampCacheKey } from "./cache.ts";
|
|
3
3
|
|
|
4
|
+
/** True in a browser, worker or React Native scope, where custom headers cause CORS preflights; in: header building, out: a boolean. */
|
|
4
5
|
export function isBrowserRuntime(): boolean {
|
|
5
6
|
try {
|
|
6
7
|
const g = globalThis as any;
|
|
@@ -23,9 +24,10 @@ export function getPackageVersion(): string {
|
|
|
23
24
|
const v = (globalThis as any).process?.env?.npm_package_version;
|
|
24
25
|
if (typeof v === "string" && v) return v;
|
|
25
26
|
} catch {}
|
|
26
|
-
return "0.5.
|
|
27
|
+
return "0.5.8";
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
// User-Agent string with version and platform; in: AGENT_ACCEL_USER_AGENT, out: a string.
|
|
29
31
|
function getAgentAccelUserAgent(): string {
|
|
30
32
|
const version = getPackageVersion();
|
|
31
33
|
try {
|
|
@@ -41,7 +43,7 @@ function getAgentAccelUserAgent(): string {
|
|
|
41
43
|
export const AGENT_ACCEL_USER_AGENT = getAgentAccelUserAgent;
|
|
42
44
|
|
|
43
45
|
// ---------------------------------------------------------------------------
|
|
44
|
-
// Attribution
|
|
46
|
+
// Attribution: configurable, default none except OpenRouter legacy.
|
|
45
47
|
// ---------------------------------------------------------------------------
|
|
46
48
|
|
|
47
49
|
/** Configurable attribution headers (OpenRouter `HTTP-Referer` / `X-Title`). */
|
|
@@ -102,7 +104,7 @@ export function setHeaderCaseInsensitive(
|
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
// ---------------------------------------------------------------------------
|
|
105
|
-
// Thought-signature namespacing
|
|
107
|
+
// Thought-signature namespacing.
|
|
106
108
|
// ---------------------------------------------------------------------------
|
|
107
109
|
|
|
108
110
|
/**
|
|
@@ -125,6 +127,7 @@ const BROWSER_DROPPED = new Set([
|
|
|
125
127
|
"x-goog-api-client",
|
|
126
128
|
]);
|
|
127
129
|
|
|
130
|
+
// Drops the headers a browser may not or should not send; in: buildSessionHeaders, out: the safe headers.
|
|
128
131
|
function stripForBrowser(headers: Record<string, string>): Record<string, string> {
|
|
129
132
|
const out: Record<string, string> = {};
|
|
130
133
|
for (const [k, v] of Object.entries(headers)) {
|
|
@@ -137,7 +140,7 @@ function stripForBrowser(headers: Record<string, string>): Record<string, string
|
|
|
137
140
|
/**
|
|
138
141
|
* Builds provider-specific session, cache-affinity, and attribution headers.
|
|
139
142
|
*
|
|
140
|
-
*
|
|
143
|
+
* Attribution is configurable via `setAttribution()` (default none; no
|
|
141
144
|
* referer/title is ever sent unless you set one). Custom (non-first-class)
|
|
142
145
|
* prefixes receive minimal `x-session-id`/`x-client-request-id` affinity only
|
|
143
146
|
* (no `session_id` alias, no attribution) when a session id is present.
|
package/src/utils/home.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { getEnv } from "./env.ts";
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* The Agent Accelerator home: ONE folder that holds everything the SDK stores
|
|
6
|
-
* (`sessions/`, `
|
|
6
|
+
* (`sessions/`, `scheduled/`, `telemetry/`, `catalog/`), so nothing is
|
|
7
7
|
* scattered across the project or the package.
|
|
8
8
|
*/
|
|
9
9
|
let defaultHome: string | undefined;
|
package/src/utils/media.ts
CHANGED
|
@@ -38,6 +38,7 @@ export function inferMimeType(input: string, fallback = "application/octet-strea
|
|
|
38
38
|
return fallback;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// True when the bytes spell `text` at `start`; in: sniffMimeType, out: a boolean.
|
|
41
42
|
const ascii = (bytes: Uint8Array, start: number, text: string): boolean =>
|
|
42
43
|
bytes.length >= start + text.length && [...text].every((ch, i) => bytes[start + i] === ch.charCodeAt(0));
|
|
43
44
|
|
package/src/utils/raw-capture.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { ProviderRawData } from "../types/model.ts";
|
|
|
3
3
|
/** How much of the wire exchange `AgentResponse.raw` keeps (see `AgentConfig.captureRaw`). */
|
|
4
4
|
export type CaptureRawMode = false | "redacted" | true;
|
|
5
5
|
|
|
6
|
+
// Serialized length of a value, 0 if it cannot be serialized; in: the body summaries, out: a number.
|
|
6
7
|
function byteLength(value: unknown): number {
|
|
7
8
|
try {
|
|
8
9
|
return JSON.stringify(value)?.length ?? 0;
|
|
@@ -11,6 +12,7 @@ function byteLength(value: unknown): number {
|
|
|
11
12
|
}
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
// Tool names from a request body's `tools`, if any; in: summarizeRequestBody, out: names or undefined.
|
|
14
16
|
function toolNames(tools: unknown): string[] | undefined {
|
|
15
17
|
if (!Array.isArray(tools)) return undefined;
|
|
16
18
|
const names = tools
|
|
@@ -34,6 +36,7 @@ function summarizeRequestBody(body: unknown): unknown {
|
|
|
34
36
|
};
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
// Content-free summary of a response body (size, id, status); in: applyCaptureRaw, out: the summary.
|
|
37
40
|
function summarizeResponseBody(body: unknown): unknown {
|
|
38
41
|
if (body === null || body === undefined) return body;
|
|
39
42
|
const b = (typeof body === "object" ? body : {}) as Record<string, unknown>;
|
package/src/utils/retry.ts
CHANGED
|
@@ -6,7 +6,7 @@ export { isAbortError };
|
|
|
6
6
|
/**
|
|
7
7
|
* Checks whether a provider error is transient and safe to retry.
|
|
8
8
|
* Structured-data only: status / code / transient flag. Never message text,
|
|
9
|
-
* so a 400 mentioning "512 tokens" is not retried
|
|
9
|
+
* so a 400 mentioning "512 tokens" is not retried.
|
|
10
10
|
*/
|
|
11
11
|
export function isTransientError(error: unknown): boolean {
|
|
12
12
|
if (!error) return false;
|
|
@@ -43,6 +43,7 @@ export function isRetryableRun(error: unknown): boolean {
|
|
|
43
43
|
return isTransientError(error);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/** The server's wait hint in ms from `retryAfterMs` or a Retry-After header, capped at 60 s; in: withRetries, out: ms or undefined. */
|
|
46
47
|
export function readRetryAfterMs(error: unknown): number | undefined {
|
|
47
48
|
const err = error as Record<string, unknown>;
|
|
48
49
|
// Preferred: milliseconds attached by the adapter from the Retry-After header.
|
package/src/utils/safe-io.ts
CHANGED
|
@@ -98,6 +98,7 @@ export async function enforceFileAccess(messages: Message[], roots: readonly str
|
|
|
98
98
|
// Remote fetch guard (SSRF + size cap)
|
|
99
99
|
// ---------------------------------------------------------------------------
|
|
100
100
|
|
|
101
|
+
// True for the private, loopback, link-local and CGNAT IPv4 ranges; in: isPrivateAddress, out: a boolean.
|
|
101
102
|
function ipv4Private(a: number, b: number): boolean {
|
|
102
103
|
return (
|
|
103
104
|
a === 0 ||
|
|
@@ -124,6 +125,7 @@ export function isPrivateAddress(address: string): boolean {
|
|
|
124
125
|
return false;
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
// True for localhost, .local/.internal names and private addresses; in: assertRemoteUrlAllowed, out: a boolean.
|
|
127
129
|
function isPrivateHostname(host: string): boolean {
|
|
128
130
|
return (
|
|
129
131
|
host === "localhost" ||
|
|
@@ -134,6 +136,7 @@ function isPrivateHostname(host: string): boolean {
|
|
|
134
136
|
);
|
|
135
137
|
}
|
|
136
138
|
|
|
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.
|
|
137
140
|
async function assertRemoteUrlAllowed(url: URL, allowPrivateNetwork: boolean): Promise<void> {
|
|
138
141
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
139
142
|
throw new RemoteFetchError("blocked", `[Agent Accelerator] Only http(s) URLs are allowed (got ${url.protocol}).`, url.href);
|
|
@@ -156,6 +159,7 @@ async function assertRemoteUrlAllowed(url: URL, allowPrivateNetwork: boolean): P
|
|
|
156
159
|
}
|
|
157
160
|
}
|
|
158
161
|
|
|
162
|
+
// Reads a response body up to `maxBytes`, throwing too_large as soon as the declared or streamed size passes it; in: fetchRemoteBytes, out: the bytes.
|
|
159
163
|
async function readCapped(res: Response, maxBytes: number, url: string): Promise<Uint8Array> {
|
|
160
164
|
const tooLarge = () =>
|
|
161
165
|
new RemoteFetchError(
|
package/src/utils/session.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { clampCacheKey } from "./cache.ts";
|
|
2
2
|
import type { ThinkingLevel } from "../types/core.ts";
|
|
3
|
-
import type { MidSessionMode } from "../types/agent.ts";
|
|
4
|
-
import type { AgentConfig } from "../types/agent.ts";
|
|
5
3
|
import type { ModelSpec } from "../types/model.ts";
|
|
6
4
|
import type { ModelProviderInstance } from "../providers/registry.ts";
|
|
7
5
|
import type { ContentPart } from "../types/message.ts";
|
|
@@ -68,7 +66,7 @@ export function createTrackingId(): string {
|
|
|
68
66
|
}
|
|
69
67
|
|
|
70
68
|
/**
|
|
71
|
-
* Monotonic clock for measuring durations
|
|
69
|
+
* Monotonic clock for measuring durations. Uses `performance.now()`
|
|
72
70
|
* when available, falling back to `Date.now()`. Differences on the same
|
|
73
71
|
* clock are valid durations; never treat the value as wall-clock epoch.
|
|
74
72
|
*
|
|
@@ -83,6 +81,7 @@ export function nowMs(): number {
|
|
|
83
81
|
return Date.now();
|
|
84
82
|
}
|
|
85
83
|
|
|
84
|
+
// 32 random lowercase hex characters; in: newRunId/newTurnId, out: a string.
|
|
86
85
|
function random32Hex(): string {
|
|
87
86
|
try {
|
|
88
87
|
const c = (globalThis as any)?.crypto;
|
|
@@ -96,7 +95,7 @@ function random32Hex(): string {
|
|
|
96
95
|
}
|
|
97
96
|
|
|
98
97
|
/**
|
|
99
|
-
* New stable run id: 32 lowercase hex chars (crypto.randomUUID, no dashes)
|
|
98
|
+
* New stable run id: 32 lowercase hex chars (crypto.randomUUID, no dashes).
|
|
100
99
|
* Opaque lineage token attached via `(metadata as any).runId`.
|
|
101
100
|
*/
|
|
102
101
|
export function newRunId(): string {
|
|
@@ -104,21 +103,14 @@ export function newRunId(): string {
|
|
|
104
103
|
}
|
|
105
104
|
|
|
106
105
|
/**
|
|
107
|
-
* New stable turn id: 32 lowercase hex chars (crypto.randomUUID, no dashes)
|
|
106
|
+
* New stable turn id: 32 lowercase hex chars (crypto.randomUUID, no dashes).
|
|
108
107
|
* Opaque lineage token attached via `(metadata as any).turnId`.
|
|
109
108
|
*/
|
|
110
109
|
export function newTurnId(): string {
|
|
111
110
|
return random32Hex();
|
|
112
111
|
}
|
|
113
112
|
|
|
114
|
-
/**
|
|
115
|
-
* Single canonical prompt preview (Q-49): merges the former
|
|
116
|
-
* `previewPrompt` (agent.ts) / `steerPreview` (loop.ts) duplicates.
|
|
117
|
-
* Bounded to 500 chars, display-only. Other modules own their copies;
|
|
118
|
-
* new code should import this one.
|
|
119
|
-
*
|
|
120
|
-
* @example `previewPrompt("hello")`
|
|
121
|
-
*/
|
|
113
|
+
/** Bounded (500 chars), display-only preview of a prompt, used in `queued`/`steer_injected` events, turn lists and `interrupted()`. */
|
|
122
114
|
export function previewPrompt(prompt: string | ContentPart[]): string {
|
|
123
115
|
if (typeof prompt === "string") return prompt.slice(0, 500);
|
|
124
116
|
try {
|
|
@@ -132,7 +124,7 @@ export function previewPrompt(prompt: string | ContentPart[]): string {
|
|
|
132
124
|
}
|
|
133
125
|
}
|
|
134
126
|
|
|
135
|
-
/** Parsed model reference returned by {@link parseModelRef}
|
|
127
|
+
/** Parsed model reference returned by {@link parseModelRef}. */
|
|
136
128
|
export interface ParsedModelRef {
|
|
137
129
|
provider: string;
|
|
138
130
|
modelId: string;
|
|
@@ -141,6 +133,7 @@ export interface ParsedModelRef {
|
|
|
141
133
|
thinking?: ThinkingLevel;
|
|
142
134
|
}
|
|
143
135
|
|
|
136
|
+
// Splits `provider/model` on the first slash (bare ids default to openrouter); in: parseModelRef, out: provider and model id.
|
|
144
137
|
function splitModelString(raw: string): { provider: string; modelId: string } {
|
|
145
138
|
const trimmed = raw.trim();
|
|
146
139
|
const slash = trimmed.indexOf("/");
|
|
@@ -153,12 +146,14 @@ function splitModelString(raw: string): { provider: string; modelId: string } {
|
|
|
153
146
|
};
|
|
154
147
|
}
|
|
155
148
|
|
|
149
|
+
// Type guard for a `{ model: string }` helper result; in: parseModelRef, out: a boolean.
|
|
156
150
|
function isModelProviderInstance(v: unknown): v is ModelProviderInstance {
|
|
157
151
|
if (!v || typeof v !== "object" || Array.isArray(v)) return false;
|
|
158
152
|
const r = v as Record<string, unknown>;
|
|
159
153
|
return typeof r["model"] === "string" && (r["model"] as string).length > 0;
|
|
160
154
|
}
|
|
161
155
|
|
|
156
|
+
// Type guard for a catalog spec (`provider` and `id` strings); in: parseModelRef, out: a boolean.
|
|
162
157
|
function isModelSpec(v: unknown): v is ModelSpec {
|
|
163
158
|
if (!v || typeof v !== "object" || Array.isArray(v)) return false;
|
|
164
159
|
const r = v as Record<string, unknown>;
|
|
@@ -166,7 +161,7 @@ function isModelSpec(v: unknown): v is ModelSpec {
|
|
|
166
161
|
}
|
|
167
162
|
|
|
168
163
|
/**
|
|
169
|
-
* Single model-ref parser
|
|
164
|
+
* Single model-ref parser. Table-driven, no env sniffing:
|
|
170
165
|
* - string `"provider/model"` → split on first `/`; bare ids default to `openrouter`.
|
|
171
166
|
* - `ModelProviderInstance` (`{ model, apiKey?, baseUrl?, thinkingLevel? }`) → parse `model`, passthrough creds.
|
|
172
167
|
* - `ModelSpec` (`{ provider, id }`) → direct mapping.
|
|
@@ -192,154 +187,3 @@ export function parseModelRef(input: string | ModelSpec | ModelProviderInstance)
|
|
|
192
187
|
}
|
|
193
188
|
throw new ConfigError("parseModelRef: expected string | ModelSpec | ModelProviderInstance.");
|
|
194
189
|
}
|
|
195
|
-
|
|
196
|
-
/** Validated, readonly agent configuration returned by {@link resolveAgentConfig} (Q-32). */
|
|
197
|
-
export interface ResolvedAgentConfig {
|
|
198
|
-
readonly model: NonNullable<AgentConfig["model"]>;
|
|
199
|
-
readonly thinkingLevel?: ThinkingLevel;
|
|
200
|
-
readonly maxTurns: number;
|
|
201
|
-
readonly midSession: { readonly mode: MidSessionMode; readonly maxQueued: number };
|
|
202
|
-
readonly toolNames: readonly string[];
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
const VALID_THINKING_LEVELS: readonly ThinkingLevel[] = [
|
|
206
|
-
"none",
|
|
207
|
-
"dynamic",
|
|
208
|
-
"minimal",
|
|
209
|
-
"low",
|
|
210
|
-
"medium",
|
|
211
|
-
"high",
|
|
212
|
-
"xhigh",
|
|
213
|
-
"max",
|
|
214
|
-
];
|
|
215
|
-
|
|
216
|
-
const VALID_MIDSESSION_MODES: readonly MidSessionMode[] = ["steer", "queue", "auto"];
|
|
217
|
-
|
|
218
|
-
/** Local name normalization mirroring `normalizeToolName` (no import cycle, Q-32). */
|
|
219
|
-
function normalizeConfigToolName(name: string): string {
|
|
220
|
-
return name
|
|
221
|
-
.trim()
|
|
222
|
-
.replace(/^(?:functions?|tools?)\./i, "")
|
|
223
|
-
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
224
|
-
.replace(/[\s-]+/g, "_")
|
|
225
|
-
.toLowerCase();
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Single config resolution (Q-32). Validates `model`, `thinkingLevel`,
|
|
230
|
-
* `maxTurns`, `midSession`, and tool-name collisions, collecting EVERY issue
|
|
231
|
-
* and throwing one `ConfigError` listing all of them. Not wired into `Agent`
|
|
232
|
-
* (other owner) — call it from setup paths that want fail-fast validation.
|
|
233
|
-
*
|
|
234
|
-
* @example `const cfg = resolveAgentConfig({ model, maxTurns: 10 });`
|
|
235
|
-
*/
|
|
236
|
-
export function resolveAgentConfig(config: AgentConfig): Readonly<ResolvedAgentConfig> {
|
|
237
|
-
const issues: string[] = [];
|
|
238
|
-
const cfg = (config ?? {}) as AgentConfig;
|
|
239
|
-
|
|
240
|
-
let model: NonNullable<AgentConfig["model"]> | undefined;
|
|
241
|
-
const rawModel = cfg.model;
|
|
242
|
-
if (typeof rawModel === "string") {
|
|
243
|
-
if (rawModel.trim()) model = rawModel;
|
|
244
|
-
else issues.push("model: must be a non-empty string | ModelSpec | ModelProviderInstance.");
|
|
245
|
-
} else if (rawModel && typeof rawModel === "object") {
|
|
246
|
-
model = rawModel as NonNullable<AgentConfig["model"]>;
|
|
247
|
-
} else {
|
|
248
|
-
issues.push("model: is required (string | ModelSpec | ModelProviderInstance).");
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
let thinkingLevel: ThinkingLevel | undefined;
|
|
252
|
-
if (cfg.thinkingLevel !== undefined) {
|
|
253
|
-
if ((VALID_THINKING_LEVELS as readonly string[]).includes(cfg.thinkingLevel)) {
|
|
254
|
-
thinkingLevel = cfg.thinkingLevel;
|
|
255
|
-
} else {
|
|
256
|
-
issues.push(
|
|
257
|
-
`thinkingLevel: invalid '${cfg.thinkingLevel}'. Expected one of: ${VALID_THINKING_LEVELS.join(", ")}.`
|
|
258
|
-
);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
let maxTurns = Infinity;
|
|
263
|
-
if (cfg.maxTurns !== undefined) {
|
|
264
|
-
const m = cfg.maxTurns;
|
|
265
|
-
if (m === 0) {
|
|
266
|
-
maxTurns = Infinity;
|
|
267
|
-
} else if (m === Infinity) {
|
|
268
|
-
maxTurns = Infinity;
|
|
269
|
-
} else if (typeof m === "number" && Number.isFinite(m) && m >= 1) {
|
|
270
|
-
maxTurns = Math.floor(m);
|
|
271
|
-
} else {
|
|
272
|
-
issues.push(`maxTurns: must be finite >= 1 or Infinity (0/omitted means unlimited). Received: ${String(m)}.`);
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
let mode: MidSessionMode = "auto";
|
|
277
|
-
if (cfg.midSession?.mode !== undefined) {
|
|
278
|
-
if ((VALID_MIDSESSION_MODES as readonly string[]).includes(cfg.midSession.mode as string)) {
|
|
279
|
-
mode = cfg.midSession.mode as MidSessionMode;
|
|
280
|
-
} else {
|
|
281
|
-
issues.push(
|
|
282
|
-
`midSession.mode: invalid '${String(cfg.midSession.mode)}'. Expected one of: ${VALID_MIDSESSION_MODES.join(", ")}.`
|
|
283
|
-
);
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
let maxQueued = 20;
|
|
287
|
-
if (cfg.midSession?.maxQueued !== undefined) {
|
|
288
|
-
const q = cfg.midSession.maxQueued;
|
|
289
|
-
if (typeof q === "number" && Number.isFinite(q) && Math.floor(q) === q && q >= 0) {
|
|
290
|
-
maxQueued = q;
|
|
291
|
-
} else {
|
|
292
|
-
issues.push(`midSession.maxQueued: must be an integer >= 0. Received: ${String(q)}.`);
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
const toolNames: string[] = [];
|
|
297
|
-
const tools = cfg.tools;
|
|
298
|
-
if (tools !== undefined) {
|
|
299
|
-
const entries: Array<[string, string]> = [];
|
|
300
|
-
if (Array.isArray(tools)) {
|
|
301
|
-
for (const def of tools) {
|
|
302
|
-
const declared = (def as { name?: unknown })?.name;
|
|
303
|
-
if (typeof declared === "string" && declared.trim()) {
|
|
304
|
-
toolNames.push(declared);
|
|
305
|
-
entries.push([declared, declared]);
|
|
306
|
-
} else {
|
|
307
|
-
issues.push("tools: array entries must declare a non-empty `name`.");
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
} else if (tools && typeof tools === "object") {
|
|
311
|
-
for (const [key, def] of Object.entries(tools)) {
|
|
312
|
-
toolNames.push(key);
|
|
313
|
-
const declared = (def as { name?: unknown })?.name;
|
|
314
|
-
entries.push([key, typeof declared === "string" && declared ? declared : key]);
|
|
315
|
-
}
|
|
316
|
-
} else {
|
|
317
|
-
issues.push("tools: must be a record or array of ToolDefinitions.");
|
|
318
|
-
}
|
|
319
|
-
const seen = new Map<string, string>();
|
|
320
|
-
for (const [key, declared] of entries) {
|
|
321
|
-
for (const candidate of new Set([key, declared])) {
|
|
322
|
-
const normalized = normalizeConfigToolName(String(candidate));
|
|
323
|
-
const prev = seen.get(normalized);
|
|
324
|
-
if (prev !== undefined && prev !== key) {
|
|
325
|
-
issues.push(
|
|
326
|
-
`tools: name collision: '${prev}' and '${key}' both normalize to '${normalized}'. Rename one tool.`
|
|
327
|
-
);
|
|
328
|
-
} else if (prev === undefined) {
|
|
329
|
-
seen.set(normalized, key);
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
if (issues.length > 0) {
|
|
336
|
-
throw new ConfigError(`Invalid Agent config (${issues.length} issue${issues.length === 1 ? "" : "s"}): ${issues.join(" ")}`);
|
|
337
|
-
}
|
|
338
|
-
return {
|
|
339
|
-
model: model as NonNullable<AgentConfig["model"]>,
|
|
340
|
-
...(thinkingLevel !== undefined ? { thinkingLevel } : {}),
|
|
341
|
-
maxTurns,
|
|
342
|
-
midSession: { mode, maxQueued },
|
|
343
|
-
toolNames,
|
|
344
|
-
};
|
|
345
|
-
}
|