@animalabs/agent-framework 0.26.0 → 0.26.1

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.
package/src/framework.ts CHANGED
@@ -116,6 +116,7 @@ import type { WorkspaceModule } from './modules/workspace/index.js';
116
116
  import type { ToolResultGuard, WithheldSpill } from './tool-result-guard.js';
117
117
  import {
118
118
  nonErrorResultText,
119
+ shownToScript,
119
120
  toolResultDataToHistoryString,
120
121
  truncateForHistory,
121
122
  DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS,
@@ -1239,6 +1240,10 @@ const RETRY_CANCELLED_BY_STOP = 'a retry waiting while the framework was stoppin
1239
1240
  * revive the earlier lifecycle's turn in the next one. */
1240
1241
  const RETRY_ACROSS_RESTART = 'a retry waiting while the framework was stopped and started again is dropped before it sends anything';
1241
1242
 
1243
+ /** The reason on the inference:aborted that ends a turn whose continuation
1244
+ * was dropped before it started, other than by an abandon or a stop. */
1245
+ const CONTINUATION_DROPPED = "a turn's continuation was dropped before it started";
1246
+
1242
1247
  /** One frame's window while it holds a turn with no stream and one of its
1243
1248
  * checks is ahead (AgentFramework.checkAhead). `abandoned` is an operator's
1244
1249
  * quiesce abandon that reached the turn there, which the frame's check
@@ -1603,14 +1608,26 @@ export interface HostModeStatus {
1603
1608
  quiesced: boolean;
1604
1609
  reason?: string;
1605
1610
  since?: number;
1606
- /** True when no turn is alive (activeTurnTokens empty — the token spans
1607
- * dequeue → settled teardown, strictly wider than activeStreams). */
1611
+ /** True when no turn is alive: no turn token (the token spans dequeue →
1612
+ * settled teardown, strictly wider than activeStreams) and no continuation
1613
+ * of a turn on its way to its start (a budget restart's or a tool-result
1614
+ * guard's retry, queued, held by a provider hold, parked or in a scheduler
1615
+ * pass), and no wake parked on provider admission. It covers the
1616
+ * framework's own turns: a caller of the direct Agent API
1617
+ * (`Agent.runInference`) owns its turns' timing. */
1608
1618
  drained: boolean;
1609
1619
  activeTurns: number;
1620
+ /** The agents with a turn alive, the ones `activeTurns` counts, sorted:
1621
+ * each from its turn's dequeue to its settled teardown, so a turn in its
1622
+ * hooks or compile, one finishing its deliveries, or one whose continuation
1623
+ * is on its way to its start, is listed. These are the agents an abandon or
1624
+ * a stop() made now would find mid-turn. */
1625
+ activeTurnAgents: string[];
1610
1626
  /** Wakes that own provider admission while waiting for an in-flight
1611
1627
  * auxiliary call — no turn token yet, but a turn is one settle away. The
1612
1628
  * admission continuation rechecks quiesce and requeues them; `drained`
1613
- * is false until it has. */
1629
+ * is false until it has. A parked continuation of a turn is counted here
1630
+ * and, as a turn alive, in `activeTurns` and `activeTurnAgents`. */
1614
1631
  parkedAdmissions: number;
1615
1632
  /** Inference requests parked by the quiesce wake gate (while quiesced), or
1616
1633
  * simply queued (while serving — non-zero right after a resume until the
@@ -1832,6 +1849,9 @@ interface ProviderAccelerationCooldown {
1832
1849
  /** Armed by an auxiliary (compression) failure with no primary involved:
1833
1850
  * release reopens auxiliary admission without synthesising an inference. */
1834
1851
  auxiliaryOrigin?: boolean;
1852
+ /** Its held requests were taken out by dropContinuations: release reopens
1853
+ * admission without synthesising an inference in their place. */
1854
+ emptiedByDrop?: boolean;
1835
1855
  /** Set when this hold is a provider wait (a stated retry-after) for the
1836
1856
  * agent's primary model: it holds primary turns only. Auxiliary calls are
1837
1857
  * admitted per model by the provider waits themselves, so a compression
@@ -2065,8 +2085,8 @@ const SCRIPT_TEXT_ANSWER_ENVELOPE_BYTES = Buffer.byteLength(
2065
2085
  * memoryMaxBytes. So the figure moves with memoryMaxBytes. What it can't
2066
2086
  * count: a result's `structured` value, which the measure leaves out and the
2067
2087
  * answer carries as JSON beside the content, and that result's blocks past
2068
- * their text, which the answer carries as they are (each further block's
2069
- * keys, an image's data); nesting past 2,000 levels; and a
2088
+ * their text, which the answer carries as the script lane gives them (each
2089
+ * further block's keys, an image's data); nesting past 2,000 levels; and a
2070
2090
  * parse that needs more of the script's memory than is free, as for JSON
2071
2091
  * dense in small objects or a script already holding most of its memory.
2072
2092
  * Such a result, or one past the figure that doesn't fit, reaches the script
@@ -2378,6 +2398,11 @@ type McpRestartClaim = { readonly restarting: string; endedBy?: string };
2378
2398
  /** Why the framework cancelled a stream it owns: the mark it records, by
2379
2399
  * agent and stream, before the cancel, for the stream's terminal to read. */
2380
2400
  type FrameworkCancelKind = 'turn_ended' | 'budget_restart' | 'quiesce_abandoned' | 'shutdown';
2401
+ /** How a turn whose continuation is dropped before it starts ends: an
2402
+ * operator's quiesce abandon or stop() settles it as a stop ends a turn's
2403
+ * start; any other drop ends it as a start dropped before it sends anything.
2404
+ * `why` is said in the line its end logs. */
2405
+ type ContinuationDrop = { kind: 'quiesce_abandoned' | 'shutdown' | 'dropped'; why: string };
2381
2406
 
2382
2407
  /** What opened a channel the agent didn't ask to open
2383
2408
  * (recordChannelAutoOpenNotice). */
@@ -2808,6 +2833,15 @@ export class AgentFramework {
2808
2833
  // and its settle except that turn's own blocks, and, before its checkpoint,
2809
2834
  // the inputs its start writes (flushForStart).
2810
2835
  private activeTurnTokens: Map<string, number> = new Map();
2836
+ /** Each running scheduler pass's requests, which it took from
2837
+ * pendingRequests: it holds those of the agents it hasn't taken up yet
2838
+ * while it awaits an earlier agent's start, so a continuation among them
2839
+ * is still on its way (pendingContinuationAgents). */
2840
+ private passRequests: Set<Map<string, InferenceRequest[]>> = new Set();
2841
+ /** Continuations whose start waits on provider admission behind an
2842
+ * auxiliary call, in no queue meanwhile. A drop takes one out and gives
2843
+ * its admission back; the park, woken, then finds it gone. */
2844
+ private parkedContinuations: Set<InferenceRequest> = new Set();
2811
2845
  /** Each turn whose settle has ended it (settleAgent, endDroppedTurn), for
2812
2846
  * its agent's scripts: its start's teardown can still hold the agent's
2813
2847
  * token, and a turn that has ended isn't live (liveTurns). A later start
@@ -2951,9 +2985,17 @@ export class AgentFramework {
2951
2985
  // config.codeExecution.enabled: its settings, and every agent's scripts,
2952
2986
  // each in a process of its own on the engine (AgentScripts). Waiters
2953
2987
  // resolve script-inner tool calls dispatched through the normal
2954
- // dispatchToolCall path.
2988
+ // dispatchToolCall path. A waiter's route is where the dispatch sent its
2989
+ // call: `toServer` is set when it went to a server's own tool (an MCPL or
2990
+ // MCP-only peer's), so the script lane's exit treats every other result as
2991
+ // a module's (scriptResultAnswer), by where the call went rather than by a
2992
+ // name lookup a connect or disconnect can change while it runs. A
2993
+ // framework tool that talks to a server itself, such as a channel tool,
2994
+ // builds its own result, which counts as a module's. A caller of
2995
+ // dispatchScriptToolCall that passes no route gets one left false, the
2996
+ // reading that withholds more.
2955
2997
  private codeExecution: { settings: IsolateSettings; scripts: AgentScripts } | null = null;
2956
- private scriptToolWaiters: Map<string, (result: ToolResult) => void> = new Map();
2998
+ private scriptToolWaiters: Map<string, { resolve: (result: ToolResult) => void; route: { toServer: boolean } }> = new Map();
2957
2999
  /** Per agent, a round that called code_execution and waits for those calls'
2958
3000
  * results before its prose is routed (ScriptRound). */
2959
3001
  private scriptRounds: Map<string, ScriptRound> = new Map();
@@ -4005,6 +4047,16 @@ export class AgentFramework {
4005
4047
  // framework must not keep up to the whole ledger budget referenced.
4006
4048
  this.toolImageLedgers.clear();
4007
4049
 
4050
+ // A turn whose continuation is still on its way ends with the stop, as
4051
+ // a turn's start a stop reaches does: the continuation would start only
4052
+ // after a start(), in a lifecycle where its logical turn is gone
4053
+ // (openLogical, below). Before the holds and the parks go, so the
4054
+ // continuation leaves them with its turn.
4055
+ step(() => {
4056
+ for (const agentName of this.pendingContinuationAgents()) {
4057
+ this.dropContinuations(agentName, { kind: 'shutdown', why: 'the framework is stopping' });
4058
+ }
4059
+ });
4008
4060
  // A stopped host must never hang behind its own cooldown.
4009
4061
  step(() => this.cancelProviderAdmission());
4010
4062
  // An open logical turn doesn't survive a stop: a start after a restart
@@ -4660,7 +4712,8 @@ export class AgentFramework {
4660
4712
  // The stated wait (recorded above for agent.model) set this hold's
4661
4713
  // length: an operator release of it returns the hold to AF's own pacing.
4662
4714
  ...(pacing?.stated ? { statedWait: { model: agent.model, ownUntil: now + pacing.ownMs } } : {}),
4663
- ...(auxiliary && (!existing || existing.auxiliaryOrigin) ? { auxiliaryOrigin: true } : {}) });
4715
+ ...(auxiliary && (!existing || existing.auxiliaryOrigin) ? { auxiliaryOrigin: true } : {}),
4716
+ ...(existing?.emptiedByDrop && held.length === 0 ? { emptiedByDrop: true } : {}) });
4664
4717
  if (auxiliary && !existing) {
4665
4718
  console.error(`[provider-cooldown] agent=${agent.name} host hold (${hostHold!.reason}) from an auxiliary call — ` +
4666
4719
  `holding primary/auxiliary for ${delayMs}ms`);
@@ -4767,9 +4820,12 @@ export class AgentFramework {
4767
4820
  ? `no longer holds primary turns: the primary model is now ${model} (the wait stays recorded)`
4768
4821
  : `primary hold ended after ${Date.now() - cooldown.startedAt}ms`));
4769
4822
  }
4770
- if (cooldown.auxiliaryOrigin && cooldown.heldRequests.length === 0) {
4771
- // Nobody asked for an inference while parked: just reopen the gate.
4772
- console.error(`[provider-cooldown] agent=${agentName} auxiliary hold released after ${Date.now() - cooldown.startedAt}ms`);
4823
+ if ((cooldown.auxiliaryOrigin || cooldown.emptiedByDrop) && cooldown.heldRequests.length === 0) {
4824
+ // Nobody asked for an inference while parked, or what was held was a
4825
+ // continuation since dropped with its turn: just reopen the gate. A
4826
+ // primary the hold left pending won't run, so its recovery ends.
4827
+ console.error(`[provider-cooldown] agent=${agentName} ${cooldown.auxiliaryOrigin ? 'auxiliary hold' : 'hold, its held continuation dropped,'} released after ${Date.now() - cooldown.startedAt}ms`);
4828
+ if (cooldown.emptiedByDrop) this.endPendingPrimary(agentName);
4773
4829
  this.flushAuxiliaryAdmission(agentName);
4774
4830
  return;
4775
4831
  }
@@ -6446,14 +6502,54 @@ export class AgentFramework {
6446
6502
  return n;
6447
6503
  }
6448
6504
 
6505
+ /**
6506
+ * A continuation the framework makes for a turn (a budget restart's, which
6507
+ * it queues, or a tool-result guard's retry, which its frame starts at
6508
+ * once) holds no turn token until its start takes one. Once its turn's
6509
+ * frame has let go of its token, its start can wait in the queue, a
6510
+ * provider hold (a retry too, when a wait holds it), a park on admission
6511
+ * or a scheduler pass's hands. Its turn is alive meanwhile. These are the
6512
+ * agents with one on its way. A
6513
+ * `tool_results_ready` continuation follows a tool round left waiting with
6514
+ * no stream, which only the direct Agent API makes (Agent's
6515
+ * runInferenceWithInjections; the framework's own rounds wait with their
6516
+ * stream, enterWaitingForTools). Its caller owns that turn's timing, so it
6517
+ * isn't counted.
6518
+ */
6519
+ private pendingContinuationAgents(): Set<string> {
6520
+ const agents = new Set<string>();
6521
+ const add = (r: InferenceRequest): void => { if (resumesLogicalTurn(r.reason)) agents.add(r.agentName); };
6522
+ // Read defensively: a framework built from the prototype, as the
6523
+ // operator tests build one, sets none of these.
6524
+ for (const r of this.pendingRequests ?? []) add(r);
6525
+ for (const cooldown of this.providerAccelerationCooldowns?.values() ?? []) for (const r of cooldown.heldRequests) add(r);
6526
+ for (const r of this.parkedContinuations ?? []) add(r);
6527
+ for (const pass of this.passRequests ?? []) for (const requests of pass.values()) for (const r of requests) add(r);
6528
+ return agents;
6529
+ }
6530
+
6531
+ /** The agents with a turn alive: holding a turn token, or with a
6532
+ * continuation of their turn on its way. */
6533
+ private turnHolders(): Set<string> {
6534
+ const holders = this.pendingContinuationAgents();
6535
+ for (const name of this.activeTurnTokens.keys()) holders.add(name);
6536
+ return holders;
6537
+ }
6538
+
6539
+ private hasTurn(agentName: string): boolean {
6540
+ return this.activeTurnTokens.has(agentName) || this.pendingContinuationAgents().has(agentName);
6541
+ }
6542
+
6449
6543
  getHostModeStatus(): HostModeStatus {
6450
6544
  const parkedAdmissions = this.parkedAdmissionCount();
6545
+ const holders = this.turnHolders();
6451
6546
  return {
6452
6547
  quiesced: this.quiesced,
6453
6548
  ...(this.quiesceReason ? { reason: this.quiesceReason } : {}),
6454
6549
  ...(this.quiescedAt !== undefined ? { since: this.quiescedAt } : {}),
6455
- drained: this.activeTurnTokens.size === 0 && parkedAdmissions === 0,
6456
- activeTurns: this.activeTurnTokens.size,
6550
+ drained: holders.size === 0 && parkedAdmissions === 0,
6551
+ activeTurns: holders.size,
6552
+ activeTurnAgents: [...holders].sort(),
6457
6553
  parkedAdmissions,
6458
6554
  gatedRequests: this.pendingRequests.filter((r) => !isTurnContinuation(r.reason)).length,
6459
6555
  backgroundScripts: this.codeExecution?.scripts.leftRunning() ?? 0,
@@ -6472,8 +6568,9 @@ export class AgentFramework {
6472
6568
  /**
6473
6569
  * Enter quiesce mode: write the deferred-write queue, persist the flag,
6474
6570
  * suppress gate deliveries, pause all MCPL data planes, and wait for
6475
- * in-flight turns to settle (turn-alive is `activeTurnTokens`, which spans
6476
- * dequeue → settled teardown). Idempotent, but for the queue's write, which
6571
+ * in-flight turns to settle (turn-alive is a turn token, which spans
6572
+ * dequeue → settled teardown, or a continuation of the turn on its way to
6573
+ * its start: turnHolders). Idempotent, but for the queue's write, which
6477
6574
  * every call makes first: when the queue can't reach disk, because its
6478
6575
  * write fails or it's over the cap, the call rejects with a
6479
6576
  * QuiesceBlockedError and the host's mode as it was, unless it has
@@ -6483,7 +6580,10 @@ export class AgentFramework {
6483
6580
  * On drain timeout the host STAYS quiesced (`drained: false` in the
6484
6581
  * result); with `abandon: true` the undrained streams are cancelled via a
6485
6582
  * dedicated cancel kind that settles the turn without feeding the
6486
- * inference-failure accounting.
6583
+ * inference-failure accounting, and a turn whose continuation hasn't
6584
+ * started ends with the continuation dropped. Drain covers the framework's
6585
+ * own turns: a caller of the direct Agent API (`Agent.runInference`) owns
6586
+ * its turns' timing.
6487
6587
  */
6488
6588
  async quiesce(opts?: {
6489
6589
  reason?: string;
@@ -6509,13 +6609,13 @@ export class AgentFramework {
6509
6609
  // out undrained must be escalatable with a second quiesce({abandon})
6510
6610
  // without resume()+re-quiesce (which would reopen data planes and
6511
6611
  // release parked wakes mid-surgery).
6512
- if (opts?.abandon && this.activeTurnTokens.size > 0) {
6612
+ if (opts?.abandon && this.turnHolders().size > 0) {
6513
6613
  const unabandonable = await this.abandonActiveTurns();
6514
6614
  this.emitTrace({
6515
6615
  type: 'host:quiesce',
6516
6616
  ...(this.quiesceReason ? { reason: this.quiesceReason } : {}),
6517
- drained: this.activeTurnTokens.size === 0,
6518
- activeTurns: this.activeTurnTokens.size,
6617
+ drained: this.turnHolders().size === 0,
6618
+ activeTurns: this.turnHolders().size,
6519
6619
  abandoned: true,
6520
6620
  ...(unabandonable.length > 0 ? { unabandonable } : {}),
6521
6621
  });
@@ -6541,7 +6641,7 @@ export class AgentFramework {
6541
6641
  this.lastUnabandonable = [];
6542
6642
  console.error(
6543
6643
  `[host-mode] quiescing${opts?.reason ? ` (${opts.reason})` : ''}: parking wakes, ` +
6544
- `pausing MCPL data planes, draining ${this.activeTurnTokens.size} in-flight turn(s)`,
6644
+ `pausing MCPL data planes, draining ${this.turnHolders().size} in-flight turn(s)`,
6545
6645
  );
6546
6646
  this.eventGate?.setQuiesced(true);
6547
6647
  for (const connection of this.mcplServerRegistry?.getAllServers() ?? []) {
@@ -6567,7 +6667,7 @@ export class AgentFramework {
6567
6667
  while (
6568
6668
  this.quiesced &&
6569
6669
  !this.queue?.isClosed &&
6570
- (this.activeTurnTokens.size > 0 || this.parkedAdmissionCount() > 0) &&
6670
+ (this.turnHolders().size > 0 || this.parkedAdmissionCount() > 0) &&
6571
6671
  Date.now() < deadline
6572
6672
  ) {
6573
6673
  await new Promise((resolve) => setTimeout(resolve, 250));
@@ -6591,7 +6691,7 @@ export class AgentFramework {
6591
6691
 
6592
6692
  let abandoned = false;
6593
6693
  let unabandonable: string[] = [];
6594
- if (this.activeTurnTokens.size > 0 && opts?.abandon) {
6694
+ if (this.turnHolders().size > 0 && opts?.abandon) {
6595
6695
  abandoned = true;
6596
6696
  unabandonable = await this.abandonActiveTurns();
6597
6697
  if (!this.quiesced) return this.getHostModeStatus();
@@ -6624,7 +6724,18 @@ export class AgentFramework {
6624
6724
  * the already-quiesced escalation path. */
6625
6725
  private async abandonActiveTurns(): Promise<string[]> {
6626
6726
  const unabandonable: string[] = [];
6727
+ // A turn whose continuation is still on its way ends with the
6728
+ // continuation dropped. Its stream has ended; a frame still letting go of
6729
+ // its token ends on its own, and settles nothing, since it handed the turn
6730
+ // to the continuation.
6731
+ const handedOff = new Set<string>();
6732
+ for (const agentName of this.pendingContinuationAgents()) {
6733
+ console.error(`[host-mode] abandoning ${agentName}'s turn before its continuation starts: the continuation is dropped`);
6734
+ this.dropContinuations(agentName, { kind: 'quiesce_abandoned', why: 'its turn was abandoned' });
6735
+ handedOff.add(agentName);
6736
+ }
6627
6737
  for (const agentName of [...this.activeTurnTokens.keys()]) {
6738
+ if (handedOff.has(agentName)) continue;
6628
6739
  const agent = this.agents.get(agentName);
6629
6740
  const state = agent?.state;
6630
6741
  const stream = state && 'stream' in state ? state.stream : undefined;
@@ -8698,6 +8809,9 @@ export class AgentFramework {
8698
8809
  this.activeTurnTokens.delete(agent.name);
8699
8810
  this.activeTurnTriggers.delete(agent.name);
8700
8811
  this.logicalTurnToolCalls.delete(agent);
8812
+ // A continuation of its turn still on its way goes too: the run, and
8813
+ // with it the turn, has ended above.
8814
+ this.dropContinuations(agent.name, null);
8701
8815
  }
8702
8816
  }
8703
8817
 
@@ -10503,7 +10617,7 @@ export class AgentFramework {
10503
10617
  const busy: string[] = [];
10504
10618
  for (const [name, a] of this.agents) {
10505
10619
  if (a.state.status !== 'idle') busy.push(`${name} is ${a.state.status}`);
10506
- else if (this.activeTurnTokens.has(name)) busy.push(`${name} is idle+turn-alive`);
10620
+ else if (this.hasTurn(name)) busy.push(`${name} is idle+turn-alive`);
10507
10621
  }
10508
10622
  for (const name of this.activeTurnTokens.keys()) {
10509
10623
  if (!this.agents.has(name)) busy.push(`${name} is turn-alive`);
@@ -11660,7 +11774,7 @@ export class AgentFramework {
11660
11774
  // was killed while the call ran: the result reaches no one, but the
11661
11775
  // call did finish.
11662
11776
  this.toolLifecycleEmitter?.onResult(event.agentName, event.callId, event.result);
11663
- scriptWaiter(event.result);
11777
+ scriptWaiter.resolve(event.result);
11664
11778
  return;
11665
11779
  }
11666
11780
 
@@ -12105,7 +12219,25 @@ export class AgentFramework {
12105
12219
  this.streamBoundaryEnds.get(currentState.stream)?.('turn_ended');
12106
12220
  }
12107
12221
  this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name });
12222
+ } else if ((overBudget || overPhysical) && this.providerAdmissionClosed) {
12223
+ // A stopping framework starts no continuation: the turn ends with
12224
+ // its stream, as stop() ends a live stream's turn.
12225
+ if (currentState.stream) this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'shutdown');
12226
+ agent.cancelStream();
12108
12227
  } else if (overBudget || overPhysical) {
12228
+ // The continuation is queued, and the stream cancelled, before the
12229
+ // boundary's usage report and the restart's trace, so a listener
12230
+ // that stops or quiesces the host on either finds the turn waiting
12231
+ // for its continuation, with nothing left of the stream to end.
12232
+ const continuation: InferenceRequest = {
12233
+ agentName: agent.name,
12234
+ reason: 'context_budget_restart',
12235
+ source: 'framework',
12236
+ timestamp: Date.now(),
12237
+ suppressProse: this.activeTurnTriggers.get(agent.name)?.suppressProse,
12238
+ silentHeartbeat: this.activeTurnTriggers.get(agent.name)?.silentHeartbeat,
12239
+ };
12240
+ this.pendingRequests.push(continuation);
12109
12241
  // Context budget exceeded: break the stream, let compile() compress.
12110
12242
  // Mark the cancel as framework-initiated BEFORE cancelling: the
12111
12243
  // membrane delivers `aborted` before the restart bumps streamId,
@@ -12113,13 +12245,19 @@ export class AgentFramework {
12113
12245
  // a terminal failure — rejecting an ephemeral's promise mid-run
12114
12246
  // and emitting a spurious inference:exhausted (same race shape as
12115
12247
  // endTurn above).
12116
- if (currentState.stream) {
12117
- this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'budget_restart');
12118
- // Its rounds were billed; the restart opens a fresh stream
12119
- // with its own usage.
12120
- this.streamBoundaryEnds.get(currentState.stream)?.(overBudget ? 'context_budget' : 'physical_window');
12248
+ if (currentState.stream) this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'budget_restart');
12249
+ try {
12250
+ agent.cancelStream();
12251
+ } catch (error) {
12252
+ // The cancel failed and the stream is still live, so the turn
12253
+ // has no continuation: take it back out of the queue.
12254
+ this.pendingRequests = this.pendingRequests.filter((r) => r !== continuation);
12255
+ throw error;
12256
+ } finally {
12257
+ // Its rounds were billed, even when the cancel throws; the
12258
+ // restart opens a fresh stream with its own usage.
12259
+ if (currentState.stream) this.streamBoundaryEnds.get(currentState.stream)?.(overBudget ? 'context_budget' : 'physical_window');
12121
12260
  }
12122
- agent.cancelStream();
12123
12261
  this.emitTrace({
12124
12262
  type: 'inference:stream_restarted',
12125
12263
  agentName: agent.name,
@@ -12130,14 +12268,6 @@ export class AgentFramework {
12130
12268
  inputTokens: overBudget ? agent.lastStreamInputTokens : projectedRealTokens,
12131
12269
  budget: overBudget ? agent.maxStreamTokens : (agent.physicalWindowTokens ?? 0),
12132
12270
  });
12133
- this.pendingRequests.push({
12134
- agentName: agent.name,
12135
- reason: 'context_budget_restart',
12136
- source: 'framework',
12137
- timestamp: Date.now(),
12138
- suppressProse: this.activeTurnTriggers.get(agent.name)?.suppressProse,
12139
- silentHeartbeat: this.activeTurnTriggers.get(agent.name)?.silentHeartbeat,
12140
- });
12141
12271
  } else if (currentState.stream) {
12142
12272
  // What this round appends is in the next call's prompt. If that
12143
12273
  // call reports no prompt, the next boundary projects it from the
@@ -12987,6 +13117,8 @@ export class AgentFramework {
12987
13117
  */
12988
13118
  private disposeConversationAgent(agentName: string): void {
12989
13119
  this.closingConversationAgents.delete(agentName);
13120
+ // A continuation of its turn still on its way goes with it.
13121
+ this.dropContinuations(agentName, { kind: 'dropped', why: 'its agent was disposed' });
12990
13122
  const channelId = this.conversationAgentHomes.get(agentName);
12991
13123
  const agent = this.agents.get(agentName);
12992
13124
  // A fork stop() ends can still be running: its stream, its tool round.
@@ -13519,9 +13651,18 @@ export class AgentFramework {
13519
13651
  recordReceipt: (record) => {
13520
13652
  // Written now, not at the throttled flush: an acknowledged acceptance
13521
13653
  // must not lose its receipt, the subject's birth, or (deferred) the
13522
- // accepted work itself to a crash. A failure here fails the acceptance.
13654
+ // accepted work itself to a crash. A failure here fails the answer:
13655
+ // the coalescer keeps the receipt, and the producer's retry writes it
13656
+ // again, with the subject's state as it is then. So a record whose
13657
+ // write failed leaves the list: a later write would carry it after
13658
+ // the batch it holds had moved on.
13523
13659
  this.coalescingRecentReceipts.push(record);
13524
- this.store.setStateJson(COALESCING_RECENT_ID, this.coalescingRecentReceipts);
13660
+ try {
13661
+ this.store.setStateJson(COALESCING_RECENT_ID, this.coalescingRecentReceipts);
13662
+ } catch (error) {
13663
+ this.coalescingRecentReceipts.pop();
13664
+ throw error;
13665
+ }
13525
13666
  },
13526
13667
  // The acceptance of coalesced work (deferred included) is observed
13527
13668
  // here, once; its later delivery reuses the frozen envelope.
@@ -13628,6 +13769,9 @@ export class AgentFramework {
13628
13769
  this.store.setStateJson(COALESCING_RECENT_ID, []);
13629
13770
  } catch (err) {
13630
13771
  console.error('[coalescing] could not persist coalescing state:', err);
13772
+ // Still unsaved: the next persist, or the stop's flush, writes it. No
13773
+ // timer of its own, so a store that keeps failing isn't retried here.
13774
+ this.coalescingSnapshotDirty ??= snapshot;
13631
13775
  }
13632
13776
  }
13633
13777
 
@@ -14253,17 +14397,28 @@ export class AgentFramework {
14253
14397
  return undefined;
14254
14398
  }
14255
14399
 
14400
+ /** One scheduler pass, whose requests stay visible as on their way until
14401
+ * it takes their agent up (passRequests). */
14256
14402
  private async processInferenceRequests(): Promise<void> {
14257
14403
  if (this.pendingRequests.length === 0) {
14258
14404
  return;
14259
14405
  }
14406
+ const requestsByAgent = new Map<string, InferenceRequest[]>();
14407
+ this.passRequests?.add(requestsByAgent);
14408
+ try {
14409
+ await this.runInferencePass(requestsByAgent);
14410
+ } finally {
14411
+ this.passRequests?.delete(requestsByAgent);
14412
+ }
14413
+ }
14414
+
14415
+ private async runInferencePass(requestsByAgent: Map<string, InferenceRequest[]>): Promise<void> {
14260
14416
 
14261
14417
  const STALE_REQUEST_MS = 30_000;
14262
14418
  const now = Date.now();
14263
14419
  const state = this.createFrameworkState();
14264
14420
 
14265
14421
  // Group requests by agent
14266
- const requestsByAgent = new Map<string, InferenceRequest[]>();
14267
14422
  for (const req of this.pendingRequests) {
14268
14423
  const existing = requestsByAgent.get(req.agentName) ?? [];
14269
14424
  existing.push(req);
@@ -14276,6 +14431,13 @@ export class AgentFramework {
14276
14431
  // Check each agent
14277
14432
  for (const [agentName, groupedRequests] of requestsByAgent) {
14278
14433
  let requests = groupedRequests;
14434
+ // Taken up now: from here to its start's turn token (or its hold or
14435
+ // park) nothing awaits, so the requests leave the pass's map, which
14436
+ // shows only what the pass still holds while it awaits a start.
14437
+ requestsByAgent.delete(agentName);
14438
+ // A drop while this pass awaited an earlier agent's start can have
14439
+ // taken every request of this agent's (dropContinuations).
14440
+ if (requests.length === 0) continue;
14279
14441
  const agent = this.agents.get(agentName);
14280
14442
  if (!agent) {
14281
14443
  // Agent not found — request is orphaned. Emit warning and drop.
@@ -14288,6 +14450,10 @@ export class AgentFramework {
14288
14450
  oldestRequestAge: now - oldest,
14289
14451
  });
14290
14452
  console.error(`[inference-dropped] agent=${agentName} reason=agent_not_found requests=${requests.length}`);
14453
+ // A continuation among them leaves its turn to end here.
14454
+ if (requests.some((r) => resumesLogicalTurn(r.reason))) {
14455
+ this.endContinuedTurn(agentName, { kind: 'dropped', why: 'its agent is gone' });
14456
+ }
14291
14457
  continue;
14292
14458
  }
14293
14459
 
@@ -14377,7 +14543,10 @@ export class AgentFramework {
14377
14543
  requests.push(...passThrough);
14378
14544
  }
14379
14545
 
14380
- const turnAlive = !budgetRestart && this.activeTurnTokens.has(agentName);
14546
+ // Turn-alive includes a continuation of the agent's turn still on its
14547
+ // way elsewhere (a park, another pass's hands): a fresh wake waits for
14548
+ // it, as for a turn holding its token.
14549
+ const turnAlive = !budgetRestart && this.hasTurn(agentName);
14381
14550
  const providerGate = this.providerGates?.get(agentName);
14382
14551
  // A primary can own provider admission while yielding to an already in-flight
14383
14552
  // auxiliary call. Keep this agent's later wakes queued, but do not block the
@@ -14455,9 +14624,12 @@ export class AgentFramework {
14455
14624
  `[inference-dropped] agent=${agentName} reason=policy-skip ` +
14456
14625
  `requests=${requests.length} triggers=${requests.map((r) => r.reason).join(',')}`,
14457
14626
  );
14458
- // A context-budget restart inherits the predecessor's EventGate liveness.
14459
- // If policy drops the queued successor, no driveStream remains to release it.
14460
- if (budgetRestart) this.eventGate?.onInferenceEnded(agentName);
14627
+ // A continuation inherits its predecessor's EventGate liveness and
14628
+ // logical turn. If policy drops it, no driveStream remains to end them,
14629
+ // so its turn ends here (endContinuedTurn).
14630
+ if (requests.some((r) => resumesLogicalTurn(r.reason))) {
14631
+ this.endContinuedTurn(agentName, { kind: 'dropped', why: 'the inference policy dropped its continuation' });
14632
+ }
14461
14633
  this.endPendingPrimary(agentName);
14462
14634
  continue;
14463
14635
  }
@@ -16912,7 +17084,12 @@ export class AgentFramework {
16912
17084
  // Do not await inside processInferenceRequests: one slow auxiliary call for
16913
17085
  // this resident must not block events or other residents. Admission stays
16914
17086
  // owned and the continuation receives it after the in-flight call settles.
17087
+ const parkedContinuation = trigger !== undefined && resumesLogicalTurn(trigger.reason);
17088
+ if (parkedContinuation) this.parkedContinuations.add(trigger);
16915
17089
  void this.waitForAuxiliaryIdle(agent.name).then(async () => {
17090
+ // A continuation dropped while it was parked left the set then, with
17091
+ // its admission given back.
17092
+ if (parkedContinuation && !this.parkedContinuations.delete(trigger)) return;
16916
17093
  if (this.providerAdmissionClosed) {
16917
17094
  this.releasePrimaryProviderGate(agent.name);
16918
17095
  return;
@@ -17072,6 +17249,93 @@ export class AgentFramework {
17072
17249
  }
17073
17250
  }
17074
17251
 
17252
+ /**
17253
+ * Drop every continuation of `agentName`'s turn still on its way, wherever
17254
+ * it waits: the queue, a provider hold (which, left empty, then releases
17255
+ * with nothing to run), a park on admission (whose admission it gives
17256
+ * back), or a scheduler pass's hands (which then never reach it). If one
17257
+ * was there, the turn ends (endContinuedTurn), unless `drop` is null: the
17258
+ * caller has ended the turn already.
17259
+ */
17260
+ private dropContinuations(agentName: string, drop: ContinuationDrop | null): void {
17261
+ const isContinuation = (r: InferenceRequest): boolean => r.agentName === agentName && resumesLogicalTurn(r.reason);
17262
+ let dropped = false;
17263
+ const take = (requests: InferenceRequest[]): InferenceRequest[] => {
17264
+ const left = requests.filter((r) => !isContinuation(r));
17265
+ if (left.length < requests.length) dropped = true;
17266
+ return left;
17267
+ };
17268
+ this.pendingRequests = take(this.pendingRequests);
17269
+ const hold = this.providerAccelerationCooldowns.get(agentName);
17270
+ if (hold) {
17271
+ const held = hold.heldRequests.length;
17272
+ hold.heldRequests = take(hold.heldRequests);
17273
+ if (held > 0 && hold.heldRequests.length === 0) hold.emptiedByDrop = true;
17274
+ }
17275
+ for (const parked of [...this.parkedContinuations]) {
17276
+ if (!isContinuation(parked)) continue;
17277
+ this.parkedContinuations.delete(parked);
17278
+ this.releasePrimaryProviderGate(agentName);
17279
+ dropped = true;
17280
+ }
17281
+ for (const pass of this.passRequests) {
17282
+ const requests = pass.get(agentName);
17283
+ if (!requests) continue;
17284
+ const left = take(requests);
17285
+ requests.length = 0;
17286
+ requests.push(...left);
17287
+ }
17288
+ if (!dropped || !drop) return;
17289
+ // A primary a provider hold left pending for what it released won't run
17290
+ // now, when nothing else of the agent's waits (endPendingPrimary).
17291
+ if (!this.pendingRequests.some((r) => r.agentName === agentName) && !this.providerAccelerationCooldowns.get(agentName)?.heldRequests.length) {
17292
+ this.endPendingPrimary(agentName);
17293
+ }
17294
+ this.endContinuedTurn(agentName, drop);
17295
+ }
17296
+
17297
+ /**
17298
+ * End a turn whose continuation won't run. The frame that handed the turn
17299
+ * to it kept the turn open for it: its logical turn, the EventGate's
17300
+ * window, the tool-result guard's batch and recovery, its typing. An
17301
+ * abandon or a stop of a registered agent settles the turn as it settles a
17302
+ * turn's start (endStoppedStart: one inference:aborted, the turn settled,
17303
+ * the gate released). Any other drop ends it as a start dropped before it
17304
+ * sends anything ends. That frame, if it hasn't let go of its token yet,
17305
+ * ends on its own and settles nothing. What was deferred for the agent
17306
+ * lands now, or at that frame's end.
17307
+ */
17308
+ private endContinuedTurn(agentName: string, drop: ContinuationDrop): void {
17309
+ console.error(`[turn-continuation] ${agentName}: its turn ends without its continuation: ${drop.why}`);
17310
+ const agent = this.agents.get(agentName);
17311
+ const startedAt = this.lastInferenceAt.get(agentName)?.startedAt ?? Date.now();
17312
+ if (agent && drop.kind !== 'dropped') {
17313
+ const routing = this.openLogical.get(agentName) ?? this.routingOf(agentName);
17314
+ this.endStoppedStart(agent, { kind: drop.kind }, routing, startedAt, undefined, false);
17315
+ } else {
17316
+ const owner = this.activeTurnTokens.get(agentName);
17317
+ this.abortDroppedTurn(agentName, owner ?? -1, drop.kind === 'dropped' ? CONTINUATION_DROPPED : drop.kind, startedAt);
17318
+ this.channelRegistry?.stopTyping();
17319
+ if (agent) {
17320
+ agent.toolResultGuard.recovering = false;
17321
+ if (agent.toolResultGuard.hasPending) {
17322
+ try {
17323
+ agent.toolResultGuard.abandon('aborted');
17324
+ } catch (error) {
17325
+ console.error(`[tool-result-guard] agent=${agentName} failed to record aborted batch:`, error);
17326
+ }
17327
+ }
17328
+ }
17329
+ const routing = this.openLogical.get(agentName) ?? this.turnRouting.get(agentName);
17330
+ if (routing) this.endDroppedTurn(agentName, routing);
17331
+ this.eventGate?.onInferenceEnded(agentName);
17332
+ }
17333
+ if (!this.activeTurnTokens.has(agentName) && this.deferredMessages.length > 0 && !this.pendingAssistantBlocks.has(agentName)) {
17334
+ this.readdDrained(this.drainDeferredFor(agentName), 'continuation-drop flush');
17335
+ this.ackDeferredWrites();
17336
+ }
17337
+ }
17338
+
17075
17339
  /**
17076
17340
  * End a turn that beginAgentTurn drops before it sends anything. The turn
17077
17341
  * emitted inference:started, and listeners that bracket a turn, such as a
@@ -17122,7 +17386,7 @@ export class AgentFramework {
17122
17386
  */
17123
17387
  private endStoppedStart(
17124
17388
  agent: Agent,
17125
- stop: { kind: 'quiesce_abandoned' } | { kind: 'caller'; reason?: string },
17389
+ stop: { kind: 'quiesce_abandoned' | 'shutdown' } | { kind: 'caller'; reason?: string },
17126
17390
  routing: TurnRouting,
17127
17391
  turnStartedAt: number,
17128
17392
  compiledRequest: NormalizedRequest | undefined,
@@ -17132,7 +17396,7 @@ export class AgentFramework {
17132
17396
  * so the note says it was stopped before it finished. */
17133
17397
  opensTurn: boolean,
17134
17398
  ): void {
17135
- console.error(`[turn] ${agent.name}: ${stop.kind === 'caller' ? 'a stop' : 'a quiesce abandon'} reached the turn's start before it ${opensTurn ? 'sent anything' : 'finished'}; it ends there`);
17399
+ console.error(`[turn] ${agent.name}: ${stop.kind === 'caller' ? 'a stop' : stop.kind === 'shutdown' ? "the framework's stop" : 'a quiesce abandon'} reached the turn's start before it ${opensTurn ? 'sent anything' : 'finished'}; it ends there`);
17136
17400
  this.channelRegistry?.stopTyping();
17137
17401
  // A start that continues a turn (a budget or guard restart) follows a
17138
17402
  // frame that kept the turn's tool-result batch, and the guard's recovery,
@@ -17160,7 +17424,7 @@ export class AgentFramework {
17160
17424
  if (stop.kind === 'caller') {
17161
17425
  this.settleDeliberateCancel({ ...common, reason: stop.reason ?? 'user', before: opensTurn ? 'sending' : 'finishing' });
17162
17426
  } else {
17163
- this.settleFrameworkCancel({ ...common, kind: 'quiesce_abandoned' });
17427
+ this.settleFrameworkCancel({ ...common, kind: stop.kind });
17164
17428
  }
17165
17429
  const last = this.lastInferenceAt.get(agent.name);
17166
17430
  if (last?.startedAt === turnStartedAt) this.lastInferenceAt.set(agent.name, { ...last, endedAt: Date.now() });
@@ -17889,8 +18153,8 @@ export class AgentFramework {
17889
18153
  if (turnEnded) this.recordProviderAccelerationRecovery(agent, compiledRequest, end);
17890
18154
  // The accounting writes to the store, and a write that fails must not
17891
18155
  // skip what the boundary does after this call: the rest of a turn's
17892
- // end, or a restart's cancel and its request, and then the event's
17893
- // dispatch to modules.
18156
+ // end, or a restart's trace (its cancel and its request come before
18157
+ // this call), and then the event's dispatch to modules.
17894
18158
  try {
17895
18159
  if (turnEnded) {
17896
18160
  completeTurn(latestUsage, durationMs);
@@ -19891,7 +20155,11 @@ export class AgentFramework {
19891
20155
  // its ids when a successor turn has already taken the agent. A fork or
19892
20156
  // run deregistered while the host is quiesced leaves its queue to its
19893
20157
  // window's release at resume (drainDeferredFor).
19894
- if (frameReachedTerminal && this.deferredMessages.length > 0 && !this.pendingAssistantBlocks.has(agent.name)) {
20158
+ // A frame that handed its turn to a continuation since dropped
20159
+ // (endContinuedTurn, which couldn't land it while this frame held the
20160
+ // token) lands it too: the turn is over.
20161
+ const handedOffTurnOver = preserveEventGateForSuccessor && !this.hasTurn(agent.name);
20162
+ if ((frameReachedTerminal || handedOffTurnOver) && this.deferredMessages.length > 0 && !this.pendingAssistantBlocks.has(agent.name)) {
19895
20163
  this.readdDrained(this.drainDeferredFor(agent.name), 'turn-end flush');
19896
20164
  this.ackDeferredWrites();
19897
20165
  }
@@ -20007,7 +20275,7 @@ export class AgentFramework {
20007
20275
  `puppet refused: the store is under live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName} — retry when it completes`,
20008
20276
  );
20009
20277
  }
20010
- if (agent.state.status !== 'idle' || this.activeTurnTokens.has(agentName)) {
20278
+ if (agent.state.status !== 'idle' || this.hasTurn(agentName)) {
20011
20279
  const shown = agent.state.status === 'idle' ? 'idle+turn-alive' : agent.state.status;
20012
20280
  throw new Error(
20013
20281
  `puppet refused: agent ${agentName} is ${shown} (requires idle — ` +
@@ -20958,9 +21226,10 @@ export class AgentFramework {
20958
21226
  if (!roundCalls.some((c) => c.name === counted.name && c.cause === counted.cause && c.refused === counted.refused)) roundCalls.push(counted);
20959
21227
  }
20960
21228
  if (refusal !== undefined) return refused(refusal);
21229
+ const route = { toServer: false };
20961
21230
  const result = await this.dispatchScriptToolCall(
20962
21231
  agentName, toolName, input as Record<string, unknown>,
20963
- context.origin as ScriptCallOrigin | undefined, context.watch?.turnId,
21232
+ context.origin as ScriptCallOrigin | undefined, context.watch?.turnId, route,
20964
21233
  );
20965
21234
  // Deferred: applied to the result of the code_execution call watching
20966
21235
  // the script — ending the turn mid-script would cancel the stream while
@@ -20971,7 +21240,7 @@ export class AgentFramework {
20971
21240
  const watching = context.watching ? context.watching() : context.watch;
20972
21241
  if (result.endTurn && watching) watching.endTurn = true;
20973
21242
  if (result.refused) return refused(result.error ?? `${toolName} was refused; nothing was done`);
20974
- const answer = await this.scriptResultAnswer(result, toolName);
21243
+ const answer = await this.scriptResultAnswer(result, toolName, route.toServer);
20975
21244
  return SCRIPT_CALL_NO_RESULT.has(result) ? { ...answer, held: true } : answer;
20976
21245
  }
20977
21246
 
@@ -20980,14 +21249,18 @@ export class AgentFramework {
20980
21249
  * isError }`, one shape for every tool. `content` is
20981
21250
  * what the model would read: a structured result's blocks as the dispatch
20982
21251
  * gave them (the direct reading, with RFC-005's treatment of an MCPL peer's
20983
- * blocks), and otherwise one text block of the model's text, which for a
20984
- * failure is its error. `structuredContent` is the tool's own structured
21252
+ * blocks there, bound to the server that sent them), a module's under
21253
+ * history's reading of RFC-005 at every depth (shownToScript), and
21254
+ * otherwise one text block of the model's text, which for a failure is its
21255
+ * error. A module's is any result whose call the dispatch didn't send to
21256
+ * a server (`toServer`, from the call's waiter). `structuredContent` is the
21257
+ * tool's own structured
20985
21258
  * content where it sent one, `false`, `0` and `null` included, else null.
20986
21259
  * A result that can't be converted to JSON (a cyclic object, a BigInt),
20987
21260
  * which the dispatch's own check makes unlikely here, is a failure naming
20988
21261
  * the tool and saying the call itself completed.
20989
21262
  */
20990
- private async scriptResultAnswer(result: ToolResult, toolName: string): Promise<ToolAnswer> {
21263
+ private async scriptResultAnswer(result: ToolResult, toolName: string, toServer: boolean): Promise<ToolAnswer> {
20991
21264
  const structured = Object.prototype.hasOwnProperty.call(result, 'structured');
20992
21265
  const isError = result.isError === true;
20993
21266
  // RFC-005 "eagerly lazy": a script receiving a result is the strongest
@@ -21004,7 +21277,10 @@ export class AgentFramework {
21004
21277
  // which isn't fetched.
21005
21278
  // Whenever the result's blocks reach the script: a structured failure's
21006
21279
  // included, as a module's error can carry content.
21007
- if ((!isError || structured) && Array.isArray(result.data) && this.resolveMcplTool(toolName) === null) {
21280
+ // Not a server's result: a module's, whose blocks no dispatch has given
21281
+ // RFC-005's treatment.
21282
+ const fromModule = !toServer;
21283
+ if ((!isError || structured) && Array.isArray(result.data) && fromModule) {
21008
21284
  const fetcher = this.getReferenceFetcher();
21009
21285
  for (const block of result.data) {
21010
21286
  const c = classifyBlock(block);
@@ -21031,6 +21307,20 @@ export class AgentFramework {
21031
21307
  // no blocks reads as its error text.
21032
21308
  if (structured && (!isError || (Array.isArray(result.data) && result.data.length > 0))) {
21033
21309
  content = Array.isArray(result.data) ? result.data : [];
21310
+ // A module's blocks, such as an MCPL peer's it got from ctx.callTool
21311
+ // and returns whole, are read as history reads a module's data: what
21312
+ // RFC-005 governs, at any depth, as the script lane shows it, and
21313
+ // anything else as sent. What a script prints comes back to the model,
21314
+ // so no uri the model's lanes withhold reaches it. What this gives is
21315
+ // a stub, a notice or the value itself, so a module that returns
21316
+ // blocks it already gave has them left as they are.
21317
+ if (fromModule) {
21318
+ try {
21319
+ content = JSON.parse(JSON.stringify(content, (_key, value: unknown) => shownToScript(value))) as unknown[];
21320
+ } catch (err) {
21321
+ return failed(err);
21322
+ }
21323
+ }
21034
21324
  } else {
21035
21325
  // As in history, a reference is its stub at any depth and inline data
21036
21326
  // claiming a uri or a disposition is withheld (RFC-005).
@@ -21067,7 +21357,9 @@ export class AgentFramework {
21067
21357
  * RFC-007: with an `origin`, observers see the call as they see a model
21068
21358
  * call (register, dispatch with its refusal sites, open; the terminal at
21069
21359
  * the waiter intercept), under the inner tool's own name and class.
21070
- * `turnId` is the turn the call was made in (dispatchToolCall).
21360
+ * `turnId` is the turn the call was made in (dispatchToolCall). `route` is
21361
+ * the waiter's: the dispatch sets `toServer` when it sends the call to a
21362
+ * server.
21071
21363
  */
21072
21364
  private dispatchScriptToolCall(
21073
21365
  agentName: string,
@@ -21075,6 +21367,7 @@ export class AgentFramework {
21075
21367
  args: Record<string, unknown>,
21076
21368
  origin?: ScriptCallOrigin,
21077
21369
  turnId?: number,
21370
+ route: { toServer: boolean } = { toServer: false },
21078
21371
  ): Promise<ToolResult> {
21079
21372
  return new Promise<ToolResult>((resolve) => {
21080
21373
  const callId = `pytc-${randomUUID()}`;
@@ -21102,9 +21395,12 @@ export class AgentFramework {
21102
21395
  }, safetyMs);
21103
21396
  safety.unref?.();
21104
21397
 
21105
- this.scriptToolWaiters.set(callId, (result) => {
21106
- clearTimeout(safety);
21107
- resolve(result);
21398
+ this.scriptToolWaiters.set(callId, {
21399
+ resolve: (result) => {
21400
+ clearTimeout(safety);
21401
+ resolve(result);
21402
+ },
21403
+ route,
21108
21404
  });
21109
21405
 
21110
21406
  const call = { id: callId, name: toolName, input: args };
@@ -21663,6 +21959,10 @@ export class AgentFramework {
21663
21959
  // Route MCPL tool calls to the appropriate server via prefix map
21664
21960
  const mcplMatch = this.resolveMcplTool(enrichedCall.name);
21665
21961
  if (mcplMatch && this.mcplServerRegistry) {
21962
+ // A script's call: its waiter keeps that it went to a server, for the
21963
+ // script lane's exit (scriptToolWaiters).
21964
+ const scriptWaiter = this.scriptToolWaiters.get(enrichedCall.id);
21965
+ if (scriptWaiter) scriptWaiter.route.toServer = true;
21666
21966
  this.dispatchMcplToolCall(agentName, enrichedCall, mcplMatch[0], mcplMatch[1]);
21667
21967
  return;
21668
21968
  }
@@ -27190,7 +27490,9 @@ export class AgentFramework {
27190
27490
  // generated JSON) stands in for what the server sent. An MCPL peer's
27191
27491
  // content blocks are the exception: what a script prints comes back to
27192
27492
  // the model, so each gets the treatment RFC-005 gives it on every lane
27193
- // the model reads. A standard peer's blocks reach the script as sent.
27493
+ // the model reads, here, where the server that sent them is known. A
27494
+ // module's blocks get it at the script lane's exit (scriptResultAnswer).
27495
+ // A standard peer's blocks reach the script as sent.
27194
27496
  const forScript = this.scriptToolWaiters?.has(call.id) ?? false;
27195
27497
  const reading = async (result: { content?: unknown; isError?: boolean; structuredContent?: unknown }, mcpl: boolean): Promise<ToolResult> => {
27196
27498
  if (!forScript || !Object.prototype.hasOwnProperty.call(result, 'structuredContent')) {