agent-accelerator 0.5.9 → 0.6.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.
Files changed (85) hide show
  1. package/README.md +64 -16
  2. package/examples/prompts/SYSTEM_PROMPT.md +2 -0
  3. package/examples/prompts/SYSTEM_PROMPT_AGENT.md +4 -0
  4. package/examples/prompts/SYSTEM_PROMPT_TOOLS.md +1 -0
  5. package/package.json +4 -1
  6. package/src/agent/agent.ts +447 -172
  7. package/src/agent/architecture.md +16 -12
  8. package/src/agent/config-blocks.ts +39 -12
  9. package/src/agent/context.ts +9 -3
  10. package/src/agent/defaults.ts +88 -0
  11. package/src/agent/delegation.ts +86 -28
  12. package/src/agent/loop.ts +474 -374
  13. package/src/agent/subagent.ts +1 -3
  14. package/src/agent/subagents-tool.ts +50 -19
  15. package/src/agent/turns.ts +3 -1
  16. package/src/agent/usage-limit.ts +92 -0
  17. package/src/agent/worker.ts +73 -30
  18. package/src/agent/workers.ts +126 -21
  19. package/src/architecture.md +17 -15
  20. package/src/index.ts +43 -19
  21. package/src/models/architecture.md +4 -3
  22. package/src/models/catalog-cache.ts +6 -5
  23. package/src/models/catalog.ts +39 -30
  24. package/src/prompts/additional-context.md +2 -0
  25. package/src/prompts/architecture.md +19 -0
  26. package/src/prompts/prompts.ts +81 -0
  27. package/src/prompts/reasoning-directive.md +5 -0
  28. package/src/{scheduler/scheduled-actions.md → prompts/scheduled-actions-tool.md} +4 -0
  29. package/src/prompts/structured-output.md +2 -0
  30. package/src/{agent/subagents.md → prompts/subagents-tool.md} +3 -0
  31. package/src/prompts/tool-bridge.md +1 -0
  32. package/src/prompts/tool-interrupted.md +1 -0
  33. package/src/prompts/tool-not-found.md +1 -0
  34. package/src/prompts/tool-repeat-blocked.md +1 -0
  35. package/src/prompts/tool-schema-error.md +1 -0
  36. package/src/prompts/tool-truncated-arguments.md +1 -0
  37. package/src/prompts/worker-failed.md +1 -0
  38. package/src/prompts/worker-replied.md +2 -0
  39. package/src/providers/architecture.md +10 -8
  40. package/src/providers/google.ts +228 -69
  41. package/src/providers/openai-compat.ts +74 -56
  42. package/src/providers/openai.ts +64 -41
  43. package/src/providers/openrouter.ts +270 -48
  44. package/src/providers/registry.ts +6 -21
  45. package/src/providers/shared.ts +87 -33
  46. package/src/providers.ts +115 -124
  47. package/src/scheduler/architecture.md +8 -7
  48. package/src/scheduler/scheduler.ts +226 -60
  49. package/src/scheduler/store.ts +23 -10
  50. package/src/scheduler/time.ts +17 -3
  51. package/src/scheduler/tool.ts +38 -16
  52. package/src/session/architecture.md +7 -6
  53. package/src/session/journal.ts +39 -12
  54. package/src/session/store.ts +126 -61
  55. package/src/session/usage.ts +58 -9
  56. package/src/streaming/architecture.md +4 -3
  57. package/src/streaming/event-stream.ts +26 -12
  58. package/src/telemetry.ts +6 -2
  59. package/src/tools/architecture.md +9 -7
  60. package/src/tools/executor.ts +109 -62
  61. package/src/tools/schema.ts +10 -6
  62. package/src/tools/subagent-result.ts +6 -1
  63. package/src/tools/tool-content.ts +32 -0
  64. package/src/tools/tool.ts +9 -11
  65. package/src/types/agent.ts +37 -22
  66. package/src/types/architecture.md +11 -10
  67. package/src/types/core.ts +5 -9
  68. package/src/types/errors.ts +26 -4
  69. package/src/types/message.ts +7 -0
  70. package/src/types/model.ts +4 -5
  71. package/src/types/scheduler.ts +4 -4
  72. package/src/types/tool.ts +14 -2
  73. package/src/update-models.ts +5 -5
  74. package/src/utils/architecture.md +16 -15
  75. package/src/utils/cache.ts +1 -3
  76. package/src/utils/durable.ts +17 -5
  77. package/src/utils/env.ts +9 -7
  78. package/src/utils/errors.ts +9 -17
  79. package/src/utils/headers.ts +3 -10
  80. package/src/utils/media.ts +23 -8
  81. package/src/utils/raw-capture.ts +9 -2
  82. package/src/utils/retry.ts +15 -3
  83. package/src/utils/safe-io.ts +29 -7
  84. package/src/utils/serialization.ts +1 -4
  85. package/src/utils/session.ts +1 -2
@@ -28,9 +28,7 @@ export function parseArguments(raw: string | undefined): Record<string, unknown>
28
28
 
29
29
  /** Trims/collapses assembled thinking parts (no edge-tripling). */
30
30
  export function normalizeThinkingParts(parts: string[]): string | undefined {
31
- const cleaned = parts
32
- .map((p) => p.replace(/\n{3,}/g, "\n\n").trim())
33
- .filter((p) => p.length > 0);
31
+ const cleaned = parts.map((p) => p.replace(/\n{3,}/g, "\n\n").trim()).filter((p) => p.length > 0);
34
32
  return cleaned.length > 0 ? cleaned.join("\n") : undefined;
35
33
  }
36
34
 
@@ -49,21 +47,35 @@ export function redactedHeaders(headers: Record<string, string>): Record<string,
49
47
  * OpenAI `type`, OpenRouter `metadata.error_type` + `metadata.raw` + `metadata.limit_source`, and Google
50
48
  * `status` code shapes; unknown bodies degrade to a 300-char slice.
51
49
  */
52
- export function readErrorPayload(
53
- bodyText: string
54
- ): { message: string; code?: string | number; errorType?: string; raw?: string; limitSource?: string } {
50
+ export function readErrorPayload(bodyText: string): {
51
+ message: string;
52
+ code?: string | number;
53
+ errorType?: string;
54
+ raw?: string;
55
+ limitSource?: string;
56
+ } {
55
57
  try {
56
58
  const parsed: unknown = JSON.parse(bodyText);
57
59
  const first = Array.isArray(parsed) ? parsed[0] : parsed;
58
- const err = (first as { error?: { message?: string; code?: string | number; type?: string; status?: string | number; metadata?: { error_type?: string; raw?: unknown; limit_source?: unknown } } })?.error;
60
+ const err = (
61
+ first as {
62
+ error?: {
63
+ message?: string;
64
+ code?: string | number;
65
+ type?: string;
66
+ status?: string | number;
67
+ metadata?: { error_type?: string; raw?: unknown; limit_source?: unknown };
68
+ };
69
+ }
70
+ )?.error;
59
71
  if (err && typeof err.message === "string") {
60
72
  const code = err.code ?? err.status;
61
- const errorType =
62
- err.type ?? err.metadata?.error_type ?? (first as { error_type?: string })?.error_type;
73
+ const errorType = err.type ?? err.metadata?.error_type ?? (first as { error_type?: string })?.error_type;
63
74
  const rawCandidate =
64
75
  (err as { metadata?: { raw?: unknown } }).metadata?.raw ??
65
76
  (first as { metadata?: { raw?: unknown } })?.metadata?.raw;
66
- const raw = typeof rawCandidate === "string" && rawCandidate.trim() ? rawCandidate.trim().slice(0, 500) : undefined;
77
+ const raw =
78
+ typeof rawCandidate === "string" && rawCandidate.trim() ? rawCandidate.trim().slice(0, 500) : undefined;
67
79
  return {
68
80
  message: err.message,
69
81
  ...(code !== undefined ? { code } : {}),
@@ -111,7 +123,9 @@ export function isNotFoundChainError(error: unknown): boolean {
111
123
  );
112
124
  if (status === 404) return true;
113
125
  // `providerCode` on a wrapped ProviderError (its `code` is the SDK kind), `code` on a raw error.
114
- return [err["providerCode"], err["code"], err["errorType"]].some((c) => String(c ?? "").toLowerCase() === "not_found");
126
+ return [err["providerCode"], err["code"], err["errorType"]].some(
127
+ (c) => String(c ?? "").toLowerCase() === "not_found"
128
+ );
115
129
  }
116
130
 
117
131
  /** Per-request timeout ms from provider options. */
@@ -121,25 +135,40 @@ export function timeoutFor(options: { timeoutMs?: number } | undefined): number
121
135
  }
122
136
 
123
137
  /** Merges a parent signal with a timeout into one effective signal. Caller must call cleanup when the request settles so the timer never holds the loop open. */
124
- export function combinedSignal(signal?: AbortSignal, timeoutMs?: number): { signal: AbortSignal | undefined; cleanup: () => void } {
138
+ export function combinedSignal(
139
+ signal?: AbortSignal,
140
+ timeoutMs?: number
141
+ ): { signal: AbortSignal | undefined; cleanup: () => void } {
125
142
  if (timeoutMs === undefined) return { signal, cleanup: () => {} };
126
143
  const ctrl = new AbortController();
127
144
  const timer = setTimeout(() => {
128
- try { ctrl.abort(); } catch {}
145
+ try {
146
+ ctrl.abort();
147
+ } catch {}
129
148
  }, timeoutMs);
130
- try { (timer as unknown as { unref?: () => void }).unref?.(); } catch {}
149
+ try {
150
+ (timer as unknown as { unref?: () => void }).unref?.();
151
+ } catch {}
131
152
  const cleanup = () => {
132
- try { clearTimeout(timer); } catch {}
153
+ try {
154
+ clearTimeout(timer);
155
+ } catch {}
133
156
  };
134
157
  if (signal) {
135
158
  if (signal.aborted) {
136
159
  cleanup();
137
160
  ctrl.abort(signal.reason); // the caller's reason survives (`interrupt(new Error("..."))`)
138
161
  } else {
139
- signal.addEventListener("abort", () => {
140
- cleanup();
141
- try { ctrl.abort(signal.reason); } catch {}
142
- }, { once: true });
162
+ signal.addEventListener(
163
+ "abort",
164
+ () => {
165
+ cleanup();
166
+ try {
167
+ ctrl.abort(signal.reason);
168
+ } catch {}
169
+ },
170
+ { once: true }
171
+ );
143
172
  }
144
173
  }
145
174
  return { signal: ctrl.signal, cleanup };
@@ -167,7 +196,11 @@ export function hasAuthHeader(headers: Record<string, string> | undefined): bool
167
196
  if (!headers) return false;
168
197
  for (const [k, v] of Object.entries(headers)) {
169
198
  const lower = k.toLowerCase();
170
- if ((lower === "authorization" || lower === "cf-aig-authorization") && typeof v === "string" && v.trim().length > 0) {
199
+ if (
200
+ (lower === "authorization" || lower === "cf-aig-authorization") &&
201
+ typeof v === "string" &&
202
+ v.trim().length > 0
203
+ ) {
171
204
  return true;
172
205
  }
173
206
  }
@@ -180,7 +213,10 @@ export function hasTruncatedToolArguments(args: unknown): boolean {
180
213
  }
181
214
 
182
215
  // True when a stream with no terminal event left a tool call whose arguments never fully arrived (none at all, or cut mid-JSON); in: the adapter's assembled calls and their records, out: its incomplete_stream guard.
183
- export function hasUnfinishedToolCall(calls: Iterable<{ startArgs: string; deltaArgs: string }>, toolCalls: Array<{ arguments: unknown }>): boolean {
216
+ export function hasUnfinishedToolCall(
217
+ calls: Iterable<{ startArgs: string; deltaArgs: string }>,
218
+ toolCalls: Array<{ arguments: unknown }>
219
+ ): boolean {
184
220
  for (const c of calls) if (!c.startArgs.trim() && !c.deltaArgs.trim()) return true;
185
221
  return toolCalls.some((c) => isTruncatedArguments(c.arguments));
186
222
  }
@@ -220,7 +256,9 @@ export function clampToolCallId(id: string): string {
220
256
 
221
257
  // The finish reason of a turn that produced tool calls: `tool_calls`, except that a turn cut off by the output limit (`length`) keeps saying so when one of its calls was cut mid-JSON; in: the wire finish reason + the assembled calls, out: each streaming adapter.
222
258
  export function toolTurnFinishReason(finishReason: string, toolCalls: Array<{ arguments: unknown }>): string {
223
- return finishReason === "length" && toolCalls.some((c) => isTruncatedArguments(c.arguments)) ? "length" : "tool_calls";
259
+ return finishReason === "length" && toolCalls.some((c) => isTruncatedArguments(c.arguments))
260
+ ? "length"
261
+ : "tool_calls";
224
262
  }
225
263
 
226
264
  // The JSON text of a tool call's arguments for a history message: `{}` for a call whose streamed arguments were cut off (the internal `__truncated_raw` marker was never the model's own output and is never resent); in: a stored tool call, out: each adapter's history builder.
@@ -249,7 +287,8 @@ export function parseRetryAfterMs(headers: Record<string, string> | undefined, n
249
287
  return undefined;
250
288
  };
251
289
  const ms = get("retry-after-ms");
252
- if (ms !== undefined && ms.trim() !== "" && Number.isFinite(Number(ms)) && Number(ms) >= 0) return Math.round(Number(ms));
290
+ if (ms !== undefined && ms.trim() !== "" && Number.isFinite(Number(ms)) && Number(ms) >= 0)
291
+ return Math.round(Number(ms));
253
292
  const ra = get("retry-after");
254
293
  if (ra === undefined || ra.trim() === "") return undefined;
255
294
  const secs = Number(ra);
@@ -287,12 +326,16 @@ export function openStreamResponse(args: {
287
326
  }
288
327
 
289
328
  /** Retry knobs from provider request options, in the shape `withRetries` expects. */
290
- export function retryOptionsFrom(options: {
291
- maxRetries?: number;
292
- maxRetryDelayMs?: number;
293
- retryBaseDelayMs?: number;
294
- retryBudgetMs?: number;
295
- } | undefined): { maxRetries?: number; maxRetryDelayMs?: number; baseDelayMs?: number; retryBudgetMs?: number } {
329
+ export function retryOptionsFrom(
330
+ options:
331
+ | {
332
+ maxRetries?: number;
333
+ maxRetryDelayMs?: number;
334
+ retryBaseDelayMs?: number;
335
+ retryBudgetMs?: number;
336
+ }
337
+ | undefined
338
+ ): { maxRetries?: number; maxRetryDelayMs?: number; baseDelayMs?: number; retryBudgetMs?: number } {
296
339
  return {
297
340
  maxRetries: options?.maxRetries,
298
341
  maxRetryDelayMs: options?.maxRetryDelayMs,
@@ -306,7 +349,10 @@ export function retryOptionsFrom(options: {
306
349
  // ---------------------------------------------------------------------------
307
350
 
308
351
  // Ties a provider stream to the caller's signal and to the stream's own cancel: one controller that aborts when either does; in: an adapter's stream(), out: the controller its request uses and the `release` its finally block calls.
309
- export function linkStreamAbort(eventStream: AssistantMessageEventStream, signal: AbortSignal | undefined): { linked: AbortController; release: () => void } {
352
+ export function linkStreamAbort(
353
+ eventStream: AssistantMessageEventStream,
354
+ signal: AbortSignal | undefined
355
+ ): { linked: AbortController; release: () => void } {
310
356
  const linked = new AbortController();
311
357
  const forwardUserAbort = (): void => {
312
358
  try {
@@ -338,7 +384,11 @@ export function linkStreamAbort(eventStream: AssistantMessageEventStream, signal
338
384
  }
339
385
 
340
386
  // Runs one request attempt under a timer that aborts `controller` after `ms` (none when unset); in: an adapter's fetchOnce, out: its Response.
341
- export async function withAbortTimer<T>(controller: AbortController, ms: number | undefined, run: () => Promise<T>): Promise<T> {
387
+ export async function withAbortTimer<T>(
388
+ controller: AbortController,
389
+ ms: number | undefined,
390
+ run: () => Promise<T>
391
+ ): Promise<T> {
342
392
  const timer =
343
393
  ms && ms > 0
344
394
  ? setTimeout(() => {
@@ -397,13 +447,17 @@ export function incompleteStreamCheck(a: {
397
447
  }): Error | undefined {
398
448
  if (a.hasTerminal || !(a.empty || a.unfinishedCall)) return undefined;
399
449
  const contentType = Object.entries(a.responseHeaders).find(([k]) => k.toLowerCase() === "content-type")?.[1];
400
- const isSSEPayload = (typeof contentType === "string" && (contentType.includes("event-stream") || contentType.startsWith("text/"))) || a.framesSeen > 0 || a.badFrames > 0;
450
+ const isSSEPayload =
451
+ (typeof contentType === "string" && (contentType.includes("event-stream") || contentType.startsWith("text/"))) ||
452
+ a.framesSeen > 0 ||
453
+ a.badFrames > 0;
401
454
  return isSSEPayload ? incompleteStreamError(a.provider, a.modelId) : undefined;
402
455
  }
403
456
 
404
457
  // Fails a stream with the right error: an abort (the signal, the stream's cancel, or an AbortError) becomes the plain `AbortError`, anything else passes through; in: an adapter's catch block, out: the stream's result() rejection.
405
458
  export function failStream(eventStream: AssistantMessageEventStream, linked: AbortController, err: unknown): void {
406
459
  const raw = err instanceof Error ? err : new Error(String(err));
407
- const isAbort = linked.signal.aborted || eventStream.isCancelled() || (raw as { name?: string }).name === "AbortError";
460
+ const isAbort =
461
+ linked.signal.aborted || eventStream.isCancelled() || (raw as { name?: string }).name === "AbortError";
408
462
  eventStream.fail(isAbort ? Object.assign(new Error("Stream aborted"), { name: "AbortError" }) : raw);
409
463
  }
package/src/providers.ts CHANGED
@@ -16,14 +16,10 @@ import { zodToJsonSchema } from "./tools/schema.ts";
16
16
  import { ValidationError } from "./types/errors.ts";
17
17
  import { TRUNCATED_ARGS_KEY } from "./types/tool.ts";
18
18
  import { telemetryBus } from "./telemetry.ts";
19
+ import { DEFAULT_PROMPTS, fillPrompt } from "./prompts/prompts.ts";
19
20
 
20
21
  /** Classification of a canonical capability on a given provider. */
21
- export type ProviderCapabilityStatus =
22
- | "NATIVE"
23
- | "TRANSLATED"
24
- | "AUTOMATIC"
25
- | "EMULATED"
26
- | "UNSUPPORTED";
22
+ export type ProviderCapabilityStatus = "NATIVE" | "TRANSLATED" | "AUTOMATIC" | "EMULATED" | "UNSUPPORTED";
27
23
 
28
24
  /**
29
25
  * Single warning mechanism for provider capability differences.
@@ -65,8 +61,12 @@ export function emitProviderWarning(options: {
65
61
  reason: options.reason,
66
62
  fallback: options.fallback,
67
63
  });
68
- try { (globalThis as any).__agentAccelWarnSink?.(msg, options); } catch {}
69
- try { console.warn(msg); } catch {}
64
+ try {
65
+ (globalThis as any).__agentAccelWarnSink?.(msg, options);
66
+ } catch {}
67
+ try {
68
+ console.warn(msg);
69
+ } catch {}
70
70
  }
71
71
 
72
72
  /** Clears deduped-warning state (mainly for tests). */
@@ -124,9 +124,7 @@ export function clearSessionRouting(sessionId?: string): void {
124
124
  * Maps a canonical ThinkingLevel onto Google Interactions `thinking_level`.
125
125
  * Google values: minimal|low|medium|high (no off switch, no xhigh/max).
126
126
  */
127
- export function mapThinkingLevelToGoogle(
128
- level: ThinkingLevel | string | undefined
129
- ): { thinkingLevel?: string } {
127
+ export function mapThinkingLevelToGoogle(level: ThinkingLevel | string | undefined): { thinkingLevel?: string } {
130
128
  if (!level) return {};
131
129
  const norm = String(level).toLowerCase().trim();
132
130
  if (norm === "dynamic") return {}; // server dynamic default (NATIVE)
@@ -171,9 +169,7 @@ export function mapServiceTierToGoogle(tier: ServiceTier | undefined): "flex" |
171
169
  * the discontinued Responses skin, which clamped `xhigh` — everything passes
172
170
  * through. `max` rides the same verbatim path. `dynamic` omits (server default).
173
171
  */
174
- export function mapThinkingLevelToOpenRouterChat(
175
- level: ThinkingLevel | string | undefined
176
- ): { effort?: string } {
172
+ export function mapThinkingLevelToOpenRouterChat(level: ThinkingLevel | string | undefined): { effort?: string } {
177
173
  if (!level) return {};
178
174
  const norm = String(level).toLowerCase().trim();
179
175
  if (norm === "dynamic") return {}; // server default
@@ -216,9 +212,16 @@ export function resolveCache(
216
212
  }
217
213
 
218
214
  /** Warns once per provider/model that per-media `resolution` is ignored (no such control on that wire for the given kinds of media); in: an adapter's request messages, out: telemetry bus only. */
219
- export function warnIgnoredMediaResolution(provider: string, messages: Message[], modelRef: string, kinds: Array<"image" | "video"> = ["image", "video"]): void {
215
+ export function warnIgnoredMediaResolution(
216
+ provider: string,
217
+ messages: Message[],
218
+ modelRef: string,
219
+ kinds: Array<"image" | "video"> = ["image", "video"]
220
+ ): void {
220
221
  const used = messages.some(
221
- (m) => Array.isArray(m.content) && m.content.some((p) => (p.type === "image" || p.type === "video") && kinds.includes(p.type) && p.resolution)
222
+ (m) =>
223
+ Array.isArray(m.content) &&
224
+ m.content.some((p) => (p.type === "image" || p.type === "video") && kinds.includes(p.type) && p.resolution)
222
225
  );
223
226
  if (!used) return;
224
227
  emitProviderWarning({
@@ -230,12 +233,49 @@ export function warnIgnoredMediaResolution(provider: string, messages: Message[]
230
233
  });
231
234
  }
232
235
 
236
+ /** Warns once per provider/model that a video's `processing` mode is ignored (only Google has one); in: an adapter's request messages, out: telemetry bus only. */
237
+ export function warnIgnoredVideoProcessing(provider: string, messages: Message[], modelRef: string): void {
238
+ const used = messages.some(
239
+ (m) => Array.isArray(m.content) && m.content.some((p) => p.type === "video" && p.processing)
240
+ );
241
+ if (!used) return;
242
+ emitProviderWarning({
243
+ provider,
244
+ capability: "video processing",
245
+ requested: modelRef,
246
+ reason: "only Google reads a video in a chosen processing mode (static or agentic).",
247
+ fallback: "the provider's default video handling",
248
+ });
249
+ }
250
+
251
+ /** Warns once per provider/model that media a tool returned with `toolContent` is not sent (only Google takes media inside a function result); the model still gets the tool's text; in: an adapter's request messages, out: telemetry bus only. */
252
+ export function warnIgnoredToolMedia(provider: string, messages: Message[], modelRef: string): void {
253
+ const used = messages.some(
254
+ (m) => Array.isArray(m.content) && m.content.some((p) => p.type === "tool_result" && p.parts && p.parts.length > 0)
255
+ );
256
+ if (!used) return;
257
+ emitProviderWarning({
258
+ provider,
259
+ capability: "media in tool results",
260
+ requested: modelRef,
261
+ reason: "only Google accepts images, audio, video or files inside a function result.",
262
+ fallback: "the tool's text only (the media is not sent)",
263
+ });
264
+ }
265
+
266
+ // Warns once that the `validated` tool choice (Google only) is dropped and the provider default (auto) is used; in: the OpenRouter and OpenAI tool-choice mappers (compat goes through the OpenAI one), out: a provider_warning on the bus.
267
+ function warnValidatedToolChoice(provider: string): void {
268
+ emitProviderWarning({
269
+ provider,
270
+ capability: "tool choice",
271
+ requested: "validated",
272
+ reason: "only Google has a validated tool-choice mode.",
273
+ fallback: "automatic tool choice",
274
+ });
275
+ }
276
+
233
277
  /** OpenRouter Chat Completions `tool_choice` wire values. */
234
- export type OpenRouterChatToolChoice =
235
- | "auto"
236
- | "none"
237
- | "required"
238
- | { type: "function"; function: { name: string } };
278
+ export type OpenRouterChatToolChoice = "auto" | "none" | "required" | { type: "function"; function: { name: string } };
239
279
 
240
280
  /**
241
281
  * Maps canonical toolChoice onto Chat Completions `tool_choice`.
@@ -245,9 +285,10 @@ export type OpenRouterChatToolChoice =
245
285
  * (`{type:function,name}`): completions nests the name under `function`.
246
286
  */
247
287
  export function mapToolChoiceToOpenRouterChat(
248
- choice: "auto" | "none" | "required" | { type: "function"; function: { name: string } } | undefined
288
+ choice: "auto" | "none" | "required" | "validated" | { type: "function"; function: { name: string } } | undefined
249
289
  ): OpenRouterChatToolChoice | undefined {
250
290
  if (!choice || choice === "auto") return undefined;
291
+ if (choice === "validated") return (warnValidatedToolChoice("openrouter"), undefined);
251
292
  if (choice === "none" || choice === "required") return choice;
252
293
  const name = choice.function?.name;
253
294
  if (name) return { type: "function", function: { name } };
@@ -260,9 +301,7 @@ export function mapToolChoiceToOpenRouterChat(
260
301
  * medium). Unlike OpenRouter (which clamps xhigh), OpenAI documents xhigh
261
302
  * and max natively so they pass through verbatim. `dynamic` omits (server default).
262
303
  */
263
- export function mapThinkingLevelToOpenAI(
264
- level: ThinkingLevel | string | undefined
265
- ): { effort?: string } {
304
+ export function mapThinkingLevelToOpenAI(level: ThinkingLevel | string | undefined): { effort?: string } {
266
305
  if (!level) return {};
267
306
  const norm = String(level).toLowerCase().trim();
268
307
  if (norm === "dynamic") return {}; // server default
@@ -296,9 +335,11 @@ export type OpenAIToolChoice = "auto" | "none" | "required" | { type: "function"
296
335
  * variants are out of scope and never emitted.
297
336
  */
298
337
  export function mapToolChoiceToOpenAI(
299
- choice: "auto" | "none" | "required" | { type: "function"; function: { name: string } } | undefined
338
+ choice: "auto" | "none" | "required" | "validated" | { type: "function"; function: { name: string } } | undefined,
339
+ provider = "openai"
300
340
  ): OpenAIToolChoice | undefined {
301
341
  if (!choice || choice === "auto") return undefined;
342
+ if (choice === "validated") return (warnValidatedToolChoice(provider), undefined);
302
343
  if (choice === "none" || choice === "required") return choice;
303
344
  const name = choice.function?.name;
304
345
  if (name) return { type: "function", name };
@@ -306,7 +347,7 @@ export function mapToolChoiceToOpenAI(
306
347
  }
307
348
 
308
349
  /** Normalized tool-choice mode shared by provider adapters. */
309
- export type CanonicalToolChoiceMode = "auto" | "any" | "none";
350
+ export type CanonicalToolChoiceMode = "auto" | "any" | "none" | "validated";
310
351
 
311
352
  /**
312
353
  * Normalizes canonical toolChoice into a provider-neutral mode + optional
@@ -314,13 +355,14 @@ export type CanonicalToolChoiceMode = "auto" | "any" | "none";
314
355
  * `{ allowed_tools: { mode, tools? } }` in its own adapter.
315
356
  */
316
357
  export function normalizeToolChoice(
317
- choice: "auto" | "none" | "required" | { type: "function"; function: { name: string } } | undefined,
358
+ choice: "auto" | "none" | "required" | "validated" | { type: "function"; function: { name: string } } | undefined,
318
359
  availableToolNames: string[]
319
360
  ): { mode: CanonicalToolChoiceMode; tools?: string[] } | undefined {
320
361
  if (!choice) return undefined;
321
362
  if (typeof choice === "string") {
322
363
  if (choice === "auto") return undefined; // server default
323
364
  if (choice === "none") return { mode: "none" };
365
+ if (choice === "validated") return { mode: "validated" };
324
366
  if (choice === "required") {
325
367
  return availableToolNames.length > 0 ? { mode: "any", tools: availableToolNames } : { mode: "any" };
326
368
  }
@@ -386,98 +428,36 @@ export function selectRawArguments(startText: string, deltaText: string): string
386
428
  }
387
429
 
388
430
  // ---------------------------------------------------------------------------
389
- // Generation controls (maxOutputTokens / stop / seed)
431
+ // Generation controls (maxOutputTokens)
390
432
  // ---------------------------------------------------------------------------
391
433
 
392
434
  /** Canonical generation controls as carried on `ProviderRequestOptions`. */
393
435
  export interface GenerationParams {
394
436
  maxOutputTokens?: number;
395
- stop?: string[];
396
- seed?: number;
397
437
  }
398
438
 
399
- /** Most stop sequences a Chat Completions request may carry (OpenRouter chat-completion api-ref `maxItems`, OpenAI the same). */
400
- const MAX_CHAT_STOP = 4;
401
- // True for a finite number; in: the generation-param mappers, out: a type guard.
402
- const finiteNumber = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
403
-
404
- /** Normalizes the public `stop` option (`string | string[]`) to a clean array, or undefined. */
405
- export function normalizeStop(stop: string | string[] | undefined): string[] | undefined {
406
- const list = (Array.isArray(stop) ? stop : stop === undefined ? [] : [stop]).filter(
407
- (s): s is string => typeof s === "string" && s.length > 0
408
- );
409
- return list.length > 0 ? list : undefined;
439
+ // The output-token cap as a whole number above zero, else undefined; in: the three generation-param mappers, out: their wire field.
440
+ function outputTokenCap(params: GenerationParams | undefined): number | undefined {
441
+ const n = params?.maxOutputTokens;
442
+ return typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
410
443
  }
411
444
 
412
- /**
413
- * Standard Chat Completions fields (OpenRouter + generic compat endpoints):
414
- * `max_tokens`, `stop`, `seed`. Only set values are
415
- * emitted, so strict endpoints never see anything the caller did not ask for.
416
- */
417
- export function mapGenerationParamsToChat(params: GenerationParams | undefined, provider = "chat", modelRef = ""): Record<string, unknown> {
418
- const out: Record<string, unknown> = {};
419
- if (!params) return out;
420
- if (finiteNumber(params.maxOutputTokens) && params.maxOutputTokens > 0) out["max_tokens"] = Math.floor(params.maxOutputTokens);
421
- if (params.stop && params.stop.length > 0) {
422
- out["stop"] = params.stop.slice(0, MAX_CHAT_STOP);
423
- if (params.stop.length > MAX_CHAT_STOP) {
424
- emitProviderWarning({
425
- provider,
426
- capability: "stop sequences",
427
- requested: `${params.stop.length} sequences (${modelRef})`,
428
- reason: `Chat Completions allows at most ${MAX_CHAT_STOP} stop sequences.`,
429
- fallback: `the first ${MAX_CHAT_STOP}`,
430
- });
431
- }
432
- }
433
- if (finiteNumber(params.seed)) out["seed"] = Math.floor(params.seed);
434
- return out;
445
+ /** Chat Completions (OpenRouter + generic compat endpoints): `max_tokens`, only when the caller set it, so strict endpoints never see anything unasked. */
446
+ export function mapGenerationParamsToChat(params: GenerationParams | undefined): { max_tokens?: number } {
447
+ const cap = outputTokenCap(params);
448
+ return cap === undefined ? {} : { max_tokens: cap };
435
449
  }
436
450
 
437
- // Warns that `stop`/`seed` have no parameter on this wire and are dropped; in: the OpenAI and Google mappers, out: provider_warning events.
438
- function warnUnsupportedStopSeed(provider: string, modelRef: string, params: GenerationParams, reasonSuffix: string): void {
439
- if (params.stop && params.stop.length > 0) {
440
- emitProviderWarning({
441
- provider,
442
- capability: "stop sequences",
443
- requested: `${params.stop.join("|")} (${modelRef})`,
444
- reason: `the ${reasonSuffix} defines no stop-sequence parameter.`,
445
- fallback: "no stop sequences (the model decides when to stop)",
446
- });
447
- }
448
- if (finiteNumber(params.seed)) {
449
- emitProviderWarning({
450
- provider,
451
- capability: "seed",
452
- requested: `${params.seed} (${modelRef})`,
453
- reason: `the ${reasonSuffix} defines no seed parameter.`,
454
- fallback: "non-deterministic sampling",
455
- });
456
- }
457
- }
458
-
459
- /** OpenAI Responses: `max_output_tokens` is native; `stop` and `seed` do not exist on this wire (warn + drop). */
460
- export function mapGenerationParamsToOpenAI(
461
- params: GenerationParams | undefined,
462
- modelRef: string
463
- ): { max_output_tokens?: number } {
464
- if (!params) return {};
465
- warnUnsupportedStopSeed("openai", modelRef, params, "OpenAI Responses API");
466
- return finiteNumber(params.maxOutputTokens) && params.maxOutputTokens > 0
467
- ? { max_output_tokens: Math.floor(params.maxOutputTokens) }
468
- : {};
451
+ /** OpenAI Responses: `max_output_tokens`. */
452
+ export function mapGenerationParamsToOpenAI(params: GenerationParams | undefined): { max_output_tokens?: number } {
453
+ const cap = outputTokenCap(params);
454
+ return cap === undefined ? {} : { max_output_tokens: cap };
469
455
  }
470
456
 
471
- /** Google Interactions `generation_config`: `max_output_tokens` is documented; `stop` and `seed` are not (warn + drop). */
472
- export function mapGenerationParamsToGoogle(
473
- params: GenerationParams | undefined,
474
- modelRef: string
475
- ): { max_output_tokens?: number } {
476
- if (!params) return {};
477
- warnUnsupportedStopSeed("google", modelRef, params, "Gemini Interactions API");
478
- return finiteNumber(params.maxOutputTokens) && params.maxOutputTokens > 0
479
- ? { max_output_tokens: Math.floor(params.maxOutputTokens) }
480
- : {};
457
+ /** Google Interactions `generation_config`: `max_output_tokens`. */
458
+ export function mapGenerationParamsToGoogle(params: GenerationParams | undefined): { max_output_tokens?: number } {
459
+ const cap = outputTokenCap(params);
460
+ return cap === undefined ? {} : { max_output_tokens: cap };
481
461
  }
482
462
 
483
463
  // ---------------------------------------------------------------------------
@@ -488,12 +468,7 @@ export function mapGenerationParamsToGoogle(
488
468
  function isZodLike(value: unknown): boolean {
489
469
  if (!value || typeof value !== "object") return false;
490
470
  const v = value as Record<string, unknown>;
491
- return (
492
- typeof v["safeParse"] === "function" ||
493
- typeof v["parse"] === "function" ||
494
- "_def" in v ||
495
- "~standard" in v
496
- );
471
+ return typeof v["safeParse"] === "function" || typeof v["parse"] === "function" || "_def" in v || "~standard" in v;
497
472
  }
498
473
 
499
474
  // True when a plain object has JSON Schema keywords; in: structured-output normalization, out: a boolean.
@@ -575,7 +550,9 @@ export function normalizeStructuredOutput(output: unknown): StructuredOutputSpec
575
550
  * present (bare Zod schema or `{ schema: Zod }`). Used by the loop for
576
551
  * post-parse validation; `undefined` means JSON-parse only.
577
552
  */
578
- export function extractOutputValidator(output: unknown): { safeParse: (v: unknown) => { success: boolean; data?: unknown; error?: { message: string } } } | undefined {
553
+ export function extractOutputValidator(
554
+ output: unknown
555
+ ): { safeParse: (v: unknown) => { success: boolean; data?: unknown; error?: { message: string } } } | undefined {
579
556
  if (!output) return undefined;
580
557
  if (isZodLike(output) && typeof (output as { safeParse?: unknown }).safeParse === "function") {
581
558
  return output as { safeParse: (v: unknown) => { success: boolean; data?: unknown; error?: { message: string } } };
@@ -637,9 +614,17 @@ export function parseStructuredOutput(text: string, output: unknown): unknown {
637
614
  * Maps a normalized spec onto OpenAI Responses `text.format`.
638
615
  * `{ type: json_schema, name, schema, strict }` (docs: responses/create).
639
616
  */
640
- export function mapOutputToOpenAI(
641
- spec: StructuredOutputSpec | undefined
642
- ): { format: { type: "json_schema"; name: string; schema: Record<string, unknown>; strict: boolean; description?: string } } | undefined {
617
+ export function mapOutputToOpenAI(spec: StructuredOutputSpec | undefined):
618
+ | {
619
+ format: {
620
+ type: "json_schema";
621
+ name: string;
622
+ schema: Record<string, unknown>;
623
+ strict: boolean;
624
+ description?: string;
625
+ };
626
+ }
627
+ | undefined {
643
628
  if (!spec) return undefined;
644
629
  return {
645
630
  format: {
@@ -670,8 +655,10 @@ export function mapOutputToGoogle(
670
655
  }
671
656
 
672
657
  /** Chat Completions `response_format` wire shape (OpenRouter + compat). */
673
- export type ChatCompletionsResponseFormat =
674
- | { type: "json_schema"; json_schema: { name: string; schema: Record<string, unknown>; strict: boolean; description?: string } };
658
+ export type ChatCompletionsResponseFormat = {
659
+ type: "json_schema";
660
+ json_schema: { name: string; schema: Record<string, unknown>; strict: boolean; description?: string };
661
+ };
675
662
 
676
663
  /**
677
664
  * Maps a normalized spec onto Chat Completions `response_format`.
@@ -713,8 +700,11 @@ export function mapOutputToCompatChat(
713
700
  *
714
701
  * @example `augmentSystemPromptWithStructuredOutput(sys, spec)`
715
702
  */
716
- export function buildStructuredOutputInstruction(spec: StructuredOutputSpec): string {
717
- return `Return ONLY valid JSON matching this JSON Schema, with no markdown fences, no commentary, no extra text.\nSchema: ${JSON.stringify(spec.schema)}`;
703
+ export function buildStructuredOutputInstruction(
704
+ spec: StructuredOutputSpec,
705
+ template = DEFAULT_PROMPTS.structuredOutput
706
+ ): string {
707
+ return fillPrompt(template, { schema: JSON.stringify(spec.schema) });
718
708
  }
719
709
 
720
710
  /**
@@ -724,11 +714,12 @@ export function buildStructuredOutputInstruction(spec: StructuredOutputSpec): st
724
714
  */
725
715
  export function augmentSystemPromptWithStructuredOutput(
726
716
  systemPrompt: string | undefined,
727
- spec: StructuredOutputSpec | undefined
717
+ spec: StructuredOutputSpec | undefined,
718
+ template?: string
728
719
  ): string | undefined {
729
720
  if (!spec) return systemPrompt;
730
- const instruction = `[Structured Output]\n${buildStructuredOutputInstruction(spec)}`;
721
+ const instruction = `[Structured Output]\n${buildStructuredOutputInstruction(spec, template)}`;
731
722
  if (!systemPrompt) return instruction;
732
- if (systemPrompt.includes("[Structured Output]")) return systemPrompt;
723
+ if (systemPrompt.includes(instruction)) return systemPrompt;
733
724
  return `${systemPrompt}\n\n${instruction}`;
734
725
  }
@@ -2,18 +2,19 @@
2
2
 
3
3
  Scheduled actions: a worker that runs later (once or repeating) and saves its answer to a file. Off unless `dynamicSubagents.allowScheduledActions` is set.
4
4
 
5
- | File | What it is |
6
- | :-- | :-- |
7
- | `scheduler.ts` | One `Scheduler` per home folder: a 1 s unref'd clock that runs only while something is pending, startup sweep, dead-owner recovery, heartbeats, cook/deliver state machine, and the public functions (`createScheduledAction`, `list/update/run/skip/deleteScheduledAction`). |
8
- | `store.ts` | Disk layer under `<home>/scheduled/`: `<id>/task.json`, `<id>/delivery.json`, run files, one short mutex per action; validation of records. |
9
- | `time.ts` | Pure `DD:HH:MM` and local-clock maths. No timezone, DST or `Intl`; `now` is always passed in. |
10
- | `tool.ts` + `scheduled-actions.md` | The model-facing `scheduled_actions` tool and `get_datetime`; the `.md` is the tool description. |
5
+ | File | What it is |
6
+ | :------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
7
+ | `scheduler.ts` | One `Scheduler` per home folder: a 1 s unref'd clock that runs only while something is pending, startup sweep, dead-owner recovery, heartbeats, cook/deliver state machine, the functions behind the API (`createScheduledAction`, `list/update/run/skip/deleteScheduledAction`, not exported from the barrel) and `ScheduledActions`, the class behind `agent.scheduled` that binds them to the agent's home. |
8
+ | `store.ts` | Disk layer under `<home>/scheduled/`: `<id>/task.json`, `<id>/delivery.json`, run files, one short mutex per action; validation of records. |
9
+ | `time.ts` | Pure `DD:HH:MM` and local-clock maths. No timezone, DST or `Intl`; `now` is always passed in. |
10
+ | `tool.ts` | The model-facing `scheduled_actions` tool and `get_datetime`; its description is the `scheduledActionsTool` prompt (`../prompts/scheduled-actions-tool.md`). |
11
11
 
12
12
  ## Invariants
13
+
13
14
  - The files are the truth: every write re-reads first and goes through the durable writer. The file format is a stable public contract: see `docs/scheduled-action-files.md` and add a changelog row for any change.
14
15
  - Write order when a run ends: run file, then `<id>/delivery.json`, then `<id>/task.json`. A crash in between is repaired by the dead-owner recovery (`recoverCooking`, at start and every 15 s): the delivery is rebuilt from the run file, or the run is marked `missed` when no run file exists.
15
16
  - `startCook` advances the next times BEFORE the run starts; a run still cooking when the next is due is skipped, never stacked.
16
- - Runs are fresh stateless workers built by `buildDynamicWorkerConfig`; their spending guardrail is `workerBudget` only.
17
+ - Runs are fresh stateless workers built by `buildDynamicWorkerConfig`; their spending guardrail is `dynamicSubagents.scheduledActionBudget` only (never `workerBudget`, `budget` or `usageLimit`).
17
18
  - The Agent registry holds `WeakRef`s by name: keep every Agent that owns actions referenced, or its runs become `missed`.
18
19
  - Several processes may share a home: scheduled actions take no home lock, only the short per-action mutex (`<id>/.mutex`); the one-process lock is on session folders only. Ownership: a process only starts, delivers or recovers actions whose Agent it registered. An action nobody here owns is left alone until its delivery time passed with no run started, then marked `missed` (`no_agent`).
19
20
  - Exactly once: a scheduled start re-checks under the action's mutex that it is still `waiting` and due; `markMissed` only changes the state the caller saw; a delivery is emitted only by the process that removed its entry from `delivery.json`.