agent-accelerator 0.6.5 → 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.
@@ -5,6 +5,9 @@ The agent and everything that runs under it.
5
5
  | File | What it is | Used by |
6
6
  | :------------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------- |
7
7
  | `agent.ts` | `Agent`: `run`/`stream`/`ask`, `steer`/`queue`/`interrupt`, persistence hooks, `resume()`/`interrupted()`, `usageReport()`, `fork()`, `toConfig()`/`fromConfig()`, the `workers` getter. | callers, workers, scheduler |
8
+ | `run-control.ts` | `RunControl`: the active run, the waiting queue and the machinery behind `steer`/`queue`/`interrupt` (`pumpQueue`, `finishCurrentRun`, urgent queueing). `Agent` keeps the public methods and calls it. | `Agent` |
9
+ | `options.ts` | `buildProviderOptions` (agent config + per-run options in one place), `resolveAgentLimits`, `normalizeThinking`, `resolveEffectiveThinking`, `normalizeToolChoiceOption`. | `Agent`, barrel |
10
+ | `named-sessions.ts` | `NamedSessions`: the in-memory contexts of `run(..., { sessionId })` (LRU 100, 1 h idle TTL). | `Agent` |
8
11
  | `loop.ts` | `runAgentLoop` / `streamAgentLoop`: per turn budget, pairing repair, file access, provider call, tool execution, repeat guard. Also `ensureToolPairing`, `accumulateUsage`. | `Agent` |
9
12
  | `context.ts` | `AgentContext`: the validated message list with checkpoint/rollback. | `Agent`, loops |
10
13
  | `delegation.ts` | Fixed workers (`agentToTool`, `buildAgentTools`), the live worker trace store, session-id helpers, budget helpers, `openWorkerTrace`/`workerMetadata`. | `Agent`, `workers.ts` |
@@ -19,7 +22,7 @@ The agent and everything that runs under it.
19
22
 
20
23
  ## Invariants
21
24
 
22
- - `runAgentLoop` and `streamAgentLoop` duplicate their turn logic: every loop change goes into both.
25
+ - `runAgentLoop` and `streamAgentLoop` still duplicate the shape of a turn, but the repeated parts live once in helpers at the top of `loop.ts` (`buildTurnOptions`, `promoteThinkingIfAllowed`, `recordModelTurn`, `emitThinkingTrace`, `rollUpToolResults`, `partialProgress`, `newTurnUsage`): change those there, and any change to the turn order itself goes into both loops.
23
26
  - An `Agent` runs one thing at a time; delegation forks a copy per call so parallel calls never queue.
24
27
  - A worker is a plain `Agent` with a parent link and presets. Never duplicate logic between Agent and worker.
25
28
  - A worker is never less constrained than its parent (`inheritableConfig()`); its children draw only from the tools it was granted.
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";
@@ -556,6 +556,163 @@ function sanitizeToolResult(r: ToolResultRecord): { sanitized: ToolResultRecord;
556
556
  return { sanitized: r, metas: [] };
557
557
  }
558
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
+
559
716
  /**
560
717
  * Stops a model from hammering the same call. Per fingerprint (name + stable
561
718
  * args) the guard counts CONSECUTIVE turns: the first two identical calls run,
@@ -751,15 +908,7 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
751
908
  // Partial-run state hoisted outside try so the catch block can attach
752
909
  // {usage,cost,turns,toolCalls,toolResults,text} to ANY thrown error.
753
910
  const startTime = Date.now();
754
- const accumulatedUsage: TokenUsage = {
755
- inputTokens: 0,
756
- outputTokens: 0,
757
- totalTokens: 0,
758
- cachedTokens: 0,
759
- cacheReadTokens: 0,
760
- cacheWriteTokens: 0,
761
- thinkingTokens: 0,
762
- };
911
+ const accumulatedUsage: TokenUsage = newTurnUsage();
763
912
  const allToolCalls: ToolCallRecord[] = [];
764
913
  const allToolResults: ToolResultRecord[] = [];
765
914
  const allSubagents: SubAgentExecutionMetadata[] = [];
@@ -799,21 +948,14 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
799
948
 
800
949
  const spec = getModelFromCatalog(provider.id, modelId);
801
950
 
802
- const providerOptions: ProviderRequestOptions = {
803
- ...options,
804
- sessionId: runOptions?.sessionId || options?.sessionId,
805
- cache: options?.cache,
806
- tools: standardTools.length > 0 ? standardTools : undefined,
807
- signal: runOptions?.signal,
808
- toolChoice: toolChoiceForTurn(options?.toolChoice, turns),
809
- ...(loopOutput.spec ? { output: loopOutput.spec } : {}),
810
- };
811
- // Per-request timeout pass-through (providers honor timeoutMs).
812
- try {
813
- if (Number.isFinite(limits.requestTimeoutMs) && limits.requestTimeoutMs !== Infinity) {
814
- (providerOptions as unknown as { timeoutMs?: number }).timeoutMs = limits.requestTimeoutMs;
815
- }
816
- } catch {}
951
+ const providerOptions = buildTurnOptions(
952
+ config,
953
+ standardTools,
954
+ runOptions?.signal,
955
+ turns,
956
+ loopOutput.spec,
957
+ limits.requestTimeoutMs
958
+ );
817
959
 
818
960
  // A long-running call (e.g. Google long decode) times each HTTP request itself: no whole-call race.
819
961
  const longRunning = provider.isLongRunning?.(modelId, providerOptions) === true;
@@ -835,34 +977,9 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
835
977
  // Canonical session routing: lets adapters detect provider switches.
836
978
  noteProviderTurn(runOptions?.sessionId || options?.sessionId, provider.id);
837
979
 
838
- // Thinking is exposed via events/traces and onTurn. Promoting it to answer text is gated (default
839
- // OFF): without the opt-in the run returns the provider's empty text and finishReason.
840
- if (
841
- (!genResult.text || genResult.text.trim() === "") &&
842
- (!genResult.toolCalls || genResult.toolCalls.length === 0) &&
843
- genResult.thinking
844
- ) {
845
- if (shouldPromoteThinkingToAnswer(config, runOptions)) {
846
- if (genResult.thinking.includes("</think>")) {
847
- const parts = genResult.thinking.split(/<\/(?:think|thought)>/i);
848
- genResult.thinking = parts[0]!.replace(/<(?:think|thought)>/i, "").trim() || undefined;
849
- genResult.text = parts.slice(1).join("").trim();
850
- } else {
851
- genResult.text = genResult.thinking;
852
- genResult.thinking = undefined;
853
- }
854
- }
855
- }
980
+ promoteThinkingIfAllowed(genResult, config, runOptions);
856
981
 
857
- accumulateUsage(accumulatedUsage, genResult.usage, spec, options?.serviceTier);
858
- try {
859
- config.onTurnUsage?.(genResult.usage, spec);
860
- } catch {}
861
- config.enforceBudget?.({
862
- ...genResult.usage,
863
- cost: computeCostFromPricing(genResult.usage, spec, options?.serviceTier),
864
- });
865
- checkTokenLimit(accumulatedUsage, limits.maxTotalTokens);
982
+ recordModelTurn(config, accumulatedUsage, genResult.usage, spec, limits.maxTotalTokens);
866
983
 
867
984
  // Record assistant turn in context
868
985
  context.addAssistantMessage(
@@ -873,25 +990,7 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
873
990
  genResult.providerSteps
874
991
  );
875
992
 
876
- // Thinking-trace telemetry (observability-only, full text, no limit):
877
- // one bus event per thinking turn carrying the text + thought signature.
878
- // Never touches provider payloads, history, or persistence formats.
879
- if (genResult.thinking) {
880
- try {
881
- telemetryBus.emit({
882
- code: "turn.thinking",
883
- message: `[Agent Accelerator] turn ${turns} thinking (${provider.id}/${modelId})`,
884
- data: {
885
- turns,
886
- model: modelId,
887
- provider: provider.id,
888
- sessionId: runOptions?.sessionId || options?.sessionId,
889
- thinking: genResult.thinking,
890
- ...(genResult.thoughtSignature ? { thoughtSignature: genResult.thoughtSignature } : {}),
891
- },
892
- });
893
- } catch {}
894
- }
993
+ emitThinkingTrace(genResult, turns, provider.id, modelId, runOptions?.sessionId || options?.sessionId);
895
994
 
896
995
  // Mid-session steer: if new queries arrived during this provider turn,
897
996
  // inject them now. Tool turns still execute first (see below) so the
@@ -943,24 +1042,7 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
943
1042
  previousToolCallFingerprints = guarded.fingerprints;
944
1043
 
945
1044
  // Process results and extract any sub-agent execution metadata
946
- const sanitizedResults: ToolResultRecord[] = [];
947
-
948
- for (const r of results) {
949
- const { sanitized, metas } = sanitizeToolResult(r);
950
- if (metas.length > 0) {
951
- allSubagents.push(...metas);
952
- for (const s of metas) {
953
- if (s.raw) s.raw = applyCaptureRaw(s.raw, config.captureRaw);
954
- const subagentSpec = getModelFromCatalog(s.provider, s.model);
955
- if (subagentSpec && (!s.usage?.cost || !s.usage.cost.totalCost)) {
956
- s.usage.cost = computeCostFromPricing(s.usage, subagentSpec);
957
- }
958
- accumulateUsage(accumulatedUsage, s.usage, subagentSpec);
959
- config.enforceBudget?.(s.usage);
960
- }
961
- }
962
- sanitizedResults.push(sanitized);
963
- }
1045
+ const sanitizedResults = rollUpToolResults(results, config, accumulatedUsage, allSubagents);
964
1046
 
965
1047
  allToolResults.push(...sanitizedResults);
966
1048
  context.addToolResults(sanitizedResults);
@@ -1058,15 +1140,14 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
1058
1140
  }
1059
1141
  // Every failure carries its partial progress for observability.
1060
1142
  try {
1061
- (err as unknown as { partial?: unknown }).partial ??= {
1062
- usage: accumulatedUsage,
1063
- cost: accumulatedUsage.cost,
1143
+ (err as unknown as { partial?: unknown }).partial ??= partialProgress(
1144
+ accumulatedUsage,
1064
1145
  turns,
1065
- toolCalls: allToolCalls,
1066
- toolResults: allToolResults,
1067
- subagents: allSubagents,
1068
- text: finalResult?.text,
1069
- };
1146
+ allToolCalls,
1147
+ allToolResults,
1148
+ allSubagents,
1149
+ finalResult?.text
1150
+ );
1070
1151
  } catch {
1071
1152
  // why: partial attachment is observability-only and must never mask the error.
1072
1153
  }
@@ -1130,15 +1211,7 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1130
1211
 
1131
1212
  (async () => {
1132
1213
  // Hoisted for catch-block partial reporting.
1133
- let accumulatedUsage: TokenUsage = {
1134
- inputTokens: 0,
1135
- outputTokens: 0,
1136
- totalTokens: 0,
1137
- cachedTokens: 0,
1138
- cacheReadTokens: 0,
1139
- cacheWriteTokens: 0,
1140
- thinkingTokens: 0,
1141
- };
1214
+ let accumulatedUsage: TokenUsage = newTurnUsage();
1142
1215
  let lastResponse: AgentResponse | null = null;
1143
1216
  let turns = 0;
1144
1217
  // Tool progress hoisted so failures carry toolCalls/toolResults.
@@ -1178,15 +1251,7 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1178
1251
  return;
1179
1252
  }
1180
1253
 
1181
- accumulatedUsage = {
1182
- inputTokens: 0,
1183
- outputTokens: 0,
1184
- totalTokens: 0,
1185
- cachedTokens: 0,
1186
- cacheReadTokens: 0,
1187
- cacheWriteTokens: 0,
1188
- thinkingTokens: 0,
1189
- };
1254
+ accumulatedUsage = newTurnUsage();
1190
1255
 
1191
1256
  // allToolCalls/allToolResults/allSubagents are hoisted above (fresh
1192
1257
  // per stream run); just reset turn counters here.
@@ -1206,20 +1271,14 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1206
1271
 
1207
1272
  const spec2 = getModelFromCatalog(provider.id, modelId);
1208
1273
 
1209
- const providerOptions: ProviderRequestOptions = {
1210
- ...options,
1211
- sessionId: runOptions?.sessionId || options?.sessionId,
1212
- cache: options?.cache,
1213
- tools: standardTools.length > 0 ? standardTools : undefined,
1214
- signal: linked.signal,
1215
- toolChoice: toolChoiceForTurn(options?.toolChoice, turns),
1216
- ...(streamOutputSpec ? { output: streamOutputSpec } : {}),
1217
- };
1218
- try {
1219
- if (Number.isFinite(streamLimits.requestTimeoutMs) && streamLimits.requestTimeoutMs !== Infinity) {
1220
- (providerOptions as unknown as { timeoutMs?: number }).timeoutMs = streamLimits.requestTimeoutMs;
1221
- }
1222
- } catch {}
1274
+ const providerOptions = buildTurnOptions(
1275
+ config,
1276
+ standardTools,
1277
+ linked.signal,
1278
+ turns,
1279
+ streamOutputSpec,
1280
+ streamLimits.requestTimeoutMs
1281
+ );
1223
1282
 
1224
1283
  const longRunning = provider.isLongRunning?.(modelId, providerOptions) === true;
1225
1284
  const innerStream = provider.stream(
@@ -1301,35 +1360,9 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1301
1360
  // Canonical session routing: lets adapters detect provider switches.
1302
1361
  noteProviderTurn(runOptions?.sessionId || options?.sessionId, provider.id);
1303
1362
 
1304
- // Thinking stays on events/traces + onTurn (default exposed). Promoting
1305
- // it to answer text is gated (default OFF): without the opt-in
1306
- // the turn keeps the provider's empty text and finishReason.
1307
- if (
1308
- (!turnResponse.text || turnResponse.text.trim() === "") &&
1309
- (!turnResponse.toolCalls || turnResponse.toolCalls.length === 0) &&
1310
- turnResponse.thinking
1311
- ) {
1312
- if (shouldPromoteThinkingToAnswer(config, runOptions)) {
1313
- if (turnResponse.thinking.includes("</think>")) {
1314
- const parts = turnResponse.thinking.split(/<\/(?:think|thought)>/i);
1315
- (turnResponse as any).thinking = parts[0]!.replace(/<(?:think|thought)>/i, "").trim() || undefined;
1316
- (turnResponse as any).text = parts.slice(1).join("").trim();
1317
- } else {
1318
- (turnResponse as any).text = turnResponse.thinking;
1319
- (turnResponse as any).thinking = undefined;
1320
- }
1321
- }
1322
- }
1363
+ promoteThinkingIfAllowed(turnResponse, config, runOptions);
1323
1364
 
1324
- accumulateUsage(accumulatedUsage, turnResponse.usage, spec2, options?.serviceTier);
1325
- try {
1326
- config.onTurnUsage?.(turnResponse.usage, spec2);
1327
- } catch {}
1328
- config.enforceBudget?.({
1329
- ...turnResponse.usage,
1330
- cost: computeCostFromPricing(turnResponse.usage, spec2, options?.serviceTier),
1331
- });
1332
- checkTokenLimit(accumulatedUsage, streamLimits.maxTotalTokens);
1365
+ recordModelTurn(config, accumulatedUsage, turnResponse.usage, spec2, streamLimits.maxTotalTokens);
1333
1366
 
1334
1367
  context.addAssistantMessage(
1335
1368
  turnResponse.text,
@@ -1339,24 +1372,7 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1339
1372
  turnResponse.providerSteps
1340
1373
  );
1341
1374
 
1342
- // Thinking-trace telemetry (observability-only, full text, no limit):
1343
- // mirrors the non-streaming loop above; bus-only, never wire/history.
1344
- if (turnResponse.thinking) {
1345
- try {
1346
- telemetryBus.emit({
1347
- code: "turn.thinking",
1348
- message: `[Agent Accelerator] turn ${turns} thinking (${provider.id}/${modelId})`,
1349
- data: {
1350
- turns,
1351
- model: modelId,
1352
- provider: provider.id,
1353
- sessionId: runOptions?.sessionId || options?.sessionId,
1354
- thinking: turnResponse.thinking,
1355
- ...(turnResponse.thoughtSignature ? { thoughtSignature: turnResponse.thoughtSignature } : {}),
1356
- },
1357
- });
1358
- } catch {}
1359
- }
1375
+ emitThinkingTrace(turnResponse, turns, provider.id, modelId, runOptions?.sessionId || options?.sessionId);
1360
1376
 
1361
1377
  const noToolSteered =
1362
1378
  !turnResponse.toolCalls || turnResponse.toolCalls.length === 0
@@ -1427,28 +1443,9 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1427
1443
  const results = guarded.results;
1428
1444
  previousToolCallFingerprints = guarded.fingerprints;
1429
1445
 
1430
- const sanitizedResults: ToolResultRecord[] = [];
1431
-
1432
- for (const r of results) {
1433
- const { sanitized, metas } = sanitizeToolResult(r);
1434
- if (metas.length > 0) {
1435
- allSubagents.push(...metas);
1436
- for (const s of metas) {
1437
- if (s.raw) s.raw = applyCaptureRaw(s.raw, config.captureRaw);
1438
- const subagentSpec = getModelFromCatalog(s.provider, s.model);
1439
- if (subagentSpec && (!s.usage?.cost || !s.usage.cost.totalCost)) {
1440
- s.usage.cost = computeCostFromPricing(s.usage, subagentSpec);
1441
- }
1442
- accumulateUsage(accumulatedUsage, s.usage, subagentSpec);
1443
- config.enforceBudget?.(s.usage);
1444
- outerStream.push({
1445
- type: "subagent_complete",
1446
- subagent: s,
1447
- });
1448
- }
1449
- }
1450
- sanitizedResults.push(sanitized);
1451
- }
1446
+ const sanitizedResults = rollUpToolResults(results, config, accumulatedUsage, allSubagents, (s) =>
1447
+ outerStream.push({ type: "subagent_complete", subagent: s })
1448
+ );
1452
1449
 
1453
1450
  for (const res of sanitizedResults) {
1454
1451
  outerStream.push({
@@ -1521,15 +1518,14 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1521
1518
  } catch (err) {
1522
1519
  const raw = err instanceof Error ? err : new Error(String(err));
1523
1520
  try {
1524
- (raw as unknown as { partial?: unknown }).partial ??= {
1525
- usage: accumulatedUsage,
1526
- cost: accumulatedUsage.cost,
1521
+ (raw as unknown as { partial?: unknown }).partial ??= partialProgress(
1522
+ accumulatedUsage,
1527
1523
  turns,
1528
- toolCalls: allToolCalls,
1529
- toolResults: allToolResults,
1530
- subagents: allSubagents,
1531
- text: lastResponse?.text,
1532
- };
1524
+ allToolCalls,
1525
+ allToolResults,
1526
+ allSubagents,
1527
+ lastResponse?.text
1528
+ );
1533
1529
  } catch {}
1534
1530
  outerStream.fail(raw);
1535
1531
  return;
@@ -1582,15 +1578,14 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1582
1578
  // why: rollback policy itself must never mask the original error.
1583
1579
  }
1584
1580
  // Every failure carries its partial progress for observability.
1585
- const partial = {
1586
- usage: accumulatedUsage,
1587
- cost: accumulatedUsage.cost,
1581
+ const partial = partialProgress(
1582
+ accumulatedUsage,
1588
1583
  turns,
1589
- toolCalls: allToolCalls,
1590
- toolResults: allToolResults,
1591
- subagents: allSubagents,
1592
- text: lastResponse?.text,
1593
- };
1584
+ allToolCalls,
1585
+ allToolResults,
1586
+ allSubagents,
1587
+ lastResponse?.text
1588
+ );
1594
1589
  // Aborts always surface as AbortError with partial attached.
1595
1590
  if (isAbort) {
1596
1591
  const abortErr = raw.name === "AbortError" ? raw : makeAbortError(partial, raw);
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The named in-memory sessions of one Agent (`run(prompt, { sessionId })`, `agent.session(id)`): one `AgentContext` per
3
+ * id, at most 100 kept (least recently used goes first) and dropped after an hour idle. The Agent's own session is
4
+ * never kept here: it is its default context.
5
+ */
6
+ import { AgentContext } from "./context.ts";
7
+ import { clearSessionRouting } from "../providers.ts";
8
+ import { clearInteractionChains } from "../providers/google.ts";
9
+
10
+ /** Max named sessions kept per Agent (LRU). */
11
+ const SESSION_LRU_MAX = 100;
12
+ /** Idle TTL for named sessions. */
13
+ const SESSION_TTL_MS = 60 * 60 * 1000;
14
+
15
+ /** The named sessions of one Agent. */
16
+ export class NamedSessions {
17
+ private readonly entries = new Map<string, { context: AgentContext; lastAccess: number }>();
18
+
19
+ constructor(
20
+ private readonly defaultId: () => string,
21
+ private readonly defaultContext: () => AgentContext,
22
+ private readonly create: () => AgentContext
23
+ ) {}
24
+
25
+ // Forgets a session's provider chain and routing state; in: every removal below, out: nothing.
26
+ private forget(id: string): void {
27
+ try {
28
+ clearInteractionChains(id);
29
+ } catch {}
30
+ try {
31
+ clearSessionRouting(id);
32
+ } catch {}
33
+ }
34
+
35
+ /** The context for `sessionId` (the default session gives the Agent's own context); expired ones are dropped and the oldest is evicted past the limit. */
36
+ contextFor(sessionId?: string): AgentContext {
37
+ const sid = sessionId || this.defaultId();
38
+ if (sid === this.defaultId()) return this.defaultContext();
39
+ const now = Date.now();
40
+ try {
41
+ for (const [key, entry] of [...this.entries]) {
42
+ if (now - entry.lastAccess > SESSION_TTL_MS) {
43
+ this.entries.delete(key);
44
+ this.forget(key);
45
+ }
46
+ }
47
+ } catch {}
48
+ const existing = this.entries.get(sid);
49
+ if (existing) {
50
+ existing.lastAccess = now;
51
+ return existing.context;
52
+ }
53
+ const fresh = this.create();
54
+ this.entries.set(sid, { context: fresh, lastAccess: now });
55
+ try {
56
+ while (this.entries.size > SESSION_LRU_MAX) {
57
+ let oldest: string | undefined;
58
+ let oldestTs = Infinity;
59
+ for (const [key, entry] of this.entries) {
60
+ if (entry.lastAccess < oldestTs) {
61
+ oldestTs = entry.lastAccess;
62
+ oldest = key;
63
+ }
64
+ }
65
+ if (!oldest) break;
66
+ this.entries.delete(oldest);
67
+ this.forget(oldest);
68
+ }
69
+ } catch {}
70
+ return fresh;
71
+ }
72
+
73
+ /** Drops one session and its provider state; no-op for an unknown id. */
74
+ dispose(id: string): void {
75
+ try {
76
+ this.entries.delete(id);
77
+ } catch {}
78
+ this.forget(id);
79
+ }
80
+
81
+ /** Drops every named session. */
82
+ clear(): void {
83
+ try {
84
+ for (const id of [...this.entries.keys()]) this.forget(id);
85
+ this.entries.clear();
86
+ } catch {}
87
+ }
88
+
89
+ /** The ids of the named sessions held now. */
90
+ ids(): string[] {
91
+ return [...this.entries.keys()];
92
+ }
93
+ }