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.
Files changed (45) hide show
  1. package/README.md +83 -112
  2. package/{SYSTEM_PROMPT_AGENT.md → examples/prompts/SYSTEM_PROMPT_AGENT.md} +1 -1
  3. package/package.json +14 -9
  4. package/src/agent/agent.ts +191 -71
  5. package/src/agent/context.ts +1 -0
  6. package/src/agent/delegation.ts +61 -12
  7. package/src/agent/loop.ts +71 -48
  8. package/src/data/README.md +6 -6
  9. package/src/index.ts +130 -45
  10. package/src/models/catalog-cache.ts +60 -9
  11. package/src/models/catalog.ts +53 -7
  12. package/src/providers/google.ts +926 -0
  13. package/src/providers/openai-compat.ts +1147 -0
  14. package/src/providers/openai.ts +959 -0
  15. package/src/providers/openrouter-responses.ts +949 -0
  16. package/src/providers/openrouter.ts +1037 -0
  17. package/src/{ai-sdk → providers}/registry.ts +43 -55
  18. package/src/providers.ts +490 -0
  19. package/src/streaming/sse-parser.ts +6 -4
  20. package/src/tools/executor.ts +19 -6
  21. package/src/tools/schema.ts +21 -11
  22. package/src/types/agent.ts +8 -1
  23. package/src/types/core.ts +1 -1
  24. package/src/types/message.ts +5 -0
  25. package/src/types/model.ts +3 -7
  26. package/src/types/provider-payloads.ts +2 -84
  27. package/src/types/tool.ts +6 -0
  28. package/src/update-models.ts +56 -0
  29. package/src/utils/cache.ts +1 -1
  30. package/src/utils/documents.ts +517 -0
  31. package/src/utils/env.ts +0 -7
  32. package/src/{ai-sdk → utils}/errors.ts +61 -2
  33. package/src/utils/headers.ts +10 -20
  34. package/src/utils/media.ts +5 -2
  35. package/src/utils/retry.ts +89 -0
  36. package/src/utils/serialization.ts +15 -0
  37. package/src/ai-sdk/converters.ts +0 -342
  38. package/src/ai-sdk/executor.ts +0 -454
  39. package/src/ai-sdk/index.ts +0 -55
  40. package/src/ai-sdk/model-provider.ts +0 -303
  41. package/src/ai-sdk/options.ts +0 -306
  42. package/src/ai-sdk/provider.ts +0 -415
  43. package/src/tokens/counter.ts +0 -136
  44. /package/{SYSTEM_PROMPT.md → examples/prompts/SYSTEM_PROMPT.md} +0 -0
  45. /package/{SYSTEM_PROMPT_TOOLS.md → examples/prompts/SYSTEM_PROMPT_TOOLS.md} +0 -0
@@ -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
- const val = line.slice(5);
60
- this.currentData.push(val.startsWith(" ") ? val.slice(1) : val.trimStart());
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
- // Feed remaining buffer as if terminated
80
- const pending = this.feed(this.buffer + "\n\n");
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
  }
@@ -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
- function normalizeToolName(name: string): string {
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 = { resolve, reject, signal };
114
- this.waiters.push(waiter);
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. 0/omitted deliberately
310
- // means there is no configured retry limit for transient failures.
311
- const maxAttempts = configuredTries && configuredTries > 0 ? configuredTries : Number.POSITIVE_INFINITY;
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.
@@ -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
- const resolved = cleanJsonSchema(target, defs);
82
- return { ...resolved, ...cleanJsonSchema(siblings, defs) } as any;
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 || {};
@@ -5,7 +5,7 @@ import type {
5
5
  CacheConfig,
6
6
  ServiceTier,
7
7
  } from "./core.ts";
8
- import type { ModelProviderInstance } from "../ai-sdk/registry.ts";
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
@@ -49,7 +49,7 @@ export interface CacheConfig {
49
49
  */
50
50
  cachedContentId?: string;
51
51
  /**
52
- * Session ID for cache affinity routing (e.g. x-session-id, x-opencode-session)
52
+ * Session ID for cache affinity routing (e.g. x-session-id)
53
53
  */
54
54
  sessionId?: string;
55
55
  /**