@animalabs/agent-framework 0.26.0 → 0.26.2

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,8 @@ 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,
120
+ shownToScriptFromServer,
119
121
  toolResultDataToHistoryString,
120
122
  truncateForHistory,
121
123
  DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS,
@@ -989,6 +991,71 @@ function resumesLogicalTurn(reason: string | undefined): boolean {
989
991
  return reason === 'context_budget_restart' || reason === 'tool_result_guard_retry';
990
992
  }
991
993
 
994
+ /**
995
+ * Marks a held turn's retry: the request a provider hold keeps for a turn
996
+ * whose call failed into it, or whose compile it held with nothing sent.
997
+ * That turn has begun and not ended: it starts again when the hold releases
998
+ * (a new start of its open turn, as 0.26.0 started it). So, as a
999
+ * continuation on its way does (continuesTurn), it keeps its agent's turn
1000
+ * alive meanwhile, passes a quiesce, and goes with an abandon, a stop() or
1001
+ * its agent's removal. A symbol, not a field: hosts build requests too, and
1002
+ * only the framework may say one is a turn's own retry. Spreads and rests
1003
+ * copy it, as every copy of a request the scheduler makes is one.
1004
+ */
1005
+ const TURN_RETRY: unique symbol = Symbol('turn-retry');
1006
+ /**
1007
+ * The batch a start that carries a turn on (continuesTurn) was merged from,
1008
+ * when the scheduler merged wakes that aren't the turn's into it: every
1009
+ * request it took in, as each was. Until the start takes its turn token it
1010
+ * stands for them all. A scheduler pass that takes it back puts the batch in
1011
+ * its place, each request with its own identity and flags; a quiesce at the
1012
+ * auxiliary park sends the batch back to the scheduler; and a caller's stop
1013
+ * or an abandon that drops the start puts back the batch's wakes, the
1014
+ * requests that don't carry the turn on (dropContinuations), where they run
1015
+ * as a turn of their own, as wakes queued while a turn streams do. Once the
1016
+ * start takes its token the turn reads them, and beginAgentTurn drops the
1017
+ * record. A symbol, as TURN_RETRY is, so spreads and rests copy it.
1018
+ */
1019
+ const MERGED_BATCH: unique symbol = Symbol('merged-batch');
1020
+ type MarkedRequest = InferenceRequest & { [TURN_RETRY]?: true; [MERGED_BATCH]?: InferenceRequest[] };
1021
+
1022
+ function mergedBatch(request: InferenceRequest): InferenceRequest[] | undefined {
1023
+ return (request as MarkedRequest)[MERGED_BATCH];
1024
+ }
1025
+
1026
+ /** The wakes of a merged start's batch (MERGED_BATCH): its requests that
1027
+ * don't carry the turn on. */
1028
+ function mergedWakes(request: InferenceRequest): InferenceRequest[] {
1029
+ return (mergedBatch(request) ?? []).filter((r) => !continuesTurn(r));
1030
+ }
1031
+
1032
+ function isTurnRetry(request: InferenceRequest): boolean {
1033
+ return (request as MarkedRequest)[TURN_RETRY] === true;
1034
+ }
1035
+
1036
+ /** How to go on sooner than a hold keeping a turn's retry lets the turn go
1037
+ * on: what surgery's and the puppet's refusals advise. */
1038
+ function heldTurnWayOn(statedWait: boolean): string {
1039
+ return 'a quiesce lets a held turn start when its hold ends and finish, so to go on sooner, end it with quiesce ' +
1040
+ 'with abandon: true, which ends it once the quiesce\'s wait for the drain runs out (at once on a host ' +
1041
+ 'already quiesced)' +
1042
+ (statedWait
1043
+ ? ", or lift a stated wait with release-provider-wait, so the turn tries again once no host's or " +
1044
+ 'acceleration hold binds it'
1045
+ : '');
1046
+ }
1047
+
1048
+ /** A copy of a held turn's trigger, marked as its retry (TURN_RETRY). */
1049
+ function asTurnRetry(trigger: InferenceRequest): InferenceRequest {
1050
+ return { ...trigger, [TURN_RETRY]: true } as MarkedRequest;
1051
+ }
1052
+
1053
+ /** A request that carries its agent's turn on: a continuation of it, or the
1054
+ * retry a provider hold keeps for it (TURN_RETRY). */
1055
+ function continuesTurn(request: InferenceRequest): boolean {
1056
+ return resumesLogicalTurn(request.reason) || isTurnRetry(request);
1057
+ }
1058
+
992
1059
  /**
993
1060
  * The answers dispatchScriptToolCall's safety timer made, when no result came
994
1061
  * for a script's call: the tool may still be running, or its result was lost,
@@ -997,6 +1064,20 @@ function resumesLogicalTurn(reason: string | undefined): boolean {
997
1064
  */
998
1065
  const SCRIPT_CALL_NO_RESULT = new WeakSet<ToolResult>();
999
1066
 
1067
+ /**
1068
+ * An MCPL peer's results whose blocks couldn't be given RFC-005's treatment
1069
+ * for a script, because JSON couldn't hold them (dispatchMcplToolCall's
1070
+ * reading), each with why. The failure is the script lane's alone: the
1071
+ * model's lane reads the same answer with no such round trip. So such a
1072
+ * result keeps the peer's own outcome, its blocks dropped, and the trace and
1073
+ * the call's terminal report what the peer answered, as on v0.26.1, while
1074
+ * the script lane's exit answers it as a result it can't convert to JSON
1075
+ * (scriptResultAnswer). A module's result that JSON can't hold differs: no
1076
+ * route can store or send it, so its dispatch makes it an error result for
1077
+ * everyone (checkedToolResult).
1078
+ */
1079
+ const SCRIPT_RESULT_UNCONVERTIBLE = new WeakMap<ToolResult, unknown>();
1080
+
1000
1081
  /**
1001
1082
  * One logical turn's routing state and receipt tallies
1002
1083
  * (AgentFramework.turnRouting): everything the turn has decided about where
@@ -1230,8 +1311,10 @@ function ephemeralRunStopped(name: string, observed?: string): Error {
1230
1311
 
1231
1312
  /** The reason on the inference:aborted that ends a turn whose retry stop()
1232
1313
  * cancelled, whichever frame was waiting to retry: beginAgentTurn's, when
1233
- * the stream couldn't start, or driveStream's, when it failed once started.
1234
- * Its inference:failed came first, and this says no retry follows it. */
1314
+ * the stream couldn't start, or driveStream's, when it failed once started;
1315
+ * or whose failure a provider hold would have kept for its retry once stop()
1316
+ * had closed admission (endFailureAtStop). Its inference:failed came first,
1317
+ * and this says no retry follows it. */
1235
1318
  const RETRY_CANCELLED_BY_STOP = 'a retry waiting while the framework was stopping is dropped before it sends anything';
1236
1319
 
1237
1320
  /** The reason on the inference:aborted that ends a turn whose start waited to
@@ -1239,6 +1322,10 @@ const RETRY_CANCELLED_BY_STOP = 'a retry waiting while the framework was stoppin
1239
1322
  * revive the earlier lifecycle's turn in the next one. */
1240
1323
  const RETRY_ACROSS_RESTART = 'a retry waiting while the framework was stopped and started again is dropped before it sends anything';
1241
1324
 
1325
+ /** The reason on the inference:aborted that ends a turn whose continuation
1326
+ * was dropped before it started, other than by an abandon or a stop. */
1327
+ const CONTINUATION_DROPPED = "a turn's continuation was dropped before it started";
1328
+
1242
1329
  /** One frame's window while it holds a turn with no stream and one of its
1243
1330
  * checks is ahead (AgentFramework.checkAhead). `abandoned` is an operator's
1244
1331
  * quiesce abandon that reached the turn there, which the frame's check
@@ -1255,6 +1342,12 @@ function isTurnContinuation(reason: string): boolean {
1255
1342
  || reason === 'tool_result_guard_retry';
1256
1343
  }
1257
1344
 
1345
+ /** A request a quiesce lets through, so a turn already alive can finish:
1346
+ * a continuation of the turn, or a held turn's retry (TURN_RETRY). */
1347
+ function passesQuiesce(request: InferenceRequest | undefined): boolean {
1348
+ return request !== undefined && (isTurnContinuation(request.reason) || isTurnRetry(request));
1349
+ }
1350
+
1258
1351
  /** Wake sources the framework generates for itself (bounce wakes,
1259
1352
  * continuations, retries, unsticks, admission requeues). A wake from any
1260
1353
  * other source — a channel message, a heartbeat or other server event, a
@@ -1603,19 +1696,35 @@ export interface HostModeStatus {
1603
1696
  quiesced: boolean;
1604
1697
  reason?: string;
1605
1698
  since?: number;
1606
- /** True when no turn is alive (activeTurnTokens empty — the token spans
1607
- * dequeue → settled teardown, strictly wider than activeStreams). */
1699
+ /** True when no turn is alive: no turn token (the token spans dequeue →
1700
+ * settled teardown, strictly wider than activeStreams), no continuation
1701
+ * of a turn on its way to its start (a budget restart's or a tool-result
1702
+ * guard's retry, queued, held by a provider hold, parked or in a scheduler
1703
+ * pass), no turn whose call failed into a provider hold waiting for the
1704
+ * hold to release its retry (wherever that retry then waits), and no wake
1705
+ * parked on provider admission. It covers the
1706
+ * framework's own turns: a caller of the direct Agent API
1707
+ * (`Agent.runInference`) owns its turns' timing. */
1608
1708
  drained: boolean;
1609
1709
  activeTurns: number;
1710
+ /** The agents with a turn alive, the ones `activeTurns` counts, sorted:
1711
+ * each from its turn's dequeue to its settled teardown, so a turn in its
1712
+ * hooks or compile, one finishing its deliveries, one whose continuation
1713
+ * is on its way to its start, or one a provider hold keeps for its retry,
1714
+ * is listed. These are the agents an abandon or
1715
+ * a stop() made now would find mid-turn. */
1716
+ activeTurnAgents: string[];
1610
1717
  /** Wakes that own provider admission while waiting for an in-flight
1611
1718
  * auxiliary call — no turn token yet, but a turn is one settle away. The
1612
1719
  * admission continuation rechecks quiesce and requeues them; `drained`
1613
- * is false until it has. */
1720
+ * is false until it has. A parked continuation of a turn is counted here
1721
+ * and, as a turn alive, in `activeTurns` and `activeTurnAgents`. */
1614
1722
  parkedAdmissions: number;
1615
1723
  /** Inference requests parked by the quiesce wake gate (while quiesced), or
1616
1724
  * simply queued (while serving — non-zero right after a resume until the
1617
1725
  * scheduler's next pass consumes them). Continuations of a held turn
1618
- * (budget restart, tool results) are never counted. */
1726
+ * (budget restart, tool results) and a held turn's retry, which a quiesce
1727
+ * lets through, are never counted. */
1619
1728
  gatedRequests: number;
1620
1729
  /** code_execution scripts still running that no call is waiting on: left
1621
1730
  * running after a yield, outside any turn. Quiesce does NOT stop them —
@@ -1832,6 +1941,9 @@ interface ProviderAccelerationCooldown {
1832
1941
  /** Armed by an auxiliary (compression) failure with no primary involved:
1833
1942
  * release reopens auxiliary admission without synthesising an inference. */
1834
1943
  auxiliaryOrigin?: boolean;
1944
+ /** Its held requests were taken out by dropContinuations: release reopens
1945
+ * admission without synthesising an inference in their place. */
1946
+ emptiedByDrop?: boolean;
1835
1947
  /** Set when this hold is a provider wait (a stated retry-after) for the
1836
1948
  * agent's primary model: it holds primary turns only. Auxiliary calls are
1837
1949
  * admitted per model by the provider waits themselves, so a compression
@@ -2028,12 +2140,14 @@ function scriptResultBrief(content: unknown[]): string {
2028
2140
 
2029
2141
  /**
2030
2142
  * What a script gets for a tool result that can't be converted to JSON,
2031
- * such as a cyclic object or a BigInt that an in-process module returned.
2143
+ * such as a cyclic object or a BigInt that an in-process module returned,
2144
+ * or an MCPL peer's blocks nested deeper than JSON.stringify can take.
2032
2145
  * It names the tool, and says the call itself completed, so a script that
2033
2146
  * reports it doesn't invite a blind retry. The host's log gets the same
2034
- * line, since the tool that returned it needs fixing whether or not the
2035
- * script reports it. The reason and the tool's own error are cut to
2036
- * VALUE_CHARS each, so the failure reaches the script whatever its memory.
2147
+ * line, since the tool, a module's or a server's, returns what a script
2148
+ * can't receive, whether or not the script reports it. The reason and the
2149
+ * tool's own error are cut to VALUE_CHARS each, so the failure reaches the
2150
+ * script whatever its memory.
2037
2151
  */
2038
2152
  function unconvertibleScriptResult(toolName: string, err: unknown, ownError?: string): string {
2039
2153
  let reason: string;
@@ -2065,13 +2179,15 @@ const SCRIPT_TEXT_ANSWER_ENVELOPE_BYTES = Buffer.byteLength(
2065
2179
  * memoryMaxBytes. So the figure moves with memoryMaxBytes. What it can't
2066
2180
  * count: a result's `structured` value, which the measure leaves out and the
2067
2181
  * 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
2182
+ * their text, which the answer carries as the script lane gives them (each
2183
+ * further block's keys, an image's data); nesting past 2,000 levels; and a
2070
2184
  * parse that needs more of the script's memory than is free, as for JSON
2071
2185
  * dense in small objects or a script already holding most of its memory.
2072
2186
  * Such a result, or one past the figure that doesn't fit, reaches the script
2073
2187
  * as ResultTooLarge, saying the call took effect, and nothing of it is cut or
2074
- * saved.
2188
+ * saved. One the host can't convert to JSON at all, such as one nested deeper
2189
+ * than its runtime's JSON.stringify can take, is answered instead as a
2190
+ * failure naming the tool and saying the call itself completed.
2075
2191
  */
2076
2192
  const scriptResultMaxChars = (memoryMaxBytes: number): number =>
2077
2193
  Math.max(0, Math.floor((Math.min(maxResultBytes(memoryMaxBytes), bufferConstants.MAX_STRING_LENGTH) - SCRIPT_TEXT_ANSWER_ENVELOPE_BYTES) / 6));
@@ -2378,6 +2494,15 @@ type McpRestartClaim = { readonly restarting: string; endedBy?: string };
2378
2494
  /** Why the framework cancelled a stream it owns: the mark it records, by
2379
2495
  * agent and stream, before the cancel, for the stream's terminal to read. */
2380
2496
  type FrameworkCancelKind = 'turn_ended' | 'budget_restart' | 'quiesce_abandoned' | 'shutdown';
2497
+ /** How a turn whose continuation is dropped before it starts ends: an
2498
+ * operator's quiesce abandon or stop() settles it as a stop ends a turn's
2499
+ * start; any other drop ends it as a start dropped before it sends anything.
2500
+ * `why` is said in the line its end logs. */
2501
+ type ContinuationDrop =
2502
+ | { kind: 'quiesce_abandoned' | 'shutdown' | 'dropped'; why: string }
2503
+ /** A caller's stop (abortInference) of a turn whose continuation or retry
2504
+ * is on its way, with the reason it gave. */
2505
+ | { kind: 'caller'; reason?: string; why: string };
2381
2506
 
2382
2507
  /** What opened a channel the agent didn't ask to open
2383
2508
  * (recordChannelAutoOpenNotice). */
@@ -2808,6 +2933,15 @@ export class AgentFramework {
2808
2933
  // and its settle except that turn's own blocks, and, before its checkpoint,
2809
2934
  // the inputs its start writes (flushForStart).
2810
2935
  private activeTurnTokens: Map<string, number> = new Map();
2936
+ /** Each running scheduler pass's requests, which it took from
2937
+ * pendingRequests: it holds those of the agents it hasn't taken up yet
2938
+ * while it awaits an earlier agent's start, so a continuation among them
2939
+ * is still on its way (pendingContinuationAgents). */
2940
+ private passRequests: Set<Map<string, InferenceRequest[]>> = new Set();
2941
+ /** Continuations whose start waits on provider admission behind an
2942
+ * auxiliary call, in no queue meanwhile. A drop takes one out and gives
2943
+ * its admission back; the park, woken, then finds it gone. */
2944
+ private parkedContinuations: Set<InferenceRequest> = new Set();
2811
2945
  /** Each turn whose settle has ended it (settleAgent, endDroppedTurn), for
2812
2946
  * its agent's scripts: its start's teardown can still hold the agent's
2813
2947
  * token, and a turn that has ended isn't live (liveTurns). A later start
@@ -2855,12 +2989,14 @@ export class AgentFramework {
2855
2989
  * is still the agent's, so a start that ends after a later frame opened
2856
2990
  * one, such as its retry's stream waiting to retry, leaves that one open.
2857
2991
  * An operator's abandon marks the window, and the frame's check collects
2858
- * the mark, which goes with the window. abortInference() and the abandon
2859
- * count a turn with no stream only here; a stop made anywhere else, as
2860
- * while an agent is idle, a turn finishes its last delivery or a provider
2861
- * hold has parked it, has no check to end it, and the next start drops
2862
- * it. Keyed by the Agent, so a replacement agent under the same name
2863
- * never shares one.
2992
+ * the mark, which goes with the window. A stop of a turn with no stream
2993
+ * lands here, or, for a turn whose continuation or retry is on its way (a
2994
+ * provider hold's, a park's, the queue's or a pass's), through
2995
+ * abortInference(), an abandon or stop(), which drop that continuation or
2996
+ * retry (dropContinuations); a stop made anywhere else, as while an agent
2997
+ * is idle or a turn finishes its last delivery, has no check to end it,
2998
+ * and the next start drops it. Keyed by the Agent, so a replacement agent
2999
+ * under the same name never shares one.
2864
3000
  */
2865
3001
  private checkAhead: Map<Agent, CheckWindow> = new Map();
2866
3002
 
@@ -2951,9 +3087,17 @@ export class AgentFramework {
2951
3087
  // config.codeExecution.enabled: its settings, and every agent's scripts,
2952
3088
  // each in a process of its own on the engine (AgentScripts). Waiters
2953
3089
  // resolve script-inner tool calls dispatched through the normal
2954
- // dispatchToolCall path.
3090
+ // dispatchToolCall path. A waiter's route is where the dispatch sent its
3091
+ // call: `toServer` is set when it went to a server's own tool (an MCPL or
3092
+ // MCP-only peer's), so the script lane's exit treats every other result as
3093
+ // a module's (scriptResultAnswer), by where the call went rather than by a
3094
+ // name lookup a connect or disconnect can change while it runs. A
3095
+ // framework tool that talks to a server itself, such as a channel tool,
3096
+ // builds its own result, which counts as a module's. A caller of
3097
+ // dispatchScriptToolCall that passes no route gets one left false, the
3098
+ // reading that withholds more.
2955
3099
  private codeExecution: { settings: IsolateSettings; scripts: AgentScripts } | null = null;
2956
- private scriptToolWaiters: Map<string, (result: ToolResult) => void> = new Map();
3100
+ private scriptToolWaiters: Map<string, { resolve: (result: ToolResult) => void; route: { toServer: boolean } }> = new Map();
2957
3101
  /** Per agent, a round that called code_execution and waits for those calls'
2958
3102
  * results before its prose is routed (ScriptRound). */
2959
3103
  private scriptRounds: Map<string, ScriptRound> = new Map();
@@ -4005,6 +4149,16 @@ export class AgentFramework {
4005
4149
  // framework must not keep up to the whole ledger budget referenced.
4006
4150
  this.toolImageLedgers.clear();
4007
4151
 
4152
+ // A turn whose continuation is still on its way ends with the stop, as
4153
+ // a turn's start a stop reaches does: the continuation would start only
4154
+ // after a start(), in a lifecycle where its logical turn is gone
4155
+ // (openLogical, below). Before the holds and the parks go, so the
4156
+ // continuation leaves them with its turn.
4157
+ step(() => {
4158
+ for (const agentName of this.pendingContinuationAgents()) {
4159
+ this.dropContinuations(agentName, { kind: 'shutdown', why: 'the framework is stopping' });
4160
+ }
4161
+ });
4008
4162
  // A stopped host must never hang behind its own cooldown.
4009
4163
  step(() => this.cancelProviderAdmission());
4010
4164
  // An open logical turn doesn't survive a stop: a start after a restart
@@ -4593,6 +4747,21 @@ export class AgentFramework {
4593
4747
  a.coalescingSubject === b.coalescingSubject && batchSubjects(a) === batchSubjects(b) &&
4594
4748
  a.readsDeferredWrite === b.readsDeferredWrite;
4595
4749
  }
4750
+ /** Keep `request` among a hold's `held` requests, once by
4751
+ * sameInferenceRequest. A held turn's retry (TURN_RETRY) whose equal is
4752
+ * already held marks that one, so the hold still keeps the turn alive. */
4753
+ private holdRequest(held: InferenceRequest[], request: InferenceRequest): void {
4754
+ const same = held.find((r) => this.sameInferenceRequest(r, request));
4755
+ if (!same) {
4756
+ held.push(request);
4757
+ return;
4758
+ }
4759
+ if (isTurnRetry(request)) (same as MarkedRequest)[TURN_RETRY] = true;
4760
+ // A merged start's batch (MERGED_BATCH) stays recorded on the request
4761
+ // kept in its place, which then stands for it, as the start did.
4762
+ const batch = mergedBatch(request);
4763
+ if (batch) (same as MarkedRequest)[MERGED_BATCH] = [...new Set([...(mergedBatch(same) ?? []), ...batch])];
4764
+ }
4596
4765
  private consultProviderHold(error: Error, agentName: string): { holdMs: number; reason: string } | undefined {
4597
4766
  if (!this.providerHoldHook) return undefined;
4598
4767
  try {
@@ -4644,14 +4813,19 @@ export class AgentFramework {
4644
4813
  // failure streak), and the wait applies to the calls that follow it.
4645
4814
  if (!acceleration && !hostHold) {
4646
4815
  return wait !== undefined && isMembraneError(error) && error.retryable === true
4647
- && this.holdForProviderWait(agent, wait, trigger ? [trigger] : [], true);
4816
+ && this.holdForProviderWait(agent, wait, trigger ? [asTurnRetry(trigger)] : [], true);
4648
4817
  }
4818
+ // As in holdForProviderWait: once stop() has closed admission, nothing is
4819
+ // held, and nothing started.
4820
+ if (this.providerAdmissionClosed) return true;
4649
4821
  const now = Date.now();
4650
4822
  const pacing = hostHold ? undefined : this.accelerationCooldownMs(agent.name, error as MembraneError);
4651
4823
  const delayMs = hostHold ? hostHold.holdMs : pacing!.delayMs;
4652
4824
  const existing = this.providerAccelerationCooldowns.get(agent.name);
4653
4825
  const held = existing?.heldRequests ?? [];
4654
- if (trigger && !held.some((r) => this.sameInferenceRequest(r, trigger))) held.push(trigger);
4826
+ // The failed turn's own request, kept as its retry (TURN_RETRY). An
4827
+ // auxiliary call's failure passes no trigger: it holds no turn of its own.
4828
+ if (trigger) this.holdRequest(held, asTurnRetry(trigger));
4655
4829
  if (existing) clearTimeout(existing.timer);
4656
4830
  const timer = setTimeout(() => this.releaseProviderAccelerationCooldown(agent.name), Math.min(delayMs, MAX_TIMER_DELAY_MS)); timer.unref?.();
4657
4831
  this.providerAccelerationCooldowns.set(agent.name, { startedAt: existing?.startedAt ?? now,
@@ -4660,7 +4834,8 @@ export class AgentFramework {
4660
4834
  // The stated wait (recorded above for agent.model) set this hold's
4661
4835
  // length: an operator release of it returns the hold to AF's own pacing.
4662
4836
  ...(pacing?.stated ? { statedWait: { model: agent.model, ownUntil: now + pacing.ownMs } } : {}),
4663
- ...(auxiliary && (!existing || existing.auxiliaryOrigin) ? { auxiliaryOrigin: true } : {}) });
4837
+ ...(auxiliary && (!existing || existing.auxiliaryOrigin) ? { auxiliaryOrigin: true } : {}),
4838
+ ...(existing?.emptiedByDrop && held.length === 0 ? { emptiedByDrop: true } : {}) });
4664
4839
  if (auxiliary && !existing) {
4665
4840
  console.error(`[provider-cooldown] agent=${agent.name} host hold (${hostHold!.reason}) from an auxiliary call — ` +
4666
4841
  `holding primary/auxiliary for ${delayMs}ms`);
@@ -4699,7 +4874,7 @@ export class AgentFramework {
4699
4874
  if (!isMembraneError(error) || typeof error.retryAfterMs !== 'number') return undefined;
4700
4875
  if (this.conversationAgentHomes.has(agent.name)) {
4701
4876
  const wait = this.recordProviderWait(agent.name, agent.model, error);
4702
- return error.retryable === true && wait !== undefined && this.holdForProviderWait(agent, wait, trigger ? [trigger] : [], true)
4877
+ return error.retryable === true && wait !== undefined && this.holdForProviderWait(agent, wait, trigger ? [asTurnRetry(trigger)] : [], true)
4703
4878
  ? 'held'
4704
4879
  : undefined;
4705
4880
  }
@@ -4767,9 +4942,12 @@ export class AgentFramework {
4767
4942
  ? `no longer holds primary turns: the primary model is now ${model} (the wait stays recorded)`
4768
4943
  : `primary hold ended after ${Date.now() - cooldown.startedAt}ms`));
4769
4944
  }
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`);
4945
+ if ((cooldown.auxiliaryOrigin || cooldown.emptiedByDrop) && cooldown.heldRequests.length === 0) {
4946
+ // Nobody asked for an inference while parked, or what was held was a
4947
+ // continuation since dropped with its turn: just reopen the gate. A
4948
+ // primary the hold left pending won't run, so its recovery ends.
4949
+ console.error(`[provider-cooldown] agent=${agentName} ${cooldown.auxiliaryOrigin ? 'auxiliary hold' : 'hold, its held continuation dropped,'} released after ${Date.now() - cooldown.startedAt}ms`);
4950
+ if (cooldown.emptiedByDrop) this.endPendingPrimary(agentName);
4773
4951
  this.flushAuxiliaryAdmission(agentName);
4774
4952
  return;
4775
4953
  }
@@ -4846,17 +5024,30 @@ export class AgentFramework {
4846
5024
  * Hold this agent's primary turns for a provider wait on its model. The
4847
5025
  * requests are kept and released together, into one fresh compile, when
4848
5026
  * the wait ends: the same path an acceleration cooldown takes, without
4849
- * parking auxiliary calls to other models.
5027
+ * parking auxiliary calls to other models. `report`, once the wait is
5028
+ * found to bind, runs before anything is held: a caller that reports its
5029
+ * turn's end there (inference:failed) does so before the hold takes the
5030
+ * turn's retry, as every failure site does.
4850
5031
  */
4851
- private holdForProviderWait(agent: Agent, wait: ProviderWait, requests: InferenceRequest[], failed = false): boolean {
5032
+ private holdForProviderWait(agent: Agent, wait: ProviderWait, requests: InferenceRequest[], failed = false, report?: () => void): boolean {
4852
5033
  const now = Date.now();
4853
5034
  const until = wait.until ?? Number.POSITIVE_INFINITY;
4854
5035
  if (until <= now) return false;
5036
+ report?.();
5037
+ // stop() has cleared every hold and dropped every turn on its way: a
5038
+ // failure or start reaching here after it holds nothing, so no retry
5039
+ // outlives the stop as a turn still alive. Nothing is started either.
5040
+ if (this.providerAdmissionClosed) return true;
5041
+ // A listener of the report may have released the wait
5042
+ // (releaseProviderWait) before anything was held: nothing binds now, so
5043
+ // the requests start, as the release of an installed hold starts them.
5044
+ if (report && !this.providerWaits?.active(agent.name, wait.model)) {
5045
+ this.pendingRequests.push(...requests);
5046
+ return true;
5047
+ }
4855
5048
  const existing = this.providerAccelerationCooldowns.get(agent.name);
4856
5049
  const held = existing?.heldRequests ?? [];
4857
- for (const request of requests) {
4858
- if (!held.some((r) => this.sameInferenceRequest(r, request))) held.push(request);
4859
- }
5050
+ for (const request of requests) this.holdRequest(held, request);
4860
5051
  // An acceleration or host hold already parks the whole agent; its release
4861
5052
  // re-checks this wait at admission.
4862
5053
  if (existing && existing.waitModel === undefined) return true;
@@ -5461,9 +5652,11 @@ export class AgentFramework {
5461
5652
 
5462
5653
  /**
5463
5654
  * Abort an in-flight inference for an agent. True when there was one to
5464
- * stop: a stream, or a turn with no stream and one of its checks ahead (its
5655
+ * stop: a stream; a turn with no stream and one of its checks ahead (its
5465
5656
  * hooks or compile, a wait to retry, the tool-result guard's wait), which
5466
- * ends at that check, before it sends anything more.
5657
+ * ends at that check, before it sends anything more; or a turn whose
5658
+ * continuation or retry is on its way (queued, kept by a provider hold,
5659
+ * parked at admission or in a scheduler pass), which ends here.
5467
5660
  */
5468
5661
  abortInference(agentName: string, reason?: string): boolean {
5469
5662
  const agent = this.agents.get(agentName);
@@ -5493,6 +5686,20 @@ export class AgentFramework {
5493
5686
  checkWindow.wake?.();
5494
5687
  return true;
5495
5688
  }
5689
+ // A turn whose continuation or retry is on its way (a budget restart or
5690
+ // a guard retry, or a failed turn's retry a provider hold keeps:
5691
+ // pendingContinuationAgents) ends here, as a stop of a turn's start ends
5692
+ // one, whether or not the frame that handed it on has let go of its
5693
+ // token yet (that frame ends on its own and settles nothing more): the
5694
+ // continuation or retry is dropped, with one inference:aborted carrying
5695
+ // the stop's reason. Wakes that arrived meanwhile aren't the turn's, and
5696
+ // still run; a hold of such wakes alone keeps no turn, and has nothing to
5697
+ // stop. Every failure site reports inference:failed before a hold takes
5698
+ // the retry, so a listener of it finds nothing held, and reports false.
5699
+ if (!result && this.pendingContinuationAgents().has(agentName)) {
5700
+ this.dropContinuations(agentName, { kind: 'caller', reason, why: 'a stop ended the turn' });
5701
+ return true;
5702
+ }
5496
5703
  return !!result;
5497
5704
  }
5498
5705
 
@@ -6446,16 +6653,77 @@ export class AgentFramework {
6446
6653
  return n;
6447
6654
  }
6448
6655
 
6656
+ /**
6657
+ * A continuation the framework makes for a turn (a budget restart's, which
6658
+ * it queues, or a tool-result guard's retry, which its frame starts at
6659
+ * once) holds no turn token until its start takes one. Once its turn's
6660
+ * frame has let go of its token, its start can wait in the queue, a
6661
+ * provider hold, a park on admission or a scheduler pass's hands. Its turn
6662
+ * is alive meanwhile. So is a turn whose call failed into a provider hold,
6663
+ * which keeps the turn's retry (TURN_RETRY), until that retry starts,
6664
+ * wherever it waits. These are the agents with one on its way. A
6665
+ * `tool_results_ready` continuation follows a tool round left waiting with
6666
+ * no stream, which only the direct Agent API makes (Agent's
6667
+ * runInferenceWithInjections; the framework's own rounds wait with their
6668
+ * stream, enterWaitingForTools). Its caller owns that turn's timing, so it
6669
+ * isn't counted.
6670
+ */
6671
+ private pendingContinuationAgents(): Set<string> {
6672
+ const agents = new Set<string>();
6673
+ const add = (r: InferenceRequest): void => { if (continuesTurn(r)) agents.add(r.agentName); };
6674
+ // Read defensively: a framework built from the prototype, as the
6675
+ // operator tests build one, sets none of these.
6676
+ for (const r of this.pendingRequests ?? []) add(r);
6677
+ for (const cooldown of this.providerAccelerationCooldowns?.values() ?? []) for (const r of cooldown.heldRequests) add(r);
6678
+ for (const r of this.parkedContinuations ?? []) add(r);
6679
+ for (const pass of this.passRequests ?? []) for (const requests of pass.values()) for (const r of requests) add(r);
6680
+ return agents;
6681
+ }
6682
+
6683
+ /** The agents with a turn alive: holding a turn token, or with a
6684
+ * continuation of their turn, or its retry, on its way. */
6685
+ private turnHolders(): Set<string> {
6686
+ const holders = this.pendingContinuationAgents();
6687
+ for (const name of this.activeTurnTokens.keys()) holders.add(name);
6688
+ return holders;
6689
+ }
6690
+
6691
+ private hasTurn(agentName: string): boolean {
6692
+ return this.activeTurnTokens.has(agentName) || this.pendingContinuationAgents().has(agentName);
6693
+ }
6694
+
6695
+ /** The provider hold keeping `agentName`'s turn for its retry (TURN_RETRY),
6696
+ * in words naming its end and its reason, and whether a stated wait holds
6697
+ * it; undefined when no hold keeps one. */
6698
+ private heldTurnRetry(agentName: string): { says: string; statedWait: boolean } | undefined {
6699
+ // Optional, as pendingContinuationAgents reads it: a framework built from
6700
+ // its prototype, as some suites build one, has no map of holds.
6701
+ const hold = this.providerAccelerationCooldowns?.get(agentName);
6702
+ if (!hold?.heldRequests.some(isTurnRetry)) return undefined;
6703
+ // A deadline past what a Date can name (an infinite wait, or a stated
6704
+ // one too long for an instant) is one only a release ends.
6705
+ const end = new Date(hold.until);
6706
+ const until = Number.isNaN(end.getTime()) ? 'until released' : `until ${end.toISOString()}`;
6707
+ return {
6708
+ says: `a provider hold keeps its turn to start again, ${until}: ${hold.reason}`,
6709
+ // release-provider-wait lifts a stated wait's hold, not a host's or an
6710
+ // acceleration cooldown's own pacing.
6711
+ statedWait: hold.waitModel !== undefined || hold.statedWait !== undefined,
6712
+ };
6713
+ }
6714
+
6449
6715
  getHostModeStatus(): HostModeStatus {
6450
6716
  const parkedAdmissions = this.parkedAdmissionCount();
6717
+ const holders = this.turnHolders();
6451
6718
  return {
6452
6719
  quiesced: this.quiesced,
6453
6720
  ...(this.quiesceReason ? { reason: this.quiesceReason } : {}),
6454
6721
  ...(this.quiescedAt !== undefined ? { since: this.quiescedAt } : {}),
6455
- drained: this.activeTurnTokens.size === 0 && parkedAdmissions === 0,
6456
- activeTurns: this.activeTurnTokens.size,
6722
+ drained: holders.size === 0 && parkedAdmissions === 0,
6723
+ activeTurns: holders.size,
6724
+ activeTurnAgents: [...holders].sort(),
6457
6725
  parkedAdmissions,
6458
- gatedRequests: this.pendingRequests.filter((r) => !isTurnContinuation(r.reason)).length,
6726
+ gatedRequests: this.pendingRequests.filter((r) => !passesQuiesce(r)).length,
6459
6727
  backgroundScripts: this.codeExecution?.scripts.leftRunning() ?? 0,
6460
6728
  deferredWrites: this.deferredMessages.length,
6461
6729
  ...(this.lastUnabandonable.length > 0 ? { unabandonable: [...this.lastUnabandonable] } : {}),
@@ -6472,8 +6740,10 @@ export class AgentFramework {
6472
6740
  /**
6473
6741
  * Enter quiesce mode: write the deferred-write queue, persist the flag,
6474
6742
  * 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
6743
+ * in-flight turns to settle (turn-alive is a turn token, which spans
6744
+ * dequeue → settled teardown, a continuation of the turn on its way to its
6745
+ * start, or the turn's retry, which a provider hold keeps or which waits
6746
+ * to start: turnHolders). Idempotent, but for the queue's write, which
6477
6747
  * every call makes first: when the queue can't reach disk, because its
6478
6748
  * write fails or it's over the cap, the call rejects with a
6479
6749
  * QuiesceBlockedError and the host's mode as it was, unless it has
@@ -6483,7 +6753,10 @@ export class AgentFramework {
6483
6753
  * On drain timeout the host STAYS quiesced (`drained: false` in the
6484
6754
  * result); with `abandon: true` the undrained streams are cancelled via a
6485
6755
  * dedicated cancel kind that settles the turn without feeding the
6486
- * inference-failure accounting.
6756
+ * inference-failure accounting, and a turn whose continuation or retry
6757
+ * hasn't started ends with it dropped. Drain covers the framework's
6758
+ * own turns: a caller of the direct Agent API (`Agent.runInference`) owns
6759
+ * its turns' timing.
6487
6760
  */
6488
6761
  async quiesce(opts?: {
6489
6762
  reason?: string;
@@ -6509,13 +6782,13 @@ export class AgentFramework {
6509
6782
  // out undrained must be escalatable with a second quiesce({abandon})
6510
6783
  // without resume()+re-quiesce (which would reopen data planes and
6511
6784
  // release parked wakes mid-surgery).
6512
- if (opts?.abandon && this.activeTurnTokens.size > 0) {
6785
+ if (opts?.abandon && this.turnHolders().size > 0) {
6513
6786
  const unabandonable = await this.abandonActiveTurns();
6514
6787
  this.emitTrace({
6515
6788
  type: 'host:quiesce',
6516
6789
  ...(this.quiesceReason ? { reason: this.quiesceReason } : {}),
6517
- drained: this.activeTurnTokens.size === 0,
6518
- activeTurns: this.activeTurnTokens.size,
6790
+ drained: this.turnHolders().size === 0,
6791
+ activeTurns: this.turnHolders().size,
6519
6792
  abandoned: true,
6520
6793
  ...(unabandonable.length > 0 ? { unabandonable } : {}),
6521
6794
  });
@@ -6541,7 +6814,7 @@ export class AgentFramework {
6541
6814
  this.lastUnabandonable = [];
6542
6815
  console.error(
6543
6816
  `[host-mode] quiescing${opts?.reason ? ` (${opts.reason})` : ''}: parking wakes, ` +
6544
- `pausing MCPL data planes, draining ${this.activeTurnTokens.size} in-flight turn(s)`,
6817
+ `pausing MCPL data planes, draining ${this.turnHolders().size} in-flight turn(s)`,
6545
6818
  );
6546
6819
  this.eventGate?.setQuiesced(true);
6547
6820
  for (const connection of this.mcplServerRegistry?.getAllServers() ?? []) {
@@ -6567,7 +6840,7 @@ export class AgentFramework {
6567
6840
  while (
6568
6841
  this.quiesced &&
6569
6842
  !this.queue?.isClosed &&
6570
- (this.activeTurnTokens.size > 0 || this.parkedAdmissionCount() > 0) &&
6843
+ (this.turnHolders().size > 0 || this.parkedAdmissionCount() > 0) &&
6571
6844
  Date.now() < deadline
6572
6845
  ) {
6573
6846
  await new Promise((resolve) => setTimeout(resolve, 250));
@@ -6591,7 +6864,7 @@ export class AgentFramework {
6591
6864
 
6592
6865
  let abandoned = false;
6593
6866
  let unabandonable: string[] = [];
6594
- if (this.activeTurnTokens.size > 0 && opts?.abandon) {
6867
+ if (this.turnHolders().size > 0 && opts?.abandon) {
6595
6868
  abandoned = true;
6596
6869
  unabandonable = await this.abandonActiveTurns();
6597
6870
  if (!this.quiesced) return this.getHostModeStatus();
@@ -6624,7 +6897,18 @@ export class AgentFramework {
6624
6897
  * the already-quiesced escalation path. */
6625
6898
  private async abandonActiveTurns(): Promise<string[]> {
6626
6899
  const unabandonable: string[] = [];
6900
+ // A turn whose continuation is still on its way ends with the
6901
+ // continuation dropped. Its stream has ended; a frame still letting go of
6902
+ // its token ends on its own, and settles nothing, since it handed the turn
6903
+ // to the continuation.
6904
+ const handedOff = new Set<string>();
6905
+ for (const agentName of this.pendingContinuationAgents()) {
6906
+ console.error(`[host-mode] abandoning ${agentName}'s turn before its continuation or held retry starts: it is dropped`);
6907
+ this.dropContinuations(agentName, { kind: 'quiesce_abandoned', why: 'its turn was abandoned' });
6908
+ handedOff.add(agentName);
6909
+ }
6627
6910
  for (const agentName of [...this.activeTurnTokens.keys()]) {
6911
+ if (handedOff.has(agentName)) continue;
6628
6912
  const agent = this.agents.get(agentName);
6629
6913
  const state = agent?.state;
6630
6914
  const stream = state && 'stream' in state ? state.stream : undefined;
@@ -8698,6 +8982,9 @@ export class AgentFramework {
8698
8982
  this.activeTurnTokens.delete(agent.name);
8699
8983
  this.activeTurnTriggers.delete(agent.name);
8700
8984
  this.logicalTurnToolCalls.delete(agent);
8985
+ // A continuation of its turn still on its way goes too: the run, and
8986
+ // with it the turn, has ended above.
8987
+ this.dropContinuations(agent.name, null);
8701
8988
  }
8702
8989
  }
8703
8990
 
@@ -10249,7 +10536,8 @@ export class AgentFramework {
10249
10536
  * by stop() while it runs.
10250
10537
  *
10251
10538
  * The branch moves under every agent sharing the store, so the switch
10252
- * reserves the store as a surgery does: every agent must be idle, or it is
10539
+ * reserves the store as a surgery does: every agent must be idle with no
10540
+ * turn alive (no continuation or held retry of one on its way), or it is
10253
10541
  * refused (OperatorActionError 'agent-busy'), and no turn starts until the
10254
10542
  * switch is done.
10255
10543
  */
@@ -10501,9 +10789,21 @@ export class AgentFramework {
10501
10789
  );
10502
10790
  }
10503
10791
  const busy: string[] = [];
10792
+ let heldTurns = false;
10793
+ let statedWaits = false;
10504
10794
  for (const [name, a] of this.agents) {
10505
- 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`);
10795
+ if (a.state.status !== 'idle') { busy.push(`${name} is ${a.state.status}`); continue; }
10796
+ if (!this.hasTurn(name)) continue;
10797
+ // A provider hold keeping the turn's retry is named: a quiesce alone
10798
+ // doesn't end that turn.
10799
+ const held = this.heldTurnRetry(name);
10800
+ if (held) {
10801
+ heldTurns = true;
10802
+ if (held.statedWait) statedWaits = true;
10803
+ busy.push(`${name} is idle+turn-alive (${held.says})`);
10804
+ } else {
10805
+ busy.push(`${name} is idle+turn-alive`);
10806
+ }
10507
10807
  }
10508
10808
  for (const name of this.activeTurnTokens.keys()) {
10509
10809
  if (!this.agents.has(name)) busy.push(`${name} is turn-alive`);
@@ -10511,7 +10811,8 @@ export class AgentFramework {
10511
10811
  if (busy.length > 0) {
10512
10812
  throw new OperatorActionError(
10513
10813
  'agent-busy',
10514
- `Cannot ${verb} while ${busy.join(', ')} — every agent sharing the store must be idle (quiesce the host first)`,
10814
+ `Cannot ${verb} while ${busy.join(', ')} — every agent sharing the store must be idle (quiesce the host first)` +
10815
+ (heldTurns ? `; ${heldTurnWayOn(statedWaits)}` : ''),
10515
10816
  );
10516
10817
  }
10517
10818
  const reserved = new Map<string, number>();
@@ -11445,10 +11746,11 @@ export class AgentFramework {
11445
11746
  // Direct inference requests (e.g. runEphemeralToCompletion) bypass the
11446
11747
  // event queue — without this the loop can exit before they're drained.
11447
11748
  // While quiesced, gate-parked requests are NOT progress and must not
11448
- // block idle (only a budget restart can still run); identity-equivalent
11449
- // to `.length > 0` when not quiesced.
11749
+ // block idle; what a quiesce lets through (passesQuiesce: a turn's
11750
+ // continuation, or a held turn's retry) can still run, and is.
11751
+ // Identity-equivalent to `.length > 0` when not quiesced.
11450
11752
  this.pendingRequests.some(
11451
- (r) => !this.quiesced || isTurnContinuation(r.reason),
11753
+ (r) => !this.quiesced || passesQuiesce(r),
11452
11754
  ) ||
11453
11755
  this.activeStreams.size > 0 ||
11454
11756
  Array.from(this.agents.values()).some((a) => a.state.status !== 'idle')
@@ -11660,7 +11962,7 @@ export class AgentFramework {
11660
11962
  // was killed while the call ran: the result reaches no one, but the
11661
11963
  // call did finish.
11662
11964
  this.toolLifecycleEmitter?.onResult(event.agentName, event.callId, event.result);
11663
- scriptWaiter(event.result);
11965
+ scriptWaiter.resolve(event.result);
11664
11966
  return;
11665
11967
  }
11666
11968
 
@@ -12105,7 +12407,25 @@ export class AgentFramework {
12105
12407
  this.streamBoundaryEnds.get(currentState.stream)?.('turn_ended');
12106
12408
  }
12107
12409
  this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name });
12410
+ } else if ((overBudget || overPhysical) && this.providerAdmissionClosed) {
12411
+ // A stopping framework starts no continuation: the turn ends with
12412
+ // its stream, as stop() ends a live stream's turn.
12413
+ if (currentState.stream) this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'shutdown');
12414
+ agent.cancelStream();
12108
12415
  } else if (overBudget || overPhysical) {
12416
+ // The continuation is queued, and the stream cancelled, before the
12417
+ // boundary's usage report and the restart's trace, so a listener
12418
+ // that stops or quiesces the host on either finds the turn waiting
12419
+ // for its continuation, with nothing left of the stream to end.
12420
+ const continuation: InferenceRequest = {
12421
+ agentName: agent.name,
12422
+ reason: 'context_budget_restart',
12423
+ source: 'framework',
12424
+ timestamp: Date.now(),
12425
+ suppressProse: this.activeTurnTriggers.get(agent.name)?.suppressProse,
12426
+ silentHeartbeat: this.activeTurnTriggers.get(agent.name)?.silentHeartbeat,
12427
+ };
12428
+ this.pendingRequests.push(continuation);
12109
12429
  // Context budget exceeded: break the stream, let compile() compress.
12110
12430
  // Mark the cancel as framework-initiated BEFORE cancelling: the
12111
12431
  // membrane delivers `aborted` before the restart bumps streamId,
@@ -12113,13 +12433,19 @@ export class AgentFramework {
12113
12433
  // a terminal failure — rejecting an ephemeral's promise mid-run
12114
12434
  // and emitting a spurious inference:exhausted (same race shape as
12115
12435
  // 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');
12436
+ if (currentState.stream) this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'budget_restart');
12437
+ try {
12438
+ agent.cancelStream();
12439
+ } catch (error) {
12440
+ // The cancel failed and the stream is still live, so the turn
12441
+ // has no continuation: take it back out of the queue.
12442
+ this.pendingRequests = this.pendingRequests.filter((r) => r !== continuation);
12443
+ throw error;
12444
+ } finally {
12445
+ // Its rounds were billed, even when the cancel throws; the
12446
+ // restart opens a fresh stream with its own usage.
12447
+ if (currentState.stream) this.streamBoundaryEnds.get(currentState.stream)?.(overBudget ? 'context_budget' : 'physical_window');
12121
12448
  }
12122
- agent.cancelStream();
12123
12449
  this.emitTrace({
12124
12450
  type: 'inference:stream_restarted',
12125
12451
  agentName: agent.name,
@@ -12130,14 +12456,6 @@ export class AgentFramework {
12130
12456
  inputTokens: overBudget ? agent.lastStreamInputTokens : projectedRealTokens,
12131
12457
  budget: overBudget ? agent.maxStreamTokens : (agent.physicalWindowTokens ?? 0),
12132
12458
  });
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
12459
  } else if (currentState.stream) {
12142
12460
  // What this round appends is in the next call's prompt. If that
12143
12461
  // call reports no prompt, the next boundary projects it from the
@@ -12987,6 +13305,8 @@ export class AgentFramework {
12987
13305
  */
12988
13306
  private disposeConversationAgent(agentName: string): void {
12989
13307
  this.closingConversationAgents.delete(agentName);
13308
+ // A continuation of its turn still on its way goes with it.
13309
+ this.dropContinuations(agentName, { kind: 'dropped', why: 'its agent was disposed' });
12990
13310
  const channelId = this.conversationAgentHomes.get(agentName);
12991
13311
  const agent = this.agents.get(agentName);
12992
13312
  // A fork stop() ends can still be running: its stream, its tool round.
@@ -13116,10 +13436,13 @@ export class AgentFramework {
13116
13436
  // (request dropped as stale, inference policy veto) would otherwise stay
13117
13437
  // registered forever. No turn, no active stream and no pending request =
13118
13438
  // done. A turn holds its token from dequeue, through its hooks and
13119
- // compile, before its stream registers, so a fork mid-compile is busy.
13439
+ // compile, before its stream registers, so a fork mid-compile is busy;
13440
+ // and a turn is alive while its continuation or a provider hold's retry
13441
+ // of it is on its way (hasTurn), so a closure turn held for its retry
13442
+ // finishes when the hold ends.
13120
13443
  for (const agentName of [...this.closingConversationAgents]) {
13121
13444
  if (
13122
- !this.activeTurnTokens.has(agentName) &&
13445
+ !this.hasTurn(agentName) &&
13123
13446
  !this.activeStreams.has(agentName) &&
13124
13447
  !this.pendingRequests.some((r) => r.agentName === agentName)
13125
13448
  ) {
@@ -13519,9 +13842,18 @@ export class AgentFramework {
13519
13842
  recordReceipt: (record) => {
13520
13843
  // Written now, not at the throttled flush: an acknowledged acceptance
13521
13844
  // 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.
13845
+ // accepted work itself to a crash. A failure here fails the answer:
13846
+ // the coalescer keeps the receipt, and the producer's retry writes it
13847
+ // again, with the subject's state as it is then. So a record whose
13848
+ // write failed leaves the list: a later write would carry it after
13849
+ // the batch it holds had moved on.
13523
13850
  this.coalescingRecentReceipts.push(record);
13524
- this.store.setStateJson(COALESCING_RECENT_ID, this.coalescingRecentReceipts);
13851
+ try {
13852
+ this.store.setStateJson(COALESCING_RECENT_ID, this.coalescingRecentReceipts);
13853
+ } catch (error) {
13854
+ this.coalescingRecentReceipts.pop();
13855
+ throw error;
13856
+ }
13525
13857
  },
13526
13858
  // The acceptance of coalesced work (deferred included) is observed
13527
13859
  // here, once; its later delivery reuses the frozen envelope.
@@ -13628,6 +13960,9 @@ export class AgentFramework {
13628
13960
  this.store.setStateJson(COALESCING_RECENT_ID, []);
13629
13961
  } catch (err) {
13630
13962
  console.error('[coalescing] could not persist coalescing state:', err);
13963
+ // Still unsaved: the next persist, or the stop's flush, writes it. No
13964
+ // timer of its own, so a store that keeps failing isn't retried here.
13965
+ this.coalescingSnapshotDirty ??= snapshot;
13631
13966
  }
13632
13967
  }
13633
13968
 
@@ -13927,6 +14262,19 @@ export class AgentFramework {
13927
14262
  for (const cooldown of this.providerAccelerationCooldowns.values()) {
13928
14263
  cooldown.heldRequests = cooldown.heldRequests.filter((r) => r.coalescingSubject !== subject);
13929
14264
  }
14265
+ // A wake of the subject's merged into a start that carries a turn on
14266
+ // (MERGED_BATCH) is withdrawn from the start's batch, so no stop puts
14267
+ // it back and no pass unpacks it.
14268
+ const unrecord = (r: InferenceRequest): void => {
14269
+ const batch = mergedBatch(r);
14270
+ if (batch?.some((w) => w.coalescingSubject === subject)) {
14271
+ (r as MarkedRequest)[MERGED_BATCH] = batch.filter((w) => w.coalescingSubject !== subject);
14272
+ }
14273
+ };
14274
+ for (const r of this.pendingRequests) unrecord(r);
14275
+ for (const cooldown of this.providerAccelerationCooldowns.values()) cooldown.heldRequests.forEach(unrecord);
14276
+ for (const r of this.parkedContinuations ?? []) unrecord(r);
14277
+ for (const pass of this.passRequests ?? []) for (const requests of pass.values()) requests.forEach(unrecord);
13930
14278
  }
13931
14279
 
13932
14280
  /**
@@ -14253,17 +14601,28 @@ export class AgentFramework {
14253
14601
  return undefined;
14254
14602
  }
14255
14603
 
14604
+ /** One scheduler pass, whose requests stay visible as on their way until
14605
+ * it takes their agent up (passRequests). */
14256
14606
  private async processInferenceRequests(): Promise<void> {
14257
14607
  if (this.pendingRequests.length === 0) {
14258
14608
  return;
14259
14609
  }
14610
+ const requestsByAgent = new Map<string, InferenceRequest[]>();
14611
+ this.passRequests?.add(requestsByAgent);
14612
+ try {
14613
+ await this.runInferencePass(requestsByAgent);
14614
+ } finally {
14615
+ this.passRequests?.delete(requestsByAgent);
14616
+ }
14617
+ }
14618
+
14619
+ private async runInferencePass(requestsByAgent: Map<string, InferenceRequest[]>): Promise<void> {
14260
14620
 
14261
14621
  const STALE_REQUEST_MS = 30_000;
14262
14622
  const now = Date.now();
14263
14623
  const state = this.createFrameworkState();
14264
14624
 
14265
14625
  // Group requests by agent
14266
- const requestsByAgent = new Map<string, InferenceRequest[]>();
14267
14626
  for (const req of this.pendingRequests) {
14268
14627
  const existing = requestsByAgent.get(req.agentName) ?? [];
14269
14628
  existing.push(req);
@@ -14276,6 +14635,13 @@ export class AgentFramework {
14276
14635
  // Check each agent
14277
14636
  for (const [agentName, groupedRequests] of requestsByAgent) {
14278
14637
  let requests = groupedRequests;
14638
+ // Taken up now: from here to its start's turn token (or its hold or
14639
+ // park) nothing awaits, so the requests leave the pass's map, which
14640
+ // shows only what the pass still holds while it awaits a start.
14641
+ requestsByAgent.delete(agentName);
14642
+ // A drop while this pass awaited an earlier agent's start can have
14643
+ // taken every request of this agent's (dropContinuations).
14644
+ if (requests.length === 0) continue;
14279
14645
  const agent = this.agents.get(agentName);
14280
14646
  if (!agent) {
14281
14647
  // Agent not found — request is orphaned. Emit warning and drop.
@@ -14288,6 +14654,10 @@ export class AgentFramework {
14288
14654
  oldestRequestAge: now - oldest,
14289
14655
  });
14290
14656
  console.error(`[inference-dropped] agent=${agentName} reason=agent_not_found requests=${requests.length}`);
14657
+ // A continuation among them leaves its turn to end here.
14658
+ if (requests.some(continuesTurn)) {
14659
+ this.endContinuedTurn(agentName, { kind: 'dropped', why: 'its agent is gone' });
14660
+ }
14291
14661
  continue;
14292
14662
  }
14293
14663
 
@@ -14297,11 +14667,7 @@ export class AgentFramework {
14297
14667
  // selected now, the turn proceeds (the wait stays recorded).
14298
14668
  const otherModel = providerCooldown.waitModel !== undefined && providerCooldown.waitModel !== agent.model;
14299
14669
  if (!otherModel && (now < providerCooldown.until || this.extendHostProviderHold(agentName, providerCooldown))) {
14300
- for (const req of requests) {
14301
- if (!providerCooldown.heldRequests.some((r) => this.sameInferenceRequest(r, req))) {
14302
- providerCooldown.heldRequests.push(req);
14303
- }
14304
- }
14670
+ for (const req of requests) this.holdRequest(providerCooldown.heldRequests, req);
14305
14671
  const recovery = this.providerAccelerationRecoveries.get(agentName);
14306
14672
  if (recovery) recovery.heldRequests = providerCooldown.heldRequests.length;
14307
14673
  // The timer owns release. Do not hot-poll these requests.
@@ -14315,6 +14681,17 @@ export class AgentFramework {
14315
14681
  if (recovery) recovery.releasedAt = now;
14316
14682
  requests = [...providerCooldown.heldRequests, ...requests];
14317
14683
  }
14684
+ // A start that carries a turn on and came back before it took its
14685
+ // token stands for the batch it was merged from (MERGED_BATCH),
14686
+ // including one a hold released just now. The batch takes its place,
14687
+ // each request as it was, judged one by one from here: a wait holds
14688
+ // them apart, a quiesce parks the wakes while the turn's request
14689
+ // passes, and the read check and the policy decide on them all; the
14690
+ // merge below records the batch anew.
14691
+ if (requests.some((r) => mergedBatch(r) !== undefined)) {
14692
+ requests = requests.flatMap((r) => mergedBatch(r) ?? [r]);
14693
+ }
14694
+
14318
14695
  // A provider's stated wait on this agent's primary model, including one
14319
14696
  // recorded before a restart, holds the turn until it passes. An
14320
14697
  // ephemeral run records nothing, its name being single-use, so no wait,
@@ -14349,9 +14726,10 @@ export class AgentFramework {
14349
14726
  // bounds a multi-day maintenance window's accumulation.
14350
14727
  if (this.quiesced) {
14351
14728
  // Continuations of a held turn (budget restart, non-streaming tool
14352
- // results) pass; everything else parks.
14353
- const passThrough = requests.filter((r) => isTurnContinuation(r.reason));
14354
- const parked = requests.filter((r) => !isTurnContinuation(r.reason));
14729
+ // results) pass, and so does a held turn's retry (passesQuiesce), so
14730
+ // a turn alive at the quiesce can finish; everything else parks.
14731
+ const passThrough = requests.filter((r) => passesQuiesce(r));
14732
+ const parked = requests.filter((r) => !passesQuiesce(r));
14355
14733
  // Coalesce per (reason, addressed): every channel wake shares one
14356
14734
  // reason, so keying on reason alone would let an ambient message
14357
14735
  // parked later evict a DM/mention parked earlier — and the
@@ -14377,7 +14755,10 @@ export class AgentFramework {
14377
14755
  requests.push(...passThrough);
14378
14756
  }
14379
14757
 
14380
- const turnAlive = !budgetRestart && this.activeTurnTokens.has(agentName);
14758
+ // Turn-alive includes a continuation of the agent's turn still on its
14759
+ // way elsewhere (a park, another pass's hands): a fresh wake waits for
14760
+ // it, as for a turn holding its token.
14761
+ const turnAlive = !budgetRestart && this.hasTurn(agentName);
14381
14762
  const providerGate = this.providerGates?.get(agentName);
14382
14763
  // A primary can own provider admission while yielding to an already in-flight
14383
14764
  // auxiliary call. Keep this agent's later wakes queued, but do not block the
@@ -14455,9 +14836,12 @@ export class AgentFramework {
14455
14836
  `[inference-dropped] agent=${agentName} reason=policy-skip ` +
14456
14837
  `requests=${requests.length} triggers=${requests.map((r) => r.reason).join(',')}`,
14457
14838
  );
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);
14839
+ // A continuation inherits its predecessor's EventGate liveness and
14840
+ // logical turn. If policy drops it, no driveStream remains to end them,
14841
+ // so its turn ends here (endContinuedTurn).
14842
+ if (requests.some(continuesTurn)) {
14843
+ this.endContinuedTurn(agentName, { kind: 'dropped', why: 'the inference policy dropped its continuation' });
14844
+ }
14461
14845
  this.endPendingPrimary(agentName);
14462
14846
  continue;
14463
14847
  }
@@ -14533,7 +14917,7 @@ export class AgentFramework {
14533
14917
  // exists only once assembly renders it. Recorded for the whole batch
14534
14918
  // (not inherited from requests[0]), so a requeued trigger stays honest.
14535
14919
  const batchOnly = !budgetRestart && requests.every((r) => r.coalescingBatch === true && !!r.coalescingSubject);
14536
- await this.startAgentStream(agent, {
14920
+ const merged: InferenceRequest = {
14537
14921
  ...trigger,
14538
14922
  coalescingBatch: batchOnly || undefined,
14539
14923
  coalescingBatchSubjects: batchOnly
@@ -14561,7 +14945,17 @@ export class AgentFramework {
14561
14945
  counterparty: budgetRestart ? undefined : provReq?.counterparty,
14562
14946
  wakeChannelId: budgetRestart ? undefined : provReq?.wakeChannelId,
14563
14947
  wakeAt: budgetRestart ? undefined : provReq?.wakeAt,
14564
- });
14948
+ };
14949
+ // A held turn's retry merged with other wakes leaves the start its
14950
+ // turn's retry (TURN_RETRY), whichever request leads the merge: held
14951
+ // again before it takes a token, it keeps the turn alive as before.
14952
+ const start: MarkedRequest = requests.some(isTurnRetry) ? asTurnRetry(merged) : merged;
14953
+ // A start that carries a turn on and took in wakes that don't records
14954
+ // the batch it was merged from (MERGED_BATCH): until it takes its
14955
+ // token it stands for them all.
14956
+ if (continuesTurn(start) && requests.some((r) => !continuesTurn(r))) start[MERGED_BATCH] = [...requests];
14957
+ else delete start[MERGED_BATCH];
14958
+ await this.startAgentStream(agent, start);
14565
14959
  }
14566
14960
  }
14567
14961
 
@@ -16912,7 +17306,12 @@ export class AgentFramework {
16912
17306
  // Do not await inside processInferenceRequests: one slow auxiliary call for
16913
17307
  // this resident must not block events or other residents. Admission stays
16914
17308
  // owned and the continuation receives it after the in-flight call settles.
17309
+ const parkedContinuation = trigger !== undefined && continuesTurn(trigger);
17310
+ if (parkedContinuation) this.parkedContinuations.add(trigger);
16915
17311
  void this.waitForAuxiliaryIdle(agent.name).then(async () => {
17312
+ // A continuation dropped while it was parked left the set then, with
17313
+ // its admission given back.
17314
+ if (parkedContinuation && !this.parkedContinuations.delete(trigger)) return;
16916
17315
  if (this.providerAdmissionClosed) {
16917
17316
  this.releasePrimaryProviderGate(agent.name);
16918
17317
  return;
@@ -16939,8 +17338,9 @@ export class AgentFramework {
16939
17338
  // park; starting here would bypass it and run a turn against a
16940
17339
  // context the operator believes frozen. Give admission back and
16941
17340
  // requeue — the scheduler re-parks it (coalesced) until resume.
16942
- // Continuations of a held turn pass, as they do at the gate.
16943
- if (this.quiesced && !isTurnContinuation(trigger?.reason ?? '')) {
17341
+ // Continuations of a held turn, and a held turn's retry, pass, as
17342
+ // they do at the gate (passesQuiesce).
17343
+ if (this.quiesced && !passesQuiesce(trigger)) {
16944
17344
  this.releasePrimaryProviderGate(agent.name);
16945
17345
  this.pendingRequests.push(trigger ?? {
16946
17346
  agentName: agent.name, reason: 'provider-admission:requeue', source: 'scheduler', timestamp: Date.now(),
@@ -16948,6 +17348,16 @@ export class AgentFramework {
16948
17348
  console.error(`[provider-admission] ${agent.name}: host quiesced while parked — wake requeued, not started`);
16949
17349
  return;
16950
17350
  }
17351
+ // A start that stands for a merged batch (MERGED_BATCH) goes back
17352
+ // to the scheduler as that batch: its gate lets the turn's request
17353
+ // through alone and parks the wakes until resume.
17354
+ const batch = trigger && mergedBatch(trigger);
17355
+ if (this.quiesced && batch) {
17356
+ this.releasePrimaryProviderGate(agent.name);
17357
+ this.pendingRequests.push(...batch);
17358
+ console.error(`[provider-admission] ${agent.name}: host quiesced while parked — the merged start goes back as its ${batch.length} requests`);
17359
+ return;
17360
+ }
16951
17361
  await this.startAgentStream(agent, trigger, attempt, true);
16952
17362
  }).catch((error) => {
16953
17363
  this.releasePrimaryProviderGate(agent.name);
@@ -17072,6 +17482,137 @@ export class AgentFramework {
17072
17482
  }
17073
17483
  }
17074
17484
 
17485
+ /**
17486
+ * Drop every continuation of `agentName`'s turn still on its way, wherever
17487
+ * it waits: the queue, a provider hold (which, left empty, then releases
17488
+ * with nothing to run), a park on admission (whose admission it gives
17489
+ * back), or a scheduler pass's hands (which then never reach it). If one
17490
+ * was there, the turn ends (endContinuedTurn), unless `drop` is null: the
17491
+ * caller has ended the turn already.
17492
+ */
17493
+ private dropContinuations(agentName: string, drop: ContinuationDrop | null): void {
17494
+ const isContinuation = (r: InferenceRequest): boolean => r.agentName === agentName && continuesTurn(r);
17495
+ // A caller's stop and an abandon end the turn, not the wakes merged into
17496
+ // a start of it that hasn't taken its token (MERGED_BATCH): they go back
17497
+ // in the queue. A stop() or a disposal ends the agent's work, and puts
17498
+ // nothing back.
17499
+ const putsBack = drop?.kind === 'caller' || drop?.kind === 'quiesce_abandoned';
17500
+ const back: InferenceRequest[] = [];
17501
+ let dropped = false;
17502
+ const take = (requests: InferenceRequest[]): InferenceRequest[] => {
17503
+ const left: InferenceRequest[] = [];
17504
+ for (const r of requests) {
17505
+ if (!isContinuation(r)) left.push(r);
17506
+ else {
17507
+ dropped = true;
17508
+ if (putsBack) back.push(...mergedWakes(r));
17509
+ }
17510
+ }
17511
+ return left;
17512
+ };
17513
+ this.pendingRequests = take(this.pendingRequests);
17514
+ const hold = this.providerAccelerationCooldowns.get(agentName);
17515
+ if (hold) {
17516
+ const held = hold.heldRequests.length;
17517
+ hold.heldRequests = take(hold.heldRequests);
17518
+ if (held > 0 && hold.heldRequests.length === 0) hold.emptiedByDrop = true;
17519
+ }
17520
+ for (const parked of [...this.parkedContinuations]) {
17521
+ if (!isContinuation(parked)) continue;
17522
+ this.parkedContinuations.delete(parked);
17523
+ this.releasePrimaryProviderGate(agentName);
17524
+ dropped = true;
17525
+ if (putsBack) back.push(...mergedWakes(parked));
17526
+ }
17527
+ for (const pass of this.passRequests) {
17528
+ const requests = pass.get(agentName);
17529
+ if (!requests) continue;
17530
+ const left = take(requests);
17531
+ requests.length = 0;
17532
+ requests.push(...left);
17533
+ }
17534
+ this.pendingRequests.push(...back);
17535
+ if (!dropped || !drop) return;
17536
+ // A primary a provider hold left pending for what it released won't run
17537
+ // now, when nothing else of the agent's waits (endPendingPrimary).
17538
+ if (!this.pendingRequests.some((r) => r.agentName === agentName) && !this.providerAccelerationCooldowns.get(agentName)?.heldRequests.length) {
17539
+ this.endPendingPrimary(agentName);
17540
+ }
17541
+ this.endContinuedTurn(agentName, drop);
17542
+ }
17543
+
17544
+ /**
17545
+ * End a turn whose continuation won't run. The frame that handed the turn
17546
+ * to it kept the turn open for it: its logical turn, the EventGate's
17547
+ * window, the tool-result guard's batch and recovery, its typing. An
17548
+ * abandon or a stop of a registered agent settles the turn as it settles a
17549
+ * turn's start (endStoppedStart: one inference:aborted, the turn settled,
17550
+ * the gate released). Any other drop ends it as a start dropped before it
17551
+ * sends anything ends. That frame, if it hasn't let go of its token yet,
17552
+ * ends on its own and settles nothing. What was deferred for the agent
17553
+ * lands now, or at that frame's end.
17554
+ */
17555
+ private endContinuedTurn(agentName: string, drop: ContinuationDrop): void {
17556
+ console.error(`[turn-continuation] ${agentName}: its turn ends without its continuation: ${drop.why}`);
17557
+ const agent = this.agents.get(agentName);
17558
+ const startedAt = this.lastInferenceAt.get(agentName)?.startedAt ?? Date.now();
17559
+ if (agent && drop.kind !== 'dropped') {
17560
+ const routing = this.openLogical.get(agentName) ?? this.routingOf(agentName);
17561
+ this.endStoppedStart(agent, drop.kind === 'caller' ? { kind: 'caller', reason: drop.reason } : { kind: drop.kind },
17562
+ routing, startedAt, undefined, false);
17563
+ } else {
17564
+ const owner = this.activeTurnTokens.get(agentName);
17565
+ this.abortDroppedTurn(agentName, owner ?? -1,
17566
+ drop.kind === 'dropped' ? CONTINUATION_DROPPED : drop.kind === 'caller' ? (drop.reason ?? 'user') : drop.kind, startedAt);
17567
+ this.channelRegistry?.stopTyping();
17568
+ if (agent) {
17569
+ agent.toolResultGuard.recovering = false;
17570
+ if (agent.toolResultGuard.hasPending) {
17571
+ try {
17572
+ agent.toolResultGuard.abandon('aborted');
17573
+ } catch (error) {
17574
+ console.error(`[tool-result-guard] agent=${agentName} failed to record aborted batch:`, error);
17575
+ }
17576
+ }
17577
+ }
17578
+ const routing = this.openLogical.get(agentName) ?? this.turnRouting.get(agentName);
17579
+ if (routing) this.endDroppedTurn(agentName, routing);
17580
+ this.eventGate?.onInferenceEnded(agentName);
17581
+ }
17582
+ if (!this.activeTurnTokens.has(agentName) && this.deferredMessages.length > 0 && !this.pendingAssistantBlocks.has(agentName)) {
17583
+ this.readdDrained(this.drainDeferredFor(agentName), 'continuation-drop flush');
17584
+ this.ackDeferredWrites();
17585
+ }
17586
+ }
17587
+
17588
+ /**
17589
+ * After a failure a provider hold would take: once stop() has closed
17590
+ * admission, the hold installs nothing (holdForProviderWait,
17591
+ * holdProviderAcceleration), so no retry follows the turn's
17592
+ * inference:failed, and the turn ends as a retry a stop cancelled ends, with
17593
+ * an inference:aborted saying so. Nothing is written. True when it ended
17594
+ * the turn.
17595
+ */
17596
+ private endFailureAtStop(agentName: string, turnToken: number, startedAt: number): boolean {
17597
+ if (!this.providerAdmissionClosed) return false;
17598
+ this.abortDroppedTurn(agentName, turnToken, RETRY_CANCELLED_BY_STOP, startedAt);
17599
+ return true;
17600
+ }
17601
+
17602
+ /**
17603
+ * A turn's start whose failure a provider hold took (or, once stop() has
17604
+ * closed admission, would have: endFailureAtStop) ends here. Its window
17605
+ * closes now, not at the start's finally a few microtasks on: no check of
17606
+ * this start is ahead, and a stop made after the failure's report must
17607
+ * find the held retry (abortInference's drop), not a window nothing will
17608
+ * collect.
17609
+ */
17610
+ private endHeldStart(agent: Agent, checkWindow: CheckWindow, turnToken: number, startedAt: number): void {
17611
+ if (this.checkAhead.get(agent) === checkWindow) this.checkAhead.delete(agent);
17612
+ this.endFailureAtStop(agent.name, turnToken, startedAt);
17613
+ this.eventGate?.onInferenceEnded(agent.name);
17614
+ }
17615
+
17075
17616
  /**
17076
17617
  * End a turn that beginAgentTurn drops before it sends anything. The turn
17077
17618
  * emitted inference:started, and listeners that bracket a turn, such as a
@@ -17122,7 +17663,7 @@ export class AgentFramework {
17122
17663
  */
17123
17664
  private endStoppedStart(
17124
17665
  agent: Agent,
17125
- stop: { kind: 'quiesce_abandoned' } | { kind: 'caller'; reason?: string },
17666
+ stop: { kind: 'quiesce_abandoned' | 'shutdown' } | { kind: 'caller'; reason?: string },
17126
17667
  routing: TurnRouting,
17127
17668
  turnStartedAt: number,
17128
17669
  compiledRequest: NormalizedRequest | undefined,
@@ -17132,7 +17673,7 @@ export class AgentFramework {
17132
17673
  * so the note says it was stopped before it finished. */
17133
17674
  opensTurn: boolean,
17134
17675
  ): 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`);
17676
+ 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
17677
  this.channelRegistry?.stopTyping();
17137
17678
  // A start that continues a turn (a budget or guard restart) follows a
17138
17679
  // frame that kept the turn's tool-result batch, and the guard's recovery,
@@ -17160,7 +17701,7 @@ export class AgentFramework {
17160
17701
  if (stop.kind === 'caller') {
17161
17702
  this.settleDeliberateCancel({ ...common, reason: stop.reason ?? 'user', before: opensTurn ? 'sending' : 'finishing' });
17162
17703
  } else {
17163
- this.settleFrameworkCancel({ ...common, kind: 'quiesce_abandoned' });
17704
+ this.settleFrameworkCancel({ ...common, kind: stop.kind });
17164
17705
  }
17165
17706
  const last = this.lastInferenceAt.get(agent.name);
17166
17707
  if (last?.startedAt === turnStartedAt) this.lastInferenceAt.set(agent.name, { ...last, endedAt: Date.now() });
@@ -17303,6 +17844,12 @@ export class AgentFramework {
17303
17844
  const { coalescingSubject: _subject, coalescingBatch: _batch, coalescingBatchSubjects: _subjects, readsDeferredWrite: _read, ...retry } = trigger;
17304
17845
  trigger = retry;
17305
17846
  }
17847
+ // The wakes merged into this start are the turn's now: it reads them,
17848
+ // so a later drop of its retry puts none of them back (MERGED_BATCH).
17849
+ if (trigger && mergedBatch(trigger)) {
17850
+ const { [MERGED_BATCH]: _batch, ...own } = trigger as MarkedRequest;
17851
+ trigger = own;
17852
+ }
17306
17853
 
17307
17854
  // Record turn checkpoint before inference (only on first attempt, not retries)
17308
17855
  if (attempt === 0 && trigger?.reason !== 'tool_result_guard_retry') {
@@ -17598,9 +18145,8 @@ export class AgentFramework {
17598
18145
  // hold the turn as the boundary would rather than send it into the wait.
17599
18146
  if (this.conversationAgentHomes.has(agent.name)) {
17600
18147
  const wait = this.providerWaits?.active(agent.name, agent.model);
17601
- if (wait && this.holdForProviderWait(agent, wait, trigger ? [trigger] : [])) {
17602
- console.error(`[provider-wait] agent=${agent.name} model=${agent.model} binds after the compile ` +
17603
- `(recorded while the turn compiled); turn held, nothing sent`);
18148
+ // The turn has begun (inference:started), so the hold keeps its retry.
18149
+ if (wait && this.holdForProviderWait(agent, wait, trigger ? [asTurnRetry(trigger)] : [], false, () => {
17604
18150
  // The stream the compile made won't be sent: it's cancelled, and
17605
18151
  // this start's window closes, before the hold is reported, so a stop
17606
18152
  // made while inference:failed is delivered finds neither, and the
@@ -17611,8 +18157,15 @@ export class AgentFramework {
17611
18157
  if (this.checkAhead.get(agent) === checkWindow) this.checkAhead.delete(agent);
17612
18158
  // The turn emitted inference:started before its compile; every turn
17613
18159
  // that does gets one terminal event, which listeners (a typing loop)
17614
- // rely on, as the failure sites' holds emit inference:failed.
18160
+ // rely on. It's reported before the hold takes the turn's retry, as
18161
+ // at every failure site, so a listener's stop finds nothing held.
17615
18162
  this.emitTrace({ type: 'inference:failed', agentName: agent.name, error: providerWaitText(agent.name, agent.model, wait) });
18163
+ })) {
18164
+ console.error(`[provider-wait] agent=${agent.name} model=${agent.model} binds after the compile ` +
18165
+ `(recorded while the turn compiled); ${this.providerAdmissionClosed ? 'the framework is stopping, so nothing is held'
18166
+ : this.providerAccelerationCooldowns.get(agent.name)?.heldRequests.some(isTurnRetry) ? 'turn held'
18167
+ : 'the wait was released as the failure was reported, so the retry is queued'}, nothing sent`);
18168
+ this.endFailureAtStop(agent.name, turnToken, turnStartedAt);
17616
18169
  this.activeStreams.delete(agent.name);
17617
18170
  this.pendingAssistantBlocks.delete(agent.name);
17618
18171
  this.channelRegistry?.stopTyping();
@@ -17697,14 +18250,16 @@ export class AgentFramework {
17697
18250
  if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
17698
18251
  // Capacity scheduling is not poisoned history. The cooldown timer owns
17699
18252
  // one later fresh compile; no immediate retry or exhaustion marker.
17700
- this.eventGate?.onInferenceEnded(agent.name);
18253
+ // (Once stop() has closed admission, nothing is held, and the turn
18254
+ // ends here: endFailureAtStop.)
18255
+ this.endHeldStart(agent, checkWindow, turnToken, turnStartedAt);
17701
18256
  return false;
17702
18257
  }
17703
18258
  const statedWait = ownsProviderGate ? undefined : this.nonOwningProviderWait(agent, err, trigger);
17704
18259
  if (statedWait === 'held') {
17705
18260
  // A conversation agent held by the provider's stated wait, as a
17706
18261
  // resident is: the hold's release owns the later compile.
17707
- this.eventGate?.onInferenceEnded(agent.name);
18262
+ this.endHeldStart(agent, checkWindow, turnToken, turnStartedAt);
17708
18263
  return false;
17709
18264
  }
17710
18265
 
@@ -17739,7 +18294,15 @@ export class AgentFramework {
17739
18294
  }
17740
18295
  // The retry re-enters startAgentStream, which replaces this frame's
17741
18296
  // turn token with its own; cleanup belongs to the innermost frame.
17742
- await this.startAgentStream(agent, trigger, attempt + 1);
18297
+ // The retry is its turn's (TURN_RETRY): this frame has begun the turn,
18298
+ // and once the retry's start is held at the last admission boundary
18299
+ // (a conversation agent's own compression can record a wait during
18300
+ // the policy's delay), this frame lets go of its token. No check of
18301
+ // this start is ahead now, so its window closes first: a stop made
18302
+ // once the retry is held finds the held retry (abortInference's
18303
+ // drop), not a window nothing will collect.
18304
+ if (this.checkAhead.get(agent) === checkWindow) this.checkAhead.delete(agent);
18305
+ await this.startAgentStream(agent, trigger && asTurnRetry(trigger), attempt + 1);
17743
18306
  } else {
17744
18307
  // Terminal failure with no driveStream: this frame owns teardown.
17745
18308
  // Token-matched (like the finally in driveStream) so a stale frame
@@ -17889,8 +18452,8 @@ export class AgentFramework {
17889
18452
  if (turnEnded) this.recordProviderAccelerationRecovery(agent, compiledRequest, end);
17890
18453
  // The accounting writes to the store, and a write that fails must not
17891
18454
  // 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.
18455
+ // end, or a restart's trace (its cancel and its request come before
18456
+ // this call), and then the event's dispatch to modules.
17894
18457
  try {
17895
18458
  if (turnEnded) {
17896
18459
  completeTurn(latestUsage, durationMs);
@@ -19313,12 +19876,16 @@ export class AgentFramework {
19313
19876
 
19314
19877
  this.abandonAgentScripts(agent.name, 'stream error', streamRouting);
19315
19878
 
19879
+ // A hold takes the turn's retry, or, once stop() has closed
19880
+ // admission, the turn ends here (endFailureAtStop).
19316
19881
  if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
19882
+ this.endFailureAtStop(agent.name, myTurnToken ?? -1, startTime);
19317
19883
  if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
19318
19884
  break;
19319
19885
  }
19320
19886
  const statedWait = ownsProviderGate ? undefined : this.nonOwningProviderWait(agent, err, trigger);
19321
19887
  if (statedWait === 'held') {
19888
+ this.endFailureAtStop(agent.name, myTurnToken ?? -1, startTime);
19322
19889
  if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
19323
19890
  break;
19324
19891
  }
@@ -19355,7 +19922,8 @@ export class AgentFramework {
19355
19922
  }
19356
19923
  return;
19357
19924
  }
19358
- await this.startAgentStream(agent, trigger, attempt + 1);
19925
+ // Marked as its turn's retry, as at the start's failure.
19926
+ await this.startAgentStream(agent, trigger && asTurnRetry(trigger), attempt + 1);
19359
19927
  } else {
19360
19928
  // The turn ends here, and its inference:failed doesn't say so,
19361
19929
  // since a retry usually follows one: an inference:aborted
@@ -19676,10 +20244,16 @@ export class AgentFramework {
19676
20244
  const durationMs = Date.now() - startTime;
19677
20245
  // The stream won't go on: the agent stops holding it before the
19678
20246
  // failure is reported, as at the stream's error event, so a stop made
19679
- // while inference:failed is delivered finds no stream.
20247
+ // while inference:failed is delivered finds no stream. It's reported
20248
+ // once, before a provider hold takes the turn's retry, as at the
20249
+ // stream's error event and at a start's failure: a listener's stop
20250
+ // finds nothing held, and one made after it ends the held turn.
19680
20251
  agent.reset();
20252
+ this.emitTrace({ type: 'inference:failed', agentName: agent.name, error: err.message, stack: err.stack });
19681
20253
  if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
19682
- this.emitTrace({ type: 'inference:failed', agentName: agent.name, error: err.message, stack: err.stack });
20254
+ // Once stop() has closed admission, nothing is held, and the turn
20255
+ // ends here (endFailureAtStop).
20256
+ this.endFailureAtStop(agent.name, myTurnToken ?? -1, startTime);
19683
20257
  logStream({ success: false,
19684
20258
  error: `Provider acceleration cooldown: ${err.message}`,
19685
20259
  request: compiledRequest ?? { note: 'streaming request rate-limited' }, durationMs });
@@ -19693,7 +20267,7 @@ export class AgentFramework {
19693
20267
  // says why it ends.
19694
20268
  const statedWait = ownsProviderGate ? undefined : this.nonOwningProviderWait(agent, err, trigger);
19695
20269
  if (statedWait === 'held') {
19696
- this.emitTrace({ type: 'inference:failed', agentName: agent.name, error: err.message, stack: err.stack });
20270
+ this.endFailureAtStop(agent.name, myTurnToken ?? -1, startTime);
19697
20271
  logStream({ success: false,
19698
20272
  error: `Provider wait: ${err.message}`,
19699
20273
  request: compiledRequest ?? { note: 'streaming request rate-limited' }, durationMs });
@@ -19701,12 +20275,6 @@ export class AgentFramework {
19701
20275
  lifecyclePhase = 'failed'; agent.reset(); if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
19702
20276
  return;
19703
20277
  }
19704
- this.emitTrace({
19705
- type: 'inference:failed',
19706
- agentName: agent.name,
19707
- error: err.message,
19708
- stack: err.stack,
19709
- });
19710
20278
  this.settleAgent(agent.name, {
19711
20279
  stopReason: 'exhausted',
19712
20280
  speech: '',
@@ -19891,7 +20459,11 @@ export class AgentFramework {
19891
20459
  // its ids when a successor turn has already taken the agent. A fork or
19892
20460
  // run deregistered while the host is quiesced leaves its queue to its
19893
20461
  // window's release at resume (drainDeferredFor).
19894
- if (frameReachedTerminal && this.deferredMessages.length > 0 && !this.pendingAssistantBlocks.has(agent.name)) {
20462
+ // A frame that handed its turn to a continuation since dropped
20463
+ // (endContinuedTurn, which couldn't land it while this frame held the
20464
+ // token) lands it too: the turn is over.
20465
+ const handedOffTurnOver = preserveEventGateForSuccessor && !this.hasTurn(agent.name);
20466
+ if ((frameReachedTerminal || handedOffTurnOver) && this.deferredMessages.length > 0 && !this.pendingAssistantBlocks.has(agent.name)) {
19895
20467
  this.readdDrained(this.drainDeferredFor(agent.name), 'turn-end flush');
19896
20468
  this.ackDeferredWrites();
19897
20469
  }
@@ -19914,8 +20486,11 @@ export class AgentFramework {
19914
20486
  // frame that ends the turn disposes it, after the flush above, so what
19915
20487
  // was held for the turn's end lands in the fork's store instead of
19916
20488
  // being dropped as addressed to no agent. A frame that hands the turn
19917
- // to a successor (a guard or budget restart) leaves the fork to it.
19918
- if (frameReachedTerminal && ownsPhysicalStream && this.closingConversationAgents.has(agent.name)) {
20489
+ // to a successor (a guard or budget restart) leaves the fork to it, and
20490
+ // so does one whose failure a provider hold keeps for its retry: the
20491
+ // turn isn't over (pendingContinuationAgents).
20492
+ if (frameReachedTerminal && ownsPhysicalStream && this.closingConversationAgents.has(agent.name)
20493
+ && !this.pendingContinuationAgents().has(agent.name)) {
19919
20494
  this.disposeConversationAgent(agent.name);
19920
20495
  }
19921
20496
  }
@@ -20007,11 +20582,16 @@ export class AgentFramework {
20007
20582
  `puppet refused: the store is under live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName} — retry when it completes`,
20008
20583
  );
20009
20584
  }
20010
- if (agent.state.status !== 'idle' || this.activeTurnTokens.has(agentName)) {
20585
+ if (agent.state.status !== 'idle' || this.hasTurn(agentName)) {
20011
20586
  const shown = agent.state.status === 'idle' ? 'idle+turn-alive' : agent.state.status;
20012
- throw new Error(
20013
- `puppet refused: agent ${agentName} is ${shown} (requires idle — ` +
20014
- `injecting a turn under an active stream corrupts wire ordering)`,
20587
+ // A turn a provider hold keeps for its retry has no stream: the refusal
20588
+ // names the hold, and how to go on sooner, as surgery's does.
20589
+ const held = agent.state.status === 'idle' ? this.heldTurnRetry(agentName) : undefined;
20590
+ throw new Error(held
20591
+ ? `puppet refused: agent ${agentName} is idle+turn-alive (${held.says}; requires idle); ` +
20592
+ heldTurnWayOn(held.statedWait)
20593
+ : `puppet refused: agent ${agentName} is ${shown} (requires idle — ` +
20594
+ `injecting a turn under an active stream corrupts wire ordering)`,
20015
20595
  );
20016
20596
  }
20017
20597
  const onSurface = this.getToolsForAgent(agentName)
@@ -20958,9 +21538,10 @@ export class AgentFramework {
20958
21538
  if (!roundCalls.some((c) => c.name === counted.name && c.cause === counted.cause && c.refused === counted.refused)) roundCalls.push(counted);
20959
21539
  }
20960
21540
  if (refusal !== undefined) return refused(refusal);
21541
+ const route = { toServer: false };
20961
21542
  const result = await this.dispatchScriptToolCall(
20962
21543
  agentName, toolName, input as Record<string, unknown>,
20963
- context.origin as ScriptCallOrigin | undefined, context.watch?.turnId,
21544
+ context.origin as ScriptCallOrigin | undefined, context.watch?.turnId, route,
20964
21545
  );
20965
21546
  // Deferred: applied to the result of the code_execution call watching
20966
21547
  // the script — ending the turn mid-script would cancel the stream while
@@ -20971,7 +21552,7 @@ export class AgentFramework {
20971
21552
  const watching = context.watching ? context.watching() : context.watch;
20972
21553
  if (result.endTurn && watching) watching.endTurn = true;
20973
21554
  if (result.refused) return refused(result.error ?? `${toolName} was refused; nothing was done`);
20974
- const answer = await this.scriptResultAnswer(result, toolName);
21555
+ const answer = await this.scriptResultAnswer(result, toolName, route.toServer);
20975
21556
  return SCRIPT_CALL_NO_RESULT.has(result) ? { ...answer, held: true } : answer;
20976
21557
  }
20977
21558
 
@@ -20980,14 +21561,20 @@ export class AgentFramework {
20980
21561
  * isError }`, one shape for every tool. `content` is
20981
21562
  * what the model would read: a structured result's blocks as the dispatch
20982
21563
  * 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
21564
+ * blocks there, bound to the server that sent them), a module's under
21565
+ * history's reading of RFC-005 at every depth (shownToScript), and
21566
+ * otherwise one text block of the model's text, which for a failure is its
21567
+ * error. A module's is any result whose call the dispatch didn't send to
21568
+ * a server (`toServer`, from the call's waiter). `structuredContent` is the
21569
+ * tool's own structured
20985
21570
  * content where it sent one, `false`, `0` and `null` included, else null.
20986
21571
  * A result that can't be converted to JSON (a cyclic object, a BigInt),
20987
21572
  * which the dispatch's own check makes unlikely here, is a failure naming
20988
- * the tool and saying the call itself completed.
21573
+ * the tool and saying the call itself completed. So is an MCPL peer's
21574
+ * result whose blocks were too deep for the dispatch's treatment, which
21575
+ * the dispatch marks (SCRIPT_RESULT_UNCONVERTIBLE).
20989
21576
  */
20990
- private async scriptResultAnswer(result: ToolResult, toolName: string): Promise<ToolAnswer> {
21577
+ private async scriptResultAnswer(result: ToolResult, toolName: string, toServer: boolean): Promise<ToolAnswer> {
20991
21578
  const structured = Object.prototype.hasOwnProperty.call(result, 'structured');
20992
21579
  const isError = result.isError === true;
20993
21580
  // RFC-005 "eagerly lazy": a script receiving a result is the strongest
@@ -21004,7 +21591,10 @@ export class AgentFramework {
21004
21591
  // which isn't fetched.
21005
21592
  // Whenever the result's blocks reach the script: a structured failure's
21006
21593
  // included, as a module's error can carry content.
21007
- if ((!isError || structured) && Array.isArray(result.data) && this.resolveMcplTool(toolName) === null) {
21594
+ // Not a server's result: a module's, whose blocks no dispatch has given
21595
+ // RFC-005's treatment.
21596
+ const fromModule = !toServer;
21597
+ if ((!isError || structured) && Array.isArray(result.data) && fromModule) {
21008
21598
  const fetcher = this.getReferenceFetcher();
21009
21599
  for (const block of result.data) {
21010
21600
  const c = classifyBlock(block);
@@ -21024,6 +21614,9 @@ export class AgentFramework {
21024
21614
  brief: error,
21025
21615
  };
21026
21616
  };
21617
+ // An MCPL peer's result whose blocks JSON couldn't hold, so that RFC-005's
21618
+ // treatment couldn't be given them at dispatch.
21619
+ if (SCRIPT_RESULT_UNCONVERTIBLE.has(result)) return failed(SCRIPT_RESULT_UNCONVERTIBLE.get(result));
21027
21620
  let content: unknown[];
21028
21621
  let text: string | undefined;
21029
21622
  // A structured result's blocks, as #538 gives them: none when its content
@@ -21031,6 +21624,20 @@ export class AgentFramework {
21031
21624
  // no blocks reads as its error text.
21032
21625
  if (structured && (!isError || (Array.isArray(result.data) && result.data.length > 0))) {
21033
21626
  content = Array.isArray(result.data) ? result.data : [];
21627
+ // A module's blocks, such as an MCPL peer's it got from ctx.callTool
21628
+ // and returns whole, are read as history reads a module's data: what
21629
+ // RFC-005 governs, at any depth, as the script lane shows it, and
21630
+ // anything else as sent. What a script prints comes back to the model,
21631
+ // so no uri the model's lanes withhold reaches it. What this gives is
21632
+ // a stub, a notice or the value itself, so a module that returns
21633
+ // blocks it already gave has them left as they are.
21634
+ if (fromModule) {
21635
+ try {
21636
+ content = JSON.parse(JSON.stringify(content, (_key, value: unknown) => shownToScript(value))) as unknown[];
21637
+ } catch (err) {
21638
+ return failed(err);
21639
+ }
21640
+ }
21034
21641
  } else {
21035
21642
  // As in history, a reference is its stub at any depth and inline data
21036
21643
  // claiming a uri or a disposition is withheld (RFC-005).
@@ -21067,7 +21674,9 @@ export class AgentFramework {
21067
21674
  * RFC-007: with an `origin`, observers see the call as they see a model
21068
21675
  * call (register, dispatch with its refusal sites, open; the terminal at
21069
21676
  * the waiter intercept), under the inner tool's own name and class.
21070
- * `turnId` is the turn the call was made in (dispatchToolCall).
21677
+ * `turnId` is the turn the call was made in (dispatchToolCall). `route` is
21678
+ * the waiter's: the dispatch sets `toServer` when it sends the call to a
21679
+ * server.
21071
21680
  */
21072
21681
  private dispatchScriptToolCall(
21073
21682
  agentName: string,
@@ -21075,6 +21684,7 @@ export class AgentFramework {
21075
21684
  args: Record<string, unknown>,
21076
21685
  origin?: ScriptCallOrigin,
21077
21686
  turnId?: number,
21687
+ route: { toServer: boolean } = { toServer: false },
21078
21688
  ): Promise<ToolResult> {
21079
21689
  return new Promise<ToolResult>((resolve) => {
21080
21690
  const callId = `pytc-${randomUUID()}`;
@@ -21102,9 +21712,12 @@ export class AgentFramework {
21102
21712
  }, safetyMs);
21103
21713
  safety.unref?.();
21104
21714
 
21105
- this.scriptToolWaiters.set(callId, (result) => {
21106
- clearTimeout(safety);
21107
- resolve(result);
21715
+ this.scriptToolWaiters.set(callId, {
21716
+ resolve: (result) => {
21717
+ clearTimeout(safety);
21718
+ resolve(result);
21719
+ },
21720
+ route,
21108
21721
  });
21109
21722
 
21110
21723
  const call = { id: callId, name: toolName, input: args };
@@ -21663,6 +22276,10 @@ export class AgentFramework {
21663
22276
  // Route MCPL tool calls to the appropriate server via prefix map
21664
22277
  const mcplMatch = this.resolveMcplTool(enrichedCall.name);
21665
22278
  if (mcplMatch && this.mcplServerRegistry) {
22279
+ // A script's call: its waiter keeps that it went to a server, for the
22280
+ // script lane's exit (scriptToolWaiters).
22281
+ const scriptWaiter = this.scriptToolWaiters.get(enrichedCall.id);
22282
+ if (scriptWaiter) scriptWaiter.route.toServer = true;
21666
22283
  this.dispatchMcplToolCall(agentName, enrichedCall, mcplMatch[0], mcplMatch[1]);
21667
22284
  return;
21668
22285
  }
@@ -26769,8 +27386,8 @@ export class AgentFramework {
26769
27386
  }
26770
27387
 
26771
27388
  /** An MCP tool call's trace: a result the server marked an error (or one
26772
- * the reading couldn't complete) is tool:failed, as on the other tool
26773
- * paths; anything else is tool:completed. */
27389
+ * the reading takes as one, such as malformed content) is tool:failed, as
27390
+ * on the other tool paths; anything else is tool:completed. */
26774
27391
  private traceMcpToolOutcome(serverId: string, tool: string, callId: string, result: ToolResult, durationMs: number): void {
26775
27392
  if (result.isError) {
26776
27393
  this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool, callId, durationMs, error: result.error ?? 'tool returned an error' });
@@ -27190,14 +27807,42 @@ export class AgentFramework {
27190
27807
  // generated JSON) stands in for what the server sent. An MCPL peer's
27191
27808
  // content blocks are the exception: what a script prints comes back to
27192
27809
  // 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.
27810
+ // the model reads, here, where the server that sent them is known, and
27811
+ // so does what RFC-005 governs at any depth within them, by the same
27812
+ // peer reading, bound to that server (shownToScriptFromServer). A
27813
+ // module's blocks get it at
27814
+ // the script lane's exit (scriptResultAnswer). A standard peer's blocks
27815
+ // reach the script as sent.
27194
27816
  const forScript = this.scriptToolWaiters?.has(call.id) ?? false;
27195
27817
  const reading = async (result: { content?: unknown; isError?: boolean; structuredContent?: unknown }, mcpl: boolean): Promise<ToolResult> => {
27196
27818
  if (!forScript || !Object.prototype.hasOwnProperty.call(result, 'structuredContent')) {
27197
27819
  return this.mcpToolResult(result, call.id, mcpl, serverId, call.name);
27198
27820
  }
27199
27821
  const direct = AgentFramework.directToolResult(result, serverId, call.name);
27200
- if (mcpl) return Array.isArray(direct.data) ? { ...direct, data: direct.data.map((block: unknown) => referenceBlockForScript(block, serverId)) } : direct;
27822
+ if (mcpl) {
27823
+ if (!Array.isArray(direct.data)) return direct;
27824
+ // Each block by the classifier, then what's nested in each, at any
27825
+ // depth. The blocks came from the wire as JSON, so the round trip
27826
+ // changes nothing else. But a value that parsed can still fail to
27827
+ // go back: past some depth JSON.stringify overflows the stack where
27828
+ // JSON.parse didn't (on Node 24.19 an array 5,000 deep does, on Bun
27829
+ // 1.4.4 one 200,000 deep). That fails the script lane's treatment
27830
+ // alone, and the peer answered: the result keeps the peer's outcome
27831
+ // for the trace and the call's terminal, and the script lane's exit
27832
+ // answers it as a result it can't convert, not as a failed dispatch
27833
+ // (SCRIPT_RESULT_UNCONVERTIBLE). Its blocks are dropped, since they
27834
+ // are untreated and the result travels on, to the waiter and to the
27835
+ // listeners of its event's trace. Its structured content is kept, as
27836
+ // every result keeps it, since RFC-005's treatment doesn't reach it.
27837
+ const blocks = direct.data.map((block: unknown) => referenceBlockForScript(block, serverId));
27838
+ try {
27839
+ return { ...direct, data: JSON.parse(JSON.stringify(blocks, (_key, value: unknown) => shownToScriptFromServer(value, serverId))) as unknown[] };
27840
+ } catch (err) {
27841
+ const unconvertible: ToolResult = { ...direct, data: [] };
27842
+ SCRIPT_RESULT_UNCONVERTIBLE.set(unconvertible, err);
27843
+ return unconvertible;
27844
+ }
27845
+ }
27201
27846
  // A standard peer's content that isn't a list of blocks is a malformed
27202
27847
  // result on the model's reading (normalizeStandardToolResult), so the
27203
27848
  // script reads it that way too, its structured content kept.
@@ -27422,7 +28067,8 @@ export class AgentFramework {
27422
28067
  * direct path (a module's callTool, a channel's tool call) doesn't come
27423
28068
  * here: it returns the server's result itself (directToolResult), for
27424
28069
  * either engine, as does a script's call whose result is structured, with
27425
- * an MCPL peer's blocks given RFC-005's treatment (referenceBlockForScript).
28070
+ * an MCPL peer's blocks given RFC-005's treatment (referenceBlockForScript),
28071
+ * and what's nested in them at any depth (shownToScriptFromServer).
27426
28072
  */
27427
28073
  private async mcpToolResult(
27428
28074
  result: { content?: unknown; isError?: boolean; structuredContent?: unknown },