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
package/src/agent/loop.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Provider, ProviderRequestOptions, ModelSpec, ProviderRawData } from "../types/model.ts";
2
- import type { ToolDefinition, ToolCallRecord, ToolResultRecord } from "../types/tool.ts";
2
+ import type { ToolDefinition, ToolCallRecord, ToolResultRecord, StandardToolDeclaration } from "../types/tool.ts";
3
3
  import type { TokenUsage } from "../types/core.ts";
4
4
  import type { AgentRunOptions } from "../types/agent.ts";
5
5
  import type { ContentPart } from "../types/message.ts";
@@ -32,6 +32,7 @@ import { isSubagentResult, normalizeSubagentMetadata } from "../tools/subagent-r
32
32
  import { BudgetExceededError, TimeoutError } from "../types/errors.ts";
33
33
  import { applyCaptureRaw, type CaptureRawMode } from "../utils/raw-capture.ts";
34
34
  import { previewPrompt } from "../utils/session.ts";
35
+ import { DEFAULT_PROMPTS, fillPrompt, type Prompts } from "../prompts/prompts.ts";
35
36
  import { noteProviderTurn, normalizeStructuredOutput, parseStructuredOutput } from "../providers.ts";
36
37
 
37
38
  /** One mid-session steer request buffered while a run is active. */
@@ -98,6 +99,8 @@ export interface AgentLoopConfig {
98
99
  steerInbox?: SteerEntry[];
99
100
  /** Called after steer entries are injected (streaming loops also push `steer_injected` events). */
100
101
  onSteerInjected?: (injectedPrompts: string[]) => void;
102
+ /** The SDK prompts for this run (`AgentConfig.prompts` resolved); the shipped ones when unset. */
103
+ prompts?: Prompts;
101
104
  /**
102
105
  * Canonical structured-output request (raw `AgentConfig.output`).
103
106
  * Normalized per turn for the provider wire; validated once at the end
@@ -111,7 +114,7 @@ export interface AgentLoopConfig {
111
114
  * Returns display previews for events/telemetry. Never throws: invalid
112
115
  * entries are skipped so one bad steer cannot fail the active turn.
113
116
  */
114
- function drainSteerInbox(context: AgentContext, inbox?: SteerEntry[]): string[] {
117
+ function drainSteerInbox(context: AgentContext, inbox?: SteerEntry[], prompts: Prompts = DEFAULT_PROMPTS): string[] {
115
118
  if (!inbox || inbox.length === 0) return [];
116
119
  const injected: string[] = [];
117
120
  while (inbox.length > 0) {
@@ -121,7 +124,7 @@ function drainSteerInbox(context: AgentContext, inbox?: SteerEntry[]): string[]
121
124
  const opts = entry.options;
122
125
  if (Array.isArray(prompt) && prompt.length === 0) continue;
123
126
  if (opts?.additionalContext) {
124
- const prefix = `[Additional Context]\n${opts.additionalContext}\n\n`;
127
+ const prefix = `${fillPrompt(prompts.additionalContext, { context: opts.additionalContext })}\n\n`;
125
128
  if (typeof prompt === "string") prompt = prefix + prompt;
126
129
  else prompt = [{ type: "text", text: prefix } as ContentPart, ...prompt];
127
130
  }
@@ -157,7 +160,7 @@ function emitTurn(
157
160
  }
158
161
 
159
162
  /**
160
- * Per-turn `toolChoice`. `"none"` holds for the whole run; a forced choice
163
+ * Per-turn `toolChoice`. `"none"` and `"validated"` (which forces nothing) hold for the whole run; a forced choice
161
164
  * (`"required"` or a pinned function) applies to the FIRST model turn only:
162
165
  * forcing a tool call on every turn would keep the model calling tools until
163
166
  * `maxTurns`, so the run could never produce a final answer.
@@ -167,7 +170,7 @@ function toolChoiceForTurn(
167
170
  turn: number
168
171
  ): ProviderRequestOptions["toolChoice"] {
169
172
  if (choice === undefined || choice === "auto") return undefined;
170
- if (choice === "none") return "none";
173
+ if (choice === "none" || choice === "validated") return choice;
171
174
  return turn === 1 ? choice : undefined;
172
175
  }
173
176
 
@@ -275,7 +278,7 @@ function warnMaxTurnsHit(agentName: string | undefined, maxTurns: number, pendin
275
278
  * - drops results whose id the model never issued.
276
279
  * Open-weight models that reuse one id for several calls keep one result per call.
277
280
  */
278
- export function ensureToolPairing(context: AgentContext): void {
281
+ export function ensureToolPairing(context: AgentContext, prompts: Prompts = DEFAULT_PROMPTS): void {
279
282
  try {
280
283
  type ToolResultPart = Extract<ContentPart, { type: "tool_result" }>;
281
284
  const messages = context.messages;
@@ -316,7 +319,7 @@ export function ensureToolPairing(context: AgentContext): void {
316
319
  type: "tool_result",
317
320
  id: p.id,
318
321
  name,
319
- result: `Error: tool_call '${name}' (${p.id}) has no result because the run was interrupted before it completed (recovered by ensureToolPairing). It may have partially executed: check the tool trace before repeating side-effecting work.`,
322
+ result: fillPrompt(prompts.toolInterrupted, { tool: name, id: p.id }),
320
323
  isError: true,
321
324
  },
322
325
  ],
@@ -472,6 +475,14 @@ export function computeCostFromPricing(usage: TokenUsage, spec?: ModelSpec, serv
472
475
  }
473
476
  }
474
477
 
478
+ // Adds the media token details of `source` into `target` (kept only when something was reported); in: accumulateUsage, out: target.inputDetails / outputDetails.
479
+ function sumDetails(target: TokenUsage, source: TokenUsage, key: "inputDetails" | "outputDetails"): void {
480
+ const from = source[key] as Record<string, number | undefined> | undefined;
481
+ if (!from) return;
482
+ const into = ((target[key] as Record<string, number> | undefined) ??= {});
483
+ for (const [k, v] of Object.entries(from)) if (typeof v === "number") into[k] = (into[k] ?? 0) + v;
484
+ }
485
+
475
486
  // ---------------------------------------------------------------------------
476
487
  // Canonical usage accumulation (pure w.r.t. source).
477
488
  // Invariants (see assertUsageInvariants in session/store.ts):
@@ -484,6 +495,7 @@ export function computeCostFromPricing(usage: TokenUsage, spec?: ModelSpec, serv
484
495
  // This function never mutates `source`: the priced cost is cloned
485
496
  // into a local before folding into the target.
486
497
  // ---------------------------------------------------------------------------
498
+ // Adds one turn's usage (and its priced cost) into a running total without touching the source; in: both loops per model turn and finished worker, out: the run's accumulatedUsage.
487
499
  export function accumulateUsage(target: TokenUsage, source: TokenUsage, spec?: ModelSpec, serviceTier?: string): void {
488
500
  target.inputTokens += source.inputTokens || 0;
489
501
  target.outputTokens += source.outputTokens || 0;
@@ -492,6 +504,8 @@ export function accumulateUsage(target: TokenUsage, source: TokenUsage, spec?: M
492
504
  target.cacheReadTokens = (target.cacheReadTokens ?? 0) + (source.cacheReadTokens ?? 0);
493
505
  target.cacheWriteTokens = (target.cacheWriteTokens ?? 0) + (source.cacheWriteTokens ?? 0);
494
506
  target.thinkingTokens = (target.thinkingTokens ?? 0) + (source.thinkingTokens ?? 0);
507
+ sumDetails(target, source, "inputDetails");
508
+ sumDetails(target, source, "outputDetails");
495
509
 
496
510
  // Canonical invariant (provider-agnostic): cache hits are a subset of
497
511
  // input. Some providers occasionally report cached > input on long chained
@@ -542,6 +556,163 @@ function sanitizeToolResult(r: ToolResultRecord): { sanitized: ToolResultRecord;
542
556
  return { sanitized: r, metas: [] };
543
557
  }
544
558
 
559
+ // ---------------------------------------------------------------------------
560
+ // Turn helpers shared by runAgentLoop and streamAgentLoop (one copy of the logic that both loops need).
561
+ // ---------------------------------------------------------------------------
562
+
563
+ // A zeroed usage total for a new run; in: both loops, out: the run's accumulatedUsage.
564
+ function newTurnUsage(): TokenUsage {
565
+ return {
566
+ inputTokens: 0,
567
+ outputTokens: 0,
568
+ totalTokens: 0,
569
+ cachedTokens: 0,
570
+ cacheReadTokens: 0,
571
+ cacheWriteTokens: 0,
572
+ thinkingTokens: 0,
573
+ };
574
+ }
575
+
576
+ // What a failed run had done so far (the `partial` attached to its error); in: both loops' catch blocks, out: err.partial.
577
+ function partialProgress(
578
+ usage: TokenUsage,
579
+ turns: number,
580
+ toolCalls: ToolCallRecord[],
581
+ toolResults: ToolResultRecord[],
582
+ subagents: SubAgentExecutionMetadata[],
583
+ text: string | undefined
584
+ ): {
585
+ usage: TokenUsage;
586
+ cost: TokenUsage["cost"];
587
+ turns: number;
588
+ toolCalls: ToolCallRecord[];
589
+ toolResults: ToolResultRecord[];
590
+ subagents: SubAgentExecutionMetadata[];
591
+ text: string | undefined;
592
+ } {
593
+ return { usage, cost: usage.cost, turns, toolCalls, toolResults, subagents, text };
594
+ }
595
+
596
+ // The provider options of one turn: the run's options plus this turn's tools, signal, forced tool choice, structured output and request timeout; in: both loops before each model call, out: provider.generate/stream options.
597
+ function buildTurnOptions(
598
+ config: AgentLoopConfig,
599
+ standardTools: StandardToolDeclaration[],
600
+ signal: AbortSignal | undefined,
601
+ turn: number,
602
+ outputSpec: import("../types/model.ts").StructuredOutputSpec | undefined,
603
+ requestTimeoutMs: number
604
+ ): ProviderRequestOptions {
605
+ const { options, runOptions } = config;
606
+ const providerOptions: ProviderRequestOptions = {
607
+ ...options,
608
+ sessionId: runOptions?.sessionId || options?.sessionId,
609
+ cache: options?.cache,
610
+ tools: standardTools.length > 0 ? standardTools : undefined,
611
+ signal,
612
+ toolChoice: toolChoiceForTurn(options?.toolChoice, turn),
613
+ ...(outputSpec ? { output: outputSpec } : {}),
614
+ };
615
+ // Per-request timeout pass-through (providers honor timeoutMs).
616
+ try {
617
+ if (Number.isFinite(requestTimeoutMs) && requestTimeoutMs !== Infinity) {
618
+ (providerOptions as unknown as { timeoutMs?: number }).timeoutMs = requestTimeoutMs;
619
+ }
620
+ } catch {}
621
+ return providerOptions;
622
+ }
623
+
624
+ // Moves an empty-text turn's reasoning into its answer, only when the caller opted in; in: a finished model turn, out: that turn's text and thinking.
625
+ function promoteThinkingIfAllowed(
626
+ turn: { text: string; thinking?: string; toolCalls?: unknown[] },
627
+ config: AgentLoopConfig,
628
+ runOptions: AgentRunOptions | undefined
629
+ ): void {
630
+ if ((turn.text && turn.text.trim() !== "") || (turn.toolCalls && turn.toolCalls.length > 0) || !turn.thinking) return;
631
+ // Thinking is exposed via events/traces and onTurn. Promoting it to answer text is gated (default OFF): without the
632
+ // opt-in the run returns the provider's empty text and finishReason.
633
+ if (!shouldPromoteThinkingToAnswer(config, runOptions)) return;
634
+ const mutable = turn as { text: string; thinking?: string };
635
+ if (turn.thinking.includes("</think>")) {
636
+ const parts = turn.thinking.split(/<\/(?:think|thought)>/i);
637
+ mutable.thinking = parts[0]!.replace(/<(?:think|thought)>/i, "").trim() || undefined;
638
+ mutable.text = parts.slice(1).join("").trim();
639
+ } else {
640
+ mutable.text = turn.thinking;
641
+ mutable.thinking = undefined;
642
+ }
643
+ }
644
+
645
+ // Counts one finished model turn: adds it to the run's usage, reports it to the agent's own spending, then checks the budgets and the token limit; in: both loops after each provider call, out: accumulatedUsage and a thrown BudgetExceededError when a cap is passed.
646
+ function recordModelTurn(
647
+ config: AgentLoopConfig,
648
+ accumulated: TokenUsage,
649
+ usage: TokenUsage,
650
+ spec: ModelSpec | undefined,
651
+ maxTotalTokens: number
652
+ ): void {
653
+ const serviceTier = config.options?.serviceTier;
654
+ accumulateUsage(accumulated, usage, spec, serviceTier);
655
+ try {
656
+ config.onTurnUsage?.(usage, spec);
657
+ } catch {}
658
+ config.enforceBudget?.({ ...usage, cost: computeCostFromPricing(usage, spec, serviceTier) });
659
+ checkTokenLimit(accumulated, maxTotalTokens);
660
+ }
661
+
662
+ // Emits the full reasoning text of a turn on the telemetry bus (observability only: never in wire bodies, history or files); in: both loops after each model turn, out: a `turn.thinking` event.
663
+ function emitThinkingTrace(
664
+ turn: { thinking?: string; thoughtSignature?: string },
665
+ turns: number,
666
+ providerId: string,
667
+ modelId: string,
668
+ sessionId: string | undefined
669
+ ): void {
670
+ if (!turn.thinking) return;
671
+ try {
672
+ telemetryBus.emit({
673
+ code: "turn.thinking",
674
+ message: `[Agent Accelerator] turn ${turns} thinking (${providerId}/${modelId})`,
675
+ data: {
676
+ turns,
677
+ model: modelId,
678
+ provider: providerId,
679
+ sessionId,
680
+ thinking: turn.thinking,
681
+ ...(turn.thoughtSignature ? { thoughtSignature: turn.thoughtSignature } : {}),
682
+ },
683
+ });
684
+ } catch {}
685
+ }
686
+
687
+ // Turns executed tool results into what goes into history: unwraps delegation envelopes, rolls each worker's usage into the run's total and budget (calling `onWorker` for each), and returns the sanitized results; in: both loops after a tool batch, out: the results for the context and `allSubagents`.
688
+ function rollUpToolResults(
689
+ results: ToolResultRecord[],
690
+ config: AgentLoopConfig,
691
+ accumulated: TokenUsage,
692
+ allSubagents: SubAgentExecutionMetadata[],
693
+ onWorker?: (worker: SubAgentExecutionMetadata) => void
694
+ ): ToolResultRecord[] {
695
+ const sanitizedResults: ToolResultRecord[] = [];
696
+ for (const r of results) {
697
+ const { sanitized, metas } = sanitizeToolResult(r);
698
+ if (metas.length > 0) {
699
+ allSubagents.push(...metas);
700
+ for (const s of metas) {
701
+ if (s.raw) s.raw = applyCaptureRaw(s.raw, config.captureRaw);
702
+ const subagentSpec = getModelFromCatalog(s.provider, s.model);
703
+ if (subagentSpec && (!s.usage?.cost || !s.usage.cost.totalCost)) {
704
+ s.usage.cost = computeCostFromPricing(s.usage, subagentSpec);
705
+ }
706
+ accumulateUsage(accumulated, s.usage, subagentSpec);
707
+ config.enforceBudget?.(s.usage);
708
+ onWorker?.(s);
709
+ }
710
+ }
711
+ sanitizedResults.push(sanitized);
712
+ }
713
+ return sanitizedResults;
714
+ }
715
+
545
716
  /**
546
717
  * Stops a model from hammering the same call. Per fingerprint (name + stable
547
718
  * args) the guard counts CONSECUTIVE turns: the first two identical calls run,
@@ -557,6 +728,7 @@ async function executeToolCallsWithRepeatGuard(options: {
557
728
  openToolLog?: ExecuteToolsOptions["openToolLog"];
558
729
  previousFingerprints: Set<string>;
559
730
  repeatCounts: Map<string, number>;
731
+ prompts?: Prompts;
560
732
  agentName?: string;
561
733
  signal?: AbortSignal;
562
734
  sessionId?: string;
@@ -590,7 +762,7 @@ async function executeToolCallsWithRepeatGuard(options: {
590
762
  { agentName: options.agentName, sessionId: options.sessionId },
591
763
  { reason: "repeat_blocked" }
592
764
  );
593
- blocked.set(call.id, makeRepeatBlockedResult(call));
765
+ blocked.set(call.id, makeRepeatBlockedResult(call, options.prompts?.toolRepeatBlocked));
594
766
  } else {
595
767
  executable.push(call);
596
768
  }
@@ -610,6 +782,7 @@ async function executeToolCallsWithRepeatGuard(options: {
610
782
  onSubagentEvent: options.onSubagentEvent,
611
783
  onResult: options.onResult,
612
784
  openToolLog: options.openToolLog,
785
+ prompts: options.prompts,
613
786
  })
614
787
  : [];
615
788
  const byId = new Map<string, ToolResultRecord[]>();
@@ -693,6 +866,7 @@ async function finishInterruptedTools(
693
866
  sessionId: config.runOptions?.sessionId || config.options?.sessionId,
694
867
  onResult: config.onToolResult,
695
868
  openToolLog: config.openToolLog,
869
+ prompts: config.prompts,
696
870
  })
697
871
  : [];
698
872
  const ran = new Map(executed.map((r) => [r.id, r]));
@@ -734,15 +908,7 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
734
908
  // Partial-run state hoisted outside try so the catch block can attach
735
909
  // {usage,cost,turns,toolCalls,toolResults,text} to ANY thrown error.
736
910
  const startTime = Date.now();
737
- const accumulatedUsage: TokenUsage = {
738
- inputTokens: 0,
739
- outputTokens: 0,
740
- totalTokens: 0,
741
- cachedTokens: 0,
742
- cacheReadTokens: 0,
743
- cacheWriteTokens: 0,
744
- thinkingTokens: 0,
745
- };
911
+ const accumulatedUsage: TokenUsage = newTurnUsage();
746
912
  const allToolCalls: ToolCallRecord[] = [];
747
913
  const allToolResults: ToolResultRecord[] = [];
748
914
  const allSubagents: SubAgentExecutionMetadata[] = [];
@@ -776,27 +942,20 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
776
942
  checkDeadline(startTime, limits.deadlineMs);
777
943
  config.enforceBudget?.();
778
944
  // Repair orphan tool pairs before every request.
779
- ensureToolPairing(context);
945
+ ensureToolPairing(context, config.prompts);
780
946
  if (config.fileAccess?.roots) await enforceFileAccess(context.messages, config.fileAccess.roots);
781
947
  turns++;
782
948
 
783
949
  const spec = getModelFromCatalog(provider.id, modelId);
784
950
 
785
- const providerOptions: ProviderRequestOptions = {
786
- ...options,
787
- sessionId: runOptions?.sessionId || options?.sessionId,
788
- cache: options?.cache,
789
- tools: standardTools.length > 0 ? standardTools : undefined,
790
- signal: runOptions?.signal,
791
- toolChoice: toolChoiceForTurn(options?.toolChoice, turns),
792
- ...(loopOutput.spec ? { output: loopOutput.spec } : {}),
793
- };
794
- // Per-request timeout pass-through (providers honor timeoutMs).
795
- try {
796
- if (Number.isFinite(limits.requestTimeoutMs) && limits.requestTimeoutMs !== Infinity) {
797
- (providerOptions as unknown as { timeoutMs?: number }).timeoutMs = limits.requestTimeoutMs;
798
- }
799
- } catch {}
951
+ const providerOptions = buildTurnOptions(
952
+ config,
953
+ standardTools,
954
+ runOptions?.signal,
955
+ turns,
956
+ loopOutput.spec,
957
+ limits.requestTimeoutMs
958
+ );
800
959
 
801
960
  // A long-running call (e.g. Google long decode) times each HTTP request itself: no whole-call race.
802
961
  const longRunning = provider.isLongRunning?.(modelId, providerOptions) === true;
@@ -818,34 +977,9 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
818
977
  // Canonical session routing: lets adapters detect provider switches.
819
978
  noteProviderTurn(runOptions?.sessionId || options?.sessionId, provider.id);
820
979
 
821
- // Thinking is exposed via events/traces and onTurn. Promoting it to answer text is gated (default
822
- // OFF): without the opt-in the run returns the provider's empty text and finishReason.
823
- if (
824
- (!genResult.text || genResult.text.trim() === "") &&
825
- (!genResult.toolCalls || genResult.toolCalls.length === 0) &&
826
- genResult.thinking
827
- ) {
828
- if (shouldPromoteThinkingToAnswer(config, runOptions)) {
829
- if (genResult.thinking.includes("</think>")) {
830
- const parts = genResult.thinking.split(/<\/(?:think|thought)>/i);
831
- genResult.thinking = parts[0]!.replace(/<(?:think|thought)>/i, "").trim() || undefined;
832
- genResult.text = parts.slice(1).join("").trim();
833
- } else {
834
- genResult.text = genResult.thinking;
835
- genResult.thinking = undefined;
836
- }
837
- }
838
- }
980
+ promoteThinkingIfAllowed(genResult, config, runOptions);
839
981
 
840
- accumulateUsage(accumulatedUsage, genResult.usage, spec, options?.serviceTier);
841
- try {
842
- config.onTurnUsage?.(genResult.usage, spec);
843
- } catch {}
844
- config.enforceBudget?.({
845
- ...genResult.usage,
846
- cost: computeCostFromPricing(genResult.usage, spec, options?.serviceTier),
847
- });
848
- checkTokenLimit(accumulatedUsage, limits.maxTotalTokens);
982
+ recordModelTurn(config, accumulatedUsage, genResult.usage, spec, limits.maxTotalTokens);
849
983
 
850
984
  // Record assistant turn in context
851
985
  context.addAssistantMessage(
@@ -856,31 +990,15 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
856
990
  genResult.providerSteps
857
991
  );
858
992
 
859
- // Thinking-trace telemetry (observability-only, full text, no limit):
860
- // one bus event per thinking turn carrying the text + thought signature.
861
- // Never touches provider payloads, history, or persistence formats.
862
- if (genResult.thinking) {
863
- try {
864
- telemetryBus.emit({
865
- code: "turn.thinking",
866
- message: `[Agent Accelerator] turn ${turns} thinking (${provider.id}/${modelId})`,
867
- data: {
868
- turns,
869
- model: modelId,
870
- provider: provider.id,
871
- sessionId: runOptions?.sessionId || options?.sessionId,
872
- thinking: genResult.thinking,
873
- ...(genResult.thoughtSignature ? { thoughtSignature: genResult.thoughtSignature } : {}),
874
- },
875
- });
876
- } catch {}
877
- }
993
+ emitThinkingTrace(genResult, turns, provider.id, modelId, runOptions?.sessionId || options?.sessionId);
878
994
 
879
995
  // Mid-session steer: if new queries arrived during this provider turn,
880
996
  // inject them now. Tool turns still execute first (see below) so the
881
997
  // current action finishes before redirection.
882
998
  const noToolSteered =
883
- !genResult.toolCalls || genResult.toolCalls.length === 0 ? drainSteerInbox(context, config.steerInbox) : [];
999
+ !genResult.toolCalls || genResult.toolCalls.length === 0
1000
+ ? drainSteerInbox(context, config.steerInbox, config.prompts)
1001
+ : [];
884
1002
 
885
1003
  // If no tool calls, generation is complete — unless steered, in which
886
1004
  // case the injected message extends the SAME run (no new response).
@@ -918,29 +1036,13 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
918
1036
  sessionId: runOptions?.sessionId || options?.sessionId,
919
1037
  onResult: config.onToolResult,
920
1038
  openToolLog: config.openToolLog,
1039
+ prompts: config.prompts,
921
1040
  });
922
1041
  const results = guarded.results;
923
1042
  previousToolCallFingerprints = guarded.fingerprints;
924
1043
 
925
1044
  // Process results and extract any sub-agent execution metadata
926
- const sanitizedResults: ToolResultRecord[] = [];
927
-
928
- for (const r of results) {
929
- const { sanitized, metas } = sanitizeToolResult(r);
930
- if (metas.length > 0) {
931
- allSubagents.push(...metas);
932
- for (const s of metas) {
933
- if (s.raw) s.raw = applyCaptureRaw(s.raw, config.captureRaw);
934
- const subagentSpec = getModelFromCatalog(s.provider, s.model);
935
- if (subagentSpec && (!s.usage?.cost || !s.usage.cost.totalCost)) {
936
- s.usage.cost = computeCostFromPricing(s.usage, subagentSpec);
937
- }
938
- accumulateUsage(accumulatedUsage, s.usage, subagentSpec);
939
- config.enforceBudget?.(s.usage);
940
- }
941
- }
942
- sanitizedResults.push(sanitized);
943
- }
1045
+ const sanitizedResults = rollUpToolResults(results, config, accumulatedUsage, allSubagents);
944
1046
 
945
1047
  allToolResults.push(...sanitizedResults);
946
1048
  context.addToolResults(sanitizedResults);
@@ -964,7 +1066,7 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
964
1066
  // Steer drain AFTER tool results so call/result pairs stay adjacent.
965
1067
  // The next loop iteration sends the injected user message to the model
966
1068
  // within the same run (same usage/turn budget).
967
- const toolSteered = drainSteerInbox(context, config.steerInbox);
1069
+ const toolSteered = drainSteerInbox(context, config.steerInbox, config.prompts);
968
1070
  if (toolSteered.length > 0) {
969
1071
  try {
970
1072
  config.onSteerInjected?.(toolSteered);
@@ -1038,15 +1140,14 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
1038
1140
  }
1039
1141
  // Every failure carries its partial progress for observability.
1040
1142
  try {
1041
- (err as unknown as { partial?: unknown }).partial ??= {
1042
- usage: accumulatedUsage,
1043
- cost: accumulatedUsage.cost,
1143
+ (err as unknown as { partial?: unknown }).partial ??= partialProgress(
1144
+ accumulatedUsage,
1044
1145
  turns,
1045
- toolCalls: allToolCalls,
1046
- toolResults: allToolResults,
1047
- subagents: allSubagents,
1048
- text: finalResult?.text,
1049
- };
1146
+ allToolCalls,
1147
+ allToolResults,
1148
+ allSubagents,
1149
+ finalResult?.text
1150
+ );
1050
1151
  } catch {
1051
1152
  // why: partial attachment is observability-only and must never mask the error.
1052
1153
  }
@@ -1110,15 +1211,7 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1110
1211
 
1111
1212
  (async () => {
1112
1213
  // Hoisted for catch-block partial reporting.
1113
- let accumulatedUsage: TokenUsage = {
1114
- inputTokens: 0,
1115
- outputTokens: 0,
1116
- totalTokens: 0,
1117
- cachedTokens: 0,
1118
- cacheReadTokens: 0,
1119
- cacheWriteTokens: 0,
1120
- thinkingTokens: 0,
1121
- };
1214
+ let accumulatedUsage: TokenUsage = newTurnUsage();
1122
1215
  let lastResponse: AgentResponse | null = null;
1123
1216
  let turns = 0;
1124
1217
  // Tool progress hoisted so failures carry toolCalls/toolResults.
@@ -1158,15 +1251,7 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1158
1251
  return;
1159
1252
  }
1160
1253
 
1161
- accumulatedUsage = {
1162
- inputTokens: 0,
1163
- outputTokens: 0,
1164
- totalTokens: 0,
1165
- cachedTokens: 0,
1166
- cacheReadTokens: 0,
1167
- cacheWriteTokens: 0,
1168
- thinkingTokens: 0,
1169
- };
1254
+ accumulatedUsage = newTurnUsage();
1170
1255
 
1171
1256
  // allToolCalls/allToolResults/allSubagents are hoisted above (fresh
1172
1257
  // per stream run); just reset turn counters here.
@@ -1180,26 +1265,20 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1180
1265
  throwIfCancelled({ usage: accumulatedUsage, turns, text: lastResponse?.text });
1181
1266
  checkDeadline(streamStartTime, streamLimits.deadlineMs);
1182
1267
  config.enforceBudget?.();
1183
- ensureToolPairing(context);
1268
+ ensureToolPairing(context, config.prompts);
1184
1269
  if (config.fileAccess?.roots) await enforceFileAccess(context.messages, config.fileAccess.roots);
1185
1270
  turns++;
1186
1271
 
1187
1272
  const spec2 = getModelFromCatalog(provider.id, modelId);
1188
1273
 
1189
- const providerOptions: ProviderRequestOptions = {
1190
- ...options,
1191
- sessionId: runOptions?.sessionId || options?.sessionId,
1192
- cache: options?.cache,
1193
- tools: standardTools.length > 0 ? standardTools : undefined,
1194
- signal: linked.signal,
1195
- toolChoice: toolChoiceForTurn(options?.toolChoice, turns),
1196
- ...(streamOutputSpec ? { output: streamOutputSpec } : {}),
1197
- };
1198
- try {
1199
- if (Number.isFinite(streamLimits.requestTimeoutMs) && streamLimits.requestTimeoutMs !== Infinity) {
1200
- (providerOptions as unknown as { timeoutMs?: number }).timeoutMs = streamLimits.requestTimeoutMs;
1201
- }
1202
- } catch {}
1274
+ const providerOptions = buildTurnOptions(
1275
+ config,
1276
+ standardTools,
1277
+ linked.signal,
1278
+ turns,
1279
+ streamOutputSpec,
1280
+ streamLimits.requestTimeoutMs
1281
+ );
1203
1282
 
1204
1283
  const longRunning = provider.isLongRunning?.(modelId, providerOptions) === true;
1205
1284
  const innerStream = provider.stream(
@@ -1281,35 +1360,9 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1281
1360
  // Canonical session routing: lets adapters detect provider switches.
1282
1361
  noteProviderTurn(runOptions?.sessionId || options?.sessionId, provider.id);
1283
1362
 
1284
- // Thinking stays on events/traces + onTurn (default exposed). Promoting
1285
- // it to answer text is gated (default OFF): without the opt-in
1286
- // the turn keeps the provider's empty text and finishReason.
1287
- if (
1288
- (!turnResponse.text || turnResponse.text.trim() === "") &&
1289
- (!turnResponse.toolCalls || turnResponse.toolCalls.length === 0) &&
1290
- turnResponse.thinking
1291
- ) {
1292
- if (shouldPromoteThinkingToAnswer(config, runOptions)) {
1293
- if (turnResponse.thinking.includes("</think>")) {
1294
- const parts = turnResponse.thinking.split(/<\/(?:think|thought)>/i);
1295
- (turnResponse as any).thinking = parts[0]!.replace(/<(?:think|thought)>/i, "").trim() || undefined;
1296
- (turnResponse as any).text = parts.slice(1).join("").trim();
1297
- } else {
1298
- (turnResponse as any).text = turnResponse.thinking;
1299
- (turnResponse as any).thinking = undefined;
1300
- }
1301
- }
1302
- }
1363
+ promoteThinkingIfAllowed(turnResponse, config, runOptions);
1303
1364
 
1304
- accumulateUsage(accumulatedUsage, turnResponse.usage, spec2, options?.serviceTier);
1305
- try {
1306
- config.onTurnUsage?.(turnResponse.usage, spec2);
1307
- } catch {}
1308
- config.enforceBudget?.({
1309
- ...turnResponse.usage,
1310
- cost: computeCostFromPricing(turnResponse.usage, spec2, options?.serviceTier),
1311
- });
1312
- checkTokenLimit(accumulatedUsage, streamLimits.maxTotalTokens);
1365
+ recordModelTurn(config, accumulatedUsage, turnResponse.usage, spec2, streamLimits.maxTotalTokens);
1313
1366
 
1314
1367
  context.addAssistantMessage(
1315
1368
  turnResponse.text,
@@ -1319,28 +1372,11 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1319
1372
  turnResponse.providerSteps
1320
1373
  );
1321
1374
 
1322
- // Thinking-trace telemetry (observability-only, full text, no limit):
1323
- // mirrors the non-streaming loop above; bus-only, never wire/history.
1324
- if (turnResponse.thinking) {
1325
- try {
1326
- telemetryBus.emit({
1327
- code: "turn.thinking",
1328
- message: `[Agent Accelerator] turn ${turns} thinking (${provider.id}/${modelId})`,
1329
- data: {
1330
- turns,
1331
- model: modelId,
1332
- provider: provider.id,
1333
- sessionId: runOptions?.sessionId || options?.sessionId,
1334
- thinking: turnResponse.thinking,
1335
- ...(turnResponse.thoughtSignature ? { thoughtSignature: turnResponse.thoughtSignature } : {}),
1336
- },
1337
- });
1338
- } catch {}
1339
- }
1375
+ emitThinkingTrace(turnResponse, turns, provider.id, modelId, runOptions?.sessionId || options?.sessionId);
1340
1376
 
1341
1377
  const noToolSteered =
1342
1378
  !turnResponse.toolCalls || turnResponse.toolCalls.length === 0
1343
- ? drainSteerInbox(context, config.steerInbox)
1379
+ ? drainSteerInbox(context, config.steerInbox, config.prompts)
1344
1380
  : [];
1345
1381
  if (!turnResponse.toolCalls || turnResponse.toolCalls.length === 0) {
1346
1382
  emitTurn(runOptions, { turns, text: turnResponse.text, thinking: turnResponse.thinking });
@@ -1388,6 +1424,7 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1388
1424
  sessionId: runOptions?.sessionId || options?.sessionId,
1389
1425
  onResult: config.onToolResult,
1390
1426
  openToolLog: config.openToolLog,
1427
+ prompts: config.prompts,
1391
1428
  onSubagentEvent: (ev) => {
1392
1429
  try {
1393
1430
  outerStream.push({
@@ -1406,28 +1443,9 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1406
1443
  const results = guarded.results;
1407
1444
  previousToolCallFingerprints = guarded.fingerprints;
1408
1445
 
1409
- const sanitizedResults: ToolResultRecord[] = [];
1410
-
1411
- for (const r of results) {
1412
- const { sanitized, metas } = sanitizeToolResult(r);
1413
- if (metas.length > 0) {
1414
- allSubagents.push(...metas);
1415
- for (const s of metas) {
1416
- if (s.raw) s.raw = applyCaptureRaw(s.raw, config.captureRaw);
1417
- const subagentSpec = getModelFromCatalog(s.provider, s.model);
1418
- if (subagentSpec && (!s.usage?.cost || !s.usage.cost.totalCost)) {
1419
- s.usage.cost = computeCostFromPricing(s.usage, subagentSpec);
1420
- }
1421
- accumulateUsage(accumulatedUsage, s.usage, subagentSpec);
1422
- config.enforceBudget?.(s.usage);
1423
- outerStream.push({
1424
- type: "subagent_complete",
1425
- subagent: s,
1426
- });
1427
- }
1428
- }
1429
- sanitizedResults.push(sanitized);
1430
- }
1446
+ const sanitizedResults = rollUpToolResults(results, config, accumulatedUsage, allSubagents, (s) =>
1447
+ outerStream.push({ type: "subagent_complete", subagent: s })
1448
+ );
1431
1449
 
1432
1450
  for (const res of sanitizedResults) {
1433
1451
  outerStream.push({
@@ -1459,7 +1477,7 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1459
1477
  emitProgress(config);
1460
1478
  break;
1461
1479
  }
1462
- const toolSteered = drainSteerInbox(context, config.steerInbox);
1480
+ const toolSteered = drainSteerInbox(context, config.steerInbox, config.prompts);
1463
1481
  if (toolSteered.length > 0) {
1464
1482
  for (const preview of toolSteered) {
1465
1483
  try {
@@ -1500,15 +1518,14 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1500
1518
  } catch (err) {
1501
1519
  const raw = err instanceof Error ? err : new Error(String(err));
1502
1520
  try {
1503
- (raw as unknown as { partial?: unknown }).partial ??= {
1504
- usage: accumulatedUsage,
1505
- cost: accumulatedUsage.cost,
1521
+ (raw as unknown as { partial?: unknown }).partial ??= partialProgress(
1522
+ accumulatedUsage,
1506
1523
  turns,
1507
- toolCalls: allToolCalls,
1508
- toolResults: allToolResults,
1509
- subagents: allSubagents,
1510
- text: lastResponse?.text,
1511
- };
1524
+ allToolCalls,
1525
+ allToolResults,
1526
+ allSubagents,
1527
+ lastResponse?.text
1528
+ );
1512
1529
  } catch {}
1513
1530
  outerStream.fail(raw);
1514
1531
  return;
@@ -1561,15 +1578,14 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1561
1578
  // why: rollback policy itself must never mask the original error.
1562
1579
  }
1563
1580
  // Every failure carries its partial progress for observability.
1564
- const partial = {
1565
- usage: accumulatedUsage,
1566
- cost: accumulatedUsage.cost,
1581
+ const partial = partialProgress(
1582
+ accumulatedUsage,
1567
1583
  turns,
1568
- toolCalls: allToolCalls,
1569
- toolResults: allToolResults,
1570
- subagents: allSubagents,
1571
- text: lastResponse?.text,
1572
- };
1584
+ allToolCalls,
1585
+ allToolResults,
1586
+ allSubagents,
1587
+ lastResponse?.text
1588
+ );
1573
1589
  // Aborts always surface as AbortError with partial attached.
1574
1590
  if (isAbort) {
1575
1591
  const abortErr = raw.name === "AbortError" ? raw : makeAbortError(partial, raw);