agent-accelerator 0.3.0 → 0.4.5
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/package.json +1 -1
- package/src/agent/agent.ts +845 -116
- package/src/agent/context.ts +35 -0
- package/src/agent/delegation.ts +390 -36
- package/src/agent/loop.ts +785 -110
- package/src/agent/subagent.ts +59 -0
- package/src/index.ts +173 -59
- package/src/models/catalog-cache.ts +68 -25
- package/src/models/catalog.ts +123 -18
- package/src/providers/google.ts +152 -43
- package/src/providers/openai-compat.ts +246 -96
- package/src/providers/openai.ts +274 -79
- package/src/providers/openrouter.ts +191 -82
- package/src/providers/registry.ts +16 -12
- package/src/providers/shared.ts +190 -0
- package/src/providers.ts +259 -9
- package/src/session/store.ts +521 -33
- package/src/streaming/event-stream.ts +156 -41
- package/src/tools/executor.ts +248 -15
- package/src/tools/tool.ts +147 -3
- package/src/types/agent.ts +244 -2
- package/src/types/errors.ts +133 -0
- package/src/types/model.ts +22 -1
- package/src/types/response.ts +133 -3
- package/src/types/tool.ts +69 -0
- package/src/update-models.ts +8 -2
- package/src/utils/cache.ts +68 -1
- package/src/utils/env.ts +23 -13
- package/src/utils/errors.ts +15 -1
- package/src/utils/headers.ts +153 -19
- package/src/utils/retry.ts +27 -13
- package/src/utils/session.ts +284 -0
- package/src/providers/openrouter-responses.ts +0 -949
- package/src/types/provider-payloads.ts +0 -352
package/src/utils/headers.ts
CHANGED
|
@@ -18,14 +18,117 @@ export function isBrowserRuntime(): boolean {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** Package version for User-Agent strings. Reads the npm-injected env first. */
|
|
22
|
+
export function getPackageVersion(): string {
|
|
23
|
+
try {
|
|
24
|
+
const v = (globalThis as any).process?.env?.npm_package_version;
|
|
25
|
+
if (typeof v === "string" && v) return v;
|
|
26
|
+
} catch {}
|
|
27
|
+
return "0.4.5";
|
|
28
|
+
}
|
|
29
|
+
|
|
21
30
|
function getAgentAccelUserAgent(): string {
|
|
31
|
+
const version = getPackageVersion();
|
|
22
32
|
try {
|
|
23
33
|
const os = (globalThis as any).process?.getBuiltinModule?.("node:os") ?? null;
|
|
24
34
|
if (os) {
|
|
25
|
-
return `agent-accel (${os.platform()} ${os.release()}; ${os.arch()})`;
|
|
35
|
+
return `agent-accel/${version} (${os.platform()} ${os.release()}; ${os.arch()})`;
|
|
26
36
|
}
|
|
27
37
|
} catch {}
|
|
28
|
-
return
|
|
38
|
+
return `agent-accel/${version} (linux; x64)`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Re-exported for tests/diagnostics (unused by wire headers directly).
|
|
42
|
+
export const AGENT_ACCEL_USER_AGENT = getAgentAccelUserAgent;
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Attribution (Q-17): configurable, default none except OpenRouter legacy.
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/** Configurable attribution headers (OpenRouter `HTTP-Referer` / `X-Title`). */
|
|
49
|
+
export interface AttributionConfig {
|
|
50
|
+
referer?: string;
|
|
51
|
+
title?: string;
|
|
52
|
+
userAgent?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let attribution: AttributionConfig = {};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Configures global attribution headers. Defaults to none (no hardcoded
|
|
59
|
+
* site); OpenRouter keeps its legacy `sashvat.com` / `Agent Accelerator`
|
|
60
|
+
* fallback when no custom attribution is set (backward compat).
|
|
61
|
+
* @example `setAttribution({ referer: "https://example.com", title: "My App" });`
|
|
62
|
+
*/
|
|
63
|
+
export function setAttribution(cfg: AttributionConfig): void {
|
|
64
|
+
attribution = { ...attribution, ...cfg };
|
|
65
|
+
if (cfg.referer === undefined && cfg.title === undefined && cfg.userAgent === undefined) {
|
|
66
|
+
// Explicit empty reset: `setAttribution({})` clears prior values.
|
|
67
|
+
if (Object.keys(cfg).length === 0) attribution = {};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Returns a copy of the current attribution config (default `{}`). */
|
|
72
|
+
export function getAttribution(): AttributionConfig {
|
|
73
|
+
return { ...attribution };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** First-class providers with documented session-affinity headers. */
|
|
77
|
+
const FIRST_CLASS = new Set(["google", "openai", "openrouter"]);
|
|
78
|
+
|
|
79
|
+
/** Case-insensitive header lookup. */
|
|
80
|
+
function findHeaderKey(headers: Record<string, string>, name: string): string | undefined {
|
|
81
|
+
const lower = name.toLowerCase();
|
|
82
|
+
for (const k of Object.keys(headers)) {
|
|
83
|
+
if (k.toLowerCase() === lower) return k;
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Sets a header case-insensitively (removes any case-variant duplicate
|
|
90
|
+
* first). Use when applying canonical `Authorization` / `Content-Type` over
|
|
91
|
+
* user-supplied `customHeaders` so `authorization` + `Authorization` never
|
|
92
|
+
* coexist on the wire.
|
|
93
|
+
* @example `setHeaderCaseInsensitive(headers, "Authorization", "Bearer k");`
|
|
94
|
+
*/
|
|
95
|
+
export function setHeaderCaseInsensitive(
|
|
96
|
+
headers: Record<string, string>,
|
|
97
|
+
name: string,
|
|
98
|
+
value: string
|
|
99
|
+
): void {
|
|
100
|
+
const existing = findHeaderKey(headers, name);
|
|
101
|
+
if (existing !== undefined && existing !== name) delete headers[existing];
|
|
102
|
+
headers[name] = value;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Thought-signature namespacing (Q-18).
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Tags a captured thought signature with its issuing provider so history
|
|
111
|
+
* builders echo it only on the same provider. Untagged (legacy) signatures
|
|
112
|
+
* echo everywhere for backward compat.
|
|
113
|
+
*/
|
|
114
|
+
export function withProviderSignature(
|
|
115
|
+
sig: string | undefined,
|
|
116
|
+
provider: string
|
|
117
|
+
): { thoughtSignature?: string; thoughtSignatureProvider?: string } {
|
|
118
|
+
if (!sig) return {};
|
|
119
|
+
return { thoughtSignature: sig, thoughtSignatureProvider: provider };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Returns true when a stored signature may be echoed on `currentProvider`:
|
|
124
|
+
* untagged (legacy) echoes everywhere, tagged echoes only on a match.
|
|
125
|
+
*/
|
|
126
|
+
export function shouldEchoSignature(
|
|
127
|
+
storedProvider: unknown,
|
|
128
|
+
currentProvider: string
|
|
129
|
+
): boolean {
|
|
130
|
+
if (!storedProvider) return true;
|
|
131
|
+
return storedProvider === currentProvider;
|
|
29
132
|
}
|
|
30
133
|
|
|
31
134
|
const BROWSER_DROPPED = new Set([
|
|
@@ -48,6 +151,13 @@ function stripForBrowser(headers: Record<string, string>): Record<string, string
|
|
|
48
151
|
/**
|
|
49
152
|
* Builds provider-specific session, cache-affinity, and attribution headers.
|
|
50
153
|
*
|
|
154
|
+
* Q-17: attribution is configurable via `setAttribution()` (default none).
|
|
155
|
+
* OpenRouter keeps its legacy `https://sashvat.com` / `Agent Accelerator`
|
|
156
|
+
* fallback when no custom attribution is set. Custom (non-first-class)
|
|
157
|
+
* prefixes receive minimal `x-session-id`/`x-client-request-id` affinity only
|
|
158
|
+
* (no `session_id` alias, no attribution) when a session id is present.
|
|
159
|
+
* `customHeaders` merge case-insensitively against canonical names.
|
|
160
|
+
*
|
|
51
161
|
* Browser note: custom `x-*` session/affinity headers force a CORS preflight
|
|
52
162
|
* (`OPTIONS`) and providers only allow-list their own documented headers, so a
|
|
53
163
|
* preflight failure surfaces as a bare `TypeError: Failed to fetch`. In browser
|
|
@@ -63,35 +173,59 @@ export function buildSessionHeaders(
|
|
|
63
173
|
explicitSessionId?: string
|
|
64
174
|
): Record<string, string> {
|
|
65
175
|
const browser = isBrowserRuntime();
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
176
|
+
const attr = getAttribution();
|
|
177
|
+
const headers: Record<string, string> = {};
|
|
178
|
+
if (!browser) {
|
|
179
|
+
setHeaderCaseInsensitive(headers, "User-Agent", attr.userAgent ?? `Agent-Accelerator/${getPackageVersion()}`);
|
|
180
|
+
}
|
|
181
|
+
for (const [k, v] of Object.entries(customHeaders ?? {})) {
|
|
182
|
+
const existing = findHeaderKey(headers, k);
|
|
183
|
+
if (existing !== undefined && existing !== k) delete headers[existing];
|
|
184
|
+
headers[k] = v;
|
|
185
|
+
}
|
|
70
186
|
|
|
71
187
|
const rawSessionId = explicitSessionId || cache?.sessionId;
|
|
72
188
|
const sessionId = clampCacheKey(rawSessionId);
|
|
189
|
+
const isFirstClass = FIRST_CLASS.has(provider);
|
|
190
|
+
|
|
191
|
+
const getHeader = (name: string): string | undefined => {
|
|
192
|
+
const key = findHeaderKey(headers, name);
|
|
193
|
+
return key !== undefined ? headers[key] : undefined;
|
|
194
|
+
};
|
|
73
195
|
|
|
74
196
|
if (sessionId && !browser) {
|
|
75
197
|
if (provider === "openrouter") {
|
|
76
|
-
headers
|
|
77
|
-
headers
|
|
78
|
-
|
|
79
|
-
|
|
198
|
+
setHeaderCaseInsensitive(headers, "x-session-id", sessionId);
|
|
199
|
+
setHeaderCaseInsensitive(headers, "x-client-request-id", sessionId);
|
|
200
|
+
const referer = attr.referer ?? "https://sashvat.com";
|
|
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;
|
|
80
204
|
} else if (provider === "google") {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
205
|
+
if (getHeader("x-goog-api-client") === undefined) {
|
|
206
|
+
headers["x-goog-api-client"] = "agent-accel/1.0";
|
|
207
|
+
}
|
|
208
|
+
setHeaderCaseInsensitive(headers, "x-session-id", sessionId);
|
|
209
|
+
setHeaderCaseInsensitive(headers, "x-client-request-id", sessionId);
|
|
210
|
+
} else if (isFirstClass) {
|
|
211
|
+
setHeaderCaseInsensitive(headers, "x-session-id", sessionId);
|
|
212
|
+
setHeaderCaseInsensitive(headers, "x-client-request-id", sessionId);
|
|
213
|
+
setHeaderCaseInsensitive(headers, "session_id", sessionId);
|
|
84
214
|
} else {
|
|
85
|
-
headers
|
|
86
|
-
|
|
87
|
-
|
|
215
|
+
// Custom OpenAI-compatible prefix: headers-only affinity. Bodies carry
|
|
216
|
+
// no affinity key on strict endpoints, so only the minimal pair is
|
|
217
|
+
// sent (no `session_id` alias, no attribution).
|
|
218
|
+
setHeaderCaseInsensitive(headers, "x-session-id", sessionId);
|
|
219
|
+
setHeaderCaseInsensitive(headers, "x-client-request-id", sessionId);
|
|
88
220
|
}
|
|
89
221
|
} else if (provider === "openrouter") {
|
|
90
|
-
|
|
91
|
-
|
|
222
|
+
const referer = attr.referer ?? "https://sashvat.com";
|
|
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;
|
|
92
226
|
}
|
|
93
227
|
|
|
94
|
-
if (provider === "google" && !browser &&
|
|
228
|
+
if (provider === "google" && !browser && getHeader("x-goog-api-client") === undefined) {
|
|
95
229
|
headers["x-goog-api-client"] = "agent-accel/1.0";
|
|
96
230
|
}
|
|
97
231
|
|
package/src/utils/retry.ts
CHANGED
|
@@ -1,23 +1,19 @@
|
|
|
1
1
|
import { toConciseProviderError } from "./errors.ts";
|
|
2
|
+
import { isAbortError } from "../types/errors.ts";
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
* Checks whether a provider error is a cancellation (never retried).
|
|
5
|
-
*/
|
|
6
|
-
export function isAbortError(error: unknown): boolean {
|
|
7
|
-
const err = error as { name?: unknown; message?: unknown } | null;
|
|
8
|
-
if (!err || typeof err !== "object") return false;
|
|
9
|
-
if ((err as { name?: unknown }).name === "AbortError") return true;
|
|
10
|
-
return /abort|cancell?ed/i.test(String((err as { message?: unknown }).message ?? error));
|
|
11
|
-
}
|
|
4
|
+
export { isAbortError };
|
|
12
5
|
|
|
13
6
|
/**
|
|
14
7
|
* Checks whether a provider error is transient and safe to retry.
|
|
8
|
+
* Structured-data only: status / code / transient flag. Never message text,
|
|
9
|
+
* so a 400 mentioning "512 tokens" is not retried (Q-24).
|
|
15
10
|
*/
|
|
16
11
|
export function isTransientError(error: unknown): boolean {
|
|
17
12
|
if (!error) return false;
|
|
18
13
|
if (isAbortError(error)) return false;
|
|
19
14
|
const err = error as Record<string, unknown>;
|
|
20
15
|
if (err["transient"] === true) return true;
|
|
16
|
+
if ((err as { retryable?: unknown }).retryable === true) return true;
|
|
21
17
|
const status = Number(
|
|
22
18
|
(err["status"] as number | undefined) ??
|
|
23
19
|
(err["statusCode"] as number | undefined) ??
|
|
@@ -30,9 +26,19 @@ export function isTransientError(error: unknown): boolean {
|
|
|
30
26
|
if (["ECONNRESET", "ECONNREFUSED", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT", "EPIPE", "ETIMEDOUT"].includes(code)) {
|
|
31
27
|
return true;
|
|
32
28
|
}
|
|
33
|
-
return
|
|
34
|
-
|
|
35
|
-
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readRetryAfterMs(error: unknown): number | undefined {
|
|
33
|
+
const err = error as Record<string, unknown>;
|
|
34
|
+
const raw =
|
|
35
|
+
(err["retryAfterMs"] as unknown) ??
|
|
36
|
+
((err["response"] as Record<string, unknown> | undefined)?.["headers"] as Record<string, unknown> | undefined)?.["retry-after"] ??
|
|
37
|
+
((err["headers"] as Record<string, unknown> | undefined)?.["retry-after"]);
|
|
38
|
+
if (raw === undefined) return undefined;
|
|
39
|
+
const secs = Number(Array.isArray(raw) ? raw[0] : raw);
|
|
40
|
+
if (!Number.isFinite(secs) || secs < 0) return undefined;
|
|
41
|
+
return Math.min(60_000, secs * 1000);
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
@@ -56,6 +62,8 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
|
56
62
|
|
|
57
63
|
/**
|
|
58
64
|
* Runs a provider call with bounded retries for transient failures.
|
|
65
|
+
* Honors Retry-After up to 60s cap. Streams retry only before first byte
|
|
66
|
+
* (callers must not retry after partial output).
|
|
59
67
|
*/
|
|
60
68
|
export async function withRetries<T>(
|
|
61
69
|
fn: () => Promise<T> | PromiseLike<T>,
|
|
@@ -64,10 +72,13 @@ export async function withRetries<T>(
|
|
|
64
72
|
maxRetryDelayMs?: number;
|
|
65
73
|
signal?: AbortSignal;
|
|
66
74
|
label?: { providerId?: string; modelId?: string };
|
|
75
|
+
retryBudgetMs?: number;
|
|
67
76
|
}
|
|
68
77
|
): Promise<T> {
|
|
69
78
|
const maxRetries = options?.maxRetries ?? 2;
|
|
70
79
|
const maxDelay = options?.maxRetryDelayMs ?? 5000;
|
|
80
|
+
const budgetMs = options?.retryBudgetMs ?? 30_000;
|
|
81
|
+
const started = Date.now();
|
|
71
82
|
const fail = (error: unknown): never => {
|
|
72
83
|
if (options?.label?.providerId && options?.label?.modelId) {
|
|
73
84
|
throw toConciseProviderError(error, options.label.providerId, options.label.modelId);
|
|
@@ -81,7 +92,10 @@ export async function withRetries<T>(
|
|
|
81
92
|
} catch (error) {
|
|
82
93
|
if (options?.signal?.aborted || isAbortError(error)) throw error;
|
|
83
94
|
if (attempt >= maxRetries || !isTransientError(error)) fail(error);
|
|
84
|
-
|
|
95
|
+
if (Date.now() - started >= budgetMs) fail(error);
|
|
96
|
+
const retryAfter = readRetryAfterMs(error);
|
|
97
|
+
const backoff = Math.min(maxDelay, 250 * 2 ** attempt + Math.random() * 100);
|
|
98
|
+
const delay = retryAfter !== undefined ? Math.min(maxDelay, Math.max(backoff, retryAfter)) : backoff;
|
|
85
99
|
attempt += 1;
|
|
86
100
|
await sleep(delay, options?.signal);
|
|
87
101
|
}
|
package/src/utils/session.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { clampCacheKey } from "./cache.ts";
|
|
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
|
+
import type { ModelSpec } from "../types/model.ts";
|
|
6
|
+
import type { ModelProviderInstance } from "../providers/registry.ts";
|
|
7
|
+
import type { ContentPart } from "../types/message.ts";
|
|
8
|
+
import { ConfigError } from "../types/errors.ts";
|
|
2
9
|
|
|
3
10
|
/**
|
|
4
11
|
* Creates or formats a unique session ID for prompt caching affinity (clamped to 64 chars like agent-accel)
|
|
@@ -60,3 +67,280 @@ export function createTrackingId(): string {
|
|
|
60
67
|
while (out.length < 32) out += Math.random().toString(16).slice(2);
|
|
61
68
|
return out.slice(0, 32);
|
|
62
69
|
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Monotonic clock for measuring durations (Q-42). Uses `performance.now()`
|
|
73
|
+
* when available, falling back to `Date.now()`. Differences on the same
|
|
74
|
+
* clock are valid durations; never treat the value as wall-clock epoch.
|
|
75
|
+
*
|
|
76
|
+
* @example `const start = nowMs(); ...; const durationMs = nowMs() - start;`
|
|
77
|
+
*/
|
|
78
|
+
export function nowMs(): number {
|
|
79
|
+
try {
|
|
80
|
+
if (typeof performance !== "undefined" && typeof performance.now === "function") {
|
|
81
|
+
return performance.now();
|
|
82
|
+
}
|
|
83
|
+
} catch {}
|
|
84
|
+
return Date.now();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function random32Hex(): string {
|
|
88
|
+
try {
|
|
89
|
+
const c = (globalThis as any)?.crypto;
|
|
90
|
+
if (c && typeof c.randomUUID === "function") {
|
|
91
|
+
return String(c.randomUUID()).replace(/-/g, "").slice(0, 32);
|
|
92
|
+
}
|
|
93
|
+
} catch {}
|
|
94
|
+
let out = "";
|
|
95
|
+
while (out.length < 32) out += Math.random().toString(16).slice(2);
|
|
96
|
+
return out.slice(0, 32);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* New stable run id: 32 lowercase hex chars (crypto.randomUUID, no dashes) (Q-42).
|
|
101
|
+
* Opaque lineage token attached via `(metadata as any).runId`.
|
|
102
|
+
*/
|
|
103
|
+
export function newRunId(): string {
|
|
104
|
+
return random32Hex();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* New stable turn id: 32 lowercase hex chars (crypto.randomUUID, no dashes) (Q-42).
|
|
109
|
+
* Opaque lineage token attached via `(metadata as any).turnId`.
|
|
110
|
+
*/
|
|
111
|
+
export function newTurnId(): string {
|
|
112
|
+
return random32Hex();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Single canonical prompt preview (Q-49): merges the former
|
|
117
|
+
* `previewPrompt` (agent.ts) / `steerPreview` (loop.ts) duplicates.
|
|
118
|
+
* Bounded to 500 chars, display-only. Other modules own their copies;
|
|
119
|
+
* new code should import this one.
|
|
120
|
+
*
|
|
121
|
+
* @example `previewPrompt("hello")`
|
|
122
|
+
*/
|
|
123
|
+
export function previewPrompt(prompt: string | ContentPart[]): string {
|
|
124
|
+
if (typeof prompt === "string") return prompt.slice(0, 500);
|
|
125
|
+
try {
|
|
126
|
+
const texts = (prompt as ContentPart[])
|
|
127
|
+
.filter((p) => (p as { type?: unknown }).type === "text")
|
|
128
|
+
.map((p) => (p as { text?: string }).text ?? "")
|
|
129
|
+
.join("\n");
|
|
130
|
+
return (texts || "[multipart]").slice(0, 500);
|
|
131
|
+
} catch {
|
|
132
|
+
return "[multipart]";
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Parsed model reference returned by {@link parseModelRef} (Q-48). */
|
|
137
|
+
export interface ParsedModelRef {
|
|
138
|
+
provider: string;
|
|
139
|
+
modelId: string;
|
|
140
|
+
apiKey?: string;
|
|
141
|
+
baseUrl?: string;
|
|
142
|
+
thinking?: ThinkingLevel;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function splitModelString(raw: string): { provider: string; modelId: string } {
|
|
146
|
+
const trimmed = raw.trim();
|
|
147
|
+
const slash = trimmed.indexOf("/");
|
|
148
|
+
if (slash <= 0 || slash >= trimmed.length - 1) {
|
|
149
|
+
return { provider: "openrouter", modelId: trimmed };
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
provider: trimmed.slice(0, slash).toLowerCase(),
|
|
153
|
+
modelId: trimmed.slice(slash + 1),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isModelProviderInstance(v: unknown): v is ModelProviderInstance {
|
|
158
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return false;
|
|
159
|
+
const r = v as Record<string, unknown>;
|
|
160
|
+
return typeof r["model"] === "string" && (r["model"] as string).length > 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isModelSpec(v: unknown): v is ModelSpec {
|
|
164
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return false;
|
|
165
|
+
const r = v as Record<string, unknown>;
|
|
166
|
+
return typeof r["provider"] === "string" && typeof r["id"] === "string";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Single model-ref parser (Q-48). Table-driven, no env sniffing:
|
|
171
|
+
* - string `"provider/model"` → split on first `/`; bare ids default to `openrouter`.
|
|
172
|
+
* - `ModelProviderInstance` (`{ model, apiKey?, baseUrl?, thinkingLevel? }`) → parse `model`, passthrough creds.
|
|
173
|
+
* - `ModelSpec` (`{ provider, id }`) → direct mapping.
|
|
174
|
+
*
|
|
175
|
+
* @example `parseModelRef("google/gemini-3.5-flash-lite")`
|
|
176
|
+
*/
|
|
177
|
+
export function parseModelRef(input: string | ModelSpec | ModelProviderInstance): ParsedModelRef {
|
|
178
|
+
if (typeof input === "string") {
|
|
179
|
+
if (!input.trim()) throw new ConfigError("parseModelRef: model string must be non-empty.");
|
|
180
|
+
const { provider, modelId } = splitModelString(input);
|
|
181
|
+
return { provider, modelId };
|
|
182
|
+
}
|
|
183
|
+
if (isModelProviderInstance(input)) {
|
|
184
|
+
const { provider, modelId } = splitModelString(input.model);
|
|
185
|
+
const out: ParsedModelRef = { provider, modelId };
|
|
186
|
+
if (input.apiKey !== undefined) out.apiKey = input.apiKey;
|
|
187
|
+
if (input.baseUrl !== undefined) out.baseUrl = input.baseUrl;
|
|
188
|
+
if (input.thinkingLevel !== undefined) out.thinking = input.thinkingLevel;
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
if (isModelSpec(input)) {
|
|
192
|
+
return { provider: String(input.provider), modelId: String(input.id) };
|
|
193
|
+
}
|
|
194
|
+
throw new ConfigError("parseModelRef: expected string | ModelSpec | ModelProviderInstance.");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Validated, readonly agent configuration returned by {@link resolveAgentConfig} (Q-32). */
|
|
198
|
+
export interface ResolvedAgentConfig {
|
|
199
|
+
readonly model: NonNullable<AgentConfig["model"]>;
|
|
200
|
+
readonly thinkingLevel?: ThinkingLevel;
|
|
201
|
+
readonly maxTurns: number;
|
|
202
|
+
readonly midSession: { readonly mode: MidSessionMode; readonly maxQueued: number };
|
|
203
|
+
readonly toolNames: readonly string[];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const VALID_THINKING_LEVELS: readonly ThinkingLevel[] = [
|
|
207
|
+
"none",
|
|
208
|
+
"dynamic",
|
|
209
|
+
"minimal",
|
|
210
|
+
"low",
|
|
211
|
+
"medium",
|
|
212
|
+
"high",
|
|
213
|
+
"xhigh",
|
|
214
|
+
"max",
|
|
215
|
+
];
|
|
216
|
+
|
|
217
|
+
const VALID_MIDSESSION_MODES: readonly MidSessionMode[] = ["steer", "queue", "auto"];
|
|
218
|
+
|
|
219
|
+
/** Local name normalization mirroring `normalizeToolName` (no import cycle, Q-32). */
|
|
220
|
+
function normalizeConfigToolName(name: string): string {
|
|
221
|
+
return name
|
|
222
|
+
.trim()
|
|
223
|
+
.replace(/^(?:functions?|tools?)\./i, "")
|
|
224
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
225
|
+
.replace(/[\s-]+/g, "_")
|
|
226
|
+
.toLowerCase();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Single config resolution (Q-32). Validates `model`, `thinkingLevel`,
|
|
231
|
+
* `maxTurns`, `midSession`, and tool-name collisions, collecting EVERY issue
|
|
232
|
+
* and throwing one `ConfigError` listing all of them. Not wired into `Agent`
|
|
233
|
+
* (other owner) — call it from setup paths that want fail-fast validation.
|
|
234
|
+
*
|
|
235
|
+
* @example `const cfg = resolveAgentConfig({ model, maxTurns: 10 });`
|
|
236
|
+
*/
|
|
237
|
+
export function resolveAgentConfig(config: AgentConfig): Readonly<ResolvedAgentConfig> {
|
|
238
|
+
const issues: string[] = [];
|
|
239
|
+
const cfg = (config ?? {}) as AgentConfig;
|
|
240
|
+
|
|
241
|
+
let model: NonNullable<AgentConfig["model"]> | undefined;
|
|
242
|
+
const rawModel = cfg.model;
|
|
243
|
+
if (typeof rawModel === "string") {
|
|
244
|
+
if (rawModel.trim()) model = rawModel;
|
|
245
|
+
else issues.push("model: must be a non-empty string | ModelSpec | ModelProviderInstance.");
|
|
246
|
+
} else if (rawModel && typeof rawModel === "object") {
|
|
247
|
+
model = rawModel as NonNullable<AgentConfig["model"]>;
|
|
248
|
+
} else {
|
|
249
|
+
issues.push("model: is required (string | ModelSpec | ModelProviderInstance).");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let thinkingLevel: ThinkingLevel | undefined;
|
|
253
|
+
if (cfg.thinkingLevel !== undefined) {
|
|
254
|
+
if ((VALID_THINKING_LEVELS as readonly string[]).includes(cfg.thinkingLevel)) {
|
|
255
|
+
thinkingLevel = cfg.thinkingLevel;
|
|
256
|
+
} else {
|
|
257
|
+
issues.push(
|
|
258
|
+
`thinkingLevel: invalid '${cfg.thinkingLevel}'. Expected one of: ${VALID_THINKING_LEVELS.join(", ")}.`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
let maxTurns = Infinity;
|
|
264
|
+
if (cfg.maxTurns !== undefined) {
|
|
265
|
+
const m = cfg.maxTurns;
|
|
266
|
+
if (m === 0) {
|
|
267
|
+
maxTurns = Infinity;
|
|
268
|
+
} else if (m === Infinity) {
|
|
269
|
+
maxTurns = Infinity;
|
|
270
|
+
} else if (typeof m === "number" && Number.isFinite(m) && m >= 1) {
|
|
271
|
+
maxTurns = Math.floor(m);
|
|
272
|
+
} else {
|
|
273
|
+
issues.push(`maxTurns: must be finite >= 1 or Infinity (0/omitted means unlimited). Received: ${String(m)}.`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
let mode: MidSessionMode = "auto";
|
|
278
|
+
if (cfg.midSession?.mode !== undefined) {
|
|
279
|
+
if ((VALID_MIDSESSION_MODES as readonly string[]).includes(cfg.midSession.mode as string)) {
|
|
280
|
+
mode = cfg.midSession.mode as MidSessionMode;
|
|
281
|
+
} else {
|
|
282
|
+
issues.push(
|
|
283
|
+
`midSession.mode: invalid '${String(cfg.midSession.mode)}'. Expected one of: ${VALID_MIDSESSION_MODES.join(", ")}.`
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
let maxQueued = 20;
|
|
288
|
+
if (cfg.midSession?.maxQueued !== undefined) {
|
|
289
|
+
const q = cfg.midSession.maxQueued;
|
|
290
|
+
if (typeof q === "number" && Number.isFinite(q) && Math.floor(q) === q && q >= 0) {
|
|
291
|
+
maxQueued = q;
|
|
292
|
+
} else {
|
|
293
|
+
issues.push(`midSession.maxQueued: must be an integer >= 0. Received: ${String(q)}.`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const toolNames: string[] = [];
|
|
298
|
+
const tools = cfg.tools;
|
|
299
|
+
if (tools !== undefined) {
|
|
300
|
+
const entries: Array<[string, string]> = [];
|
|
301
|
+
if (Array.isArray(tools)) {
|
|
302
|
+
for (const def of tools) {
|
|
303
|
+
const declared = (def as { name?: unknown })?.name;
|
|
304
|
+
if (typeof declared === "string" && declared.trim()) {
|
|
305
|
+
toolNames.push(declared);
|
|
306
|
+
entries.push([declared, declared]);
|
|
307
|
+
} else {
|
|
308
|
+
issues.push("tools: array entries must declare a non-empty `name`.");
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
} else if (tools && typeof tools === "object") {
|
|
312
|
+
for (const [key, def] of Object.entries(tools)) {
|
|
313
|
+
toolNames.push(key);
|
|
314
|
+
const declared = (def as { name?: unknown })?.name;
|
|
315
|
+
entries.push([key, typeof declared === "string" && declared ? declared : key]);
|
|
316
|
+
}
|
|
317
|
+
} else {
|
|
318
|
+
issues.push("tools: must be a record or array of ToolDefinitions.");
|
|
319
|
+
}
|
|
320
|
+
const seen = new Map<string, string>();
|
|
321
|
+
for (const [key, declared] of entries) {
|
|
322
|
+
for (const candidate of new Set([key, declared])) {
|
|
323
|
+
const normalized = normalizeConfigToolName(String(candidate));
|
|
324
|
+
const prev = seen.get(normalized);
|
|
325
|
+
if (prev !== undefined && prev !== key) {
|
|
326
|
+
issues.push(
|
|
327
|
+
`tools: name collision: '${prev}' and '${key}' both normalize to '${normalized}'. Rename one tool.`
|
|
328
|
+
);
|
|
329
|
+
} else if (prev === undefined) {
|
|
330
|
+
seen.set(normalized, key);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (issues.length > 0) {
|
|
337
|
+
throw new ConfigError(`Invalid Agent config (${issues.length} issue${issues.length === 1 ? "" : "s"}): ${issues.join(" ")}`);
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
model: model as NonNullable<AgentConfig["model"]>,
|
|
341
|
+
...(thinkingLevel !== undefined ? { thinkingLevel } : {}),
|
|
342
|
+
maxTurns,
|
|
343
|
+
midSession: { mode, maxQueued },
|
|
344
|
+
toolNames,
|
|
345
|
+
};
|
|
346
|
+
}
|