agent-accelerator 0.1.0 → 0.2.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 +83 -112
- package/{SYSTEM_PROMPT_AGENT.md → examples/prompts/SYSTEM_PROMPT_AGENT.md} +1 -1
- package/package.json +14 -9
- package/src/agent/agent.ts +191 -71
- package/src/agent/context.ts +1 -0
- package/src/agent/delegation.ts +61 -12
- package/src/agent/loop.ts +71 -48
- package/src/data/README.md +6 -6
- package/src/index.ts +130 -45
- package/src/models/catalog-cache.ts +60 -9
- package/src/models/catalog.ts +53 -7
- package/src/providers/google.ts +926 -0
- package/src/providers/openai-compat.ts +1147 -0
- package/src/providers/openai.ts +959 -0
- package/src/providers/openrouter-responses.ts +949 -0
- package/src/providers/openrouter.ts +1037 -0
- package/src/{ai-sdk → providers}/registry.ts +43 -55
- package/src/providers.ts +490 -0
- package/src/streaming/sse-parser.ts +6 -4
- package/src/tools/executor.ts +19 -6
- package/src/tools/schema.ts +21 -11
- package/src/types/agent.ts +8 -1
- package/src/types/core.ts +1 -1
- package/src/types/message.ts +5 -0
- package/src/types/model.ts +3 -7
- package/src/types/provider-payloads.ts +2 -84
- package/src/types/tool.ts +6 -0
- package/src/update-models.ts +56 -0
- package/src/utils/cache.ts +1 -1
- package/src/utils/documents.ts +517 -0
- package/src/utils/env.ts +0 -7
- package/src/{ai-sdk → utils}/errors.ts +61 -2
- package/src/utils/headers.ts +10 -20
- package/src/utils/media.ts +5 -2
- package/src/utils/retry.ts +89 -0
- package/src/utils/serialization.ts +15 -0
- package/src/ai-sdk/converters.ts +0 -342
- package/src/ai-sdk/executor.ts +0 -454
- package/src/ai-sdk/index.ts +0 -55
- package/src/ai-sdk/model-provider.ts +0 -303
- package/src/ai-sdk/options.ts +0 -306
- package/src/ai-sdk/provider.ts +0 -415
- package/src/tokens/counter.ts +0 -136
- /package/{SYSTEM_PROMPT.md → examples/prompts/SYSTEM_PROMPT.md} +0 -0
- /package/{SYSTEM_PROMPT_TOOLS.md → examples/prompts/SYSTEM_PROMPT_TOOLS.md} +0 -0
package/src/providers.ts
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical provider contract for Agent Accelerator.
|
|
3
|
+
*
|
|
4
|
+
* This module is provider-agnostic: it defines how canonical Agent Accelerator
|
|
5
|
+
* capabilities map onto provider concepts WITHOUT containing any
|
|
6
|
+
* provider-specific HTTP, endpoints, headers, or response parsing. Those live
|
|
7
|
+
* in `src/providers/<provider>.ts` (e.g. `src/providers/google.ts`).
|
|
8
|
+
*
|
|
9
|
+
* Rule: OpenAI, OpenRouter, and future providers must be addable here without
|
|
10
|
+
* redesigning this abstraction — only new per-provider mappers/adapters.
|
|
11
|
+
*/
|
|
12
|
+
import type { CacheConfig, ServiceTier, ThinkingLevel } from "./types/core.ts";
|
|
13
|
+
|
|
14
|
+
/** Classification of a canonical capability on a given provider. */
|
|
15
|
+
export type ProviderCapabilityStatus =
|
|
16
|
+
| "NATIVE"
|
|
17
|
+
| "TRANSLATED"
|
|
18
|
+
| "AUTOMATIC"
|
|
19
|
+
| "EMULATED"
|
|
20
|
+
| "UNSUPPORTED";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Single warning mechanism for provider capability differences.
|
|
24
|
+
*
|
|
25
|
+
* There is no pre-existing logger in `src/` (only CLI logs in
|
|
26
|
+
* `update-models.ts`), so this `console.warn` line IS the mechanism — do not
|
|
27
|
+
* introduce a second one. Used when a requested capability cannot be applied
|
|
28
|
+
* as-is (e.g. Google explicit cache retention) so it never silently
|
|
29
|
+
* disappears.
|
|
30
|
+
*
|
|
31
|
+
* Warnings are deduped per provider+capability+requested: agent loops call
|
|
32
|
+
* mappers once per turn, and repeating the same warning every turn is noise.
|
|
33
|
+
* A repeated identical request logs once per process.
|
|
34
|
+
*
|
|
35
|
+
* @example `emitProviderWarning({ provider: "google", capability: "cache retention", requested: "high", reason: "...", fallback: "..." })`
|
|
36
|
+
*/
|
|
37
|
+
const emittedWarnings = new Set<string>();
|
|
38
|
+
|
|
39
|
+
export function emitProviderWarning(options: {
|
|
40
|
+
provider: string;
|
|
41
|
+
capability: string;
|
|
42
|
+
requested?: string;
|
|
43
|
+
reason: string;
|
|
44
|
+
fallback: string;
|
|
45
|
+
}): void {
|
|
46
|
+
const key = `${options.provider}::${options.capability}::${options.requested ?? ""}`;
|
|
47
|
+
if (emittedWarnings.has(key)) return;
|
|
48
|
+
emittedWarnings.add(key);
|
|
49
|
+
const requested = options.requested ? ` (requested: "${options.requested}")` : "";
|
|
50
|
+
// Leading newline: warnings fire at the start of a turn's request build,
|
|
51
|
+
// when the previous turn's streamed text may have left the terminal cursor
|
|
52
|
+
// mid-line. Without it the warning glues onto streamed output.
|
|
53
|
+
console.warn(
|
|
54
|
+
`\n[Agent Accelerator] WARNING [${options.provider}] ${options.capability}${requested}: ${options.reason} Using ${options.fallback} instead.`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Clears deduped-warning state (mainly for tests). */
|
|
59
|
+
export function clearEmittedWarnings(): void {
|
|
60
|
+
emittedWarnings.clear();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Per-session provider routing state (canonical conversation stays agnostic)
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
interface SessionRoutingState {
|
|
68
|
+
/** Provider id that served the last turn in this session. */
|
|
69
|
+
lastProvider?: string;
|
|
70
|
+
/** True once a session mixed providers and must stay on explicit history. */
|
|
71
|
+
mixedProviders?: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const sessionRouting = new Map<string, SessionRoutingState>();
|
|
75
|
+
|
|
76
|
+
/** Records which provider served a turn so adapters can detect switches. */
|
|
77
|
+
export function noteProviderTurn(sessionId: string | undefined, providerId: string): void {
|
|
78
|
+
if (!sessionId) return;
|
|
79
|
+
const state = sessionRouting.get(sessionId) ?? {};
|
|
80
|
+
if (state.lastProvider && state.lastProvider !== providerId) {
|
|
81
|
+
state.mixedProviders = true;
|
|
82
|
+
}
|
|
83
|
+
state.lastProvider = providerId;
|
|
84
|
+
sessionRouting.set(sessionId, state);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Returns the provider id that served the previous turn in this session. */
|
|
88
|
+
export function lastProviderFor(sessionId: string | undefined): string | undefined {
|
|
89
|
+
if (!sessionId) return undefined;
|
|
90
|
+
return sessionRouting.get(sessionId)?.lastProvider;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** True when this session already mixed providers (history must be explicit). */
|
|
94
|
+
export function isMixedProviderSession(sessionId: string | undefined): boolean {
|
|
95
|
+
if (!sessionId) return false;
|
|
96
|
+
return sessionRouting.get(sessionId)?.mixedProviders === true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Clears routing state for a session (mainly for tests). */
|
|
100
|
+
export function clearSessionRouting(sessionId?: string): void {
|
|
101
|
+
if (sessionId) sessionRouting.delete(sessionId);
|
|
102
|
+
else sessionRouting.clear();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Canonical capability mappers (pure, no I/O)
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Maps a canonical ThinkingLevel onto Google Interactions `thinking_level`.
|
|
111
|
+
* Google values: minimal|low|medium|high (no off switch, no xhigh).
|
|
112
|
+
*/
|
|
113
|
+
export function mapThinkingLevelToGoogle(
|
|
114
|
+
level: ThinkingLevel | string | undefined
|
|
115
|
+
): { thinkingLevel?: string } {
|
|
116
|
+
if (!level) return {};
|
|
117
|
+
const norm = String(level).toLowerCase().trim();
|
|
118
|
+
if (norm === "dynamic") return {}; // server dynamic default (NATIVE)
|
|
119
|
+
if (norm === "none") {
|
|
120
|
+
emitProviderWarning({
|
|
121
|
+
provider: "google",
|
|
122
|
+
capability: "thinking level",
|
|
123
|
+
requested: String(level),
|
|
124
|
+
reason: "the Interactions API has no disable/off level — thought steps are always present.",
|
|
125
|
+
fallback: "the server default (omit thinking_level)",
|
|
126
|
+
});
|
|
127
|
+
return {};
|
|
128
|
+
}
|
|
129
|
+
if (norm === "xhigh") {
|
|
130
|
+
emitProviderWarning({
|
|
131
|
+
provider: "google",
|
|
132
|
+
capability: "thinking level",
|
|
133
|
+
requested: String(level),
|
|
134
|
+
reason: "Google's maximum thinking level is high.",
|
|
135
|
+
fallback: "thinking_level high",
|
|
136
|
+
});
|
|
137
|
+
return { thinkingLevel: "high" };
|
|
138
|
+
}
|
|
139
|
+
if (norm === "minimal" || norm === "low" || norm === "medium" || norm === "high") {
|
|
140
|
+
return { thinkingLevel: norm };
|
|
141
|
+
}
|
|
142
|
+
return {};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Maps canonical ServiceTier onto Google `service_tier` (omit = standard). */
|
|
146
|
+
export function mapServiceTierToGoogle(tier: ServiceTier | undefined): "flex" | "priority" | undefined {
|
|
147
|
+
if (tier === "flex" || tier === "priority") return tier;
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Maps a canonical ThinkingLevel onto OpenRouter Chat Completions
|
|
153
|
+
* `reasoning.effort`.
|
|
154
|
+
*
|
|
155
|
+
* Completions documents `reasoning_effort: xhigh|high|medium|low|minimal|none`
|
|
156
|
+
* (parameters.md) and live probes accept `xhigh` verbatim (`03`), so — unlike
|
|
157
|
+
* the discontinued Responses skin, which clamped `xhigh` — everything passes
|
|
158
|
+
* through. `dynamic` omits (server default).
|
|
159
|
+
*/
|
|
160
|
+
export function mapThinkingLevelToOpenRouterChat(
|
|
161
|
+
level: ThinkingLevel | string | undefined
|
|
162
|
+
): { effort?: string } {
|
|
163
|
+
if (!level) return {};
|
|
164
|
+
const norm = String(level).toLowerCase().trim();
|
|
165
|
+
if (norm === "dynamic") return {}; // server default
|
|
166
|
+
if (
|
|
167
|
+
norm === "none" ||
|
|
168
|
+
norm === "minimal" ||
|
|
169
|
+
norm === "low" ||
|
|
170
|
+
norm === "medium" ||
|
|
171
|
+
norm === "high" ||
|
|
172
|
+
norm === "xhigh"
|
|
173
|
+
) {
|
|
174
|
+
return { effort: norm };
|
|
175
|
+
}
|
|
176
|
+
return {};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Maps a canonical ThinkingLevel onto OpenRouter Responses `reasoning.effort`.
|
|
181
|
+
* Documented efforts: minimal|low|medium|high (server default medium).
|
|
182
|
+
* Wire-validated extras: `none` disables reasoning output; `xhigh` is echoed
|
|
183
|
+
* but is NOT a documented level, so it clamps to `high` with a warning.
|
|
184
|
+
*
|
|
185
|
+
* @deprecated The Responses skin is discontinued for OpenRouter
|
|
186
|
+
* (`src/providers/openrouter-responses.ts`, archived). Use
|
|
187
|
+
* {@link mapThinkingLevelToOpenRouterChat} with the stable Chat Completions
|
|
188
|
+
* transport instead.
|
|
189
|
+
*/
|
|
190
|
+
export function mapThinkingLevelToOpenRouter(
|
|
191
|
+
level: ThinkingLevel | string | undefined
|
|
192
|
+
): { effort?: string } {
|
|
193
|
+
if (!level) return {};
|
|
194
|
+
const norm = String(level).toLowerCase().trim();
|
|
195
|
+
if (norm === "dynamic") return {}; // server default (medium)
|
|
196
|
+
if (norm === "none") return { effort: "none" };
|
|
197
|
+
if (norm === "xhigh") {
|
|
198
|
+
emitProviderWarning({
|
|
199
|
+
provider: "openrouter",
|
|
200
|
+
capability: "thinking level",
|
|
201
|
+
requested: String(level),
|
|
202
|
+
reason: "OpenRouter documents reasoning efforts up to high.",
|
|
203
|
+
fallback: "reasoning effort high",
|
|
204
|
+
});
|
|
205
|
+
return { effort: "high" };
|
|
206
|
+
}
|
|
207
|
+
if (norm === "minimal" || norm === "low" || norm === "medium" || norm === "high") {
|
|
208
|
+
return { effort: norm };
|
|
209
|
+
}
|
|
210
|
+
return {};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Maps canonical ServiceTier onto OpenRouter `service_tier` (omit = auto). */
|
|
214
|
+
export function mapServiceTierToOpenRouter(tier: ServiceTier | undefined): "flex" | "priority" | undefined {
|
|
215
|
+
if (tier === "flex" || tier === "priority") return tier;
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Applies canonical cache config for OpenRouter. Session affinity flows via
|
|
221
|
+
* top-level body `session_id` (sent by the adapter) plus the `x-session-id`
|
|
222
|
+
* header fallback; there is no retention body primitive, so explicit
|
|
223
|
+
* retention/cachedContentId are UNSUPPORTED: warn and drop.
|
|
224
|
+
*/
|
|
225
|
+
export function applyCacheForOpenRouter(
|
|
226
|
+
cache: CacheConfig | undefined,
|
|
227
|
+
modelRef: string
|
|
228
|
+
): void {
|
|
229
|
+
if (!cache) return;
|
|
230
|
+
if (cache.retention && cache.retention !== "implicit") {
|
|
231
|
+
emitProviderWarning({
|
|
232
|
+
provider: "openrouter",
|
|
233
|
+
capability: "cache retention",
|
|
234
|
+
requested: `${cache.retention} (${modelRef})`,
|
|
235
|
+
reason: "OpenRouter documents no retention control on the stable Chat Completions endpoint.",
|
|
236
|
+
fallback: "default gateway caching with headers-only session affinity (no retention payload is sent)",
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
if (cache.cachedContentId) {
|
|
240
|
+
emitProviderWarning({
|
|
241
|
+
provider: "openrouter",
|
|
242
|
+
capability: "explicit cached content",
|
|
243
|
+
requested: cache.cachedContentId,
|
|
244
|
+
reason: "cached content references do not exist on the Responses API.",
|
|
245
|
+
fallback: "full-history sends instead (cachedContentId is ignored)",
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Applies canonical cache config for custom OpenAI-compatible endpoints.
|
|
252
|
+
* Affinity is headers-only (`x-session-id`): unknown body properties make
|
|
253
|
+
* strict endpoints (groq, ollama, …) fail, so nothing cache-related is ever
|
|
254
|
+
* placed in the body. Explicit retention/cachedContentId are UNSUPPORTED:
|
|
255
|
+
* warn and drop.
|
|
256
|
+
*/
|
|
257
|
+
export function applyCacheForCustom(
|
|
258
|
+
prefix: string,
|
|
259
|
+
cache: CacheConfig | undefined,
|
|
260
|
+
modelRef: string
|
|
261
|
+
): void {
|
|
262
|
+
if (!cache) return;
|
|
263
|
+
if (cache.retention && cache.retention !== "implicit") {
|
|
264
|
+
emitProviderWarning({
|
|
265
|
+
provider: prefix,
|
|
266
|
+
capability: "cache retention",
|
|
267
|
+
requested: `${cache.retention} (${modelRef})`,
|
|
268
|
+
reason: "custom OpenAI-compatible endpoints define no retention control, and strict endpoints reject unknown body properties.",
|
|
269
|
+
fallback: "default caching with headers-only session affinity (no retention payload is sent)",
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
if (cache.cachedContentId) {
|
|
273
|
+
emitProviderWarning({
|
|
274
|
+
provider: prefix,
|
|
275
|
+
capability: "explicit cached content",
|
|
276
|
+
requested: cache.cachedContentId,
|
|
277
|
+
reason: "cached content references do not exist on OpenAI-compatible endpoints.",
|
|
278
|
+
fallback: "full-history sends instead (cachedContentId is ignored)",
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** OpenRouter Chat Completions `tool_choice` wire values. */
|
|
284
|
+
export type OpenRouterChatToolChoice =
|
|
285
|
+
| "auto"
|
|
286
|
+
| "none"
|
|
287
|
+
| "required"
|
|
288
|
+
| { type: "function"; function: { name: string } };
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Maps canonical toolChoice onto Chat Completions `tool_choice`.
|
|
292
|
+
* `auto` is the server default (omitted); `required` is documented
|
|
293
|
+
* (parameters.md) and accepted live (`05`); a function pin passes through
|
|
294
|
+
* verbatim. Note the shape differs from the discontinued Responses skin
|
|
295
|
+
* (`{type:function,name}`): completions nests the name under `function`.
|
|
296
|
+
*/
|
|
297
|
+
export function mapToolChoiceToOpenRouterChat(
|
|
298
|
+
choice: "auto" | "none" | "required" | { type: "function"; function: { name: string } } | undefined
|
|
299
|
+
): OpenRouterChatToolChoice | undefined {
|
|
300
|
+
if (!choice || choice === "auto") return undefined;
|
|
301
|
+
if (choice === "none" || choice === "required") return choice;
|
|
302
|
+
const name = choice.function?.name;
|
|
303
|
+
if (name) return { type: "function", function: { name } };
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** OpenRouter Responses `tool_choice` wire values.
|
|
308
|
+
* @deprecated Discontinued skin; see {@link OpenRouterChatToolChoice}. */
|
|
309
|
+
export type OpenRouterToolChoice = "auto" | "none" | "required" | { type: "function"; name: string };
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Maps canonical toolChoice onto OpenRouter Responses `tool_choice`.
|
|
313
|
+
* `auto` is the server default (omitted); `required` is natively supported
|
|
314
|
+
* (validated); a function pin passes through verbatim.
|
|
315
|
+
*/
|
|
316
|
+
export function mapToolChoiceToOpenRouter(
|
|
317
|
+
choice: "auto" | "none" | "required" | { type: "function"; function: { name: string } } | undefined
|
|
318
|
+
): OpenRouterToolChoice | undefined {
|
|
319
|
+
if (!choice || choice === "auto") return undefined;
|
|
320
|
+
if (choice === "none" || choice === "required") return choice;
|
|
321
|
+
const name = choice.function?.name;
|
|
322
|
+
if (name) return { type: "function", name };
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Maps a canonical ThinkingLevel onto OpenAI Responses `reasoning.effort`.
|
|
328
|
+
* Documented efforts: none|minimal|low|medium|high|xhigh|max (server default
|
|
329
|
+
* medium). Unlike OpenRouter (which clamps xhigh), OpenAI documents xhigh
|
|
330
|
+
* natively so it passes through verbatim. `dynamic` omits (server default).
|
|
331
|
+
*/
|
|
332
|
+
export function mapThinkingLevelToOpenAI(
|
|
333
|
+
level: ThinkingLevel | string | undefined
|
|
334
|
+
): { effort?: string } {
|
|
335
|
+
if (!level) return {};
|
|
336
|
+
const norm = String(level).toLowerCase().trim();
|
|
337
|
+
if (norm === "dynamic") return {}; // server default
|
|
338
|
+
if (
|
|
339
|
+
norm === "none" ||
|
|
340
|
+
norm === "minimal" ||
|
|
341
|
+
norm === "low" ||
|
|
342
|
+
norm === "medium" ||
|
|
343
|
+
norm === "high" ||
|
|
344
|
+
norm === "xhigh"
|
|
345
|
+
) {
|
|
346
|
+
return { effort: norm };
|
|
347
|
+
}
|
|
348
|
+
return {};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Maps canonical ServiceTier onto OpenAI `service_tier` (omit = auto). */
|
|
352
|
+
export function mapServiceTierToOpenAI(tier: ServiceTier | undefined): "flex" | "priority" | undefined {
|
|
353
|
+
if (tier === "flex" || tier === "priority") return tier;
|
|
354
|
+
return undefined;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Applies canonical cache config for OpenAI. Session affinity flows via
|
|
359
|
+
* `prompt_cache_key` (handled by the adapter); explicit retention control
|
|
360
|
+
* lives in `prompt_cache_options` (gpt-5.6+ explicit breakpoints) which is
|
|
361
|
+
* out of scope for the most-important subset, so retention/cachedContentId
|
|
362
|
+
* are UNSUPPORTED: warn and drop. `prompt_cache_retention` is deprecated.
|
|
363
|
+
*/
|
|
364
|
+
export function applyCacheForOpenAI(
|
|
365
|
+
cache: CacheConfig | undefined,
|
|
366
|
+
modelRef: string
|
|
367
|
+
): void {
|
|
368
|
+
if (!cache) return;
|
|
369
|
+
if (cache.retention && cache.retention !== "implicit") {
|
|
370
|
+
emitProviderWarning({
|
|
371
|
+
provider: "openai",
|
|
372
|
+
capability: "cache retention",
|
|
373
|
+
requested: `${cache.retention} (${modelRef})`,
|
|
374
|
+
reason: "the native Responses adapter uses prompt_cache_key affinity only; explicit retention modes are out of scope.",
|
|
375
|
+
fallback: "default caching with prompt_cache_key affinity (no retention payload is sent)",
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
if (cache.cachedContentId) {
|
|
379
|
+
emitProviderWarning({
|
|
380
|
+
provider: "openai",
|
|
381
|
+
capability: "explicit cached content",
|
|
382
|
+
requested: cache.cachedContentId,
|
|
383
|
+
reason: "cached content references do not exist on the Responses API.",
|
|
384
|
+
fallback: "full-history sends instead (cachedContentId is ignored)",
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** OpenAI Responses `tool_choice` wire values (most-important subset). */
|
|
390
|
+
export type OpenAIToolChoice = "auto" | "none" | "required" | { type: "function"; name: string };
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Maps canonical toolChoice onto OpenAI Responses `tool_choice`.
|
|
394
|
+
* `auto` is the server default (omitted); `required` forces one or more
|
|
395
|
+
* calls; a function pin passes through verbatim. Built-in/MCP/allowed_tools
|
|
396
|
+
* variants are out of scope and never emitted.
|
|
397
|
+
*/
|
|
398
|
+
export function mapToolChoiceToOpenAI(
|
|
399
|
+
choice: "auto" | "none" | "required" | { type: "function"; function: { name: string } } | undefined
|
|
400
|
+
): OpenAIToolChoice | undefined {
|
|
401
|
+
if (!choice || choice === "auto") return undefined;
|
|
402
|
+
if (choice === "none" || choice === "required") return choice;
|
|
403
|
+
const name = choice.function?.name;
|
|
404
|
+
if (name) return { type: "function", name };
|
|
405
|
+
return undefined;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Applies canonical cache config for Google. The Interactions API supports
|
|
410
|
+
* implicit/automatic caching ONLY — there is no cache payload to send.
|
|
411
|
+
* Explicit retention/cachedContentId are UNSUPPORTED: warn and drop.
|
|
412
|
+
*/
|
|
413
|
+
export function applyCacheForGoogle(
|
|
414
|
+
cache: CacheConfig | undefined,
|
|
415
|
+
modelRef: string
|
|
416
|
+
): void {
|
|
417
|
+
if (!cache) return;
|
|
418
|
+
if (cache.retention && cache.retention !== "implicit") {
|
|
419
|
+
emitProviderWarning({
|
|
420
|
+
provider: "google",
|
|
421
|
+
capability: "cache retention",
|
|
422
|
+
requested: `${cache.retention} (${modelRef})`,
|
|
423
|
+
reason: "the Interactions API does not support user-defined explicit cache retention.",
|
|
424
|
+
fallback: "Google automatic/implicit caching (no cache payload is sent)",
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
if (cache.cachedContentId) {
|
|
428
|
+
emitProviderWarning({
|
|
429
|
+
provider: "google",
|
|
430
|
+
capability: "explicit cached content",
|
|
431
|
+
requested: cache.cachedContentId,
|
|
432
|
+
reason: "cachedContents resources do not exist on the Interactions API.",
|
|
433
|
+
fallback: "stateful interaction chaining / stateless history instead (cachedContentId is ignored)",
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Normalized tool-choice mode shared by provider adapters. */
|
|
439
|
+
export type CanonicalToolChoiceMode = "auto" | "any" | "none";
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Normalizes canonical toolChoice into a provider-neutral mode + optional
|
|
443
|
+
* pinned tool name. Google renders this as
|
|
444
|
+
* `{ allowed_tools: { mode, tools? } }` in its own adapter.
|
|
445
|
+
*/
|
|
446
|
+
export function normalizeToolChoice(
|
|
447
|
+
choice: "auto" | "none" | "required" | { type: "function"; function: { name: string } } | undefined,
|
|
448
|
+
availableToolNames: string[]
|
|
449
|
+
): { mode: CanonicalToolChoiceMode; tools?: string[] } | undefined {
|
|
450
|
+
if (!choice) return undefined;
|
|
451
|
+
if (typeof choice === "string") {
|
|
452
|
+
if (choice === "auto") return undefined; // server default
|
|
453
|
+
if (choice === "none") return { mode: "none" };
|
|
454
|
+
if (choice === "required") {
|
|
455
|
+
return availableToolNames.length > 0 ? { mode: "any", tools: availableToolNames } : { mode: "any" };
|
|
456
|
+
}
|
|
457
|
+
return undefined;
|
|
458
|
+
}
|
|
459
|
+
const name = choice.function?.name;
|
|
460
|
+
if (name) return { mode: "any", tools: [name] };
|
|
461
|
+
return { mode: "any" };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
465
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Reconstructs tool arguments from streamed chunks.
|
|
470
|
+
*
|
|
471
|
+
* Wire behavior varies across providers: some send the complete JSON in a
|
|
472
|
+
* single delta after an empty initial payload, others genuinely split
|
|
473
|
+
* partials across start + deltas. Concatenating blindly can yield
|
|
474
|
+
* `"{}{...}"` (unparseable), so candidates are tried in order and the first
|
|
475
|
+
* chunk that parses to a plain object wins.
|
|
476
|
+
*
|
|
477
|
+
* @example `parseStreamedToolArguments("{}", '{"location":"Paris"}')`
|
|
478
|
+
*/
|
|
479
|
+
export function parseStreamedToolArguments(startText: string, deltaText: string): Record<string, unknown> {
|
|
480
|
+
const candidates = [startText + deltaText, deltaText, startText].filter((c) => c && c.trim());
|
|
481
|
+
for (const candidate of candidates) {
|
|
482
|
+
try {
|
|
483
|
+
const parsed: unknown = JSON.parse(candidate);
|
|
484
|
+
if (isPlainObject(parsed)) return parsed;
|
|
485
|
+
} catch {
|
|
486
|
+
// try next candidate
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return { raw: startText + deltaText };
|
|
490
|
+
}
|
|
@@ -56,8 +56,9 @@ export class SSEParser {
|
|
|
56
56
|
// Comment / ping
|
|
57
57
|
continue;
|
|
58
58
|
} else if (line.startsWith("data:")) {
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
// Per WHATWG SSE, strip exactly one leading space — never tabs or
|
|
60
|
+
// further whitespace, so indented code/JSON payloads survive intact.
|
|
61
|
+
this.currentData.push(line.startsWith("data: ") ? line.slice(6) : line.slice(5));
|
|
61
62
|
} else if (line.startsWith("event:")) {
|
|
62
63
|
const val = line.slice(6);
|
|
63
64
|
this.currentEvent = (val.startsWith(" ") ? val.slice(1) : val).trim();
|
|
@@ -76,8 +77,9 @@ export class SSEParser {
|
|
|
76
77
|
const messages: SSEMessage[] = [];
|
|
77
78
|
// S7: flush pending currentData first, then treat remaining buffer as final lines (avoid duplication)
|
|
78
79
|
if (this.buffer.trim() !== "") {
|
|
79
|
-
//
|
|
80
|
-
|
|
80
|
+
// Terminate the leftover buffer in place: feed() appends its argument
|
|
81
|
+
// to this.buffer, so re-feeding the buffer itself would duplicate it.
|
|
82
|
+
const pending = this.feed("\n\n");
|
|
81
83
|
messages.push(...pending);
|
|
82
84
|
this.buffer = "";
|
|
83
85
|
}
|
package/src/tools/executor.ts
CHANGED
|
@@ -36,7 +36,8 @@ function integerOption(value: number | string | undefined): number | undefined {
|
|
|
36
36
|
return parsed === undefined ? undefined : Math.floor(parsed);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
/** Normalizes a tool name for case/format-insensitive matching (shared with delegation grants). */
|
|
40
|
+
export function normalizeToolName(name: string): string {
|
|
40
41
|
return name
|
|
41
42
|
.trim()
|
|
42
43
|
.replace(/^(?:functions?|tools?)\./i, "")
|
|
@@ -99,6 +100,7 @@ class Semaphore {
|
|
|
99
100
|
resolve: (release: () => void) => void;
|
|
100
101
|
reject: (error: Error) => void;
|
|
101
102
|
signal?: AbortSignal;
|
|
103
|
+
onAbort?: () => void;
|
|
102
104
|
}> = [];
|
|
103
105
|
|
|
104
106
|
constructor(private readonly limit: number) {}
|
|
@@ -110,13 +112,19 @@ class Semaphore {
|
|
|
110
112
|
return Promise.resolve(() => this.release());
|
|
111
113
|
}
|
|
112
114
|
return new Promise((resolve, reject) => {
|
|
113
|
-
const waiter
|
|
114
|
-
|
|
115
|
+
const waiter: {
|
|
116
|
+
resolve: (release: () => void) => void;
|
|
117
|
+
reject: (error: Error) => void;
|
|
118
|
+
signal?: AbortSignal;
|
|
119
|
+
onAbort?: () => void;
|
|
120
|
+
} = { resolve, reject, signal };
|
|
115
121
|
const onAbort = () => {
|
|
116
122
|
const index = this.waiters.indexOf(waiter);
|
|
117
123
|
if (index >= 0) this.waiters.splice(index, 1);
|
|
118
124
|
reject(new Error("Tool execution aborted"));
|
|
119
125
|
};
|
|
126
|
+
waiter.onAbort = onAbort;
|
|
127
|
+
this.waiters.push(waiter);
|
|
120
128
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
121
129
|
});
|
|
122
130
|
}
|
|
@@ -130,6 +138,9 @@ class Semaphore {
|
|
|
130
138
|
continue;
|
|
131
139
|
}
|
|
132
140
|
this.active++;
|
|
141
|
+
// The waiter is leaving the queue: drop its abort listener so long-lived
|
|
142
|
+
// session signals don't accumulate one listener per queued tool call.
|
|
143
|
+
if (waiter.onAbort) waiter.signal?.removeEventListener("abort", waiter.onAbort);
|
|
133
144
|
waiter.resolve(() => this.release());
|
|
134
145
|
return;
|
|
135
146
|
}
|
|
@@ -306,9 +317,11 @@ export async function executeToolCalls(
|
|
|
306
317
|
}
|
|
307
318
|
|
|
308
319
|
const configuredTries = integerOption(toolDef.maxTries);
|
|
309
|
-
// A positive value is the total attempt count.
|
|
310
|
-
//
|
|
311
|
-
|
|
320
|
+
// A positive value is the total attempt count. 0/omitted falls back to
|
|
321
|
+
// a bounded default: an unbounded retry loop on a persistently failing
|
|
322
|
+
// dependency (e.g. steady 503/429) would hang the agent loop forever,
|
|
323
|
+
// so callers opt into more attempts explicitly via maxTries.
|
|
324
|
+
const maxAttempts = configuredTries && configuredTries > 0 ? configuredTries : 3;
|
|
312
325
|
const timeoutMs = numericOption(toolDef.timeoutMs);
|
|
313
326
|
// Telemetry starts when the tool body is about to run, excluding queue
|
|
314
327
|
// wait and schema validation. Retries/backoff remain part of this call.
|
package/src/tools/schema.ts
CHANGED
|
@@ -65,7 +65,7 @@ export function zodToJsonSchema(schema: unknown): Record<string, unknown> {
|
|
|
65
65
|
*
|
|
66
66
|
* @example `const clean = cleanJsonSchema(rawSchema);`
|
|
67
67
|
*/
|
|
68
|
-
export function cleanJsonSchema(schema: any, rootDefs?: Record<string, any>): Record<string, unknown> {
|
|
68
|
+
export function cleanJsonSchema(schema: any, rootDefs?: Record<string, any>, seenRefs: Set<string> = new Set()): Record<string, unknown> {
|
|
69
69
|
if (typeof schema !== "object" || schema === null) {
|
|
70
70
|
return schema;
|
|
71
71
|
}
|
|
@@ -78,8 +78,18 @@ export function cleanJsonSchema(schema: any, rootDefs?: Record<string, any>): Re
|
|
|
78
78
|
if (target) {
|
|
79
79
|
// Merge sibling props (e.g. description) with target
|
|
80
80
|
const { $ref, ...siblings } = schema;
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
if (seenRefs.has(refName)) {
|
|
82
|
+
// Recursive schema (AST nodes, trees, nested categories): stop
|
|
83
|
+
// expanding to avoid overflowing the stack; keep siblings.
|
|
84
|
+
return { type: "object", ...cleanJsonSchema(siblings, defs, seenRefs) } as any;
|
|
85
|
+
}
|
|
86
|
+
seenRefs.add(refName);
|
|
87
|
+
try {
|
|
88
|
+
const resolved = cleanJsonSchema(target, defs, seenRefs);
|
|
89
|
+
return { ...resolved, ...cleanJsonSchema(siblings, defs, seenRefs) } as any;
|
|
90
|
+
} finally {
|
|
91
|
+
seenRefs.delete(refName);
|
|
92
|
+
}
|
|
83
93
|
}
|
|
84
94
|
}
|
|
85
95
|
const { $schema, $defs, definitions, ...rest } = schema;
|
|
@@ -94,22 +104,22 @@ export function cleanJsonSchema(schema: any, rootDefs?: Record<string, any>): Re
|
|
|
94
104
|
if (rest.properties && typeof rest.properties === "object") {
|
|
95
105
|
const cleanedProps: Record<string, unknown> = {};
|
|
96
106
|
for (const [key, value] of Object.entries(rest.properties)) {
|
|
97
|
-
cleanedProps[key] = cleanJsonSchema(value, defs);
|
|
107
|
+
cleanedProps[key] = cleanJsonSchema(value, defs, seenRefs);
|
|
98
108
|
}
|
|
99
109
|
rest.properties = cleanedProps;
|
|
100
110
|
}
|
|
101
111
|
|
|
102
112
|
if (rest.items) {
|
|
103
|
-
rest.items = cleanJsonSchema(rest.items, defs);
|
|
113
|
+
rest.items = cleanJsonSchema(rest.items, defs, seenRefs);
|
|
104
114
|
}
|
|
105
|
-
if (rest.anyOf) rest.anyOf = (rest.anyOf as any[]).map((v: any) => cleanJsonSchema(v, defs));
|
|
106
|
-
if (rest.oneOf) rest.oneOf = (rest.oneOf as any[]).map((v: any) => cleanJsonSchema(v, defs));
|
|
107
|
-
if (rest.allOf) rest.allOf = (rest.allOf as any[]).map((v: any) => cleanJsonSchema(v, defs));
|
|
108
|
-
if (rest.prefixItems) rest.prefixItems = (rest.prefixItems as any[]).map((v: any) => cleanJsonSchema(v, defs));
|
|
115
|
+
if (rest.anyOf) rest.anyOf = (rest.anyOf as any[]).map((v: any) => cleanJsonSchema(v, defs, seenRefs));
|
|
116
|
+
if (rest.oneOf) rest.oneOf = (rest.oneOf as any[]).map((v: any) => cleanJsonSchema(v, defs, seenRefs));
|
|
117
|
+
if (rest.allOf) rest.allOf = (rest.allOf as any[]).map((v: any) => cleanJsonSchema(v, defs, seenRefs));
|
|
118
|
+
if (rest.prefixItems) rest.prefixItems = (rest.prefixItems as any[]).map((v: any) => cleanJsonSchema(v, defs, seenRefs));
|
|
109
119
|
// Recursively clean nested $ref inside properties that were not top-level
|
|
110
120
|
for (const k of Object.keys(rest)) {
|
|
111
121
|
if (rest[k] && typeof rest[k] === "object" && !Array.isArray(rest[k]) && (rest[k] as any).$ref) {
|
|
112
|
-
rest[k] = cleanJsonSchema(rest[k], defs);
|
|
122
|
+
rest[k] = cleanJsonSchema(rest[k], defs, seenRefs);
|
|
113
123
|
}
|
|
114
124
|
}
|
|
115
125
|
return rest;
|
|
@@ -167,7 +177,7 @@ function inferZodPropertyType(prop: any): Record<string, unknown> {
|
|
|
167
177
|
}
|
|
168
178
|
if (tn.includes("literal")) {
|
|
169
179
|
const val = unwrapped._def?.value;
|
|
170
|
-
return { type: typeof val, enum: [val], ...(description ? { description } : {}) };
|
|
180
|
+
return { type: val === null ? "null" : typeof val, enum: [val], ...(description ? { description } : {}) };
|
|
171
181
|
}
|
|
172
182
|
if (tn.includes("array") || tn === "zodarray") {
|
|
173
183
|
const elem = unwrapped._def?.type || unwrapped._def?.element || unwrapped._def?.valueType || {};
|
package/src/types/agent.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
CacheConfig,
|
|
6
6
|
ServiceTier,
|
|
7
7
|
} from "./core.ts";
|
|
8
|
-
import type { ModelProviderInstance } from "../
|
|
8
|
+
import type { ModelProviderInstance } from "../providers/registry.ts";
|
|
9
9
|
import type { Agent } from "../agent/agent.ts";
|
|
10
10
|
|
|
11
11
|
/** Configuration used to construct an {@link Agent}. */
|
|
@@ -34,6 +34,13 @@ export interface AgentConfig {
|
|
|
34
34
|
cache?: CacheConfig;
|
|
35
35
|
/** Provider service tier, when supported. */
|
|
36
36
|
serviceTier?: ServiceTier;
|
|
37
|
+
/**
|
|
38
|
+
* When true, `file` parts are converted client-side to `<Document>` Markdown
|
|
39
|
+
* for models lacking native support (capable models still receive files
|
|
40
|
+
* natively; unknown models count as capable). Also auto-registers the
|
|
41
|
+
* `convert_document_to_markdown` tool for path/URL mentions in plain text.
|
|
42
|
+
*/
|
|
43
|
+
bypassInputFileModality?: boolean;
|
|
37
44
|
/** Stable conversation/cache session ID. */
|
|
38
45
|
sessionId?: string;
|
|
39
46
|
/** Headers merged into every provider request. */
|
package/src/types/core.ts
CHANGED