agent-accelerator 0.6.0 → 0.6.6

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 (55) hide show
  1. package/README.md +62 -18
  2. package/package.json +1 -1
  3. package/src/agent/agent.ts +192 -695
  4. package/src/agent/architecture.md +19 -13
  5. package/src/agent/context.ts +1 -0
  6. package/src/agent/defaults.ts +88 -0
  7. package/src/agent/delegation.ts +8 -3
  8. package/src/agent/loop.ts +242 -226
  9. package/src/agent/named-sessions.ts +93 -0
  10. package/src/agent/options.ts +166 -0
  11. package/src/agent/run-control.ts +248 -0
  12. package/src/agent/subagents-tool.ts +14 -21
  13. package/src/agent/usage-limit.ts +92 -0
  14. package/src/agent/worker.ts +17 -3
  15. package/src/agent/workers.ts +13 -1
  16. package/src/architecture.md +1 -0
  17. package/src/index.ts +11 -11
  18. package/src/prompts/additional-context.md +2 -0
  19. package/src/prompts/architecture.md +19 -0
  20. package/src/prompts/prompts.ts +81 -0
  21. package/src/prompts/reasoning-directive.md +5 -0
  22. package/src/prompts/structured-output.md +2 -0
  23. package/src/prompts/tool-bridge.md +1 -0
  24. package/src/prompts/tool-interrupted.md +1 -0
  25. package/src/prompts/tool-not-found.md +1 -0
  26. package/src/prompts/tool-repeat-blocked.md +1 -0
  27. package/src/prompts/tool-schema-error.md +1 -0
  28. package/src/prompts/tool-truncated-arguments.md +1 -0
  29. package/src/prompts/worker-failed.md +1 -0
  30. package/src/prompts/worker-replied.md +2 -0
  31. package/src/providers/architecture.md +8 -8
  32. package/src/providers/google.ts +83 -13
  33. package/src/providers/openai-compat.ts +17 -7
  34. package/src/providers/openai.ts +10 -4
  35. package/src/providers/openrouter.ts +185 -18
  36. package/src/providers.ts +59 -9
  37. package/src/scheduler/architecture.md +7 -7
  38. package/src/scheduler/scheduler.ts +40 -0
  39. package/src/scheduler/tool.ts +5 -9
  40. package/src/session/journal.ts +1 -0
  41. package/src/session/store.ts +4 -0
  42. package/src/streaming/event-stream.ts +1 -1
  43. package/src/tools/architecture.md +7 -6
  44. package/src/tools/executor.ts +60 -15
  45. package/src/tools/tool-content.ts +32 -0
  46. package/src/types/agent.ts +40 -8
  47. package/src/types/core.ts +4 -0
  48. package/src/types/message.ts +7 -0
  49. package/src/types/model.ts +4 -1
  50. package/src/types/scheduler.ts +4 -4
  51. package/src/types/tool.ts +2 -0
  52. package/src/utils/errors.ts +3 -3
  53. package/src/utils/headers.ts +1 -1
  54. /package/src/{scheduler/scheduled-actions.md → prompts/scheduled-actions-tool.md} +0 -0
  55. /package/src/{agent/subagents.md → prompts/subagents-tool.md} +0 -0
@@ -91,6 +91,8 @@ import {
91
91
  augmentSystemPromptWithStructuredOutput,
92
92
  emitProviderWarning,
93
93
  warnIgnoredMediaResolution,
94
+ warnIgnoredVideoProcessing,
95
+ warnIgnoredToolMedia,
94
96
  } from "../providers.ts";
95
97
 
96
98
  // ---------------------------------------------------------------------------
@@ -551,6 +553,8 @@ export class OpenAIResponsesProvider implements Provider {
551
553
  // `12h` maps to `prompt_cache_retention: "24h"`; everything else stays on implicit prompt_cache_key affinity.
552
554
  const cache = resolveCache("openai", options?.cache, ["implicit", "12h"], `openai/${modelId}`);
553
555
  warnIgnoredMediaResolution("openai", context.messages, `openai/${modelId}`);
556
+ warnIgnoredVideoProcessing("openai", context.messages, `openai/${modelId}`);
557
+ warnIgnoredToolMedia("openai", context.messages, `openai/${modelId}`);
554
558
 
555
559
  // Structured outputs: native `text.format` when supported, else emulated
556
560
  // via instructions (catalog `structured_output: false` would 400).
@@ -564,7 +568,11 @@ export class OpenAIResponsesProvider implements Provider {
564
568
  }
565
569
  if (outputSpec && !modelSupportsStructuredOutput("openai", modelId)) {
566
570
  outputEmulated = true;
567
- effectiveSystemPrompt = augmentSystemPromptWithStructuredOutput(context.systemPrompt, outputSpec);
571
+ effectiveSystemPrompt = augmentSystemPromptWithStructuredOutput(
572
+ context.systemPrompt,
573
+ outputSpec,
574
+ options?.prompts?.structuredOutput
575
+ );
568
576
  emitProviderWarning({
569
577
  provider: "openai",
570
578
  capability: "structured output",
@@ -586,9 +594,7 @@ export class OpenAIResponsesProvider implements Provider {
586
594
  if (effectiveSystemPrompt) body.instructions = effectiveSystemPrompt;
587
595
  const aiTools = toOpenAITools(tools);
588
596
  if (aiTools) body.tools = aiTools;
589
- const toolChoice = mapToolChoiceToOpenAI(
590
- options?.toolChoice as "auto" | "none" | "required" | { type: "function"; function: { name: string } } | undefined
591
- );
597
+ const toolChoice = mapToolChoiceToOpenAI(options?.toolChoice);
592
598
  if (toolChoice !== undefined) body.tool_choice = toolChoice;
593
599
  if (effort) body.reasoning = { effort };
594
600
  if (serviceTier) body.service_tier = serviceTier;
@@ -6,7 +6,7 @@
6
6
  * never in the canonical layer (`src/providers.ts`).
7
7
  *
8
8
  * Wire contract: `references/testings/openrouter/CONTRACT.md` (docs + live
9
- * captures on `stealth/space-bunny-alpha`, 2026-09-24). Capability matrix:
9
+ * captures, 2026-09-24). Capability matrix:
10
10
  * `references/testings/openrouter/CAPABILITY-MATRIX.md`.
11
11
  *
12
12
  * This is the STABLE transport. The beta Responses skin
@@ -24,9 +24,10 @@
24
24
  * - IDs are provider-generated (`gen-…` generations, `call_…`/uuid tool ids)
25
25
  * and echoed verbatim (`tool_call_id`). The `call_${random}` fallback in
26
26
  * parsers is a local canonical correlation id only; it is never sent.
27
- * - Prior-turn reasoning is NOT resent (chat history is messages + tool
28
- * calls only). This matches the Responses adapter behavior and avoids 400s
29
- * on strict routes; documented in the capability matrix.
27
+ * - Prior-turn reasoning is resent as `reasoning_details` exactly as returned (kept on the assistant message as
28
+ * `providerSteps`), which tool turns need (reasoning-tokens.md, "Preserving Reasoning"). Plain `reasoning` text alone is
29
+ * never resent. Parsed-PDF `file` annotations ride the same way and go back as `annotations`, so the router does not parse
30
+ * the same PDF again (pdf-inputs.md, "Skip Parsing Costs").
30
31
  */
31
32
  import type {
32
33
  Provider,
@@ -47,6 +48,7 @@ import { normalizeMediaInput } from "../utils/media.ts";
47
48
  import { safeStringify } from "../utils/serialization.ts";
48
49
  import { toConciseProviderError, assertModalitiesSupported } from "../utils/errors.ts";
49
50
  import { ConfigError, isAbortError } from "../types/errors.ts";
51
+ import { DEFAULT_PROMPTS } from "../prompts/prompts.ts";
50
52
  import {
51
53
  newToolCallId,
52
54
  redactedHeaders,
@@ -84,6 +86,8 @@ import {
84
86
  augmentSystemPromptWithStructuredOutput,
85
87
  emitProviderWarning,
86
88
  warnIgnoredMediaResolution,
89
+ warnIgnoredVideoProcessing,
90
+ warnIgnoredToolMedia,
87
91
  } from "../providers.ts";
88
92
 
89
93
  // ---------------------------------------------------------------------------
@@ -108,6 +112,8 @@ type ChatMessage =
108
112
  type: "function";
109
113
  function: { name: string; arguments: string };
110
114
  }>;
115
+ reasoning_details?: unknown[];
116
+ annotations?: unknown[];
111
117
  name?: string;
112
118
  }
113
119
  | { role: "tool"; content: string; tool_call_id: string; name?: string }
@@ -118,7 +124,10 @@ interface ChatRequestBody {
118
124
  messages: ChatMessage[];
119
125
  tools?: Array<Record<string, unknown>>;
120
126
  tool_choice?: string | { type: "function"; function: { name: string } };
121
- reasoning?: { effort: string };
127
+ reasoning?: { effort?: string; exclude?: boolean };
128
+ provider?: Record<string, unknown>;
129
+ parallel_tool_calls?: boolean;
130
+ user?: string;
122
131
  service_tier?: string;
123
132
  session_id?: string;
124
133
  cache_control?: { type: "ephemeral"; ttl: "5m" | "1h" };
@@ -135,8 +144,13 @@ interface ChatUsage {
135
144
  prompt_tokens?: number;
136
145
  completion_tokens?: number;
137
146
  total_tokens?: number;
138
- prompt_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number };
139
- completion_tokens_details?: { reasoning_tokens?: number };
147
+ prompt_tokens_details?: {
148
+ cached_tokens?: number;
149
+ cache_write_tokens?: number;
150
+ audio_tokens?: number;
151
+ video_tokens?: number;
152
+ };
153
+ completion_tokens_details?: { reasoning_tokens?: number; audio_tokens?: number; image_tokens?: number };
140
154
  cost?: number;
141
155
  [k: string]: unknown;
142
156
  }
@@ -188,6 +202,71 @@ function midStreamStatus(code: unknown, errorType: unknown): number | undefined
188
202
  return typeof errorType === "string" ? RETRYABLE_ERROR_TYPE_STATUS[errorType] : undefined;
189
203
  }
190
204
 
205
+ // OpenRouter options that come from env vars, never from AgentConfig (provider routing, `parallel_tool_calls`, `reasoning.exclude`, `user`). A per-Agent `env` wins over the process env; nothing is sent when no variable is set; a bad value is a ConfigError before any network call. NOT supported on purpose (the owner does not want them for now): `reasoning.max_tokens` and `trace`; in: buildBody, out: the extra body fields.
206
+ const ROUTING_LISTS = { OPENROUTER_ORDER: "order", OPENROUTER_ONLY: "only", OPENROUTER_IGNORE: "ignore" } as const;
207
+ const ROUTING_FLAGS = {
208
+ OPENROUTER_ALLOW_FALLBACKS: "allow_fallbacks",
209
+ OPENROUTER_REQUIRE_PARAMETERS: "require_parameters",
210
+ OPENROUTER_ZDR: "zdr",
211
+ } as const;
212
+ function openRouterEnvOptions(options?: ProviderRequestOptions): {
213
+ provider?: Record<string, unknown>;
214
+ parallel_tool_calls?: boolean;
215
+ reasoning_exclude?: boolean;
216
+ user?: string;
217
+ } {
218
+ const read = (name: string): string | undefined => {
219
+ const own = options?.env?.[name]?.trim();
220
+ return own ? own : getEnv(name);
221
+ };
222
+ const bad = (name: string, value: string, expected: string): never => {
223
+ throw new ConfigError(`[Agent Accelerator] Invalid ${name} ${JSON.stringify(value)}: expected ${expected}.`);
224
+ };
225
+ const flag = (name: string): boolean | undefined => {
226
+ const v = read(name);
227
+ if (v === undefined) return undefined;
228
+ const t = v.toLowerCase();
229
+ if (t === "true" || t === "1") return true;
230
+ if (t === "false" || t === "0") return false;
231
+ return bad(name, v, "true or false");
232
+ };
233
+ const provider: Record<string, unknown> = {};
234
+ for (const [name, field] of Object.entries(ROUTING_LISTS)) {
235
+ const v = read(name);
236
+ if (v === undefined) continue;
237
+ const list = v
238
+ .split(",")
239
+ .map((x) => x.trim())
240
+ .filter(Boolean);
241
+ if (list.length === 0) bad(name, v, "a comma-separated list of provider slugs");
242
+ provider[field] = list;
243
+ }
244
+ for (const [name, field] of Object.entries(ROUTING_FLAGS)) {
245
+ const v = flag(name);
246
+ if (v !== undefined) provider[field] = v;
247
+ }
248
+ const collection = read("OPENROUTER_DATA_COLLECTION");
249
+ if (collection !== undefined) {
250
+ if (collection !== "allow" && collection !== "deny") bad("OPENROUTER_DATA_COLLECTION", collection, "allow or deny");
251
+ provider["data_collection"] = collection;
252
+ }
253
+ const sort = read("OPENROUTER_SORT");
254
+ if (sort !== undefined) {
255
+ if (sort !== "price" && sort !== "throughput" && sort !== "latency")
256
+ bad("OPENROUTER_SORT", sort, "price, throughput or latency");
257
+ provider["sort"] = sort;
258
+ }
259
+ const parallel = flag("OPENROUTER_PARALLEL_TOOL_CALLS");
260
+ const exclude = flag("OPENROUTER_REASONING_EXCLUDE");
261
+ const user = read("OPENROUTER_USER");
262
+ return {
263
+ ...(Object.keys(provider).length > 0 ? { provider } : {}),
264
+ ...(parallel !== undefined ? { parallel_tool_calls: parallel } : {}),
265
+ ...(exclude !== undefined ? { reasoning_exclude: exclude } : {}),
266
+ ...(user ? { user } : {}),
267
+ };
268
+ }
269
+
191
270
  // Canonical tool declarations as Chat Completions function tools; in: options.tools, out: request `tools`.
192
271
  function toOpenRouterTools(tools?: StandardToolDeclaration[]): Array<Record<string, unknown>> | undefined {
193
272
  if (!tools || tools.length === 0) return undefined;
@@ -311,6 +390,48 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<ChatContentPa
311
390
  return blocks;
312
391
  }
313
392
 
393
+ // Folds one streamed `reasoning_details` chunk into the turn's blocks: chunks with the same index (else id) are one block whose text, summary, data and signature are concatenated or filled in, in order; in: the stream handler, out: the blocks replayed on the next request.
394
+ function mergeReasoningDetails(blocks: Array<Record<string, unknown>>, chunk: unknown): void {
395
+ if (!chunk || typeof chunk !== "object" || Array.isArray(chunk)) return;
396
+ const c = chunk as Record<string, unknown>;
397
+ const same = blocks.find((b) =>
398
+ typeof c["index"] === "number" ? b["index"] === c["index"] : c["id"] != null && b["id"] === c["id"]
399
+ );
400
+ if (!same) {
401
+ blocks.push({ ...c });
402
+ return;
403
+ }
404
+ for (const [k, v] of Object.entries(c)) {
405
+ if (v === null || v === undefined) continue;
406
+ if ((k === "text" || k === "summary" || k === "data") && typeof v === "string" && typeof same[k] === "string")
407
+ same[k] = (same[k] as string) + v;
408
+ else same[k] = v;
409
+ }
410
+ }
411
+
412
+ // Adds a streamed `file` annotation (a parsed PDF) once per `file.hash`; in: the stream handler, out: the annotations replayed on the next request.
413
+ function mergeAnnotation(blocks: Array<Record<string, unknown>>, chunk: unknown): void {
414
+ if (!chunk || typeof chunk !== "object" || (chunk as { type?: unknown }).type !== "file") return;
415
+ const hash = (chunk as { file?: { hash?: unknown } }).file?.hash;
416
+ if (typeof hash === "string" && blocks.some((b) => (b as { file?: { hash?: unknown } }).file?.hash === hash)) return;
417
+ blocks.push(chunk as Record<string, unknown>);
418
+ }
419
+
420
+ // The reasoning blocks and the parsed-PDF annotations of an assistant turn as OpenRouter returned them (both live in `providerSteps`, told apart by `type: "file"`); in: a stored assistant message, out: fullHistoryMessages.
421
+ function storedReplay(m: { providerSteps?: { provider: string; steps: unknown[] } }): {
422
+ reasoning_details?: unknown[];
423
+ annotations?: unknown[];
424
+ } {
425
+ if (m.providerSteps?.provider !== "openrouter") return {};
426
+ const isFile = (x: unknown): boolean => (x as { type?: unknown } | null)?.type === "file";
427
+ const annotations = m.providerSteps.steps.filter(isFile);
428
+ const details = m.providerSteps.steps.filter((x) => !isFile(x));
429
+ return {
430
+ ...(details.length > 0 ? { reasoning_details: details } : {}),
431
+ ...(annotations.length > 0 ? { annotations } : {}),
432
+ };
433
+ }
434
+
314
435
  // A tool result as the text of a tool message; in: fullHistoryMessages, out: a string.
315
436
  function resultToOutput(result: unknown): string {
316
437
  if (typeof result === "string") return result;
@@ -323,7 +444,10 @@ function resultToOutput(result: unknown): string {
323
444
  * carries everything. Assistant items carry no provider `id`/`status`
324
445
  * requirements (unlike the discontinued Responses skin).
325
446
  */
326
- async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessage[]> {
447
+ async function fullHistoryMessages(
448
+ context: ProviderContext,
449
+ bridge = DEFAULT_PROMPTS.toolBridge
450
+ ): Promise<ChatMessage[]> {
327
451
  // Map assistant tool_call ids so tool results pair even though the
328
452
  // executor keys results by the call id.
329
453
  const pairing = new Map<string, string>();
@@ -354,7 +478,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
354
478
  // Strict routes reject `user` directly after `tool`: bridge with a
355
479
  // synthetic assistant turn (pi api/openai-completions.ts:1233).
356
480
  if (lastRole === "tool") {
357
- messages.push({ role: "assistant", content: "I have processed the tool results." });
481
+ messages.push({ role: "assistant", content: bridge });
358
482
  }
359
483
  if (typeof m.content === "string") {
360
484
  if (m.content) {
@@ -369,9 +493,10 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
369
493
  }
370
494
  }
371
495
  } else if (m.role === "assistant") {
496
+ const replay = storedReplay(m);
372
497
  if (typeof m.content === "string") {
373
498
  if (m.content) {
374
- messages.push({ role: "assistant", content: m.content });
499
+ messages.push({ role: "assistant", content: m.content, ...replay });
375
500
  }
376
501
  continue;
377
502
  }
@@ -387,8 +512,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
387
512
  } else if (part.type === "text" && part.text) {
388
513
  texts.push(part.text);
389
514
  }
390
- // Prior-turn reasoning is NOT resent: chat history is messages +
391
- // tool calls only (documented decision; avoids strict-route 400s).
515
+ // Reasoning is not rebuilt from text: the stored `reasoning_details` blocks are replayed verbatim below.
392
516
  }
393
517
  // Skip empty assistant turns (no text + no tool calls): strict
394
518
  // routes reject them, and they carry no signal (pi drop rule).
@@ -397,6 +521,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
397
521
  role: "assistant",
398
522
  content: texts.length > 0 ? texts.join("\n") : null,
399
523
  ...(calls.length > 0 ? { tool_calls: calls } : {}),
524
+ ...replay,
400
525
  });
401
526
  lastRole = "assistant";
402
527
  } else if (m.role === "tool") {
@@ -448,6 +573,25 @@ function mapUsage(raw?: ChatUsage): TokenUsage {
448
573
  cacheWriteTokens: raw?.prompt_tokens_details?.cache_write_tokens ?? 0,
449
574
  thinkingTokens: raw?.completion_tokens_details?.reasoning_tokens ?? 0,
450
575
  };
576
+ // Media tokens are listed only when the provider reported more than zero of them.
577
+ const count = (n: unknown): number | undefined =>
578
+ typeof n === "number" && Number.isFinite(n) && n > 0 ? n : undefined;
579
+ const inDet = {
580
+ audioTokens: count(raw?.prompt_tokens_details?.audio_tokens),
581
+ videoTokens: count(raw?.prompt_tokens_details?.video_tokens),
582
+ };
583
+ const outDet = {
584
+ audioTokens: count(raw?.completion_tokens_details?.audio_tokens),
585
+ imageTokens: count(raw?.completion_tokens_details?.image_tokens),
586
+ };
587
+ const keep = (d: Record<string, number | undefined>): Record<string, number> | undefined => {
588
+ const kept = Object.fromEntries(Object.entries(d).filter(([, v]) => v !== undefined)) as Record<string, number>;
589
+ return Object.keys(kept).length > 0 ? kept : undefined;
590
+ };
591
+ const inKept = keep(inDet);
592
+ const outKept = keep(outDet);
593
+ if (inKept) usage.inputDetails = inKept;
594
+ if (outKept) usage.outputDetails = outKept;
451
595
  if (typeof raw?.cost === "number" && raw.cost >= 0) {
452
596
  // an explicit 0 (a free model, a response-cache hit) is a known cost, not a missing one
453
597
  usage.cost = { totalCost: raw.cost };
@@ -502,6 +646,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
502
646
  `openrouter/${modelId}`
503
647
  );
504
648
  warnIgnoredMediaResolution("openrouter", context.messages, `openrouter/${modelId}`, ["video"]);
649
+ warnIgnoredVideoProcessing("openrouter", context.messages, `openrouter/${modelId}`);
650
+ warnIgnoredToolMedia("openrouter", context.messages, `openrouter/${modelId}`);
505
651
 
506
652
  // Structured outputs: native `response_format` when supported, else
507
653
  // emulated via system-prompt instruction + client validation (catalog
@@ -519,7 +665,11 @@ export class OpenRouterChatCompletionsProvider implements Provider {
519
665
  outputEmulated = true;
520
666
  effectiveContext = {
521
667
  ...context,
522
- systemPrompt: augmentSystemPromptWithStructuredOutput(context.systemPrompt, outputSpec),
668
+ systemPrompt: augmentSystemPromptWithStructuredOutput(
669
+ context.systemPrompt,
670
+ outputSpec,
671
+ options?.prompts?.structuredOutput
672
+ ),
523
673
  };
524
674
  emitProviderWarning({
525
675
  provider: "openrouter",
@@ -530,7 +680,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
530
680
  fallback: "emulated JSON instruction + client validation (no response_format is sent)",
531
681
  });
532
682
  }
533
- const messages = await fullHistoryMessages(effectiveContext);
683
+ const messages = await fullHistoryMessages(effectiveContext, options?.prompts?.toolBridge);
534
684
  const body: ChatRequestBody = {
535
685
  model: modelId,
536
686
  messages,
@@ -538,11 +688,17 @@ export class OpenRouterChatCompletionsProvider implements Provider {
538
688
  };
539
689
  const orTools = toOpenRouterTools(tools);
540
690
  if (orTools) body.tools = orTools;
541
- const toolChoice = mapToolChoiceToOpenRouterChat(
542
- options?.toolChoice as "auto" | "none" | "required" | { type: "function"; function: { name: string } } | undefined
543
- );
691
+ const toolChoice = mapToolChoiceToOpenRouterChat(options?.toolChoice);
544
692
  if (toolChoice !== undefined) body.tool_choice = toolChoice;
545
- if (effort) body.reasoning = { effort };
693
+ const env = openRouterEnvOptions(options);
694
+ if (effort || env.reasoning_exclude !== undefined)
695
+ body.reasoning = {
696
+ ...(effort ? { effort } : {}),
697
+ ...(env.reasoning_exclude !== undefined ? { exclude: env.reasoning_exclude } : {}),
698
+ };
699
+ if (env.provider) body.provider = env.provider;
700
+ if (env.parallel_tool_calls !== undefined && orTools) body.parallel_tool_calls = env.parallel_tool_calls;
701
+ if (env.user) body.user = env.user;
546
702
  if (serviceTier) body.service_tier = serviceTier;
547
703
  if (cache === "5m" || cache === "1h") body.cache_control = { type: "ephemeral", ttl: cache };
548
704
  // Documented Chat Completions generation controls (only those the caller set).
@@ -653,6 +809,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
653
809
  provider: "openrouter",
654
810
  raw: res.raw,
655
811
  durationMs: res.durationMs,
812
+ providerSteps: res.providerSteps,
656
813
  };
657
814
  } catch (err) {
658
815
  // A stream reports its own generic abort; the caller's reason (`interrupt(new Error("..."))`) is what the run should carry.
@@ -748,6 +905,10 @@ export class OpenRouterChatCompletionsProvider implements Provider {
748
905
  // Index of the most recent tool-call delta: unindexed chunks continue
749
906
  // it (falling back to calls.size would fork a phantom call per chunk).
750
907
  let lastToolIndex = 0;
908
+ // The turn's reasoning blocks exactly as returned, replayed on the next request (tool turns need them).
909
+ const reasoningBlocks: Array<Record<string, unknown>> = [];
910
+ // Parsed PDFs (`file` annotations) of the turn: sent back so OpenRouter does not parse the same file again.
911
+ const annotationBlocks: Array<Record<string, unknown>> = [];
751
912
  let usage: TokenUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
752
913
  let finishReason = "stop";
753
914
  let sawFinishReason = false;
@@ -812,6 +973,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
812
973
  const details = delta["reasoning_details"];
813
974
  if (Array.isArray(details)) {
814
975
  for (const block of details) {
976
+ mergeReasoningDetails(reasoningBlocks, block);
815
977
  const t = (block as { text?: string })?.text;
816
978
  if (typeof t === "string" && t) {
817
979
  thinking += t;
@@ -833,6 +995,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
833
995
  partialThinking: thinking,
834
996
  });
835
997
  }
998
+ if (Array.isArray(delta["annotations"]))
999
+ for (const a of delta["annotations"]) mergeAnnotation(annotationBlocks, a);
836
1000
  // Tool-call deltas accumulate per choice index.
837
1001
  const deltaCalls = delta["tool_calls"];
838
1002
  if (Array.isArray(deltaCalls)) {
@@ -942,6 +1106,9 @@ export class OpenRouterChatCompletionsProvider implements Provider {
942
1106
  response: { ...responseMeta, body: completedBody, badFrames } as unknown as ProviderRawData["response"],
943
1107
  },
944
1108
  durationMs: Date.now() - startTime,
1109
+ ...(reasoningBlocks.length + annotationBlocks.length > 0
1110
+ ? { providerSteps: { provider: "openrouter", steps: [...reasoningBlocks, ...annotationBlocks] } }
1111
+ : {}),
945
1112
  });
946
1113
  eventStream.push({ type: "done", delta: "", usage, finishReason, responseId });
947
1114
  eventStream.end(finalResponse);
package/src/providers.ts CHANGED
@@ -16,6 +16,7 @@ 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
22
  export type ProviderCapabilityStatus = "NATIVE" | "TRANSLATED" | "AUTOMATIC" | "EMULATED" | "UNSUPPORTED";
@@ -232,6 +233,47 @@ export function warnIgnoredMediaResolution(
232
233
  });
233
234
  }
234
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
+
235
277
  /** OpenRouter Chat Completions `tool_choice` wire values. */
236
278
  export type OpenRouterChatToolChoice = "auto" | "none" | "required" | { type: "function"; function: { name: string } };
237
279
 
@@ -243,9 +285,10 @@ export type OpenRouterChatToolChoice = "auto" | "none" | "required" | { type: "f
243
285
  * (`{type:function,name}`): completions nests the name under `function`.
244
286
  */
245
287
  export function mapToolChoiceToOpenRouterChat(
246
- choice: "auto" | "none" | "required" | { type: "function"; function: { name: string } } | undefined
288
+ choice: "auto" | "none" | "required" | "validated" | { type: "function"; function: { name: string } } | undefined
247
289
  ): OpenRouterChatToolChoice | undefined {
248
290
  if (!choice || choice === "auto") return undefined;
291
+ if (choice === "validated") return (warnValidatedToolChoice("openrouter"), undefined);
249
292
  if (choice === "none" || choice === "required") return choice;
250
293
  const name = choice.function?.name;
251
294
  if (name) return { type: "function", function: { name } };
@@ -292,9 +335,11 @@ export type OpenAIToolChoice = "auto" | "none" | "required" | { type: "function"
292
335
  * variants are out of scope and never emitted.
293
336
  */
294
337
  export function mapToolChoiceToOpenAI(
295
- 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"
296
340
  ): OpenAIToolChoice | undefined {
297
341
  if (!choice || choice === "auto") return undefined;
342
+ if (choice === "validated") return (warnValidatedToolChoice(provider), undefined);
298
343
  if (choice === "none" || choice === "required") return choice;
299
344
  const name = choice.function?.name;
300
345
  if (name) return { type: "function", name };
@@ -302,7 +347,7 @@ export function mapToolChoiceToOpenAI(
302
347
  }
303
348
 
304
349
  /** Normalized tool-choice mode shared by provider adapters. */
305
- export type CanonicalToolChoiceMode = "auto" | "any" | "none";
350
+ export type CanonicalToolChoiceMode = "auto" | "any" | "none" | "validated";
306
351
 
307
352
  /**
308
353
  * Normalizes canonical toolChoice into a provider-neutral mode + optional
@@ -310,13 +355,14 @@ export type CanonicalToolChoiceMode = "auto" | "any" | "none";
310
355
  * `{ allowed_tools: { mode, tools? } }` in its own adapter.
311
356
  */
312
357
  export function normalizeToolChoice(
313
- choice: "auto" | "none" | "required" | { type: "function"; function: { name: string } } | undefined,
358
+ choice: "auto" | "none" | "required" | "validated" | { type: "function"; function: { name: string } } | undefined,
314
359
  availableToolNames: string[]
315
360
  ): { mode: CanonicalToolChoiceMode; tools?: string[] } | undefined {
316
361
  if (!choice) return undefined;
317
362
  if (typeof choice === "string") {
318
363
  if (choice === "auto") return undefined; // server default
319
364
  if (choice === "none") return { mode: "none" };
365
+ if (choice === "validated") return { mode: "validated" };
320
366
  if (choice === "required") {
321
367
  return availableToolNames.length > 0 ? { mode: "any", tools: availableToolNames } : { mode: "any" };
322
368
  }
@@ -654,8 +700,11 @@ export function mapOutputToCompatChat(
654
700
  *
655
701
  * @example `augmentSystemPromptWithStructuredOutput(sys, spec)`
656
702
  */
657
- export function buildStructuredOutputInstruction(spec: StructuredOutputSpec): string {
658
- 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) });
659
708
  }
660
709
 
661
710
  /**
@@ -665,11 +714,12 @@ export function buildStructuredOutputInstruction(spec: StructuredOutputSpec): st
665
714
  */
666
715
  export function augmentSystemPromptWithStructuredOutput(
667
716
  systemPrompt: string | undefined,
668
- spec: StructuredOutputSpec | undefined
717
+ spec: StructuredOutputSpec | undefined,
718
+ template?: string
669
719
  ): string | undefined {
670
720
  if (!spec) return systemPrompt;
671
- const instruction = `[Structured Output]\n${buildStructuredOutputInstruction(spec)}`;
721
+ const instruction = `[Structured Output]\n${buildStructuredOutputInstruction(spec, template)}`;
672
722
  if (!systemPrompt) return instruction;
673
- if (systemPrompt.includes("[Structured Output]")) return systemPrompt;
723
+ if (systemPrompt.includes(instruction)) return systemPrompt;
674
724
  return `${systemPrompt}\n\n${instruction}`;
675
725
  }
@@ -2,19 +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
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.
15
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.
16
16
  - `startCook` advances the next times BEFORE the run starts; a run still cooking when the next is due is skipped, never stacked.
17
- - 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`).
18
18
  - The Agent registry holds `WeakRef`s by name: keep every Agent that owns actions referenced, or its runs become `missed`.
19
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`).
20
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`.
@@ -1052,3 +1052,43 @@ export function deleteScheduledAction(
1052
1052
  ): Promise<DeleteResult> {
1053
1053
  return schedulerFor(opts).delete(id, { force: opts?.force, purge: opts?.purge });
1054
1054
  }
1055
+
1056
+ /**
1057
+ * The scheduled-action API of one Agent (`agent.scheduled`): every method works on that agent's home folder, so an Agent
1058
+ * with its own `home` never needs `{ home }` passed. `create` needs `dynamicSubagents.allowScheduledActions` (else a
1059
+ * `ConfigError`). `list`, `update`, `run`, `skip` and `delete` work on every action in the home, as the model's
1060
+ * `scheduled_actions` tool does; `run` needs the action's creating Agent alive in this process.
1061
+ */
1062
+ export class ScheduledActions {
1063
+ constructor(private readonly agent: Agent) {}
1064
+
1065
+ /** Schedules a new action under this agent. Returns the saved record; throws one `ValidationError` listing every problem. */
1066
+ create(input: ScheduledActionInput): ScheduledActionRecord {
1067
+ return createScheduledAction(this.agent, input);
1068
+ }
1069
+
1070
+ /** Every action in this agent's home with its full metadata and state, plus a `running` flag. */
1071
+ list(): Array<ScheduledActionRecord & { running: boolean }> {
1072
+ return listScheduledActions({ home: this.agent.home });
1073
+ }
1074
+
1075
+ /** Changes instructions, task, tools, timeout, role or any schedule field of action `id` (refused for a schedule change while it cooks). */
1076
+ update(id: string, patch: ScheduledActionPatch): ScheduledActionRecord {
1077
+ return updateScheduledAction(id, patch, { home: this.agent.home });
1078
+ }
1079
+
1080
+ /** Runs action `id` now, outside its schedule (also resolves a missed run). */
1081
+ run(id: string): ScheduledActionRecord {
1082
+ return runScheduledAction(id, { home: this.agent.home });
1083
+ }
1084
+
1085
+ /** Ignores a missed run of `id` and waits for the next scheduled time (a one-shot becomes `done`). */
1086
+ skip(id: string): ScheduledActionRecord {
1087
+ return skipScheduledAction(id, { home: this.agent.home });
1088
+ }
1089
+
1090
+ /** Deletes action `id` for good. While it is cooking nothing changes unless `force: true`; `purge: true` also deletes its past run files. */
1091
+ delete(id: string, opts?: { force?: boolean; purge?: boolean }): Promise<DeleteResult> {
1092
+ return deleteScheduledAction(id, { home: this.agent.home, ...opts });
1093
+ }
1094
+ }