@animalabs/agent-framework 0.26.1 → 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
@@ -117,6 +117,7 @@ import type { ToolResultGuard, WithheldSpill } from './tool-result-guard.js';
117
117
  import {
118
118
  nonErrorResultText,
119
119
  shownToScript,
120
+ shownToScriptFromServer,
120
121
  toolResultDataToHistoryString,
121
122
  truncateForHistory,
122
123
  DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS,
@@ -990,6 +991,71 @@ function resumesLogicalTurn(reason: string | undefined): boolean {
990
991
  return reason === 'context_budget_restart' || reason === 'tool_result_guard_retry';
991
992
  }
992
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
+
993
1059
  /**
994
1060
  * The answers dispatchScriptToolCall's safety timer made, when no result came
995
1061
  * for a script's call: the tool may still be running, or its result was lost,
@@ -998,6 +1064,20 @@ function resumesLogicalTurn(reason: string | undefined): boolean {
998
1064
  */
999
1065
  const SCRIPT_CALL_NO_RESULT = new WeakSet<ToolResult>();
1000
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
+
1001
1081
  /**
1002
1082
  * One logical turn's routing state and receipt tallies
1003
1083
  * (AgentFramework.turnRouting): everything the turn has decided about where
@@ -1231,8 +1311,10 @@ function ephemeralRunStopped(name: string, observed?: string): Error {
1231
1311
 
1232
1312
  /** The reason on the inference:aborted that ends a turn whose retry stop()
1233
1313
  * cancelled, whichever frame was waiting to retry: beginAgentTurn's, when
1234
- * the stream couldn't start, or driveStream's, when it failed once started.
1235
- * 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. */
1236
1318
  const RETRY_CANCELLED_BY_STOP = 'a retry waiting while the framework was stopping is dropped before it sends anything';
1237
1319
 
1238
1320
  /** The reason on the inference:aborted that ends a turn whose start waited to
@@ -1260,6 +1342,12 @@ function isTurnContinuation(reason: string): boolean {
1260
1342
  || reason === 'tool_result_guard_retry';
1261
1343
  }
1262
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
+
1263
1351
  /** Wake sources the framework generates for itself (bounce wakes,
1264
1352
  * continuations, retries, unsticks, admission requeues). A wake from any
1265
1353
  * other source — a channel message, a heartbeat or other server event, a
@@ -1609,18 +1697,21 @@ export interface HostModeStatus {
1609
1697
  reason?: string;
1610
1698
  since?: number;
1611
1699
  /** True when no turn is alive: no turn token (the token spans dequeue →
1612
- * settled teardown, strictly wider than activeStreams) and no continuation
1700
+ * settled teardown, strictly wider than activeStreams), no continuation
1613
1701
  * of a turn on its way to its start (a budget restart's or a tool-result
1614
1702
  * guard's retry, queued, held by a provider hold, parked or in a scheduler
1615
- * pass), and no wake parked on provider admission. It covers the
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
1616
1706
  * framework's own turns: a caller of the direct Agent API
1617
1707
  * (`Agent.runInference`) owns its turns' timing. */
1618
1708
  drained: boolean;
1619
1709
  activeTurns: number;
1620
1710
  /** The agents with a turn alive, the ones `activeTurns` counts, sorted:
1621
1711
  * each from its turn's dequeue to its settled teardown, so a turn in its
1622
- * hooks or compile, one finishing its deliveries, or one whose continuation
1623
- * is on its way to its start, is listed. These are the agents an abandon or
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
1624
1715
  * a stop() made now would find mid-turn. */
1625
1716
  activeTurnAgents: string[];
1626
1717
  /** Wakes that own provider admission while waiting for an in-flight
@@ -1632,7 +1723,8 @@ export interface HostModeStatus {
1632
1723
  /** Inference requests parked by the quiesce wake gate (while quiesced), or
1633
1724
  * simply queued (while serving — non-zero right after a resume until the
1634
1725
  * scheduler's next pass consumes them). Continuations of a held turn
1635
- * (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. */
1636
1728
  gatedRequests: number;
1637
1729
  /** code_execution scripts still running that no call is waiting on: left
1638
1730
  * running after a yield, outside any turn. Quiesce does NOT stop them —
@@ -2048,12 +2140,14 @@ function scriptResultBrief(content: unknown[]): string {
2048
2140
 
2049
2141
  /**
2050
2142
  * What a script gets for a tool result that can't be converted to JSON,
2051
- * 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.
2052
2145
  * It names the tool, and says the call itself completed, so a script that
2053
2146
  * reports it doesn't invite a blind retry. The host's log gets the same
2054
- * line, since the tool that returned it needs fixing whether or not the
2055
- * script reports it. The reason and the tool's own error are cut to
2056
- * 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.
2057
2151
  */
2058
2152
  function unconvertibleScriptResult(toolName: string, err: unknown, ownError?: string): string {
2059
2153
  let reason: string;
@@ -2091,7 +2185,9 @@ const SCRIPT_TEXT_ANSWER_ENVELOPE_BYTES = Buffer.byteLength(
2091
2185
  * dense in small objects or a script already holding most of its memory.
2092
2186
  * Such a result, or one past the figure that doesn't fit, reaches the script
2093
2187
  * as ResultTooLarge, saying the call took effect, and nothing of it is cut or
2094
- * 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.
2095
2191
  */
2096
2192
  const scriptResultMaxChars = (memoryMaxBytes: number): number =>
2097
2193
  Math.max(0, Math.floor((Math.min(maxResultBytes(memoryMaxBytes), bufferConstants.MAX_STRING_LENGTH) - SCRIPT_TEXT_ANSWER_ENVELOPE_BYTES) / 6));
@@ -2402,7 +2498,11 @@ type FrameworkCancelKind = 'turn_ended' | 'budget_restart' | 'quiesce_abandoned'
2402
2498
  * operator's quiesce abandon or stop() settles it as a stop ends a turn's
2403
2499
  * start; any other drop ends it as a start dropped before it sends anything.
2404
2500
  * `why` is said in the line its end logs. */
2405
- type ContinuationDrop = { kind: 'quiesce_abandoned' | 'shutdown' | 'dropped'; why: string };
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 };
2406
2506
 
2407
2507
  /** What opened a channel the agent didn't ask to open
2408
2508
  * (recordChannelAutoOpenNotice). */
@@ -2889,12 +2989,14 @@ export class AgentFramework {
2889
2989
  * is still the agent's, so a start that ends after a later frame opened
2890
2990
  * one, such as its retry's stream waiting to retry, leaves that one open.
2891
2991
  * An operator's abandon marks the window, and the frame's check collects
2892
- * the mark, which goes with the window. abortInference() and the abandon
2893
- * count a turn with no stream only here; a stop made anywhere else, as
2894
- * while an agent is idle, a turn finishes its last delivery or a provider
2895
- * hold has parked it, has no check to end it, and the next start drops
2896
- * it. Keyed by the Agent, so a replacement agent under the same name
2897
- * 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.
2898
3000
  */
2899
3001
  private checkAhead: Map<Agent, CheckWindow> = new Map();
2900
3002
 
@@ -4645,6 +4747,21 @@ export class AgentFramework {
4645
4747
  a.coalescingSubject === b.coalescingSubject && batchSubjects(a) === batchSubjects(b) &&
4646
4748
  a.readsDeferredWrite === b.readsDeferredWrite;
4647
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
+ }
4648
4765
  private consultProviderHold(error: Error, agentName: string): { holdMs: number; reason: string } | undefined {
4649
4766
  if (!this.providerHoldHook) return undefined;
4650
4767
  try {
@@ -4696,14 +4813,19 @@ export class AgentFramework {
4696
4813
  // failure streak), and the wait applies to the calls that follow it.
4697
4814
  if (!acceleration && !hostHold) {
4698
4815
  return wait !== undefined && isMembraneError(error) && error.retryable === true
4699
- && this.holdForProviderWait(agent, wait, trigger ? [trigger] : [], true);
4816
+ && this.holdForProviderWait(agent, wait, trigger ? [asTurnRetry(trigger)] : [], true);
4700
4817
  }
4818
+ // As in holdForProviderWait: once stop() has closed admission, nothing is
4819
+ // held, and nothing started.
4820
+ if (this.providerAdmissionClosed) return true;
4701
4821
  const now = Date.now();
4702
4822
  const pacing = hostHold ? undefined : this.accelerationCooldownMs(agent.name, error as MembraneError);
4703
4823
  const delayMs = hostHold ? hostHold.holdMs : pacing!.delayMs;
4704
4824
  const existing = this.providerAccelerationCooldowns.get(agent.name);
4705
4825
  const held = existing?.heldRequests ?? [];
4706
- 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));
4707
4829
  if (existing) clearTimeout(existing.timer);
4708
4830
  const timer = setTimeout(() => this.releaseProviderAccelerationCooldown(agent.name), Math.min(delayMs, MAX_TIMER_DELAY_MS)); timer.unref?.();
4709
4831
  this.providerAccelerationCooldowns.set(agent.name, { startedAt: existing?.startedAt ?? now,
@@ -4752,7 +4874,7 @@ export class AgentFramework {
4752
4874
  if (!isMembraneError(error) || typeof error.retryAfterMs !== 'number') return undefined;
4753
4875
  if (this.conversationAgentHomes.has(agent.name)) {
4754
4876
  const wait = this.recordProviderWait(agent.name, agent.model, error);
4755
- 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)
4756
4878
  ? 'held'
4757
4879
  : undefined;
4758
4880
  }
@@ -4902,17 +5024,30 @@ export class AgentFramework {
4902
5024
  * Hold this agent's primary turns for a provider wait on its model. The
4903
5025
  * requests are kept and released together, into one fresh compile, when
4904
5026
  * the wait ends: the same path an acceleration cooldown takes, without
4905
- * 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.
4906
5031
  */
4907
- 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 {
4908
5033
  const now = Date.now();
4909
5034
  const until = wait.until ?? Number.POSITIVE_INFINITY;
4910
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
+ }
4911
5048
  const existing = this.providerAccelerationCooldowns.get(agent.name);
4912
5049
  const held = existing?.heldRequests ?? [];
4913
- for (const request of requests) {
4914
- if (!held.some((r) => this.sameInferenceRequest(r, request))) held.push(request);
4915
- }
5050
+ for (const request of requests) this.holdRequest(held, request);
4916
5051
  // An acceleration or host hold already parks the whole agent; its release
4917
5052
  // re-checks this wait at admission.
4918
5053
  if (existing && existing.waitModel === undefined) return true;
@@ -5517,9 +5652,11 @@ export class AgentFramework {
5517
5652
 
5518
5653
  /**
5519
5654
  * Abort an in-flight inference for an agent. True when there was one to
5520
- * 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
5521
5656
  * hooks or compile, a wait to retry, the tool-result guard's wait), which
5522
- * 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.
5523
5660
  */
5524
5661
  abortInference(agentName: string, reason?: string): boolean {
5525
5662
  const agent = this.agents.get(agentName);
@@ -5549,6 +5686,20 @@ export class AgentFramework {
5549
5686
  checkWindow.wake?.();
5550
5687
  return true;
5551
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
+ }
5552
5703
  return !!result;
5553
5704
  }
5554
5705
 
@@ -6507,9 +6658,10 @@ export class AgentFramework {
6507
6658
  * it queues, or a tool-result guard's retry, which its frame starts at
6508
6659
  * once) holds no turn token until its start takes one. Once its turn's
6509
6660
  * frame has let go of its token, its start can wait in the queue, a
6510
- * provider hold (a retry too, when a wait holds it), a park on admission
6511
- * or a scheduler pass's hands. Its turn is alive meanwhile. These are the
6512
- * agents with one on its way. A
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
6513
6665
  * `tool_results_ready` continuation follows a tool round left waiting with
6514
6666
  * no stream, which only the direct Agent API makes (Agent's
6515
6667
  * runInferenceWithInjections; the framework's own rounds wait with their
@@ -6518,7 +6670,7 @@ export class AgentFramework {
6518
6670
  */
6519
6671
  private pendingContinuationAgents(): Set<string> {
6520
6672
  const agents = new Set<string>();
6521
- const add = (r: InferenceRequest): void => { if (resumesLogicalTurn(r.reason)) agents.add(r.agentName); };
6673
+ const add = (r: InferenceRequest): void => { if (continuesTurn(r)) agents.add(r.agentName); };
6522
6674
  // Read defensively: a framework built from the prototype, as the
6523
6675
  // operator tests build one, sets none of these.
6524
6676
  for (const r of this.pendingRequests ?? []) add(r);
@@ -6529,7 +6681,7 @@ export class AgentFramework {
6529
6681
  }
6530
6682
 
6531
6683
  /** The agents with a turn alive: holding a turn token, or with a
6532
- * continuation of their turn on its way. */
6684
+ * continuation of their turn, or its retry, on its way. */
6533
6685
  private turnHolders(): Set<string> {
6534
6686
  const holders = this.pendingContinuationAgents();
6535
6687
  for (const name of this.activeTurnTokens.keys()) holders.add(name);
@@ -6540,6 +6692,26 @@ export class AgentFramework {
6540
6692
  return this.activeTurnTokens.has(agentName) || this.pendingContinuationAgents().has(agentName);
6541
6693
  }
6542
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
+
6543
6715
  getHostModeStatus(): HostModeStatus {
6544
6716
  const parkedAdmissions = this.parkedAdmissionCount();
6545
6717
  const holders = this.turnHolders();
@@ -6551,7 +6723,7 @@ export class AgentFramework {
6551
6723
  activeTurns: holders.size,
6552
6724
  activeTurnAgents: [...holders].sort(),
6553
6725
  parkedAdmissions,
6554
- gatedRequests: this.pendingRequests.filter((r) => !isTurnContinuation(r.reason)).length,
6726
+ gatedRequests: this.pendingRequests.filter((r) => !passesQuiesce(r)).length,
6555
6727
  backgroundScripts: this.codeExecution?.scripts.leftRunning() ?? 0,
6556
6728
  deferredWrites: this.deferredMessages.length,
6557
6729
  ...(this.lastUnabandonable.length > 0 ? { unabandonable: [...this.lastUnabandonable] } : {}),
@@ -6569,8 +6741,9 @@ export class AgentFramework {
6569
6741
  * Enter quiesce mode: write the deferred-write queue, persist the flag,
6570
6742
  * suppress gate deliveries, pause all MCPL data planes, and wait for
6571
6743
  * in-flight turns to settle (turn-alive is a turn token, which spans
6572
- * dequeue → settled teardown, or a continuation of the turn on its way to
6573
- * its start: turnHolders). Idempotent, but for the queue's write, which
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
6574
6747
  * every call makes first: when the queue can't reach disk, because its
6575
6748
  * write fails or it's over the cap, the call rejects with a
6576
6749
  * QuiesceBlockedError and the host's mode as it was, unless it has
@@ -6580,8 +6753,8 @@ export class AgentFramework {
6580
6753
  * On drain timeout the host STAYS quiesced (`drained: false` in the
6581
6754
  * result); with `abandon: true` the undrained streams are cancelled via a
6582
6755
  * dedicated cancel kind that settles the turn without feeding the
6583
- * inference-failure accounting, and a turn whose continuation hasn't
6584
- * started ends with the continuation dropped. Drain covers the framework's
6756
+ * inference-failure accounting, and a turn whose continuation or retry
6757
+ * hasn't started ends with it dropped. Drain covers the framework's
6585
6758
  * own turns: a caller of the direct Agent API (`Agent.runInference`) owns
6586
6759
  * its turns' timing.
6587
6760
  */
@@ -6730,7 +6903,7 @@ export class AgentFramework {
6730
6903
  // to the continuation.
6731
6904
  const handedOff = new Set<string>();
6732
6905
  for (const agentName of this.pendingContinuationAgents()) {
6733
- console.error(`[host-mode] abandoning ${agentName}'s turn before its continuation starts: the continuation is dropped`);
6906
+ console.error(`[host-mode] abandoning ${agentName}'s turn before its continuation or held retry starts: it is dropped`);
6734
6907
  this.dropContinuations(agentName, { kind: 'quiesce_abandoned', why: 'its turn was abandoned' });
6735
6908
  handedOff.add(agentName);
6736
6909
  }
@@ -10363,7 +10536,8 @@ export class AgentFramework {
10363
10536
  * by stop() while it runs.
10364
10537
  *
10365
10538
  * The branch moves under every agent sharing the store, so the switch
10366
- * 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
10367
10541
  * refused (OperatorActionError 'agent-busy'), and no turn starts until the
10368
10542
  * switch is done.
10369
10543
  */
@@ -10615,9 +10789,21 @@ export class AgentFramework {
10615
10789
  );
10616
10790
  }
10617
10791
  const busy: string[] = [];
10792
+ let heldTurns = false;
10793
+ let statedWaits = false;
10618
10794
  for (const [name, a] of this.agents) {
10619
- if (a.state.status !== 'idle') busy.push(`${name} is ${a.state.status}`);
10620
- else if (this.hasTurn(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
+ }
10621
10807
  }
10622
10808
  for (const name of this.activeTurnTokens.keys()) {
10623
10809
  if (!this.agents.has(name)) busy.push(`${name} is turn-alive`);
@@ -10625,7 +10811,8 @@ export class AgentFramework {
10625
10811
  if (busy.length > 0) {
10626
10812
  throw new OperatorActionError(
10627
10813
  'agent-busy',
10628
- `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)}` : ''),
10629
10816
  );
10630
10817
  }
10631
10818
  const reserved = new Map<string, number>();
@@ -11559,10 +11746,11 @@ export class AgentFramework {
11559
11746
  // Direct inference requests (e.g. runEphemeralToCompletion) bypass the
11560
11747
  // event queue — without this the loop can exit before they're drained.
11561
11748
  // While quiesced, gate-parked requests are NOT progress and must not
11562
- // block idle (only a budget restart can still run); identity-equivalent
11563
- // 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.
11564
11752
  this.pendingRequests.some(
11565
- (r) => !this.quiesced || isTurnContinuation(r.reason),
11753
+ (r) => !this.quiesced || passesQuiesce(r),
11566
11754
  ) ||
11567
11755
  this.activeStreams.size > 0 ||
11568
11756
  Array.from(this.agents.values()).some((a) => a.state.status !== 'idle')
@@ -13248,10 +13436,13 @@ export class AgentFramework {
13248
13436
  // (request dropped as stale, inference policy veto) would otherwise stay
13249
13437
  // registered forever. No turn, no active stream and no pending request =
13250
13438
  // done. A turn holds its token from dequeue, through its hooks and
13251
- // 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.
13252
13443
  for (const agentName of [...this.closingConversationAgents]) {
13253
13444
  if (
13254
- !this.activeTurnTokens.has(agentName) &&
13445
+ !this.hasTurn(agentName) &&
13255
13446
  !this.activeStreams.has(agentName) &&
13256
13447
  !this.pendingRequests.some((r) => r.agentName === agentName)
13257
13448
  ) {
@@ -14071,6 +14262,19 @@ export class AgentFramework {
14071
14262
  for (const cooldown of this.providerAccelerationCooldowns.values()) {
14072
14263
  cooldown.heldRequests = cooldown.heldRequests.filter((r) => r.coalescingSubject !== subject);
14073
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);
14074
14278
  }
14075
14279
 
14076
14280
  /**
@@ -14451,7 +14655,7 @@ export class AgentFramework {
14451
14655
  });
14452
14656
  console.error(`[inference-dropped] agent=${agentName} reason=agent_not_found requests=${requests.length}`);
14453
14657
  // A continuation among them leaves its turn to end here.
14454
- if (requests.some((r) => resumesLogicalTurn(r.reason))) {
14658
+ if (requests.some(continuesTurn)) {
14455
14659
  this.endContinuedTurn(agentName, { kind: 'dropped', why: 'its agent is gone' });
14456
14660
  }
14457
14661
  continue;
@@ -14463,11 +14667,7 @@ export class AgentFramework {
14463
14667
  // selected now, the turn proceeds (the wait stays recorded).
14464
14668
  const otherModel = providerCooldown.waitModel !== undefined && providerCooldown.waitModel !== agent.model;
14465
14669
  if (!otherModel && (now < providerCooldown.until || this.extendHostProviderHold(agentName, providerCooldown))) {
14466
- for (const req of requests) {
14467
- if (!providerCooldown.heldRequests.some((r) => this.sameInferenceRequest(r, req))) {
14468
- providerCooldown.heldRequests.push(req);
14469
- }
14470
- }
14670
+ for (const req of requests) this.holdRequest(providerCooldown.heldRequests, req);
14471
14671
  const recovery = this.providerAccelerationRecoveries.get(agentName);
14472
14672
  if (recovery) recovery.heldRequests = providerCooldown.heldRequests.length;
14473
14673
  // The timer owns release. Do not hot-poll these requests.
@@ -14481,6 +14681,17 @@ export class AgentFramework {
14481
14681
  if (recovery) recovery.releasedAt = now;
14482
14682
  requests = [...providerCooldown.heldRequests, ...requests];
14483
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
+
14484
14695
  // A provider's stated wait on this agent's primary model, including one
14485
14696
  // recorded before a restart, holds the turn until it passes. An
14486
14697
  // ephemeral run records nothing, its name being single-use, so no wait,
@@ -14515,9 +14726,10 @@ export class AgentFramework {
14515
14726
  // bounds a multi-day maintenance window's accumulation.
14516
14727
  if (this.quiesced) {
14517
14728
  // Continuations of a held turn (budget restart, non-streaming tool
14518
- // results) pass; everything else parks.
14519
- const passThrough = requests.filter((r) => isTurnContinuation(r.reason));
14520
- 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));
14521
14733
  // Coalesce per (reason, addressed): every channel wake shares one
14522
14734
  // reason, so keying on reason alone would let an ambient message
14523
14735
  // parked later evict a DM/mention parked earlier — and the
@@ -14627,7 +14839,7 @@ export class AgentFramework {
14627
14839
  // A continuation inherits its predecessor's EventGate liveness and
14628
14840
  // logical turn. If policy drops it, no driveStream remains to end them,
14629
14841
  // so its turn ends here (endContinuedTurn).
14630
- if (requests.some((r) => resumesLogicalTurn(r.reason))) {
14842
+ if (requests.some(continuesTurn)) {
14631
14843
  this.endContinuedTurn(agentName, { kind: 'dropped', why: 'the inference policy dropped its continuation' });
14632
14844
  }
14633
14845
  this.endPendingPrimary(agentName);
@@ -14705,7 +14917,7 @@ export class AgentFramework {
14705
14917
  // exists only once assembly renders it. Recorded for the whole batch
14706
14918
  // (not inherited from requests[0]), so a requeued trigger stays honest.
14707
14919
  const batchOnly = !budgetRestart && requests.every((r) => r.coalescingBatch === true && !!r.coalescingSubject);
14708
- await this.startAgentStream(agent, {
14920
+ const merged: InferenceRequest = {
14709
14921
  ...trigger,
14710
14922
  coalescingBatch: batchOnly || undefined,
14711
14923
  coalescingBatchSubjects: batchOnly
@@ -14733,7 +14945,17 @@ export class AgentFramework {
14733
14945
  counterparty: budgetRestart ? undefined : provReq?.counterparty,
14734
14946
  wakeChannelId: budgetRestart ? undefined : provReq?.wakeChannelId,
14735
14947
  wakeAt: budgetRestart ? undefined : provReq?.wakeAt,
14736
- });
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);
14737
14959
  }
14738
14960
  }
14739
14961
 
@@ -17084,7 +17306,7 @@ export class AgentFramework {
17084
17306
  // Do not await inside processInferenceRequests: one slow auxiliary call for
17085
17307
  // this resident must not block events or other residents. Admission stays
17086
17308
  // owned and the continuation receives it after the in-flight call settles.
17087
- const parkedContinuation = trigger !== undefined && resumesLogicalTurn(trigger.reason);
17309
+ const parkedContinuation = trigger !== undefined && continuesTurn(trigger);
17088
17310
  if (parkedContinuation) this.parkedContinuations.add(trigger);
17089
17311
  void this.waitForAuxiliaryIdle(agent.name).then(async () => {
17090
17312
  // A continuation dropped while it was parked left the set then, with
@@ -17116,8 +17338,9 @@ export class AgentFramework {
17116
17338
  // park; starting here would bypass it and run a turn against a
17117
17339
  // context the operator believes frozen. Give admission back and
17118
17340
  // requeue — the scheduler re-parks it (coalesced) until resume.
17119
- // Continuations of a held turn pass, as they do at the gate.
17120
- 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)) {
17121
17344
  this.releasePrimaryProviderGate(agent.name);
17122
17345
  this.pendingRequests.push(trigger ?? {
17123
17346
  agentName: agent.name, reason: 'provider-admission:requeue', source: 'scheduler', timestamp: Date.now(),
@@ -17125,6 +17348,16 @@ export class AgentFramework {
17125
17348
  console.error(`[provider-admission] ${agent.name}: host quiesced while parked — wake requeued, not started`);
17126
17349
  return;
17127
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
+ }
17128
17361
  await this.startAgentStream(agent, trigger, attempt, true);
17129
17362
  }).catch((error) => {
17130
17363
  this.releasePrimaryProviderGate(agent.name);
@@ -17258,11 +17491,23 @@ export class AgentFramework {
17258
17491
  * caller has ended the turn already.
17259
17492
  */
17260
17493
  private dropContinuations(agentName: string, drop: ContinuationDrop | null): void {
17261
- const isContinuation = (r: InferenceRequest): boolean => r.agentName === agentName && resumesLogicalTurn(r.reason);
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[] = [];
17262
17501
  let dropped = false;
17263
17502
  const take = (requests: InferenceRequest[]): InferenceRequest[] => {
17264
- const left = requests.filter((r) => !isContinuation(r));
17265
- if (left.length < requests.length) dropped = true;
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
+ }
17266
17511
  return left;
17267
17512
  };
17268
17513
  this.pendingRequests = take(this.pendingRequests);
@@ -17277,6 +17522,7 @@ export class AgentFramework {
17277
17522
  this.parkedContinuations.delete(parked);
17278
17523
  this.releasePrimaryProviderGate(agentName);
17279
17524
  dropped = true;
17525
+ if (putsBack) back.push(...mergedWakes(parked));
17280
17526
  }
17281
17527
  for (const pass of this.passRequests) {
17282
17528
  const requests = pass.get(agentName);
@@ -17285,6 +17531,7 @@ export class AgentFramework {
17285
17531
  requests.length = 0;
17286
17532
  requests.push(...left);
17287
17533
  }
17534
+ this.pendingRequests.push(...back);
17288
17535
  if (!dropped || !drop) return;
17289
17536
  // A primary a provider hold left pending for what it released won't run
17290
17537
  // now, when nothing else of the agent's waits (endPendingPrimary).
@@ -17311,10 +17558,12 @@ export class AgentFramework {
17311
17558
  const startedAt = this.lastInferenceAt.get(agentName)?.startedAt ?? Date.now();
17312
17559
  if (agent && drop.kind !== 'dropped') {
17313
17560
  const routing = this.openLogical.get(agentName) ?? this.routingOf(agentName);
17314
- this.endStoppedStart(agent, { kind: drop.kind }, routing, startedAt, undefined, false);
17561
+ this.endStoppedStart(agent, drop.kind === 'caller' ? { kind: 'caller', reason: drop.reason } : { kind: drop.kind },
17562
+ routing, startedAt, undefined, false);
17315
17563
  } else {
17316
17564
  const owner = this.activeTurnTokens.get(agentName);
17317
- this.abortDroppedTurn(agentName, owner ?? -1, drop.kind === 'dropped' ? CONTINUATION_DROPPED : drop.kind, startedAt);
17565
+ this.abortDroppedTurn(agentName, owner ?? -1,
17566
+ drop.kind === 'dropped' ? CONTINUATION_DROPPED : drop.kind === 'caller' ? (drop.reason ?? 'user') : drop.kind, startedAt);
17318
17567
  this.channelRegistry?.stopTyping();
17319
17568
  if (agent) {
17320
17569
  agent.toolResultGuard.recovering = false;
@@ -17336,6 +17585,34 @@ export class AgentFramework {
17336
17585
  }
17337
17586
  }
17338
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
+
17339
17616
  /**
17340
17617
  * End a turn that beginAgentTurn drops before it sends anything. The turn
17341
17618
  * emitted inference:started, and listeners that bracket a turn, such as a
@@ -17567,6 +17844,12 @@ export class AgentFramework {
17567
17844
  const { coalescingSubject: _subject, coalescingBatch: _batch, coalescingBatchSubjects: _subjects, readsDeferredWrite: _read, ...retry } = trigger;
17568
17845
  trigger = retry;
17569
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
+ }
17570
17853
 
17571
17854
  // Record turn checkpoint before inference (only on first attempt, not retries)
17572
17855
  if (attempt === 0 && trigger?.reason !== 'tool_result_guard_retry') {
@@ -17862,9 +18145,8 @@ export class AgentFramework {
17862
18145
  // hold the turn as the boundary would rather than send it into the wait.
17863
18146
  if (this.conversationAgentHomes.has(agent.name)) {
17864
18147
  const wait = this.providerWaits?.active(agent.name, agent.model);
17865
- if (wait && this.holdForProviderWait(agent, wait, trigger ? [trigger] : [])) {
17866
- console.error(`[provider-wait] agent=${agent.name} model=${agent.model} binds after the compile ` +
17867
- `(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, () => {
17868
18150
  // The stream the compile made won't be sent: it's cancelled, and
17869
18151
  // this start's window closes, before the hold is reported, so a stop
17870
18152
  // made while inference:failed is delivered finds neither, and the
@@ -17875,8 +18157,15 @@ export class AgentFramework {
17875
18157
  if (this.checkAhead.get(agent) === checkWindow) this.checkAhead.delete(agent);
17876
18158
  // The turn emitted inference:started before its compile; every turn
17877
18159
  // that does gets one terminal event, which listeners (a typing loop)
17878
- // 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.
17879
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);
17880
18169
  this.activeStreams.delete(agent.name);
17881
18170
  this.pendingAssistantBlocks.delete(agent.name);
17882
18171
  this.channelRegistry?.stopTyping();
@@ -17961,14 +18250,16 @@ export class AgentFramework {
17961
18250
  if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
17962
18251
  // Capacity scheduling is not poisoned history. The cooldown timer owns
17963
18252
  // one later fresh compile; no immediate retry or exhaustion marker.
17964
- 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);
17965
18256
  return false;
17966
18257
  }
17967
18258
  const statedWait = ownsProviderGate ? undefined : this.nonOwningProviderWait(agent, err, trigger);
17968
18259
  if (statedWait === 'held') {
17969
18260
  // A conversation agent held by the provider's stated wait, as a
17970
18261
  // resident is: the hold's release owns the later compile.
17971
- this.eventGate?.onInferenceEnded(agent.name);
18262
+ this.endHeldStart(agent, checkWindow, turnToken, turnStartedAt);
17972
18263
  return false;
17973
18264
  }
17974
18265
 
@@ -18003,7 +18294,15 @@ export class AgentFramework {
18003
18294
  }
18004
18295
  // The retry re-enters startAgentStream, which replaces this frame's
18005
18296
  // turn token with its own; cleanup belongs to the innermost frame.
18006
- 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);
18007
18306
  } else {
18008
18307
  // Terminal failure with no driveStream: this frame owns teardown.
18009
18308
  // Token-matched (like the finally in driveStream) so a stale frame
@@ -19577,12 +19876,16 @@ export class AgentFramework {
19577
19876
 
19578
19877
  this.abandonAgentScripts(agent.name, 'stream error', streamRouting);
19579
19878
 
19879
+ // A hold takes the turn's retry, or, once stop() has closed
19880
+ // admission, the turn ends here (endFailureAtStop).
19580
19881
  if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
19882
+ this.endFailureAtStop(agent.name, myTurnToken ?? -1, startTime);
19581
19883
  if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
19582
19884
  break;
19583
19885
  }
19584
19886
  const statedWait = ownsProviderGate ? undefined : this.nonOwningProviderWait(agent, err, trigger);
19585
19887
  if (statedWait === 'held') {
19888
+ this.endFailureAtStop(agent.name, myTurnToken ?? -1, startTime);
19586
19889
  if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
19587
19890
  break;
19588
19891
  }
@@ -19619,7 +19922,8 @@ export class AgentFramework {
19619
19922
  }
19620
19923
  return;
19621
19924
  }
19622
- 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);
19623
19927
  } else {
19624
19928
  // The turn ends here, and its inference:failed doesn't say so,
19625
19929
  // since a retry usually follows one: an inference:aborted
@@ -19940,10 +20244,16 @@ export class AgentFramework {
19940
20244
  const durationMs = Date.now() - startTime;
19941
20245
  // The stream won't go on: the agent stops holding it before the
19942
20246
  // failure is reported, as at the stream's error event, so a stop made
19943
- // 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.
19944
20251
  agent.reset();
20252
+ this.emitTrace({ type: 'inference:failed', agentName: agent.name, error: err.message, stack: err.stack });
19945
20253
  if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
19946
- 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);
19947
20257
  logStream({ success: false,
19948
20258
  error: `Provider acceleration cooldown: ${err.message}`,
19949
20259
  request: compiledRequest ?? { note: 'streaming request rate-limited' }, durationMs });
@@ -19957,7 +20267,7 @@ export class AgentFramework {
19957
20267
  // says why it ends.
19958
20268
  const statedWait = ownsProviderGate ? undefined : this.nonOwningProviderWait(agent, err, trigger);
19959
20269
  if (statedWait === 'held') {
19960
- this.emitTrace({ type: 'inference:failed', agentName: agent.name, error: err.message, stack: err.stack });
20270
+ this.endFailureAtStop(agent.name, myTurnToken ?? -1, startTime);
19961
20271
  logStream({ success: false,
19962
20272
  error: `Provider wait: ${err.message}`,
19963
20273
  request: compiledRequest ?? { note: 'streaming request rate-limited' }, durationMs });
@@ -19965,12 +20275,6 @@ export class AgentFramework {
19965
20275
  lifecyclePhase = 'failed'; agent.reset(); if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
19966
20276
  return;
19967
20277
  }
19968
- this.emitTrace({
19969
- type: 'inference:failed',
19970
- agentName: agent.name,
19971
- error: err.message,
19972
- stack: err.stack,
19973
- });
19974
20278
  this.settleAgent(agent.name, {
19975
20279
  stopReason: 'exhausted',
19976
20280
  speech: '',
@@ -20182,8 +20486,11 @@ export class AgentFramework {
20182
20486
  // frame that ends the turn disposes it, after the flush above, so what
20183
20487
  // was held for the turn's end lands in the fork's store instead of
20184
20488
  // being dropped as addressed to no agent. A frame that hands the turn
20185
- // to a successor (a guard or budget restart) leaves the fork to it.
20186
- 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)) {
20187
20494
  this.disposeConversationAgent(agent.name);
20188
20495
  }
20189
20496
  }
@@ -20277,9 +20584,14 @@ export class AgentFramework {
20277
20584
  }
20278
20585
  if (agent.state.status !== 'idle' || this.hasTurn(agentName)) {
20279
20586
  const shown = agent.state.status === 'idle' ? 'idle+turn-alive' : agent.state.status;
20280
- throw new Error(
20281
- `puppet refused: agent ${agentName} is ${shown} (requires idle — ` +
20282
- `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)`,
20283
20595
  );
20284
20596
  }
20285
20597
  const onSurface = this.getToolsForAgent(agentName)
@@ -21258,7 +21570,9 @@ export class AgentFramework {
21258
21570
  * content where it sent one, `false`, `0` and `null` included, else null.
21259
21571
  * A result that can't be converted to JSON (a cyclic object, a BigInt),
21260
21572
  * which the dispatch's own check makes unlikely here, is a failure naming
21261
- * 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).
21262
21576
  */
21263
21577
  private async scriptResultAnswer(result: ToolResult, toolName: string, toServer: boolean): Promise<ToolAnswer> {
21264
21578
  const structured = Object.prototype.hasOwnProperty.call(result, 'structured');
@@ -21300,6 +21614,9 @@ export class AgentFramework {
21300
21614
  brief: error,
21301
21615
  };
21302
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));
21303
21620
  let content: unknown[];
21304
21621
  let text: string | undefined;
21305
21622
  // A structured result's blocks, as #538 gives them: none when its content
@@ -27069,8 +27386,8 @@ export class AgentFramework {
27069
27386
  }
27070
27387
 
27071
27388
  /** An MCP tool call's trace: a result the server marked an error (or one
27072
- * the reading couldn't complete) is tool:failed, as on the other tool
27073
- * 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. */
27074
27391
  private traceMcpToolOutcome(serverId: string, tool: string, callId: string, result: ToolResult, durationMs: number): void {
27075
27392
  if (result.isError) {
27076
27393
  this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool, callId, durationMs, error: result.error ?? 'tool returned an error' });
@@ -27490,16 +27807,42 @@ export class AgentFramework {
27490
27807
  // generated JSON) stands in for what the server sent. An MCPL peer's
27491
27808
  // content blocks are the exception: what a script prints comes back to
27492
27809
  // the model, so each gets the treatment RFC-005 gives it on every lane
27493
- // the model reads, here, where the server that sent them is known. A
27494
- // module's blocks get it at the script lane's exit (scriptResultAnswer).
27495
- // A standard peer's blocks reach the script as sent.
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.
27496
27816
  const forScript = this.scriptToolWaiters?.has(call.id) ?? false;
27497
27817
  const reading = async (result: { content?: unknown; isError?: boolean; structuredContent?: unknown }, mcpl: boolean): Promise<ToolResult> => {
27498
27818
  if (!forScript || !Object.prototype.hasOwnProperty.call(result, 'structuredContent')) {
27499
27819
  return this.mcpToolResult(result, call.id, mcpl, serverId, call.name);
27500
27820
  }
27501
27821
  const direct = AgentFramework.directToolResult(result, serverId, call.name);
27502
- 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
+ }
27503
27846
  // A standard peer's content that isn't a list of blocks is a malformed
27504
27847
  // result on the model's reading (normalizeStandardToolResult), so the
27505
27848
  // script reads it that way too, its structured content kept.
@@ -27724,7 +28067,8 @@ export class AgentFramework {
27724
28067
  * direct path (a module's callTool, a channel's tool call) doesn't come
27725
28068
  * here: it returns the server's result itself (directToolResult), for
27726
28069
  * either engine, as does a script's call whose result is structured, with
27727
- * 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).
27728
28072
  */
27729
28073
  private async mcpToolResult(
27730
28074
  result: { content?: unknown; isError?: boolean; structuredContent?: unknown },