@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.
@@ -45,7 +45,7 @@ import { safeSlice } from './safe-slice.js';
45
45
  import { SEND_TOOLS, bareToolName } from './send-tools.js';
46
46
  import { boundedErrorMessage, errorMessage } from './error-message.js';
47
47
  import { ProviderWaits, EVERY_MODEL, isReleaseModel, withoutControls } from './provider-waits.js';
48
- import { nonErrorResultText, shownToScript, toolResultDataToHistoryString, truncateForHistory, DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, } from './tool-result-history.js';
48
+ import { nonErrorResultText, shownToScript, shownToScriptFromServer, toolResultDataToHistoryString, truncateForHistory, DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, } from './tool-result-history.js';
49
49
  import { TOOL_IMAGE_REF_RE, ToolImageLedger, cutStoredToolResultToFit, formatKeptImageSlots, parseImagePlaceholders, sha256Hex, storedImageSlots, toolResultCutAt, toolResultShare, } from './tool-image-ledger.js';
50
50
  import { randomUUID, createHash } from 'node:crypto';
51
51
  import { describeCodeExecution, CODE_EXECUTION_TOOL_NAME, isolateSettings, validateCodeExecutionConfig, } from './code-execution/tool-definition.js';
@@ -671,6 +671,63 @@ function announcedDomainsOf(announce) {
671
671
  function resumesLogicalTurn(reason) {
672
672
  return reason === 'context_budget_restart' || reason === 'tool_result_guard_retry';
673
673
  }
674
+ /**
675
+ * Marks a held turn's retry: the request a provider hold keeps for a turn
676
+ * whose call failed into it, or whose compile it held with nothing sent.
677
+ * That turn has begun and not ended: it starts again when the hold releases
678
+ * (a new start of its open turn, as 0.26.0 started it). So, as a
679
+ * continuation on its way does (continuesTurn), it keeps its agent's turn
680
+ * alive meanwhile, passes a quiesce, and goes with an abandon, a stop() or
681
+ * its agent's removal. A symbol, not a field: hosts build requests too, and
682
+ * only the framework may say one is a turn's own retry. Spreads and rests
683
+ * copy it, as every copy of a request the scheduler makes is one.
684
+ */
685
+ const TURN_RETRY = Symbol('turn-retry');
686
+ /**
687
+ * The batch a start that carries a turn on (continuesTurn) was merged from,
688
+ * when the scheduler merged wakes that aren't the turn's into it: every
689
+ * request it took in, as each was. Until the start takes its turn token it
690
+ * stands for them all. A scheduler pass that takes it back puts the batch in
691
+ * its place, each request with its own identity and flags; a quiesce at the
692
+ * auxiliary park sends the batch back to the scheduler; and a caller's stop
693
+ * or an abandon that drops the start puts back the batch's wakes, the
694
+ * requests that don't carry the turn on (dropContinuations), where they run
695
+ * as a turn of their own, as wakes queued while a turn streams do. Once the
696
+ * start takes its token the turn reads them, and beginAgentTurn drops the
697
+ * record. A symbol, as TURN_RETRY is, so spreads and rests copy it.
698
+ */
699
+ const MERGED_BATCH = Symbol('merged-batch');
700
+ function mergedBatch(request) {
701
+ return request[MERGED_BATCH];
702
+ }
703
+ /** The wakes of a merged start's batch (MERGED_BATCH): its requests that
704
+ * don't carry the turn on. */
705
+ function mergedWakes(request) {
706
+ return (mergedBatch(request) ?? []).filter((r) => !continuesTurn(r));
707
+ }
708
+ function isTurnRetry(request) {
709
+ return request[TURN_RETRY] === true;
710
+ }
711
+ /** How to go on sooner than a hold keeping a turn's retry lets the turn go
712
+ * on: what surgery's and the puppet's refusals advise. */
713
+ function heldTurnWayOn(statedWait) {
714
+ return 'a quiesce lets a held turn start when its hold ends and finish, so to go on sooner, end it with quiesce ' +
715
+ 'with abandon: true, which ends it once the quiesce\'s wait for the drain runs out (at once on a host ' +
716
+ 'already quiesced)' +
717
+ (statedWait
718
+ ? ", or lift a stated wait with release-provider-wait, so the turn tries again once no host's or " +
719
+ 'acceleration hold binds it'
720
+ : '');
721
+ }
722
+ /** A copy of a held turn's trigger, marked as its retry (TURN_RETRY). */
723
+ function asTurnRetry(trigger) {
724
+ return { ...trigger, [TURN_RETRY]: true };
725
+ }
726
+ /** A request that carries its agent's turn on: a continuation of it, or the
727
+ * retry a provider hold keeps for it (TURN_RETRY). */
728
+ function continuesTurn(request) {
729
+ return resumesLogicalTurn(request.reason) || isTurnRetry(request);
730
+ }
674
731
  /**
675
732
  * The answers dispatchScriptToolCall's safety timer made, when no result came
676
733
  * for a script's call: the tool may still be running, or its result was lost,
@@ -678,6 +735,19 @@ function resumesLogicalTurn(reason) {
678
735
  * place in the host taken until its run ends.
679
736
  */
680
737
  const SCRIPT_CALL_NO_RESULT = new WeakSet();
738
+ /**
739
+ * An MCPL peer's results whose blocks couldn't be given RFC-005's treatment
740
+ * for a script, because JSON couldn't hold them (dispatchMcplToolCall's
741
+ * reading), each with why. The failure is the script lane's alone: the
742
+ * model's lane reads the same answer with no such round trip. So such a
743
+ * result keeps the peer's own outcome, its blocks dropped, and the trace and
744
+ * the call's terminal report what the peer answered, as on v0.26.1, while
745
+ * the script lane's exit answers it as a result it can't convert to JSON
746
+ * (scriptResultAnswer). A module's result that JSON can't hold differs: no
747
+ * route can store or send it, so its dispatch makes it an error result for
748
+ * everyone (checkedToolResult).
749
+ */
750
+ const SCRIPT_RESULT_UNCONVERTIBLE = new WeakMap();
681
751
  /** The last TurnRouting.turnId given out: ids increase, and are unique within the process. */
682
752
  let lastTurnId = 0;
683
753
  function freshTurnRouting() {
@@ -705,8 +775,10 @@ function ephemeralRunStopped(name, observed) {
705
775
  }
706
776
  /** The reason on the inference:aborted that ends a turn whose retry stop()
707
777
  * cancelled, whichever frame was waiting to retry: beginAgentTurn's, when
708
- * the stream couldn't start, or driveStream's, when it failed once started.
709
- * Its inference:failed came first, and this says no retry follows it. */
778
+ * the stream couldn't start, or driveStream's, when it failed once started;
779
+ * or whose failure a provider hold would have kept for its retry once stop()
780
+ * had closed admission (endFailureAtStop). Its inference:failed came first,
781
+ * and this says no retry follows it. */
710
782
  const RETRY_CANCELLED_BY_STOP = 'a retry waiting while the framework was stopping is dropped before it sends anything';
711
783
  /** The reason on the inference:aborted that ends a turn whose start waited to
712
784
  * retry a failed compile across a stop() and a start(): the retry would
@@ -719,6 +791,11 @@ function isTurnContinuation(reason) {
719
791
  return reason === 'context_budget_restart' || reason === 'tool_results_ready'
720
792
  || reason === 'tool_result_guard_retry';
721
793
  }
794
+ /** A request a quiesce lets through, so a turn already alive can finish:
795
+ * a continuation of the turn, or a held turn's retry (TURN_RETRY). */
796
+ function passesQuiesce(request) {
797
+ return request !== undefined && (isTurnContinuation(request.reason) || isTurnRetry(request));
798
+ }
722
799
  /** Wake sources the framework generates for itself (bounce wakes,
723
800
  * continuations, retries, unsticks, admission requeues). A wake from any
724
801
  * other source — a channel message, a heartbeat or other server event, a
@@ -1190,12 +1267,14 @@ function scriptResultBrief(content) {
1190
1267
  }
1191
1268
  /**
1192
1269
  * What a script gets for a tool result that can't be converted to JSON,
1193
- * such as a cyclic object or a BigInt that an in-process module returned.
1270
+ * such as a cyclic object or a BigInt that an in-process module returned,
1271
+ * or an MCPL peer's blocks nested deeper than JSON.stringify can take.
1194
1272
  * It names the tool, and says the call itself completed, so a script that
1195
1273
  * reports it doesn't invite a blind retry. The host's log gets the same
1196
- * line, since the tool that returned it needs fixing whether or not the
1197
- * script reports it. The reason and the tool's own error are cut to
1198
- * VALUE_CHARS each, so the failure reaches the script whatever its memory.
1274
+ * line, since the tool, a module's or a server's, returns what a script
1275
+ * can't receive, whether or not the script reports it. The reason and the
1276
+ * tool's own error are cut to VALUE_CHARS each, so the failure reaches the
1277
+ * script whatever its memory.
1199
1278
  */
1200
1279
  function unconvertibleScriptResult(toolName, err, ownError) {
1201
1280
  let reason;
@@ -1230,7 +1309,9 @@ const SCRIPT_TEXT_ANSWER_ENVELOPE_BYTES = Buffer.byteLength(JSON.stringify({ con
1230
1309
  * dense in small objects or a script already holding most of its memory.
1231
1310
  * Such a result, or one past the figure that doesn't fit, reaches the script
1232
1311
  * as ResultTooLarge, saying the call took effect, and nothing of it is cut or
1233
- * saved.
1312
+ * saved. One the host can't convert to JSON at all, such as one nested deeper
1313
+ * than its runtime's JSON.stringify can take, is answered instead as a
1314
+ * failure naming the tool and saying the call itself completed.
1234
1315
  */
1235
1316
  const scriptResultMaxChars = (memoryMaxBytes) => Math.max(0, Math.floor((Math.min(maxResultBytes(memoryMaxBytes), bufferConstants.MAX_STRING_LENGTH) - SCRIPT_TEXT_ANSWER_ENVELOPE_BYTES) / 6));
1236
1317
  /**
@@ -1974,12 +2055,14 @@ export class AgentFramework {
1974
2055
  * is still the agent's, so a start that ends after a later frame opened
1975
2056
  * one, such as its retry's stream waiting to retry, leaves that one open.
1976
2057
  * An operator's abandon marks the window, and the frame's check collects
1977
- * the mark, which goes with the window. abortInference() and the abandon
1978
- * count a turn with no stream only here; a stop made anywhere else, as
1979
- * while an agent is idle, a turn finishes its last delivery or a provider
1980
- * hold has parked it, has no check to end it, and the next start drops
1981
- * it. Keyed by the Agent, so a replacement agent under the same name
1982
- * never shares one.
2058
+ * the mark, which goes with the window. A stop of a turn with no stream
2059
+ * lands here, or, for a turn whose continuation or retry is on its way (a
2060
+ * provider hold's, a park's, the queue's or a pass's), through
2061
+ * abortInference(), an abandon or stop(), which drop that continuation or
2062
+ * retry (dropContinuations); a stop made anywhere else, as while an agent
2063
+ * is idle or a turn finishes its last delivery, has no check to end it,
2064
+ * and the next start drops it. Keyed by the Agent, so a replacement agent
2065
+ * under the same name never shares one.
1983
2066
  */
1984
2067
  checkAhead = new Map();
1985
2068
  /** Structured calls executed in the current logical turn. Unlike the
@@ -3649,6 +3732,23 @@ export class AgentFramework {
3649
3732
  a.coalescingSubject === b.coalescingSubject && batchSubjects(a) === batchSubjects(b) &&
3650
3733
  a.readsDeferredWrite === b.readsDeferredWrite;
3651
3734
  }
3735
+ /** Keep `request` among a hold's `held` requests, once by
3736
+ * sameInferenceRequest. A held turn's retry (TURN_RETRY) whose equal is
3737
+ * already held marks that one, so the hold still keeps the turn alive. */
3738
+ holdRequest(held, request) {
3739
+ const same = held.find((r) => this.sameInferenceRequest(r, request));
3740
+ if (!same) {
3741
+ held.push(request);
3742
+ return;
3743
+ }
3744
+ if (isTurnRetry(request))
3745
+ same[TURN_RETRY] = true;
3746
+ // A merged start's batch (MERGED_BATCH) stays recorded on the request
3747
+ // kept in its place, which then stands for it, as the start did.
3748
+ const batch = mergedBatch(request);
3749
+ if (batch)
3750
+ same[MERGED_BATCH] = [...new Set([...(mergedBatch(same) ?? []), ...batch])];
3751
+ }
3652
3752
  consultProviderHold(error, agentName) {
3653
3753
  if (!this.providerHoldHook)
3654
3754
  return undefined;
@@ -3706,15 +3806,21 @@ export class AgentFramework {
3706
3806
  // failure streak), and the wait applies to the calls that follow it.
3707
3807
  if (!acceleration && !hostHold) {
3708
3808
  return wait !== undefined && isMembraneError(error) && error.retryable === true
3709
- && this.holdForProviderWait(agent, wait, trigger ? [trigger] : [], true);
3809
+ && this.holdForProviderWait(agent, wait, trigger ? [asTurnRetry(trigger)] : [], true);
3710
3810
  }
3811
+ // As in holdForProviderWait: once stop() has closed admission, nothing is
3812
+ // held, and nothing started.
3813
+ if (this.providerAdmissionClosed)
3814
+ return true;
3711
3815
  const now = Date.now();
3712
3816
  const pacing = hostHold ? undefined : this.accelerationCooldownMs(agent.name, error);
3713
3817
  const delayMs = hostHold ? hostHold.holdMs : pacing.delayMs;
3714
3818
  const existing = this.providerAccelerationCooldowns.get(agent.name);
3715
3819
  const held = existing?.heldRequests ?? [];
3716
- if (trigger && !held.some((r) => this.sameInferenceRequest(r, trigger)))
3717
- held.push(trigger);
3820
+ // The failed turn's own request, kept as its retry (TURN_RETRY). An
3821
+ // auxiliary call's failure passes no trigger: it holds no turn of its own.
3822
+ if (trigger)
3823
+ this.holdRequest(held, asTurnRetry(trigger));
3718
3824
  if (existing)
3719
3825
  clearTimeout(existing.timer);
3720
3826
  const timer = setTimeout(() => this.releaseProviderAccelerationCooldown(agent.name), Math.min(delayMs, MAX_TIMER_DELAY_MS));
@@ -3767,7 +3873,7 @@ export class AgentFramework {
3767
3873
  return undefined;
3768
3874
  if (this.conversationAgentHomes.has(agent.name)) {
3769
3875
  const wait = this.recordProviderWait(agent.name, agent.model, error);
3770
- return error.retryable === true && wait !== undefined && this.holdForProviderWait(agent, wait, trigger ? [trigger] : [], true)
3876
+ return error.retryable === true && wait !== undefined && this.holdForProviderWait(agent, wait, trigger ? [asTurnRetry(trigger)] : [], true)
3771
3877
  ? 'held'
3772
3878
  : undefined;
3773
3879
  }
@@ -3922,19 +4028,33 @@ export class AgentFramework {
3922
4028
  * Hold this agent's primary turns for a provider wait on its model. The
3923
4029
  * requests are kept and released together, into one fresh compile, when
3924
4030
  * the wait ends: the same path an acceleration cooldown takes, without
3925
- * parking auxiliary calls to other models.
4031
+ * parking auxiliary calls to other models. `report`, once the wait is
4032
+ * found to bind, runs before anything is held: a caller that reports its
4033
+ * turn's end there (inference:failed) does so before the hold takes the
4034
+ * turn's retry, as every failure site does.
3926
4035
  */
3927
- holdForProviderWait(agent, wait, requests, failed = false) {
4036
+ holdForProviderWait(agent, wait, requests, failed = false, report) {
3928
4037
  const now = Date.now();
3929
4038
  const until = wait.until ?? Number.POSITIVE_INFINITY;
3930
4039
  if (until <= now)
3931
4040
  return false;
4041
+ report?.();
4042
+ // stop() has cleared every hold and dropped every turn on its way: a
4043
+ // failure or start reaching here after it holds nothing, so no retry
4044
+ // outlives the stop as a turn still alive. Nothing is started either.
4045
+ if (this.providerAdmissionClosed)
4046
+ return true;
4047
+ // A listener of the report may have released the wait
4048
+ // (releaseProviderWait) before anything was held: nothing binds now, so
4049
+ // the requests start, as the release of an installed hold starts them.
4050
+ if (report && !this.providerWaits?.active(agent.name, wait.model)) {
4051
+ this.pendingRequests.push(...requests);
4052
+ return true;
4053
+ }
3932
4054
  const existing = this.providerAccelerationCooldowns.get(agent.name);
3933
4055
  const held = existing?.heldRequests ?? [];
3934
- for (const request of requests) {
3935
- if (!held.some((r) => this.sameInferenceRequest(r, request)))
3936
- held.push(request);
3937
- }
4056
+ for (const request of requests)
4057
+ this.holdRequest(held, request);
3938
4058
  // An acceleration or host hold already parks the whole agent; its release
3939
4059
  // re-checks this wait at admission.
3940
4060
  if (existing && existing.waitModel === undefined)
@@ -4514,9 +4634,11 @@ export class AgentFramework {
4514
4634
  }
4515
4635
  /**
4516
4636
  * Abort an in-flight inference for an agent. True when there was one to
4517
- * stop: a stream, or a turn with no stream and one of its checks ahead (its
4637
+ * stop: a stream; a turn with no stream and one of its checks ahead (its
4518
4638
  * hooks or compile, a wait to retry, the tool-result guard's wait), which
4519
- * ends at that check, before it sends anything more.
4639
+ * ends at that check, before it sends anything more; or a turn whose
4640
+ * continuation or retry is on its way (queued, kept by a provider hold,
4641
+ * parked at admission or in a scheduler pass), which ends here.
4520
4642
  */
4521
4643
  abortInference(agentName, reason) {
4522
4644
  const agent = this.agents.get(agentName);
@@ -4546,6 +4668,20 @@ export class AgentFramework {
4546
4668
  checkWindow.wake?.();
4547
4669
  return true;
4548
4670
  }
4671
+ // A turn whose continuation or retry is on its way (a budget restart or
4672
+ // a guard retry, or a failed turn's retry a provider hold keeps:
4673
+ // pendingContinuationAgents) ends here, as a stop of a turn's start ends
4674
+ // one, whether or not the frame that handed it on has let go of its
4675
+ // token yet (that frame ends on its own and settles nothing more): the
4676
+ // continuation or retry is dropped, with one inference:aborted carrying
4677
+ // the stop's reason. Wakes that arrived meanwhile aren't the turn's, and
4678
+ // still run; a hold of such wakes alone keeps no turn, and has nothing to
4679
+ // stop. Every failure site reports inference:failed before a hold takes
4680
+ // the retry, so a listener of it finds nothing held, and reports false.
4681
+ if (!result && this.pendingContinuationAgents().has(agentName)) {
4682
+ this.dropContinuations(agentName, { kind: 'caller', reason, why: 'a stop ended the turn' });
4683
+ return true;
4684
+ }
4549
4685
  return !!result;
4550
4686
  }
4551
4687
  /**
@@ -5433,9 +5569,10 @@ export class AgentFramework {
5433
5569
  * it queues, or a tool-result guard's retry, which its frame starts at
5434
5570
  * once) holds no turn token until its start takes one. Once its turn's
5435
5571
  * frame has let go of its token, its start can wait in the queue, a
5436
- * provider hold (a retry too, when a wait holds it), a park on admission
5437
- * or a scheduler pass's hands. Its turn is alive meanwhile. These are the
5438
- * agents with one on its way. A
5572
+ * provider hold, a park on admission or a scheduler pass's hands. Its turn
5573
+ * is alive meanwhile. So is a turn whose call failed into a provider hold,
5574
+ * which keeps the turn's retry (TURN_RETRY), until that retry starts,
5575
+ * wherever it waits. These are the agents with one on its way. A
5439
5576
  * `tool_results_ready` continuation follows a tool round left waiting with
5440
5577
  * no stream, which only the direct Agent API makes (Agent's
5441
5578
  * runInferenceWithInjections; the framework's own rounds wait with their
@@ -5444,7 +5581,7 @@ export class AgentFramework {
5444
5581
  */
5445
5582
  pendingContinuationAgents() {
5446
5583
  const agents = new Set();
5447
- const add = (r) => { if (resumesLogicalTurn(r.reason))
5584
+ const add = (r) => { if (continuesTurn(r))
5448
5585
  agents.add(r.agentName); };
5449
5586
  // Read defensively: a framework built from the prototype, as the
5450
5587
  // operator tests build one, sets none of these.
@@ -5462,7 +5599,7 @@ export class AgentFramework {
5462
5599
  return agents;
5463
5600
  }
5464
5601
  /** The agents with a turn alive: holding a turn token, or with a
5465
- * continuation of their turn on its way. */
5602
+ * continuation of their turn, or its retry, on its way. */
5466
5603
  turnHolders() {
5467
5604
  const holders = this.pendingContinuationAgents();
5468
5605
  for (const name of this.activeTurnTokens.keys())
@@ -5472,6 +5609,26 @@ export class AgentFramework {
5472
5609
  hasTurn(agentName) {
5473
5610
  return this.activeTurnTokens.has(agentName) || this.pendingContinuationAgents().has(agentName);
5474
5611
  }
5612
+ /** The provider hold keeping `agentName`'s turn for its retry (TURN_RETRY),
5613
+ * in words naming its end and its reason, and whether a stated wait holds
5614
+ * it; undefined when no hold keeps one. */
5615
+ heldTurnRetry(agentName) {
5616
+ // Optional, as pendingContinuationAgents reads it: a framework built from
5617
+ // its prototype, as some suites build one, has no map of holds.
5618
+ const hold = this.providerAccelerationCooldowns?.get(agentName);
5619
+ if (!hold?.heldRequests.some(isTurnRetry))
5620
+ return undefined;
5621
+ // A deadline past what a Date can name (an infinite wait, or a stated
5622
+ // one too long for an instant) is one only a release ends.
5623
+ const end = new Date(hold.until);
5624
+ const until = Number.isNaN(end.getTime()) ? 'until released' : `until ${end.toISOString()}`;
5625
+ return {
5626
+ says: `a provider hold keeps its turn to start again, ${until}: ${hold.reason}`,
5627
+ // release-provider-wait lifts a stated wait's hold, not a host's or an
5628
+ // acceleration cooldown's own pacing.
5629
+ statedWait: hold.waitModel !== undefined || hold.statedWait !== undefined,
5630
+ };
5631
+ }
5475
5632
  getHostModeStatus() {
5476
5633
  const parkedAdmissions = this.parkedAdmissionCount();
5477
5634
  const holders = this.turnHolders();
@@ -5483,7 +5640,7 @@ export class AgentFramework {
5483
5640
  activeTurns: holders.size,
5484
5641
  activeTurnAgents: [...holders].sort(),
5485
5642
  parkedAdmissions,
5486
- gatedRequests: this.pendingRequests.filter((r) => !isTurnContinuation(r.reason)).length,
5643
+ gatedRequests: this.pendingRequests.filter((r) => !passesQuiesce(r)).length,
5487
5644
  backgroundScripts: this.codeExecution?.scripts.leftRunning() ?? 0,
5488
5645
  deferredWrites: this.deferredMessages.length,
5489
5646
  ...(this.lastUnabandonable.length > 0 ? { unabandonable: [...this.lastUnabandonable] } : {}),
@@ -5499,8 +5656,9 @@ export class AgentFramework {
5499
5656
  * Enter quiesce mode: write the deferred-write queue, persist the flag,
5500
5657
  * suppress gate deliveries, pause all MCPL data planes, and wait for
5501
5658
  * in-flight turns to settle (turn-alive is a turn token, which spans
5502
- * dequeue → settled teardown, or a continuation of the turn on its way to
5503
- * its start: turnHolders). Idempotent, but for the queue's write, which
5659
+ * dequeue → settled teardown, a continuation of the turn on its way to its
5660
+ * start, or the turn's retry, which a provider hold keeps or which waits
5661
+ * to start: turnHolders). Idempotent, but for the queue's write, which
5504
5662
  * every call makes first: when the queue can't reach disk, because its
5505
5663
  * write fails or it's over the cap, the call rejects with a
5506
5664
  * QuiesceBlockedError and the host's mode as it was, unless it has
@@ -5510,8 +5668,8 @@ export class AgentFramework {
5510
5668
  * On drain timeout the host STAYS quiesced (`drained: false` in the
5511
5669
  * result); with `abandon: true` the undrained streams are cancelled via a
5512
5670
  * dedicated cancel kind that settles the turn without feeding the
5513
- * inference-failure accounting, and a turn whose continuation hasn't
5514
- * started ends with the continuation dropped. Drain covers the framework's
5671
+ * inference-failure accounting, and a turn whose continuation or retry
5672
+ * hasn't started ends with it dropped. Drain covers the framework's
5515
5673
  * own turns: a caller of the direct Agent API (`Agent.runInference`) owns
5516
5674
  * its turns' timing.
5517
5675
  */
@@ -5638,7 +5796,7 @@ export class AgentFramework {
5638
5796
  // to the continuation.
5639
5797
  const handedOff = new Set();
5640
5798
  for (const agentName of this.pendingContinuationAgents()) {
5641
- console.error(`[host-mode] abandoning ${agentName}'s turn before its continuation starts: the continuation is dropped`);
5799
+ console.error(`[host-mode] abandoning ${agentName}'s turn before its continuation or held retry starts: it is dropped`);
5642
5800
  this.dropContinuations(agentName, { kind: 'quiesce_abandoned', why: 'its turn was abandoned' });
5643
5801
  handedOff.add(agentName);
5644
5802
  }
@@ -8987,7 +9145,8 @@ export class AgentFramework {
8987
9145
  * by stop() while it runs.
8988
9146
  *
8989
9147
  * The branch moves under every agent sharing the store, so the switch
8990
- * reserves the store as a surgery does: every agent must be idle, or it is
9148
+ * reserves the store as a surgery does: every agent must be idle with no
9149
+ * turn alive (no continuation or held retry of one on its way), or it is
8991
9150
  * refused (OperatorActionError 'agent-busy'), and no turn starts until the
8992
9151
  * switch is done.
8993
9152
  */
@@ -9201,18 +9360,35 @@ export class AgentFramework {
9201
9360
  throw new OperatorActionError('agent-busy', `Cannot ${verb}: a live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName} is already in progress`);
9202
9361
  }
9203
9362
  const busy = [];
9363
+ let heldTurns = false;
9364
+ let statedWaits = false;
9204
9365
  for (const [name, a] of this.agents) {
9205
- if (a.state.status !== 'idle')
9366
+ if (a.state.status !== 'idle') {
9206
9367
  busy.push(`${name} is ${a.state.status}`);
9207
- else if (this.hasTurn(name))
9368
+ continue;
9369
+ }
9370
+ if (!this.hasTurn(name))
9371
+ continue;
9372
+ // A provider hold keeping the turn's retry is named: a quiesce alone
9373
+ // doesn't end that turn.
9374
+ const held = this.heldTurnRetry(name);
9375
+ if (held) {
9376
+ heldTurns = true;
9377
+ if (held.statedWait)
9378
+ statedWaits = true;
9379
+ busy.push(`${name} is idle+turn-alive (${held.says})`);
9380
+ }
9381
+ else {
9208
9382
  busy.push(`${name} is idle+turn-alive`);
9383
+ }
9209
9384
  }
9210
9385
  for (const name of this.activeTurnTokens.keys()) {
9211
9386
  if (!this.agents.has(name))
9212
9387
  busy.push(`${name} is turn-alive`);
9213
9388
  }
9214
9389
  if (busy.length > 0) {
9215
- throw new OperatorActionError('agent-busy', `Cannot ${verb} while ${busy.join(', ')} — every agent sharing the store must be idle (quiesce the host first)`);
9390
+ throw new OperatorActionError('agent-busy', `Cannot ${verb} while ${busy.join(', ')} — every agent sharing the store must be idle (quiesce the host first)` +
9391
+ (heldTurns ? `; ${heldTurnWayOn(statedWaits)}` : ''));
9216
9392
  }
9217
9393
  const reserved = new Map();
9218
9394
  for (const name of this.agents.keys()) {
@@ -9994,9 +10170,10 @@ export class AgentFramework {
9994
10170
  // Direct inference requests (e.g. runEphemeralToCompletion) bypass the
9995
10171
  // event queue — without this the loop can exit before they're drained.
9996
10172
  // While quiesced, gate-parked requests are NOT progress and must not
9997
- // block idle (only a budget restart can still run); identity-equivalent
9998
- // to `.length > 0` when not quiesced.
9999
- this.pendingRequests.some((r) => !this.quiesced || isTurnContinuation(r.reason)) ||
10173
+ // block idle; what a quiesce lets through (passesQuiesce: a turn's
10174
+ // continuation, or a held turn's retry) can still run, and is.
10175
+ // Identity-equivalent to `.length > 0` when not quiesced.
10176
+ this.pendingRequests.some((r) => !this.quiesced || passesQuiesce(r)) ||
10000
10177
  this.activeStreams.size > 0 ||
10001
10178
  Array.from(this.agents.values()).some((a) => a.state.status !== 'idle')) {
10002
10179
  await this.processNextEvent();
@@ -11553,9 +11730,12 @@ export class AgentFramework {
11553
11730
  // (request dropped as stale, inference policy veto) would otherwise stay
11554
11731
  // registered forever. No turn, no active stream and no pending request =
11555
11732
  // done. A turn holds its token from dequeue, through its hooks and
11556
- // compile, before its stream registers, so a fork mid-compile is busy.
11733
+ // compile, before its stream registers, so a fork mid-compile is busy;
11734
+ // and a turn is alive while its continuation or a provider hold's retry
11735
+ // of it is on its way (hasTurn), so a closure turn held for its retry
11736
+ // finishes when the hold ends.
11557
11737
  for (const agentName of [...this.closingConversationAgents]) {
11558
- if (!this.activeTurnTokens.has(agentName) &&
11738
+ if (!this.hasTurn(agentName) &&
11559
11739
  !this.activeStreams.has(agentName) &&
11560
11740
  !this.pendingRequests.some((r) => r.agentName === agentName)) {
11561
11741
  this.disposeConversationAgent(agentName);
@@ -12342,6 +12522,24 @@ export class AgentFramework {
12342
12522
  for (const cooldown of this.providerAccelerationCooldowns.values()) {
12343
12523
  cooldown.heldRequests = cooldown.heldRequests.filter((r) => r.coalescingSubject !== subject);
12344
12524
  }
12525
+ // A wake of the subject's merged into a start that carries a turn on
12526
+ // (MERGED_BATCH) is withdrawn from the start's batch, so no stop puts
12527
+ // it back and no pass unpacks it.
12528
+ const unrecord = (r) => {
12529
+ const batch = mergedBatch(r);
12530
+ if (batch?.some((w) => w.coalescingSubject === subject)) {
12531
+ r[MERGED_BATCH] = batch.filter((w) => w.coalescingSubject !== subject);
12532
+ }
12533
+ };
12534
+ for (const r of this.pendingRequests)
12535
+ unrecord(r);
12536
+ for (const cooldown of this.providerAccelerationCooldowns.values())
12537
+ cooldown.heldRequests.forEach(unrecord);
12538
+ for (const r of this.parkedContinuations ?? [])
12539
+ unrecord(r);
12540
+ for (const pass of this.passRequests ?? [])
12541
+ for (const requests of pass.values())
12542
+ requests.forEach(unrecord);
12345
12543
  }
12346
12544
  /**
12347
12545
  * A placement in the deferred queue is upgraded in place once the flush has
@@ -12720,7 +12918,7 @@ export class AgentFramework {
12720
12918
  });
12721
12919
  console.error(`[inference-dropped] agent=${agentName} reason=agent_not_found requests=${requests.length}`);
12722
12920
  // A continuation among them leaves its turn to end here.
12723
- if (requests.some((r) => resumesLogicalTurn(r.reason))) {
12921
+ if (requests.some(continuesTurn)) {
12724
12922
  this.endContinuedTurn(agentName, { kind: 'dropped', why: 'its agent is gone' });
12725
12923
  }
12726
12924
  continue;
@@ -12731,11 +12929,8 @@ export class AgentFramework {
12731
12929
  // selected now, the turn proceeds (the wait stays recorded).
12732
12930
  const otherModel = providerCooldown.waitModel !== undefined && providerCooldown.waitModel !== agent.model;
12733
12931
  if (!otherModel && (now < providerCooldown.until || this.extendHostProviderHold(agentName, providerCooldown))) {
12734
- for (const req of requests) {
12735
- if (!providerCooldown.heldRequests.some((r) => this.sameInferenceRequest(r, req))) {
12736
- providerCooldown.heldRequests.push(req);
12737
- }
12738
- }
12932
+ for (const req of requests)
12933
+ this.holdRequest(providerCooldown.heldRequests, req);
12739
12934
  const recovery = this.providerAccelerationRecoveries.get(agentName);
12740
12935
  if (recovery)
12741
12936
  recovery.heldRequests = providerCooldown.heldRequests.length;
@@ -12751,6 +12946,16 @@ export class AgentFramework {
12751
12946
  recovery.releasedAt = now;
12752
12947
  requests = [...providerCooldown.heldRequests, ...requests];
12753
12948
  }
12949
+ // A start that carries a turn on and came back before it took its
12950
+ // token stands for the batch it was merged from (MERGED_BATCH),
12951
+ // including one a hold released just now. The batch takes its place,
12952
+ // each request as it was, judged one by one from here: a wait holds
12953
+ // them apart, a quiesce parks the wakes while the turn's request
12954
+ // passes, and the read check and the policy decide on them all; the
12955
+ // merge below records the batch anew.
12956
+ if (requests.some((r) => mergedBatch(r) !== undefined)) {
12957
+ requests = requests.flatMap((r) => mergedBatch(r) ?? [r]);
12958
+ }
12754
12959
  // A provider's stated wait on this agent's primary model, including one
12755
12960
  // recorded before a restart, holds the turn until it passes. An
12756
12961
  // ephemeral run records nothing, its name being single-use, so no wait,
@@ -12784,9 +12989,10 @@ export class AgentFramework {
12784
12989
  // bounds a multi-day maintenance window's accumulation.
12785
12990
  if (this.quiesced) {
12786
12991
  // Continuations of a held turn (budget restart, non-streaming tool
12787
- // results) pass; everything else parks.
12788
- const passThrough = requests.filter((r) => isTurnContinuation(r.reason));
12789
- const parked = requests.filter((r) => !isTurnContinuation(r.reason));
12992
+ // results) pass, and so does a held turn's retry (passesQuiesce), so
12993
+ // a turn alive at the quiesce can finish; everything else parks.
12994
+ const passThrough = requests.filter((r) => passesQuiesce(r));
12995
+ const parked = requests.filter((r) => !passesQuiesce(r));
12790
12996
  // Coalesce per (reason, addressed): every channel wake shares one
12791
12997
  // reason, so keying on reason alone would let an ambient message
12792
12998
  // parked later evict a DM/mention parked earlier — and the
@@ -12888,7 +13094,7 @@ export class AgentFramework {
12888
13094
  // A continuation inherits its predecessor's EventGate liveness and
12889
13095
  // logical turn. If policy drops it, no driveStream remains to end them,
12890
13096
  // so its turn ends here (endContinuedTurn).
12891
- if (requests.some((r) => resumesLogicalTurn(r.reason))) {
13097
+ if (requests.some(continuesTurn)) {
12892
13098
  this.endContinuedTurn(agentName, { kind: 'dropped', why: 'the inference policy dropped its continuation' });
12893
13099
  }
12894
13100
  this.endPendingPrimary(agentName);
@@ -12970,7 +13176,7 @@ export class AgentFramework {
12970
13176
  // exists only once assembly renders it. Recorded for the whole batch
12971
13177
  // (not inherited from requests[0]), so a requeued trigger stays honest.
12972
13178
  const batchOnly = !budgetRestart && requests.every((r) => r.coalescingBatch === true && !!r.coalescingSubject);
12973
- await this.startAgentStream(agent, {
13179
+ const merged = {
12974
13180
  ...trigger,
12975
13181
  coalescingBatch: batchOnly || undefined,
12976
13182
  coalescingBatchSubjects: batchOnly
@@ -12998,7 +13204,19 @@ export class AgentFramework {
12998
13204
  counterparty: budgetRestart ? undefined : provReq?.counterparty,
12999
13205
  wakeChannelId: budgetRestart ? undefined : provReq?.wakeChannelId,
13000
13206
  wakeAt: budgetRestart ? undefined : provReq?.wakeAt,
13001
- });
13207
+ };
13208
+ // A held turn's retry merged with other wakes leaves the start its
13209
+ // turn's retry (TURN_RETRY), whichever request leads the merge: held
13210
+ // again before it takes a token, it keeps the turn alive as before.
13211
+ const start = requests.some(isTurnRetry) ? asTurnRetry(merged) : merged;
13212
+ // A start that carries a turn on and took in wakes that don't records
13213
+ // the batch it was merged from (MERGED_BATCH): until it takes its
13214
+ // token it stands for them all.
13215
+ if (continuesTurn(start) && requests.some((r) => !continuesTurn(r)))
13216
+ start[MERGED_BATCH] = [...requests];
13217
+ else
13218
+ delete start[MERGED_BATCH];
13219
+ await this.startAgentStream(agent, start);
13002
13220
  }
13003
13221
  }
13004
13222
  /**
@@ -15243,7 +15461,7 @@ export class AgentFramework {
15243
15461
  // Do not await inside processInferenceRequests: one slow auxiliary call for
15244
15462
  // this resident must not block events or other residents. Admission stays
15245
15463
  // owned and the continuation receives it after the in-flight call settles.
15246
- const parkedContinuation = trigger !== undefined && resumesLogicalTurn(trigger.reason);
15464
+ const parkedContinuation = trigger !== undefined && continuesTurn(trigger);
15247
15465
  if (parkedContinuation)
15248
15466
  this.parkedContinuations.add(trigger);
15249
15467
  void this.waitForAuxiliaryIdle(agent.name).then(async () => {
@@ -15277,8 +15495,9 @@ export class AgentFramework {
15277
15495
  // park; starting here would bypass it and run a turn against a
15278
15496
  // context the operator believes frozen. Give admission back and
15279
15497
  // requeue — the scheduler re-parks it (coalesced) until resume.
15280
- // Continuations of a held turn pass, as they do at the gate.
15281
- if (this.quiesced && !isTurnContinuation(trigger?.reason ?? '')) {
15498
+ // Continuations of a held turn, and a held turn's retry, pass, as
15499
+ // they do at the gate (passesQuiesce).
15500
+ if (this.quiesced && !passesQuiesce(trigger)) {
15282
15501
  this.releasePrimaryProviderGate(agent.name);
15283
15502
  this.pendingRequests.push(trigger ?? {
15284
15503
  agentName: agent.name, reason: 'provider-admission:requeue', source: 'scheduler', timestamp: Date.now(),
@@ -15286,6 +15505,16 @@ export class AgentFramework {
15286
15505
  console.error(`[provider-admission] ${agent.name}: host quiesced while parked — wake requeued, not started`);
15287
15506
  return;
15288
15507
  }
15508
+ // A start that stands for a merged batch (MERGED_BATCH) goes back
15509
+ // to the scheduler as that batch: its gate lets the turn's request
15510
+ // through alone and parks the wakes until resume.
15511
+ const batch = trigger && mergedBatch(trigger);
15512
+ if (this.quiesced && batch) {
15513
+ this.releasePrimaryProviderGate(agent.name);
15514
+ this.pendingRequests.push(...batch);
15515
+ console.error(`[provider-admission] ${agent.name}: host quiesced while parked — the merged start goes back as its ${batch.length} requests`);
15516
+ return;
15517
+ }
15289
15518
  await this.startAgentStream(agent, trigger, attempt, true);
15290
15519
  }).catch((error) => {
15291
15520
  this.releasePrimaryProviderGate(agent.name);
@@ -15424,12 +15653,25 @@ export class AgentFramework {
15424
15653
  * caller has ended the turn already.
15425
15654
  */
15426
15655
  dropContinuations(agentName, drop) {
15427
- const isContinuation = (r) => r.agentName === agentName && resumesLogicalTurn(r.reason);
15656
+ const isContinuation = (r) => r.agentName === agentName && continuesTurn(r);
15657
+ // A caller's stop and an abandon end the turn, not the wakes merged into
15658
+ // a start of it that hasn't taken its token (MERGED_BATCH): they go back
15659
+ // in the queue. A stop() or a disposal ends the agent's work, and puts
15660
+ // nothing back.
15661
+ const putsBack = drop?.kind === 'caller' || drop?.kind === 'quiesce_abandoned';
15662
+ const back = [];
15428
15663
  let dropped = false;
15429
15664
  const take = (requests) => {
15430
- const left = requests.filter((r) => !isContinuation(r));
15431
- if (left.length < requests.length)
15432
- dropped = true;
15665
+ const left = [];
15666
+ for (const r of requests) {
15667
+ if (!isContinuation(r))
15668
+ left.push(r);
15669
+ else {
15670
+ dropped = true;
15671
+ if (putsBack)
15672
+ back.push(...mergedWakes(r));
15673
+ }
15674
+ }
15433
15675
  return left;
15434
15676
  };
15435
15677
  this.pendingRequests = take(this.pendingRequests);
@@ -15446,6 +15688,8 @@ export class AgentFramework {
15446
15688
  this.parkedContinuations.delete(parked);
15447
15689
  this.releasePrimaryProviderGate(agentName);
15448
15690
  dropped = true;
15691
+ if (putsBack)
15692
+ back.push(...mergedWakes(parked));
15449
15693
  }
15450
15694
  for (const pass of this.passRequests) {
15451
15695
  const requests = pass.get(agentName);
@@ -15455,6 +15699,7 @@ export class AgentFramework {
15455
15699
  requests.length = 0;
15456
15700
  requests.push(...left);
15457
15701
  }
15702
+ this.pendingRequests.push(...back);
15458
15703
  if (!dropped || !drop)
15459
15704
  return;
15460
15705
  // A primary a provider hold left pending for what it released won't run
@@ -15481,11 +15726,11 @@ export class AgentFramework {
15481
15726
  const startedAt = this.lastInferenceAt.get(agentName)?.startedAt ?? Date.now();
15482
15727
  if (agent && drop.kind !== 'dropped') {
15483
15728
  const routing = this.openLogical.get(agentName) ?? this.routingOf(agentName);
15484
- this.endStoppedStart(agent, { kind: drop.kind }, routing, startedAt, undefined, false);
15729
+ this.endStoppedStart(agent, drop.kind === 'caller' ? { kind: 'caller', reason: drop.reason } : { kind: drop.kind }, routing, startedAt, undefined, false);
15485
15730
  }
15486
15731
  else {
15487
15732
  const owner = this.activeTurnTokens.get(agentName);
15488
- this.abortDroppedTurn(agentName, owner ?? -1, drop.kind === 'dropped' ? CONTINUATION_DROPPED : drop.kind, startedAt);
15733
+ this.abortDroppedTurn(agentName, owner ?? -1, drop.kind === 'dropped' ? CONTINUATION_DROPPED : drop.kind === 'caller' ? (drop.reason ?? 'user') : drop.kind, startedAt);
15489
15734
  this.channelRegistry?.stopTyping();
15490
15735
  if (agent) {
15491
15736
  agent.toolResultGuard.recovering = false;
@@ -15508,6 +15753,34 @@ export class AgentFramework {
15508
15753
  this.ackDeferredWrites();
15509
15754
  }
15510
15755
  }
15756
+ /**
15757
+ * After a failure a provider hold would take: once stop() has closed
15758
+ * admission, the hold installs nothing (holdForProviderWait,
15759
+ * holdProviderAcceleration), so no retry follows the turn's
15760
+ * inference:failed, and the turn ends as a retry a stop cancelled ends, with
15761
+ * an inference:aborted saying so. Nothing is written. True when it ended
15762
+ * the turn.
15763
+ */
15764
+ endFailureAtStop(agentName, turnToken, startedAt) {
15765
+ if (!this.providerAdmissionClosed)
15766
+ return false;
15767
+ this.abortDroppedTurn(agentName, turnToken, RETRY_CANCELLED_BY_STOP, startedAt);
15768
+ return true;
15769
+ }
15770
+ /**
15771
+ * A turn's start whose failure a provider hold took (or, once stop() has
15772
+ * closed admission, would have: endFailureAtStop) ends here. Its window
15773
+ * closes now, not at the start's finally a few microtasks on: no check of
15774
+ * this start is ahead, and a stop made after the failure's report must
15775
+ * find the held retry (abortInference's drop), not a window nothing will
15776
+ * collect.
15777
+ */
15778
+ endHeldStart(agent, checkWindow, turnToken, startedAt) {
15779
+ if (this.checkAhead.get(agent) === checkWindow)
15780
+ this.checkAhead.delete(agent);
15781
+ this.endFailureAtStop(agent.name, turnToken, startedAt);
15782
+ this.eventGate?.onInferenceEnded(agent.name);
15783
+ }
15511
15784
  /**
15512
15785
  * End a turn that beginAgentTurn drops before it sends anything. The turn
15513
15786
  * emitted inference:started, and listeners that bracket a turn, such as a
@@ -15726,6 +15999,12 @@ export class AgentFramework {
15726
15999
  const { coalescingSubject: _subject, coalescingBatch: _batch, coalescingBatchSubjects: _subjects, readsDeferredWrite: _read, ...retry } = trigger;
15727
16000
  trigger = retry;
15728
16001
  }
16002
+ // The wakes merged into this start are the turn's now: it reads them,
16003
+ // so a later drop of its retry puts none of them back (MERGED_BATCH).
16004
+ if (trigger && mergedBatch(trigger)) {
16005
+ const { [MERGED_BATCH]: _batch, ...own } = trigger;
16006
+ trigger = own;
16007
+ }
15729
16008
  // Record turn checkpoint before inference (only on first attempt, not retries)
15730
16009
  if (attempt === 0 && trigger?.reason !== 'tool_result_guard_retry') {
15731
16010
  this.recordTurnCheckpoint(agent.name);
@@ -16011,9 +16290,8 @@ export class AgentFramework {
16011
16290
  // hold the turn as the boundary would rather than send it into the wait.
16012
16291
  if (this.conversationAgentHomes.has(agent.name)) {
16013
16292
  const wait = this.providerWaits?.active(agent.name, agent.model);
16014
- if (wait && this.holdForProviderWait(agent, wait, trigger ? [trigger] : [])) {
16015
- console.error(`[provider-wait] agent=${agent.name} model=${agent.model} binds after the compile ` +
16016
- `(recorded while the turn compiled); turn held, nothing sent`);
16293
+ // The turn has begun (inference:started), so the hold keeps its retry.
16294
+ if (wait && this.holdForProviderWait(agent, wait, trigger ? [asTurnRetry(trigger)] : [], false, () => {
16017
16295
  // The stream the compile made won't be sent: it's cancelled, and
16018
16296
  // this start's window closes, before the hold is reported, so a stop
16019
16297
  // made while inference:failed is delivered finds neither, and the
@@ -16025,8 +16303,15 @@ export class AgentFramework {
16025
16303
  this.checkAhead.delete(agent);
16026
16304
  // The turn emitted inference:started before its compile; every turn
16027
16305
  // that does gets one terminal event, which listeners (a typing loop)
16028
- // rely on, as the failure sites' holds emit inference:failed.
16306
+ // rely on. It's reported before the hold takes the turn's retry, as
16307
+ // at every failure site, so a listener's stop finds nothing held.
16029
16308
  this.emitTrace({ type: 'inference:failed', agentName: agent.name, error: providerWaitText(agent.name, agent.model, wait) });
16309
+ })) {
16310
+ console.error(`[provider-wait] agent=${agent.name} model=${agent.model} binds after the compile ` +
16311
+ `(recorded while the turn compiled); ${this.providerAdmissionClosed ? 'the framework is stopping, so nothing is held'
16312
+ : this.providerAccelerationCooldowns.get(agent.name)?.heldRequests.some(isTurnRetry) ? 'turn held'
16313
+ : 'the wait was released as the failure was reported, so the retry is queued'}, nothing sent`);
16314
+ this.endFailureAtStop(agent.name, turnToken, turnStartedAt);
16030
16315
  this.activeStreams.delete(agent.name);
16031
16316
  this.pendingAssistantBlocks.delete(agent.name);
16032
16317
  this.channelRegistry?.stopTyping();
@@ -16101,14 +16386,16 @@ export class AgentFramework {
16101
16386
  if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
16102
16387
  // Capacity scheduling is not poisoned history. The cooldown timer owns
16103
16388
  // one later fresh compile; no immediate retry or exhaustion marker.
16104
- this.eventGate?.onInferenceEnded(agent.name);
16389
+ // (Once stop() has closed admission, nothing is held, and the turn
16390
+ // ends here: endFailureAtStop.)
16391
+ this.endHeldStart(agent, checkWindow, turnToken, turnStartedAt);
16105
16392
  return false;
16106
16393
  }
16107
16394
  const statedWait = ownsProviderGate ? undefined : this.nonOwningProviderWait(agent, err, trigger);
16108
16395
  if (statedWait === 'held') {
16109
16396
  // A conversation agent held by the provider's stated wait, as a
16110
16397
  // resident is: the hold's release owns the later compile.
16111
- this.eventGate?.onInferenceEnded(agent.name);
16398
+ this.endHeldStart(agent, checkWindow, turnToken, turnStartedAt);
16112
16399
  return false;
16113
16400
  }
16114
16401
  const action = statedWait === 'fail' ? { retry: false } : this.errorPolicy.onInferenceError(err, agent.name, attempt);
@@ -16141,7 +16428,16 @@ export class AgentFramework {
16141
16428
  }
16142
16429
  // The retry re-enters startAgentStream, which replaces this frame's
16143
16430
  // turn token with its own; cleanup belongs to the innermost frame.
16144
- await this.startAgentStream(agent, trigger, attempt + 1);
16431
+ // The retry is its turn's (TURN_RETRY): this frame has begun the turn,
16432
+ // and once the retry's start is held at the last admission boundary
16433
+ // (a conversation agent's own compression can record a wait during
16434
+ // the policy's delay), this frame lets go of its token. No check of
16435
+ // this start is ahead now, so its window closes first: a stop made
16436
+ // once the retry is held finds the held retry (abortInference's
16437
+ // drop), not a window nothing will collect.
16438
+ if (this.checkAhead.get(agent) === checkWindow)
16439
+ this.checkAhead.delete(agent);
16440
+ await this.startAgentStream(agent, trigger && asTurnRetry(trigger), attempt + 1);
16145
16441
  }
16146
16442
  else {
16147
16443
  // Terminal failure with no driveStream: this frame owns teardown.
@@ -17625,13 +17921,17 @@ export class AgentFramework {
17625
17921
  if (agent.streamId !== myStreamId)
17626
17922
  break;
17627
17923
  this.abandonAgentScripts(agent.name, 'stream error', streamRouting);
17924
+ // A hold takes the turn's retry, or, once stop() has closed
17925
+ // admission, the turn ends here (endFailureAtStop).
17628
17926
  if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
17927
+ this.endFailureAtStop(agent.name, myTurnToken ?? -1, startTime);
17629
17928
  if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
17630
17929
  this.eventGate?.onInferenceEnded(agent.name);
17631
17930
  break;
17632
17931
  }
17633
17932
  const statedWait = ownsProviderGate ? undefined : this.nonOwningProviderWait(agent, err, trigger);
17634
17933
  if (statedWait === 'held') {
17934
+ this.endFailureAtStop(agent.name, myTurnToken ?? -1, startTime);
17635
17935
  if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
17636
17936
  this.eventGate?.onInferenceEnded(agent.name);
17637
17937
  break;
@@ -17670,7 +17970,8 @@ export class AgentFramework {
17670
17970
  }
17671
17971
  return;
17672
17972
  }
17673
- await this.startAgentStream(agent, trigger, attempt + 1);
17973
+ // Marked as its turn's retry, as at the start's failure.
17974
+ await this.startAgentStream(agent, trigger && asTurnRetry(trigger), attempt + 1);
17674
17975
  }
17675
17976
  else {
17676
17977
  // The turn ends here, and its inference:failed doesn't say so,
@@ -17980,10 +18281,16 @@ export class AgentFramework {
17980
18281
  const durationMs = Date.now() - startTime;
17981
18282
  // The stream won't go on: the agent stops holding it before the
17982
18283
  // failure is reported, as at the stream's error event, so a stop made
17983
- // while inference:failed is delivered finds no stream.
18284
+ // while inference:failed is delivered finds no stream. It's reported
18285
+ // once, before a provider hold takes the turn's retry, as at the
18286
+ // stream's error event and at a start's failure: a listener's stop
18287
+ // finds nothing held, and one made after it ends the held turn.
17984
18288
  agent.reset();
18289
+ this.emitTrace({ type: 'inference:failed', agentName: agent.name, error: err.message, stack: err.stack });
17985
18290
  if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
17986
- this.emitTrace({ type: 'inference:failed', agentName: agent.name, error: err.message, stack: err.stack });
18291
+ // Once stop() has closed admission, nothing is held, and the turn
18292
+ // ends here (endFailureAtStop).
18293
+ this.endFailureAtStop(agent.name, myTurnToken ?? -1, startTime);
17987
18294
  logStream({ success: false,
17988
18295
  error: `Provider acceleration cooldown: ${err.message}`,
17989
18296
  request: compiledRequest ?? { note: 'streaming request rate-limited' }, durationMs });
@@ -18000,7 +18307,7 @@ export class AgentFramework {
18000
18307
  // says why it ends.
18001
18308
  const statedWait = ownsProviderGate ? undefined : this.nonOwningProviderWait(agent, err, trigger);
18002
18309
  if (statedWait === 'held') {
18003
- this.emitTrace({ type: 'inference:failed', agentName: agent.name, error: err.message, stack: err.stack });
18310
+ this.endFailureAtStop(agent.name, myTurnToken ?? -1, startTime);
18004
18311
  logStream({ success: false,
18005
18312
  error: `Provider wait: ${err.message}`,
18006
18313
  request: compiledRequest ?? { note: 'streaming request rate-limited' }, durationMs });
@@ -18011,12 +18318,6 @@ export class AgentFramework {
18011
18318
  this.eventGate?.onInferenceEnded(agent.name);
18012
18319
  return;
18013
18320
  }
18014
- this.emitTrace({
18015
- type: 'inference:failed',
18016
- agentName: agent.name,
18017
- error: err.message,
18018
- stack: err.stack,
18019
- });
18020
18321
  this.settleAgent(agent.name, {
18021
18322
  stopReason: 'exhausted',
18022
18323
  speech: '',
@@ -18224,8 +18525,11 @@ export class AgentFramework {
18224
18525
  // frame that ends the turn disposes it, after the flush above, so what
18225
18526
  // was held for the turn's end lands in the fork's store instead of
18226
18527
  // being dropped as addressed to no agent. A frame that hands the turn
18227
- // to a successor (a guard or budget restart) leaves the fork to it.
18228
- if (frameReachedTerminal && ownsPhysicalStream && this.closingConversationAgents.has(agent.name)) {
18528
+ // to a successor (a guard or budget restart) leaves the fork to it, and
18529
+ // so does one whose failure a provider hold keeps for its retry: the
18530
+ // turn isn't over (pendingContinuationAgents).
18531
+ if (frameReachedTerminal && ownsPhysicalStream && this.closingConversationAgents.has(agent.name)
18532
+ && !this.pendingContinuationAgents().has(agent.name)) {
18229
18533
  this.disposeConversationAgent(agent.name);
18230
18534
  }
18231
18535
  }
@@ -18307,8 +18611,14 @@ export class AgentFramework {
18307
18611
  }
18308
18612
  if (agent.state.status !== 'idle' || this.hasTurn(agentName)) {
18309
18613
  const shown = agent.state.status === 'idle' ? 'idle+turn-alive' : agent.state.status;
18310
- throw new Error(`puppet refused: agent ${agentName} is ${shown} (requires idle — ` +
18311
- `injecting a turn under an active stream corrupts wire ordering)`);
18614
+ // A turn a provider hold keeps for its retry has no stream: the refusal
18615
+ // names the hold, and how to go on sooner, as surgery's does.
18616
+ const held = agent.state.status === 'idle' ? this.heldTurnRetry(agentName) : undefined;
18617
+ throw new Error(held
18618
+ ? `puppet refused: agent ${agentName} is idle+turn-alive (${held.says}; requires idle); ` +
18619
+ heldTurnWayOn(held.statedWait)
18620
+ : `puppet refused: agent ${agentName} is ${shown} (requires idle — ` +
18621
+ `injecting a turn under an active stream corrupts wire ordering)`);
18312
18622
  }
18313
18623
  const onSurface = this.getToolsForAgent(agentName)
18314
18624
  .some((t) => t.name === toolName && agent.canUseTool(t.name));
@@ -19233,7 +19543,9 @@ export class AgentFramework {
19233
19543
  * content where it sent one, `false`, `0` and `null` included, else null.
19234
19544
  * A result that can't be converted to JSON (a cyclic object, a BigInt),
19235
19545
  * which the dispatch's own check makes unlikely here, is a failure naming
19236
- * the tool and saying the call itself completed.
19546
+ * the tool and saying the call itself completed. So is an MCPL peer's
19547
+ * result whose blocks were too deep for the dispatch's treatment, which
19548
+ * the dispatch marks (SCRIPT_RESULT_UNCONVERTIBLE).
19237
19549
  */
19238
19550
  async scriptResultAnswer(result, toolName, toServer) {
19239
19551
  const structured = Object.prototype.hasOwnProperty.call(result, 'structured');
@@ -19277,6 +19589,10 @@ export class AgentFramework {
19277
19589
  brief: error,
19278
19590
  };
19279
19591
  };
19592
+ // An MCPL peer's result whose blocks JSON couldn't hold, so that RFC-005's
19593
+ // treatment couldn't be given them at dispatch.
19594
+ if (SCRIPT_RESULT_UNCONVERTIBLE.has(result))
19595
+ return failed(SCRIPT_RESULT_UNCONVERTIBLE.get(result));
19280
19596
  let content;
19281
19597
  let text;
19282
19598
  // A structured result's blocks, as #538 gives them: none when its content
@@ -24507,8 +24823,8 @@ export class AgentFramework {
24507
24823
  });
24508
24824
  }
24509
24825
  /** An MCP tool call's trace: a result the server marked an error (or one
24510
- * the reading couldn't complete) is tool:failed, as on the other tool
24511
- * paths; anything else is tool:completed. */
24826
+ * the reading takes as one, such as malformed content) is tool:failed, as
24827
+ * on the other tool paths; anything else is tool:completed. */
24512
24828
  traceMcpToolOutcome(serverId, tool, callId, result, durationMs) {
24513
24829
  if (result.isError) {
24514
24830
  this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool, callId, durationMs, error: result.error ?? 'tool returned an error' });
@@ -24917,17 +25233,44 @@ export class AgentFramework {
24917
25233
  // generated JSON) stands in for what the server sent. An MCPL peer's
24918
25234
  // content blocks are the exception: what a script prints comes back to
24919
25235
  // the model, so each gets the treatment RFC-005 gives it on every lane
24920
- // the model reads, here, where the server that sent them is known. A
24921
- // module's blocks get it at the script lane's exit (scriptResultAnswer).
24922
- // A standard peer's blocks reach the script as sent.
25236
+ // the model reads, here, where the server that sent them is known, and
25237
+ // so does what RFC-005 governs at any depth within them, by the same
25238
+ // peer reading, bound to that server (shownToScriptFromServer). A
25239
+ // module's blocks get it at
25240
+ // the script lane's exit (scriptResultAnswer). A standard peer's blocks
25241
+ // reach the script as sent.
24923
25242
  const forScript = this.scriptToolWaiters?.has(call.id) ?? false;
24924
25243
  const reading = async (result, mcpl) => {
24925
25244
  if (!forScript || !Object.prototype.hasOwnProperty.call(result, 'structuredContent')) {
24926
25245
  return this.mcpToolResult(result, call.id, mcpl, serverId, call.name);
24927
25246
  }
24928
25247
  const direct = AgentFramework.directToolResult(result, serverId, call.name);
24929
- if (mcpl)
24930
- return Array.isArray(direct.data) ? { ...direct, data: direct.data.map((block) => referenceBlockForScript(block, serverId)) } : direct;
25248
+ if (mcpl) {
25249
+ if (!Array.isArray(direct.data))
25250
+ return direct;
25251
+ // Each block by the classifier, then what's nested in each, at any
25252
+ // depth. The blocks came from the wire as JSON, so the round trip
25253
+ // changes nothing else. But a value that parsed can still fail to
25254
+ // go back: past some depth JSON.stringify overflows the stack where
25255
+ // JSON.parse didn't (on Node 24.19 an array 5,000 deep does, on Bun
25256
+ // 1.4.4 one 200,000 deep). That fails the script lane's treatment
25257
+ // alone, and the peer answered: the result keeps the peer's outcome
25258
+ // for the trace and the call's terminal, and the script lane's exit
25259
+ // answers it as a result it can't convert, not as a failed dispatch
25260
+ // (SCRIPT_RESULT_UNCONVERTIBLE). Its blocks are dropped, since they
25261
+ // are untreated and the result travels on, to the waiter and to the
25262
+ // listeners of its event's trace. Its structured content is kept, as
25263
+ // every result keeps it, since RFC-005's treatment doesn't reach it.
25264
+ const blocks = direct.data.map((block) => referenceBlockForScript(block, serverId));
25265
+ try {
25266
+ return { ...direct, data: JSON.parse(JSON.stringify(blocks, (_key, value) => shownToScriptFromServer(value, serverId))) };
25267
+ }
25268
+ catch (err) {
25269
+ const unconvertible = { ...direct, data: [] };
25270
+ SCRIPT_RESULT_UNCONVERTIBLE.set(unconvertible, err);
25271
+ return unconvertible;
25272
+ }
25273
+ }
24931
25274
  // A standard peer's content that isn't a list of blocks is a malformed
24932
25275
  // result on the model's reading (normalizeStandardToolResult), so the
24933
25276
  // script reads it that way too, its structured content kept.
@@ -25125,7 +25468,8 @@ export class AgentFramework {
25125
25468
  * direct path (a module's callTool, a channel's tool call) doesn't come
25126
25469
  * here: it returns the server's result itself (directToolResult), for
25127
25470
  * either engine, as does a script's call whose result is structured, with
25128
- * an MCPL peer's blocks given RFC-005's treatment (referenceBlockForScript).
25471
+ * an MCPL peer's blocks given RFC-005's treatment (referenceBlockForScript),
25472
+ * and what's nested in them at any depth (shownToScriptFromServer).
25129
25473
  */
25130
25474
  async mcpToolResult(result, callId, mcplPeer, serverId, callName) {
25131
25475
  const hasStructured = Object.prototype.hasOwnProperty.call(result, 'structuredContent');