@animalabs/agent-framework 0.26.0 → 0.26.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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, 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,17 +775,27 @@ 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
713
785
  * revive the earlier lifecycle's turn in the next one. */
714
786
  const RETRY_ACROSS_RESTART = 'a retry waiting while the framework was stopped and started again is dropped before it sends anything';
787
+ /** The reason on the inference:aborted that ends a turn whose continuation
788
+ * was dropped before it started, other than by an abandon or a stop. */
789
+ const CONTINUATION_DROPPED = "a turn's continuation was dropped before it started";
715
790
  function isTurnContinuation(reason) {
716
791
  return reason === 'context_budget_restart' || reason === 'tool_results_ready'
717
792
  || reason === 'tool_result_guard_retry';
718
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
+ }
719
799
  /** Wake sources the framework generates for itself (bounce wakes,
720
800
  * continuations, retries, unsticks, admission requeues). A wake from any
721
801
  * other source — a channel message, a heartbeat or other server event, a
@@ -1187,12 +1267,14 @@ function scriptResultBrief(content) {
1187
1267
  }
1188
1268
  /**
1189
1269
  * What a script gets for a tool result that can't be converted to JSON,
1190
- * 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.
1191
1272
  * It names the tool, and says the call itself completed, so a script that
1192
1273
  * reports it doesn't invite a blind retry. The host's log gets the same
1193
- * line, since the tool that returned it needs fixing whether or not the
1194
- * script reports it. The reason and the tool's own error are cut to
1195
- * 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.
1196
1278
  */
1197
1279
  function unconvertibleScriptResult(toolName, err, ownError) {
1198
1280
  let reason;
@@ -1221,13 +1303,15 @@ const SCRIPT_TEXT_ANSWER_ENVELOPE_BYTES = Buffer.byteLength(JSON.stringify({ con
1221
1303
  * memoryMaxBytes. So the figure moves with memoryMaxBytes. What it can't
1222
1304
  * count: a result's `structured` value, which the measure leaves out and the
1223
1305
  * answer carries as JSON beside the content, and that result's blocks past
1224
- * their text, which the answer carries as they are (each further block's
1225
- * keys, an image's data); nesting past 2,000 levels; and a
1306
+ * their text, which the answer carries as the script lane gives them (each
1307
+ * further block's keys, an image's data); nesting past 2,000 levels; and a
1226
1308
  * parse that needs more of the script's memory than is free, as for JSON
1227
1309
  * dense in small objects or a script already holding most of its memory.
1228
1310
  * Such a result, or one past the figure that doesn't fit, reaches the script
1229
1311
  * as ResultTooLarge, saying the call took effect, and nothing of it is cut or
1230
- * 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.
1231
1315
  */
1232
1316
  const scriptResultMaxChars = (memoryMaxBytes) => Math.max(0, Math.floor((Math.min(maxResultBytes(memoryMaxBytes), bufferConstants.MAX_STRING_LENGTH) - SCRIPT_TEXT_ANSWER_ENVELOPE_BYTES) / 6));
1233
1317
  /**
@@ -1918,6 +2002,15 @@ export class AgentFramework {
1918
2002
  // and its settle except that turn's own blocks, and, before its checkpoint,
1919
2003
  // the inputs its start writes (flushForStart).
1920
2004
  activeTurnTokens = new Map();
2005
+ /** Each running scheduler pass's requests, which it took from
2006
+ * pendingRequests: it holds those of the agents it hasn't taken up yet
2007
+ * while it awaits an earlier agent's start, so a continuation among them
2008
+ * is still on its way (pendingContinuationAgents). */
2009
+ passRequests = new Set();
2010
+ /** Continuations whose start waits on provider admission behind an
2011
+ * auxiliary call, in no queue meanwhile. A drop takes one out and gives
2012
+ * its admission back; the park, woken, then finds it gone. */
2013
+ parkedContinuations = new Set();
1921
2014
  /** Each turn whose settle has ended it (settleAgent, endDroppedTurn), for
1922
2015
  * its agent's scripts: its start's teardown can still hold the agent's
1923
2016
  * token, and a turn that has ended isn't live (liveTurns). A later start
@@ -1962,12 +2055,14 @@ export class AgentFramework {
1962
2055
  * is still the agent's, so a start that ends after a later frame opened
1963
2056
  * one, such as its retry's stream waiting to retry, leaves that one open.
1964
2057
  * An operator's abandon marks the window, and the frame's check collects
1965
- * the mark, which goes with the window. abortInference() and the abandon
1966
- * count a turn with no stream only here; a stop made anywhere else, as
1967
- * while an agent is idle, a turn finishes its last delivery or a provider
1968
- * hold has parked it, has no check to end it, and the next start drops
1969
- * it. Keyed by the Agent, so a replacement agent under the same name
1970
- * 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.
1971
2066
  */
1972
2067
  checkAhead = new Map();
1973
2068
  /** Structured calls executed in the current logical turn. Unlike the
@@ -2050,7 +2145,15 @@ export class AgentFramework {
2050
2145
  // config.codeExecution.enabled: its settings, and every agent's scripts,
2051
2146
  // each in a process of its own on the engine (AgentScripts). Waiters
2052
2147
  // resolve script-inner tool calls dispatched through the normal
2053
- // dispatchToolCall path.
2148
+ // dispatchToolCall path. A waiter's route is where the dispatch sent its
2149
+ // call: `toServer` is set when it went to a server's own tool (an MCPL or
2150
+ // MCP-only peer's), so the script lane's exit treats every other result as
2151
+ // a module's (scriptResultAnswer), by where the call went rather than by a
2152
+ // name lookup a connect or disconnect can change while it runs. A
2153
+ // framework tool that talks to a server itself, such as a channel tool,
2154
+ // builds its own result, which counts as a module's. A caller of
2155
+ // dispatchScriptToolCall that passes no route gets one left false, the
2156
+ // reading that withholds more.
2054
2157
  codeExecution = null;
2055
2158
  scriptToolWaiters = new Map();
2056
2159
  /** Per agent, a round that called code_execution and waits for those calls'
@@ -3026,6 +3129,16 @@ export class AgentFramework {
3026
3129
  // Retained tool images are per-process by design; a retained stopped
3027
3130
  // framework must not keep up to the whole ledger budget referenced.
3028
3131
  this.toolImageLedgers.clear();
3132
+ // A turn whose continuation is still on its way ends with the stop, as
3133
+ // a turn's start a stop reaches does: the continuation would start only
3134
+ // after a start(), in a lifecycle where its logical turn is gone
3135
+ // (openLogical, below). Before the holds and the parks go, so the
3136
+ // continuation leaves them with its turn.
3137
+ step(() => {
3138
+ for (const agentName of this.pendingContinuationAgents()) {
3139
+ this.dropContinuations(agentName, { kind: 'shutdown', why: 'the framework is stopping' });
3140
+ }
3141
+ });
3029
3142
  // A stopped host must never hang behind its own cooldown.
3030
3143
  step(() => this.cancelProviderAdmission());
3031
3144
  // An open logical turn doesn't survive a stop: a start after a restart
@@ -3619,6 +3732,23 @@ export class AgentFramework {
3619
3732
  a.coalescingSubject === b.coalescingSubject && batchSubjects(a) === batchSubjects(b) &&
3620
3733
  a.readsDeferredWrite === b.readsDeferredWrite;
3621
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
+ }
3622
3752
  consultProviderHold(error, agentName) {
3623
3753
  if (!this.providerHoldHook)
3624
3754
  return undefined;
@@ -3676,15 +3806,21 @@ export class AgentFramework {
3676
3806
  // failure streak), and the wait applies to the calls that follow it.
3677
3807
  if (!acceleration && !hostHold) {
3678
3808
  return wait !== undefined && isMembraneError(error) && error.retryable === true
3679
- && this.holdForProviderWait(agent, wait, trigger ? [trigger] : [], true);
3809
+ && this.holdForProviderWait(agent, wait, trigger ? [asTurnRetry(trigger)] : [], true);
3680
3810
  }
3811
+ // As in holdForProviderWait: once stop() has closed admission, nothing is
3812
+ // held, and nothing started.
3813
+ if (this.providerAdmissionClosed)
3814
+ return true;
3681
3815
  const now = Date.now();
3682
3816
  const pacing = hostHold ? undefined : this.accelerationCooldownMs(agent.name, error);
3683
3817
  const delayMs = hostHold ? hostHold.holdMs : pacing.delayMs;
3684
3818
  const existing = this.providerAccelerationCooldowns.get(agent.name);
3685
3819
  const held = existing?.heldRequests ?? [];
3686
- if (trigger && !held.some((r) => this.sameInferenceRequest(r, trigger)))
3687
- 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));
3688
3824
  if (existing)
3689
3825
  clearTimeout(existing.timer);
3690
3826
  const timer = setTimeout(() => this.releaseProviderAccelerationCooldown(agent.name), Math.min(delayMs, MAX_TIMER_DELAY_MS));
@@ -3695,7 +3831,8 @@ export class AgentFramework {
3695
3831
  // The stated wait (recorded above for agent.model) set this hold's
3696
3832
  // length: an operator release of it returns the hold to AF's own pacing.
3697
3833
  ...(pacing?.stated ? { statedWait: { model: agent.model, ownUntil: now + pacing.ownMs } } : {}),
3698
- ...(auxiliary && (!existing || existing.auxiliaryOrigin) ? { auxiliaryOrigin: true } : {}) });
3834
+ ...(auxiliary && (!existing || existing.auxiliaryOrigin) ? { auxiliaryOrigin: true } : {}),
3835
+ ...(existing?.emptiedByDrop && held.length === 0 ? { emptiedByDrop: true } : {}) });
3699
3836
  if (auxiliary && !existing) {
3700
3837
  console.error(`[provider-cooldown] agent=${agent.name} host hold (${hostHold.reason}) from an auxiliary call — ` +
3701
3838
  `holding primary/auxiliary for ${delayMs}ms`);
@@ -3736,7 +3873,7 @@ export class AgentFramework {
3736
3873
  return undefined;
3737
3874
  if (this.conversationAgentHomes.has(agent.name)) {
3738
3875
  const wait = this.recordProviderWait(agent.name, agent.model, error);
3739
- 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)
3740
3877
  ? 'held'
3741
3878
  : undefined;
3742
3879
  }
@@ -3808,9 +3945,13 @@ export class AgentFramework {
3808
3945
  ? `no longer holds primary turns: the primary model is now ${model} (the wait stays recorded)`
3809
3946
  : `primary hold ended after ${Date.now() - cooldown.startedAt}ms`));
3810
3947
  }
3811
- if (cooldown.auxiliaryOrigin && cooldown.heldRequests.length === 0) {
3812
- // Nobody asked for an inference while parked: just reopen the gate.
3813
- console.error(`[provider-cooldown] agent=${agentName} auxiliary hold released after ${Date.now() - cooldown.startedAt}ms`);
3948
+ if ((cooldown.auxiliaryOrigin || cooldown.emptiedByDrop) && cooldown.heldRequests.length === 0) {
3949
+ // Nobody asked for an inference while parked, or what was held was a
3950
+ // continuation since dropped with its turn: just reopen the gate. A
3951
+ // primary the hold left pending won't run, so its recovery ends.
3952
+ console.error(`[provider-cooldown] agent=${agentName} ${cooldown.auxiliaryOrigin ? 'auxiliary hold' : 'hold, its held continuation dropped,'} released after ${Date.now() - cooldown.startedAt}ms`);
3953
+ if (cooldown.emptiedByDrop)
3954
+ this.endPendingPrimary(agentName);
3814
3955
  this.flushAuxiliaryAdmission(agentName);
3815
3956
  return;
3816
3957
  }
@@ -3887,19 +4028,33 @@ export class AgentFramework {
3887
4028
  * Hold this agent's primary turns for a provider wait on its model. The
3888
4029
  * requests are kept and released together, into one fresh compile, when
3889
4030
  * the wait ends: the same path an acceleration cooldown takes, without
3890
- * 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.
3891
4035
  */
3892
- holdForProviderWait(agent, wait, requests, failed = false) {
4036
+ holdForProviderWait(agent, wait, requests, failed = false, report) {
3893
4037
  const now = Date.now();
3894
4038
  const until = wait.until ?? Number.POSITIVE_INFINITY;
3895
4039
  if (until <= now)
3896
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
+ }
3897
4054
  const existing = this.providerAccelerationCooldowns.get(agent.name);
3898
4055
  const held = existing?.heldRequests ?? [];
3899
- for (const request of requests) {
3900
- if (!held.some((r) => this.sameInferenceRequest(r, request)))
3901
- held.push(request);
3902
- }
4056
+ for (const request of requests)
4057
+ this.holdRequest(held, request);
3903
4058
  // An acceleration or host hold already parks the whole agent; its release
3904
4059
  // re-checks this wait at admission.
3905
4060
  if (existing && existing.waitModel === undefined)
@@ -4479,9 +4634,11 @@ export class AgentFramework {
4479
4634
  }
4480
4635
  /**
4481
4636
  * Abort an in-flight inference for an agent. True when there was one to
4482
- * 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
4483
4638
  * hooks or compile, a wait to retry, the tool-result guard's wait), which
4484
- * 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.
4485
4642
  */
4486
4643
  abortInference(agentName, reason) {
4487
4644
  const agent = this.agents.get(agentName);
@@ -4511,6 +4668,20 @@ export class AgentFramework {
4511
4668
  checkWindow.wake?.();
4512
4669
  return true;
4513
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
+ }
4514
4685
  return !!result;
4515
4686
  }
4516
4687
  /**
@@ -5393,16 +5564,83 @@ export class AgentFramework {
5393
5564
  }
5394
5565
  return n;
5395
5566
  }
5567
+ /**
5568
+ * A continuation the framework makes for a turn (a budget restart's, which
5569
+ * it queues, or a tool-result guard's retry, which its frame starts at
5570
+ * once) holds no turn token until its start takes one. Once its turn's
5571
+ * frame has let go of its token, its start can wait in the queue, 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
5576
+ * `tool_results_ready` continuation follows a tool round left waiting with
5577
+ * no stream, which only the direct Agent API makes (Agent's
5578
+ * runInferenceWithInjections; the framework's own rounds wait with their
5579
+ * stream, enterWaitingForTools). Its caller owns that turn's timing, so it
5580
+ * isn't counted.
5581
+ */
5582
+ pendingContinuationAgents() {
5583
+ const agents = new Set();
5584
+ const add = (r) => { if (continuesTurn(r))
5585
+ agents.add(r.agentName); };
5586
+ // Read defensively: a framework built from the prototype, as the
5587
+ // operator tests build one, sets none of these.
5588
+ for (const r of this.pendingRequests ?? [])
5589
+ add(r);
5590
+ for (const cooldown of this.providerAccelerationCooldowns?.values() ?? [])
5591
+ for (const r of cooldown.heldRequests)
5592
+ add(r);
5593
+ for (const r of this.parkedContinuations ?? [])
5594
+ add(r);
5595
+ for (const pass of this.passRequests ?? [])
5596
+ for (const requests of pass.values())
5597
+ for (const r of requests)
5598
+ add(r);
5599
+ return agents;
5600
+ }
5601
+ /** The agents with a turn alive: holding a turn token, or with a
5602
+ * continuation of their turn, or its retry, on its way. */
5603
+ turnHolders() {
5604
+ const holders = this.pendingContinuationAgents();
5605
+ for (const name of this.activeTurnTokens.keys())
5606
+ holders.add(name);
5607
+ return holders;
5608
+ }
5609
+ hasTurn(agentName) {
5610
+ return this.activeTurnTokens.has(agentName) || this.pendingContinuationAgents().has(agentName);
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
+ }
5396
5632
  getHostModeStatus() {
5397
5633
  const parkedAdmissions = this.parkedAdmissionCount();
5634
+ const holders = this.turnHolders();
5398
5635
  return {
5399
5636
  quiesced: this.quiesced,
5400
5637
  ...(this.quiesceReason ? { reason: this.quiesceReason } : {}),
5401
5638
  ...(this.quiescedAt !== undefined ? { since: this.quiescedAt } : {}),
5402
- drained: this.activeTurnTokens.size === 0 && parkedAdmissions === 0,
5403
- activeTurns: this.activeTurnTokens.size,
5639
+ drained: holders.size === 0 && parkedAdmissions === 0,
5640
+ activeTurns: holders.size,
5641
+ activeTurnAgents: [...holders].sort(),
5404
5642
  parkedAdmissions,
5405
- gatedRequests: this.pendingRequests.filter((r) => !isTurnContinuation(r.reason)).length,
5643
+ gatedRequests: this.pendingRequests.filter((r) => !passesQuiesce(r)).length,
5406
5644
  backgroundScripts: this.codeExecution?.scripts.leftRunning() ?? 0,
5407
5645
  deferredWrites: this.deferredMessages.length,
5408
5646
  ...(this.lastUnabandonable.length > 0 ? { unabandonable: [...this.lastUnabandonable] } : {}),
@@ -5417,8 +5655,10 @@ export class AgentFramework {
5417
5655
  /**
5418
5656
  * Enter quiesce mode: write the deferred-write queue, persist the flag,
5419
5657
  * suppress gate deliveries, pause all MCPL data planes, and wait for
5420
- * in-flight turns to settle (turn-alive is `activeTurnTokens`, which spans
5421
- * dequeue → settled teardown). Idempotent, but for the queue's write, which
5658
+ * in-flight turns to settle (turn-alive is a turn token, which spans
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
5422
5662
  * every call makes first: when the queue can't reach disk, because its
5423
5663
  * write fails or it's over the cap, the call rejects with a
5424
5664
  * QuiesceBlockedError and the host's mode as it was, unless it has
@@ -5428,7 +5668,10 @@ export class AgentFramework {
5428
5668
  * On drain timeout the host STAYS quiesced (`drained: false` in the
5429
5669
  * result); with `abandon: true` the undrained streams are cancelled via a
5430
5670
  * dedicated cancel kind that settles the turn without feeding the
5431
- * inference-failure accounting.
5671
+ * inference-failure accounting, and a turn whose continuation or retry
5672
+ * hasn't started ends with it dropped. Drain covers the framework's
5673
+ * own turns: a caller of the direct Agent API (`Agent.runInference`) owns
5674
+ * its turns' timing.
5432
5675
  */
5433
5676
  async quiesce(opts) {
5434
5677
  this.refuseOperatorAfterStop('quiesce');
@@ -5445,13 +5688,13 @@ export class AgentFramework {
5445
5688
  // out undrained must be escalatable with a second quiesce({abandon})
5446
5689
  // without resume()+re-quiesce (which would reopen data planes and
5447
5690
  // release parked wakes mid-surgery).
5448
- if (opts?.abandon && this.activeTurnTokens.size > 0) {
5691
+ if (opts?.abandon && this.turnHolders().size > 0) {
5449
5692
  const unabandonable = await this.abandonActiveTurns();
5450
5693
  this.emitTrace({
5451
5694
  type: 'host:quiesce',
5452
5695
  ...(this.quiesceReason ? { reason: this.quiesceReason } : {}),
5453
- drained: this.activeTurnTokens.size === 0,
5454
- activeTurns: this.activeTurnTokens.size,
5696
+ drained: this.turnHolders().size === 0,
5697
+ activeTurns: this.turnHolders().size,
5455
5698
  abandoned: true,
5456
5699
  ...(unabandonable.length > 0 ? { unabandonable } : {}),
5457
5700
  });
@@ -5476,7 +5719,7 @@ export class AgentFramework {
5476
5719
  this.quiescedAt = since;
5477
5720
  this.lastUnabandonable = [];
5478
5721
  console.error(`[host-mode] quiescing${opts?.reason ? ` (${opts.reason})` : ''}: parking wakes, ` +
5479
- `pausing MCPL data planes, draining ${this.activeTurnTokens.size} in-flight turn(s)`);
5722
+ `pausing MCPL data planes, draining ${this.turnHolders().size} in-flight turn(s)`);
5480
5723
  this.eventGate?.setQuiesced(true);
5481
5724
  for (const connection of this.mcplServerRegistry?.getAllServers() ?? []) {
5482
5725
  connection.pauseDataPlane();
@@ -5496,7 +5739,7 @@ export class AgentFramework {
5496
5739
  // continuation's quiesce recheck requeues it — wait for that to happen).
5497
5740
  while (this.quiesced &&
5498
5741
  !this.queue?.isClosed &&
5499
- (this.activeTurnTokens.size > 0 || this.parkedAdmissionCount() > 0) &&
5742
+ (this.turnHolders().size > 0 || this.parkedAdmissionCount() > 0) &&
5500
5743
  Date.now() < deadline) {
5501
5744
  await new Promise((resolve) => setTimeout(resolve, 250));
5502
5745
  }
@@ -5517,7 +5760,7 @@ export class AgentFramework {
5517
5760
  }
5518
5761
  let abandoned = false;
5519
5762
  let unabandonable = [];
5520
- if (this.activeTurnTokens.size > 0 && opts?.abandon) {
5763
+ if (this.turnHolders().size > 0 && opts?.abandon) {
5521
5764
  abandoned = true;
5522
5765
  unabandonable = await this.abandonActiveTurns();
5523
5766
  if (!this.quiesced)
@@ -5547,7 +5790,19 @@ export class AgentFramework {
5547
5790
  * the already-quiesced escalation path. */
5548
5791
  async abandonActiveTurns() {
5549
5792
  const unabandonable = [];
5793
+ // A turn whose continuation is still on its way ends with the
5794
+ // continuation dropped. Its stream has ended; a frame still letting go of
5795
+ // its token ends on its own, and settles nothing, since it handed the turn
5796
+ // to the continuation.
5797
+ const handedOff = new Set();
5798
+ for (const agentName of this.pendingContinuationAgents()) {
5799
+ console.error(`[host-mode] abandoning ${agentName}'s turn before its continuation or held retry starts: it is dropped`);
5800
+ this.dropContinuations(agentName, { kind: 'quiesce_abandoned', why: 'its turn was abandoned' });
5801
+ handedOff.add(agentName);
5802
+ }
5550
5803
  for (const agentName of [...this.activeTurnTokens.keys()]) {
5804
+ if (handedOff.has(agentName))
5805
+ continue;
5551
5806
  const agent = this.agents.get(agentName);
5552
5807
  const state = agent?.state;
5553
5808
  const stream = state && 'stream' in state ? state.stream : undefined;
@@ -7496,6 +7751,9 @@ export class AgentFramework {
7496
7751
  this.activeTurnTokens.delete(agent.name);
7497
7752
  this.activeTurnTriggers.delete(agent.name);
7498
7753
  this.logicalTurnToolCalls.delete(agent);
7754
+ // A continuation of its turn still on its way goes too: the run, and
7755
+ // with it the turn, has ended above.
7756
+ this.dropContinuations(agent.name, null);
7499
7757
  }
7500
7758
  }
7501
7759
  /**
@@ -8887,7 +9145,8 @@ export class AgentFramework {
8887
9145
  * by stop() while it runs.
8888
9146
  *
8889
9147
  * The branch moves under every agent sharing the store, so the switch
8890
- * 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
8891
9150
  * refused (OperatorActionError 'agent-busy'), and no turn starts until the
8892
9151
  * switch is done.
8893
9152
  */
@@ -9101,18 +9360,35 @@ export class AgentFramework {
9101
9360
  throw new OperatorActionError('agent-busy', `Cannot ${verb}: a live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName} is already in progress`);
9102
9361
  }
9103
9362
  const busy = [];
9363
+ let heldTurns = false;
9364
+ let statedWaits = false;
9104
9365
  for (const [name, a] of this.agents) {
9105
- if (a.state.status !== 'idle')
9366
+ if (a.state.status !== 'idle') {
9106
9367
  busy.push(`${name} is ${a.state.status}`);
9107
- else if (this.activeTurnTokens.has(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 {
9108
9382
  busy.push(`${name} is idle+turn-alive`);
9383
+ }
9109
9384
  }
9110
9385
  for (const name of this.activeTurnTokens.keys()) {
9111
9386
  if (!this.agents.has(name))
9112
9387
  busy.push(`${name} is turn-alive`);
9113
9388
  }
9114
9389
  if (busy.length > 0) {
9115
- 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)}` : ''));
9116
9392
  }
9117
9393
  const reserved = new Map();
9118
9394
  for (const name of this.agents.keys()) {
@@ -9894,9 +10170,10 @@ export class AgentFramework {
9894
10170
  // Direct inference requests (e.g. runEphemeralToCompletion) bypass the
9895
10171
  // event queue — without this the loop can exit before they're drained.
9896
10172
  // While quiesced, gate-parked requests are NOT progress and must not
9897
- // block idle (only a budget restart can still run); identity-equivalent
9898
- // to `.length > 0` when not quiesced.
9899
- 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)) ||
9900
10177
  this.activeStreams.size > 0 ||
9901
10178
  Array.from(this.agents.values()).some((a) => a.state.status !== 'idle')) {
9902
10179
  await this.processNextEvent();
@@ -10087,7 +10364,7 @@ export class AgentFramework {
10087
10364
  // was killed while the call ran: the result reaches no one, but the
10088
10365
  // call did finish.
10089
10366
  this.toolLifecycleEmitter?.onResult(event.agentName, event.callId, event.result);
10090
- scriptWaiter(event.result);
10367
+ scriptWaiter.resolve(event.result);
10091
10368
  return;
10092
10369
  }
10093
10370
  // RFC-007: the call's terminal (completed / failed) — the result
@@ -10513,7 +10790,27 @@ export class AgentFramework {
10513
10790
  }
10514
10791
  this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name });
10515
10792
  }
10793
+ else if ((overBudget || overPhysical) && this.providerAdmissionClosed) {
10794
+ // A stopping framework starts no continuation: the turn ends with
10795
+ // its stream, as stop() ends a live stream's turn.
10796
+ if (currentState.stream)
10797
+ this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'shutdown');
10798
+ agent.cancelStream();
10799
+ }
10516
10800
  else if (overBudget || overPhysical) {
10801
+ // The continuation is queued, and the stream cancelled, before the
10802
+ // boundary's usage report and the restart's trace, so a listener
10803
+ // that stops or quiesces the host on either finds the turn waiting
10804
+ // for its continuation, with nothing left of the stream to end.
10805
+ const continuation = {
10806
+ agentName: agent.name,
10807
+ reason: 'context_budget_restart',
10808
+ source: 'framework',
10809
+ timestamp: Date.now(),
10810
+ suppressProse: this.activeTurnTriggers.get(agent.name)?.suppressProse,
10811
+ silentHeartbeat: this.activeTurnTriggers.get(agent.name)?.silentHeartbeat,
10812
+ };
10813
+ this.pendingRequests.push(continuation);
10517
10814
  // Context budget exceeded: break the stream, let compile() compress.
10518
10815
  // Mark the cancel as framework-initiated BEFORE cancelling: the
10519
10816
  // membrane delivers `aborted` before the restart bumps streamId,
@@ -10521,13 +10818,23 @@ export class AgentFramework {
10521
10818
  // a terminal failure — rejecting an ephemeral's promise mid-run
10522
10819
  // and emitting a spurious inference:exhausted (same race shape as
10523
10820
  // endTurn above).
10524
- if (currentState.stream) {
10821
+ if (currentState.stream)
10525
10822
  this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'budget_restart');
10526
- // Its rounds were billed; the restart opens a fresh stream
10527
- // with its own usage.
10528
- this.streamBoundaryEnds.get(currentState.stream)?.(overBudget ? 'context_budget' : 'physical_window');
10823
+ try {
10824
+ agent.cancelStream();
10825
+ }
10826
+ catch (error) {
10827
+ // The cancel failed and the stream is still live, so the turn
10828
+ // has no continuation: take it back out of the queue.
10829
+ this.pendingRequests = this.pendingRequests.filter((r) => r !== continuation);
10830
+ throw error;
10831
+ }
10832
+ finally {
10833
+ // Its rounds were billed, even when the cancel throws; the
10834
+ // restart opens a fresh stream with its own usage.
10835
+ if (currentState.stream)
10836
+ this.streamBoundaryEnds.get(currentState.stream)?.(overBudget ? 'context_budget' : 'physical_window');
10529
10837
  }
10530
- agent.cancelStream();
10531
10838
  this.emitTrace({
10532
10839
  type: 'inference:stream_restarted',
10533
10840
  agentName: agent.name,
@@ -10538,14 +10845,6 @@ export class AgentFramework {
10538
10845
  inputTokens: overBudget ? agent.lastStreamInputTokens : projectedRealTokens,
10539
10846
  budget: overBudget ? agent.maxStreamTokens : (agent.physicalWindowTokens ?? 0),
10540
10847
  });
10541
- this.pendingRequests.push({
10542
- agentName: agent.name,
10543
- reason: 'context_budget_restart',
10544
- source: 'framework',
10545
- timestamp: Date.now(),
10546
- suppressProse: this.activeTurnTriggers.get(agent.name)?.suppressProse,
10547
- silentHeartbeat: this.activeTurnTriggers.get(agent.name)?.silentHeartbeat,
10548
- });
10549
10848
  }
10550
10849
  else if (currentState.stream) {
10551
10850
  // What this round appends is in the next call's prompt. If that
@@ -11306,6 +11605,8 @@ export class AgentFramework {
11306
11605
  */
11307
11606
  disposeConversationAgent(agentName) {
11308
11607
  this.closingConversationAgents.delete(agentName);
11608
+ // A continuation of its turn still on its way goes with it.
11609
+ this.dropContinuations(agentName, { kind: 'dropped', why: 'its agent was disposed' });
11309
11610
  const channelId = this.conversationAgentHomes.get(agentName);
11310
11611
  const agent = this.agents.get(agentName);
11311
11612
  // A fork stop() ends can still be running: its stream, its tool round.
@@ -11429,9 +11730,12 @@ export class AgentFramework {
11429
11730
  // (request dropped as stale, inference policy veto) would otherwise stay
11430
11731
  // registered forever. No turn, no active stream and no pending request =
11431
11732
  // done. A turn holds its token from dequeue, through its hooks and
11432
- // 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.
11433
11737
  for (const agentName of [...this.closingConversationAgents]) {
11434
- if (!this.activeTurnTokens.has(agentName) &&
11738
+ if (!this.hasTurn(agentName) &&
11435
11739
  !this.activeStreams.has(agentName) &&
11436
11740
  !this.pendingRequests.some((r) => r.agentName === agentName)) {
11437
11741
  this.disposeConversationAgent(agentName);
@@ -11780,9 +12084,19 @@ export class AgentFramework {
11780
12084
  recordReceipt: (record) => {
11781
12085
  // Written now, not at the throttled flush: an acknowledged acceptance
11782
12086
  // must not lose its receipt, the subject's birth, or (deferred) the
11783
- // accepted work itself to a crash. A failure here fails the acceptance.
12087
+ // accepted work itself to a crash. A failure here fails the answer:
12088
+ // the coalescer keeps the receipt, and the producer's retry writes it
12089
+ // again, with the subject's state as it is then. So a record whose
12090
+ // write failed leaves the list: a later write would carry it after
12091
+ // the batch it holds had moved on.
11784
12092
  this.coalescingRecentReceipts.push(record);
11785
- this.store.setStateJson(COALESCING_RECENT_ID, this.coalescingRecentReceipts);
12093
+ try {
12094
+ this.store.setStateJson(COALESCING_RECENT_ID, this.coalescingRecentReceipts);
12095
+ }
12096
+ catch (error) {
12097
+ this.coalescingRecentReceipts.pop();
12098
+ throw error;
12099
+ }
11786
12100
  },
11787
12101
  // The acceptance of coalesced work (deferred included) is observed
11788
12102
  // here, once; its later delivery reuses the frozen envelope.
@@ -11904,6 +12218,9 @@ export class AgentFramework {
11904
12218
  }
11905
12219
  catch (err) {
11906
12220
  console.error('[coalescing] could not persist coalescing state:', err);
12221
+ // Still unsaved: the next persist, or the stop's flush, writes it. No
12222
+ // timer of its own, so a store that keeps failing isn't retried here.
12223
+ this.coalescingSnapshotDirty ??= snapshot;
11907
12224
  }
11908
12225
  }
11909
12226
  /** Serialize both lanes' admissions: create, edit and delete of one subject stay ordered. */
@@ -12205,6 +12522,24 @@ export class AgentFramework {
12205
12522
  for (const cooldown of this.providerAccelerationCooldowns.values()) {
12206
12523
  cooldown.heldRequests = cooldown.heldRequests.filter((r) => r.coalescingSubject !== subject);
12207
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);
12208
12543
  }
12209
12544
  /**
12210
12545
  * A placement in the deferred queue is upgraded in place once the flush has
@@ -12532,15 +12867,26 @@ export class AgentFramework {
12532
12867
  }
12533
12868
  return undefined;
12534
12869
  }
12870
+ /** One scheduler pass, whose requests stay visible as on their way until
12871
+ * it takes their agent up (passRequests). */
12535
12872
  async processInferenceRequests() {
12536
12873
  if (this.pendingRequests.length === 0) {
12537
12874
  return;
12538
12875
  }
12876
+ const requestsByAgent = new Map();
12877
+ this.passRequests?.add(requestsByAgent);
12878
+ try {
12879
+ await this.runInferencePass(requestsByAgent);
12880
+ }
12881
+ finally {
12882
+ this.passRequests?.delete(requestsByAgent);
12883
+ }
12884
+ }
12885
+ async runInferencePass(requestsByAgent) {
12539
12886
  const STALE_REQUEST_MS = 30_000;
12540
12887
  const now = Date.now();
12541
12888
  const state = this.createFrameworkState();
12542
12889
  // Group requests by agent
12543
- const requestsByAgent = new Map();
12544
12890
  for (const req of this.pendingRequests) {
12545
12891
  const existing = requestsByAgent.get(req.agentName) ?? [];
12546
12892
  existing.push(req);
@@ -12551,6 +12897,14 @@ export class AgentFramework {
12551
12897
  // Check each agent
12552
12898
  for (const [agentName, groupedRequests] of requestsByAgent) {
12553
12899
  let requests = groupedRequests;
12900
+ // Taken up now: from here to its start's turn token (or its hold or
12901
+ // park) nothing awaits, so the requests leave the pass's map, which
12902
+ // shows only what the pass still holds while it awaits a start.
12903
+ requestsByAgent.delete(agentName);
12904
+ // A drop while this pass awaited an earlier agent's start can have
12905
+ // taken every request of this agent's (dropContinuations).
12906
+ if (requests.length === 0)
12907
+ continue;
12554
12908
  const agent = this.agents.get(agentName);
12555
12909
  if (!agent) {
12556
12910
  // Agent not found — request is orphaned. Emit warning and drop.
@@ -12563,6 +12917,10 @@ export class AgentFramework {
12563
12917
  oldestRequestAge: now - oldest,
12564
12918
  });
12565
12919
  console.error(`[inference-dropped] agent=${agentName} reason=agent_not_found requests=${requests.length}`);
12920
+ // A continuation among them leaves its turn to end here.
12921
+ if (requests.some(continuesTurn)) {
12922
+ this.endContinuedTurn(agentName, { kind: 'dropped', why: 'its agent is gone' });
12923
+ }
12566
12924
  continue;
12567
12925
  }
12568
12926
  const providerCooldown = this.providerAccelerationCooldowns?.get(agentName);
@@ -12571,11 +12929,8 @@ export class AgentFramework {
12571
12929
  // selected now, the turn proceeds (the wait stays recorded).
12572
12930
  const otherModel = providerCooldown.waitModel !== undefined && providerCooldown.waitModel !== agent.model;
12573
12931
  if (!otherModel && (now < providerCooldown.until || this.extendHostProviderHold(agentName, providerCooldown))) {
12574
- for (const req of requests) {
12575
- if (!providerCooldown.heldRequests.some((r) => this.sameInferenceRequest(r, req))) {
12576
- providerCooldown.heldRequests.push(req);
12577
- }
12578
- }
12932
+ for (const req of requests)
12933
+ this.holdRequest(providerCooldown.heldRequests, req);
12579
12934
  const recovery = this.providerAccelerationRecoveries.get(agentName);
12580
12935
  if (recovery)
12581
12936
  recovery.heldRequests = providerCooldown.heldRequests.length;
@@ -12591,6 +12946,16 @@ export class AgentFramework {
12591
12946
  recovery.releasedAt = now;
12592
12947
  requests = [...providerCooldown.heldRequests, ...requests];
12593
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
+ }
12594
12959
  // A provider's stated wait on this agent's primary model, including one
12595
12960
  // recorded before a restart, holds the turn until it passes. An
12596
12961
  // ephemeral run records nothing, its name being single-use, so no wait,
@@ -12624,9 +12989,10 @@ export class AgentFramework {
12624
12989
  // bounds a multi-day maintenance window's accumulation.
12625
12990
  if (this.quiesced) {
12626
12991
  // Continuations of a held turn (budget restart, non-streaming tool
12627
- // results) pass; everything else parks.
12628
- const passThrough = requests.filter((r) => isTurnContinuation(r.reason));
12629
- 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));
12630
12996
  // Coalesce per (reason, addressed): every channel wake shares one
12631
12997
  // reason, so keying on reason alone would let an ambient message
12632
12998
  // parked later evict a DM/mention parked earlier — and the
@@ -12652,7 +13018,10 @@ export class AgentFramework {
12652
13018
  requests.length = 0;
12653
13019
  requests.push(...passThrough);
12654
13020
  }
12655
- const turnAlive = !budgetRestart && this.activeTurnTokens.has(agentName);
13021
+ // Turn-alive includes a continuation of the agent's turn still on its
13022
+ // way elsewhere (a park, another pass's hands): a fresh wake waits for
13023
+ // it, as for a turn holding its token.
13024
+ const turnAlive = !budgetRestart && this.hasTurn(agentName);
12656
13025
  const providerGate = this.providerGates?.get(agentName);
12657
13026
  // A primary can own provider admission while yielding to an already in-flight
12658
13027
  // auxiliary call. Keep this agent's later wakes queued, but do not block the
@@ -12722,10 +13091,12 @@ export class AgentFramework {
12722
13091
  // exactly this class of silent drop. One stderr line per drop.
12723
13092
  console.error(`[inference-dropped] agent=${agentName} reason=policy-skip ` +
12724
13093
  `requests=${requests.length} triggers=${requests.map((r) => r.reason).join(',')}`);
12725
- // A context-budget restart inherits the predecessor's EventGate liveness.
12726
- // If policy drops the queued successor, no driveStream remains to release it.
12727
- if (budgetRestart)
12728
- this.eventGate?.onInferenceEnded(agentName);
13094
+ // A continuation inherits its predecessor's EventGate liveness and
13095
+ // logical turn. If policy drops it, no driveStream remains to end them,
13096
+ // so its turn ends here (endContinuedTurn).
13097
+ if (requests.some(continuesTurn)) {
13098
+ this.endContinuedTurn(agentName, { kind: 'dropped', why: 'the inference policy dropped its continuation' });
13099
+ }
12729
13100
  this.endPendingPrimary(agentName);
12730
13101
  continue;
12731
13102
  }
@@ -12805,7 +13176,7 @@ export class AgentFramework {
12805
13176
  // exists only once assembly renders it. Recorded for the whole batch
12806
13177
  // (not inherited from requests[0]), so a requeued trigger stays honest.
12807
13178
  const batchOnly = !budgetRestart && requests.every((r) => r.coalescingBatch === true && !!r.coalescingSubject);
12808
- await this.startAgentStream(agent, {
13179
+ const merged = {
12809
13180
  ...trigger,
12810
13181
  coalescingBatch: batchOnly || undefined,
12811
13182
  coalescingBatchSubjects: batchOnly
@@ -12833,7 +13204,19 @@ export class AgentFramework {
12833
13204
  counterparty: budgetRestart ? undefined : provReq?.counterparty,
12834
13205
  wakeChannelId: budgetRestart ? undefined : provReq?.wakeChannelId,
12835
13206
  wakeAt: budgetRestart ? undefined : provReq?.wakeAt,
12836
- });
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);
12837
13220
  }
12838
13221
  }
12839
13222
  /**
@@ -15078,7 +15461,14 @@ export class AgentFramework {
15078
15461
  // Do not await inside processInferenceRequests: one slow auxiliary call for
15079
15462
  // this resident must not block events or other residents. Admission stays
15080
15463
  // owned and the continuation receives it after the in-flight call settles.
15464
+ const parkedContinuation = trigger !== undefined && continuesTurn(trigger);
15465
+ if (parkedContinuation)
15466
+ this.parkedContinuations.add(trigger);
15081
15467
  void this.waitForAuxiliaryIdle(agent.name).then(async () => {
15468
+ // A continuation dropped while it was parked left the set then, with
15469
+ // its admission given back.
15470
+ if (parkedContinuation && !this.parkedContinuations.delete(trigger))
15471
+ return;
15082
15472
  if (this.providerAdmissionClosed) {
15083
15473
  this.releasePrimaryProviderGate(agent.name);
15084
15474
  return;
@@ -15105,8 +15495,9 @@ export class AgentFramework {
15105
15495
  // park; starting here would bypass it and run a turn against a
15106
15496
  // context the operator believes frozen. Give admission back and
15107
15497
  // requeue — the scheduler re-parks it (coalesced) until resume.
15108
- // Continuations of a held turn pass, as they do at the gate.
15109
- 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)) {
15110
15501
  this.releasePrimaryProviderGate(agent.name);
15111
15502
  this.pendingRequests.push(trigger ?? {
15112
15503
  agentName: agent.name, reason: 'provider-admission:requeue', source: 'scheduler', timestamp: Date.now(),
@@ -15114,6 +15505,16 @@ export class AgentFramework {
15114
15505
  console.error(`[provider-admission] ${agent.name}: host quiesced while parked — wake requeued, not started`);
15115
15506
  return;
15116
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
+ }
15117
15518
  await this.startAgentStream(agent, trigger, attempt, true);
15118
15519
  }).catch((error) => {
15119
15520
  this.releasePrimaryProviderGate(agent.name);
@@ -15243,6 +15644,143 @@ export class AgentFramework {
15243
15644
  }
15244
15645
  }
15245
15646
  }
15647
+ /**
15648
+ * Drop every continuation of `agentName`'s turn still on its way, wherever
15649
+ * it waits: the queue, a provider hold (which, left empty, then releases
15650
+ * with nothing to run), a park on admission (whose admission it gives
15651
+ * back), or a scheduler pass's hands (which then never reach it). If one
15652
+ * was there, the turn ends (endContinuedTurn), unless `drop` is null: the
15653
+ * caller has ended the turn already.
15654
+ */
15655
+ dropContinuations(agentName, drop) {
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 = [];
15663
+ let dropped = false;
15664
+ const take = (requests) => {
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
+ }
15675
+ return left;
15676
+ };
15677
+ this.pendingRequests = take(this.pendingRequests);
15678
+ const hold = this.providerAccelerationCooldowns.get(agentName);
15679
+ if (hold) {
15680
+ const held = hold.heldRequests.length;
15681
+ hold.heldRequests = take(hold.heldRequests);
15682
+ if (held > 0 && hold.heldRequests.length === 0)
15683
+ hold.emptiedByDrop = true;
15684
+ }
15685
+ for (const parked of [...this.parkedContinuations]) {
15686
+ if (!isContinuation(parked))
15687
+ continue;
15688
+ this.parkedContinuations.delete(parked);
15689
+ this.releasePrimaryProviderGate(agentName);
15690
+ dropped = true;
15691
+ if (putsBack)
15692
+ back.push(...mergedWakes(parked));
15693
+ }
15694
+ for (const pass of this.passRequests) {
15695
+ const requests = pass.get(agentName);
15696
+ if (!requests)
15697
+ continue;
15698
+ const left = take(requests);
15699
+ requests.length = 0;
15700
+ requests.push(...left);
15701
+ }
15702
+ this.pendingRequests.push(...back);
15703
+ if (!dropped || !drop)
15704
+ return;
15705
+ // A primary a provider hold left pending for what it released won't run
15706
+ // now, when nothing else of the agent's waits (endPendingPrimary).
15707
+ if (!this.pendingRequests.some((r) => r.agentName === agentName) && !this.providerAccelerationCooldowns.get(agentName)?.heldRequests.length) {
15708
+ this.endPendingPrimary(agentName);
15709
+ }
15710
+ this.endContinuedTurn(agentName, drop);
15711
+ }
15712
+ /**
15713
+ * End a turn whose continuation won't run. The frame that handed the turn
15714
+ * to it kept the turn open for it: its logical turn, the EventGate's
15715
+ * window, the tool-result guard's batch and recovery, its typing. An
15716
+ * abandon or a stop of a registered agent settles the turn as it settles a
15717
+ * turn's start (endStoppedStart: one inference:aborted, the turn settled,
15718
+ * the gate released). Any other drop ends it as a start dropped before it
15719
+ * sends anything ends. That frame, if it hasn't let go of its token yet,
15720
+ * ends on its own and settles nothing. What was deferred for the agent
15721
+ * lands now, or at that frame's end.
15722
+ */
15723
+ endContinuedTurn(agentName, drop) {
15724
+ console.error(`[turn-continuation] ${agentName}: its turn ends without its continuation: ${drop.why}`);
15725
+ const agent = this.agents.get(agentName);
15726
+ const startedAt = this.lastInferenceAt.get(agentName)?.startedAt ?? Date.now();
15727
+ if (agent && drop.kind !== 'dropped') {
15728
+ const routing = this.openLogical.get(agentName) ?? this.routingOf(agentName);
15729
+ this.endStoppedStart(agent, drop.kind === 'caller' ? { kind: 'caller', reason: drop.reason } : { kind: drop.kind }, routing, startedAt, undefined, false);
15730
+ }
15731
+ else {
15732
+ const owner = this.activeTurnTokens.get(agentName);
15733
+ this.abortDroppedTurn(agentName, owner ?? -1, drop.kind === 'dropped' ? CONTINUATION_DROPPED : drop.kind === 'caller' ? (drop.reason ?? 'user') : drop.kind, startedAt);
15734
+ this.channelRegistry?.stopTyping();
15735
+ if (agent) {
15736
+ agent.toolResultGuard.recovering = false;
15737
+ if (agent.toolResultGuard.hasPending) {
15738
+ try {
15739
+ agent.toolResultGuard.abandon('aborted');
15740
+ }
15741
+ catch (error) {
15742
+ console.error(`[tool-result-guard] agent=${agentName} failed to record aborted batch:`, error);
15743
+ }
15744
+ }
15745
+ }
15746
+ const routing = this.openLogical.get(agentName) ?? this.turnRouting.get(agentName);
15747
+ if (routing)
15748
+ this.endDroppedTurn(agentName, routing);
15749
+ this.eventGate?.onInferenceEnded(agentName);
15750
+ }
15751
+ if (!this.activeTurnTokens.has(agentName) && this.deferredMessages.length > 0 && !this.pendingAssistantBlocks.has(agentName)) {
15752
+ this.readdDrained(this.drainDeferredFor(agentName), 'continuation-drop flush');
15753
+ this.ackDeferredWrites();
15754
+ }
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
+ }
15246
15784
  /**
15247
15785
  * End a turn that beginAgentTurn drops before it sends anything. The turn
15248
15786
  * emitted inference:started, and listeners that bracket a turn, such as a
@@ -15297,7 +15835,7 @@ export class AgentFramework {
15297
15835
  * continues one (a restart or a retry) may follow rounds already sent,
15298
15836
  * so the note says it was stopped before it finished. */
15299
15837
  opensTurn) {
15300
- console.error(`[turn] ${agent.name}: ${stop.kind === 'caller' ? 'a stop' : 'a quiesce abandon'} reached the turn's start before it ${opensTurn ? 'sent anything' : 'finished'}; it ends there`);
15838
+ console.error(`[turn] ${agent.name}: ${stop.kind === 'caller' ? 'a stop' : stop.kind === 'shutdown' ? "the framework's stop" : 'a quiesce abandon'} reached the turn's start before it ${opensTurn ? 'sent anything' : 'finished'}; it ends there`);
15301
15839
  this.channelRegistry?.stopTyping();
15302
15840
  // A start that continues a turn (a budget or guard restart) follows a
15303
15841
  // frame that kept the turn's tool-result batch, and the guard's recovery,
@@ -15325,7 +15863,7 @@ export class AgentFramework {
15325
15863
  this.settleDeliberateCancel({ ...common, reason: stop.reason ?? 'user', before: opensTurn ? 'sending' : 'finishing' });
15326
15864
  }
15327
15865
  else {
15328
- this.settleFrameworkCancel({ ...common, kind: 'quiesce_abandoned' });
15866
+ this.settleFrameworkCancel({ ...common, kind: stop.kind });
15329
15867
  }
15330
15868
  const last = this.lastInferenceAt.get(agent.name);
15331
15869
  if (last?.startedAt === turnStartedAt)
@@ -15461,6 +15999,12 @@ export class AgentFramework {
15461
15999
  const { coalescingSubject: _subject, coalescingBatch: _batch, coalescingBatchSubjects: _subjects, readsDeferredWrite: _read, ...retry } = trigger;
15462
16000
  trigger = retry;
15463
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
+ }
15464
16008
  // Record turn checkpoint before inference (only on first attempt, not retries)
15465
16009
  if (attempt === 0 && trigger?.reason !== 'tool_result_guard_retry') {
15466
16010
  this.recordTurnCheckpoint(agent.name);
@@ -15746,9 +16290,8 @@ export class AgentFramework {
15746
16290
  // hold the turn as the boundary would rather than send it into the wait.
15747
16291
  if (this.conversationAgentHomes.has(agent.name)) {
15748
16292
  const wait = this.providerWaits?.active(agent.name, agent.model);
15749
- if (wait && this.holdForProviderWait(agent, wait, trigger ? [trigger] : [])) {
15750
- console.error(`[provider-wait] agent=${agent.name} model=${agent.model} binds after the compile ` +
15751
- `(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, () => {
15752
16295
  // The stream the compile made won't be sent: it's cancelled, and
15753
16296
  // this start's window closes, before the hold is reported, so a stop
15754
16297
  // made while inference:failed is delivered finds neither, and the
@@ -15760,8 +16303,15 @@ export class AgentFramework {
15760
16303
  this.checkAhead.delete(agent);
15761
16304
  // The turn emitted inference:started before its compile; every turn
15762
16305
  // that does gets one terminal event, which listeners (a typing loop)
15763
- // 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.
15764
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);
15765
16315
  this.activeStreams.delete(agent.name);
15766
16316
  this.pendingAssistantBlocks.delete(agent.name);
15767
16317
  this.channelRegistry?.stopTyping();
@@ -15836,14 +16386,16 @@ export class AgentFramework {
15836
16386
  if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
15837
16387
  // Capacity scheduling is not poisoned history. The cooldown timer owns
15838
16388
  // one later fresh compile; no immediate retry or exhaustion marker.
15839
- 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);
15840
16392
  return false;
15841
16393
  }
15842
16394
  const statedWait = ownsProviderGate ? undefined : this.nonOwningProviderWait(agent, err, trigger);
15843
16395
  if (statedWait === 'held') {
15844
16396
  // A conversation agent held by the provider's stated wait, as a
15845
16397
  // resident is: the hold's release owns the later compile.
15846
- this.eventGate?.onInferenceEnded(agent.name);
16398
+ this.endHeldStart(agent, checkWindow, turnToken, turnStartedAt);
15847
16399
  return false;
15848
16400
  }
15849
16401
  const action = statedWait === 'fail' ? { retry: false } : this.errorPolicy.onInferenceError(err, agent.name, attempt);
@@ -15876,7 +16428,16 @@ export class AgentFramework {
15876
16428
  }
15877
16429
  // The retry re-enters startAgentStream, which replaces this frame's
15878
16430
  // turn token with its own; cleanup belongs to the innermost frame.
15879
- 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);
15880
16441
  }
15881
16442
  else {
15882
16443
  // Terminal failure with no driveStream: this frame owns teardown.
@@ -16019,8 +16580,8 @@ export class AgentFramework {
16019
16580
  this.recordProviderAccelerationRecovery(agent, compiledRequest, end);
16020
16581
  // The accounting writes to the store, and a write that fails must not
16021
16582
  // skip what the boundary does after this call: the rest of a turn's
16022
- // end, or a restart's cancel and its request, and then the event's
16023
- // dispatch to modules.
16583
+ // end, or a restart's trace (its cancel and its request come before
16584
+ // this call), and then the event's dispatch to modules.
16024
16585
  try {
16025
16586
  if (turnEnded) {
16026
16587
  completeTurn(latestUsage, durationMs);
@@ -17360,13 +17921,17 @@ export class AgentFramework {
17360
17921
  if (agent.streamId !== myStreamId)
17361
17922
  break;
17362
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).
17363
17926
  if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
17927
+ this.endFailureAtStop(agent.name, myTurnToken ?? -1, startTime);
17364
17928
  if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
17365
17929
  this.eventGate?.onInferenceEnded(agent.name);
17366
17930
  break;
17367
17931
  }
17368
17932
  const statedWait = ownsProviderGate ? undefined : this.nonOwningProviderWait(agent, err, trigger);
17369
17933
  if (statedWait === 'held') {
17934
+ this.endFailureAtStop(agent.name, myTurnToken ?? -1, startTime);
17370
17935
  if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId)
17371
17936
  this.eventGate?.onInferenceEnded(agent.name);
17372
17937
  break;
@@ -17405,7 +17970,8 @@ export class AgentFramework {
17405
17970
  }
17406
17971
  return;
17407
17972
  }
17408
- 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);
17409
17975
  }
17410
17976
  else {
17411
17977
  // The turn ends here, and its inference:failed doesn't say so,
@@ -17715,10 +18281,16 @@ export class AgentFramework {
17715
18281
  const durationMs = Date.now() - startTime;
17716
18282
  // The stream won't go on: the agent stops holding it before the
17717
18283
  // failure is reported, as at the stream's error event, so a stop made
17718
- // 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.
17719
18288
  agent.reset();
18289
+ this.emitTrace({ type: 'inference:failed', agentName: agent.name, error: err.message, stack: err.stack });
17720
18290
  if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
17721
- 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);
17722
18294
  logStream({ success: false,
17723
18295
  error: `Provider acceleration cooldown: ${err.message}`,
17724
18296
  request: compiledRequest ?? { note: 'streaming request rate-limited' }, durationMs });
@@ -17735,7 +18307,7 @@ export class AgentFramework {
17735
18307
  // says why it ends.
17736
18308
  const statedWait = ownsProviderGate ? undefined : this.nonOwningProviderWait(agent, err, trigger);
17737
18309
  if (statedWait === 'held') {
17738
- this.emitTrace({ type: 'inference:failed', agentName: agent.name, error: err.message, stack: err.stack });
18310
+ this.endFailureAtStop(agent.name, myTurnToken ?? -1, startTime);
17739
18311
  logStream({ success: false,
17740
18312
  error: `Provider wait: ${err.message}`,
17741
18313
  request: compiledRequest ?? { note: 'streaming request rate-limited' }, durationMs });
@@ -17746,12 +18318,6 @@ export class AgentFramework {
17746
18318
  this.eventGate?.onInferenceEnded(agent.name);
17747
18319
  return;
17748
18320
  }
17749
- this.emitTrace({
17750
- type: 'inference:failed',
17751
- agentName: agent.name,
17752
- error: err.message,
17753
- stack: err.stack,
17754
- });
17755
18321
  this.settleAgent(agent.name, {
17756
18322
  stopReason: 'exhausted',
17757
18323
  speech: '',
@@ -17933,7 +18499,11 @@ export class AgentFramework {
17933
18499
  // its ids when a successor turn has already taken the agent. A fork or
17934
18500
  // run deregistered while the host is quiesced leaves its queue to its
17935
18501
  // window's release at resume (drainDeferredFor).
17936
- if (frameReachedTerminal && this.deferredMessages.length > 0 && !this.pendingAssistantBlocks.has(agent.name)) {
18502
+ // A frame that handed its turn to a continuation since dropped
18503
+ // (endContinuedTurn, which couldn't land it while this frame held the
18504
+ // token) lands it too: the turn is over.
18505
+ const handedOffTurnOver = preserveEventGateForSuccessor && !this.hasTurn(agent.name);
18506
+ if ((frameReachedTerminal || handedOffTurnOver) && this.deferredMessages.length > 0 && !this.pendingAssistantBlocks.has(agent.name)) {
17937
18507
  this.readdDrained(this.drainDeferredFor(agent.name), 'turn-end flush');
17938
18508
  this.ackDeferredWrites();
17939
18509
  }
@@ -17955,8 +18525,11 @@ export class AgentFramework {
17955
18525
  // frame that ends the turn disposes it, after the flush above, so what
17956
18526
  // was held for the turn's end lands in the fork's store instead of
17957
18527
  // being dropped as addressed to no agent. A frame that hands the turn
17958
- // to a successor (a guard or budget restart) leaves the fork to it.
17959
- 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)) {
17960
18533
  this.disposeConversationAgent(agent.name);
17961
18534
  }
17962
18535
  }
@@ -18036,10 +18609,16 @@ export class AgentFramework {
18036
18609
  if (this.surgeryHold) {
18037
18610
  throw new Error(`puppet refused: the store is under live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName} — retry when it completes`);
18038
18611
  }
18039
- if (agent.state.status !== 'idle' || this.activeTurnTokens.has(agentName)) {
18612
+ if (agent.state.status !== 'idle' || this.hasTurn(agentName)) {
18040
18613
  const shown = agent.state.status === 'idle' ? 'idle+turn-alive' : agent.state.status;
18041
- throw new Error(`puppet refused: agent ${agentName} is ${shown} (requires idle — ` +
18042
- `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)`);
18043
18622
  }
18044
18623
  const onSurface = this.getToolsForAgent(agentName)
18045
18624
  .some((t) => t.name === toolName && agent.canUseTool(t.name));
@@ -18934,7 +19513,8 @@ export class AgentFramework {
18934
19513
  }
18935
19514
  if (refusal !== undefined)
18936
19515
  return refused(refusal);
18937
- const result = await this.dispatchScriptToolCall(agentName, toolName, input, context.origin, context.watch?.turnId);
19516
+ const route = { toServer: false };
19517
+ const result = await this.dispatchScriptToolCall(agentName, toolName, input, context.origin, context.watch?.turnId, route);
18938
19518
  // Deferred: applied to the result of the code_execution call watching
18939
19519
  // the script — ending the turn mid-script would cancel the stream while
18940
19520
  // the script still runs and wedge the tool round. A call a left script
@@ -18946,7 +19526,7 @@ export class AgentFramework {
18946
19526
  watching.endTurn = true;
18947
19527
  if (result.refused)
18948
19528
  return refused(result.error ?? `${toolName} was refused; nothing was done`);
18949
- const answer = await this.scriptResultAnswer(result, toolName);
19529
+ const answer = await this.scriptResultAnswer(result, toolName, route.toServer);
18950
19530
  return SCRIPT_CALL_NO_RESULT.has(result) ? { ...answer, held: true } : answer;
18951
19531
  }
18952
19532
  /**
@@ -18954,14 +19534,20 @@ export class AgentFramework {
18954
19534
  * isError }`, one shape for every tool. `content` is
18955
19535
  * what the model would read: a structured result's blocks as the dispatch
18956
19536
  * gave them (the direct reading, with RFC-005's treatment of an MCPL peer's
18957
- * blocks), and otherwise one text block of the model's text, which for a
18958
- * failure is its error. `structuredContent` is the tool's own structured
19537
+ * blocks there, bound to the server that sent them), a module's under
19538
+ * history's reading of RFC-005 at every depth (shownToScript), and
19539
+ * otherwise one text block of the model's text, which for a failure is its
19540
+ * error. A module's is any result whose call the dispatch didn't send to
19541
+ * a server (`toServer`, from the call's waiter). `structuredContent` is the
19542
+ * tool's own structured
18959
19543
  * content where it sent one, `false`, `0` and `null` included, else null.
18960
19544
  * A result that can't be converted to JSON (a cyclic object, a BigInt),
18961
19545
  * which the dispatch's own check makes unlikely here, is a failure naming
18962
- * 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).
18963
19549
  */
18964
- async scriptResultAnswer(result, toolName) {
19550
+ async scriptResultAnswer(result, toolName, toServer) {
18965
19551
  const structured = Object.prototype.hasOwnProperty.call(result, 'structured');
18966
19552
  const isError = result.isError === true;
18967
19553
  // RFC-005 "eagerly lazy": a script receiving a result is the strongest
@@ -18978,7 +19564,10 @@ export class AgentFramework {
18978
19564
  // which isn't fetched.
18979
19565
  // Whenever the result's blocks reach the script: a structured failure's
18980
19566
  // included, as a module's error can carry content.
18981
- if ((!isError || structured) && Array.isArray(result.data) && this.resolveMcplTool(toolName) === null) {
19567
+ // Not a server's result: a module's, whose blocks no dispatch has given
19568
+ // RFC-005's treatment.
19569
+ const fromModule = !toServer;
19570
+ if ((!isError || structured) && Array.isArray(result.data) && fromModule) {
18982
19571
  const fetcher = this.getReferenceFetcher();
18983
19572
  for (const block of result.data) {
18984
19573
  const c = classifyBlock(block);
@@ -19000,6 +19589,10 @@ export class AgentFramework {
19000
19589
  brief: error,
19001
19590
  };
19002
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));
19003
19596
  let content;
19004
19597
  let text;
19005
19598
  // A structured result's blocks, as #538 gives them: none when its content
@@ -19007,6 +19600,21 @@ export class AgentFramework {
19007
19600
  // no blocks reads as its error text.
19008
19601
  if (structured && (!isError || (Array.isArray(result.data) && result.data.length > 0))) {
19009
19602
  content = Array.isArray(result.data) ? result.data : [];
19603
+ // A module's blocks, such as an MCPL peer's it got from ctx.callTool
19604
+ // and returns whole, are read as history reads a module's data: what
19605
+ // RFC-005 governs, at any depth, as the script lane shows it, and
19606
+ // anything else as sent. What a script prints comes back to the model,
19607
+ // so no uri the model's lanes withhold reaches it. What this gives is
19608
+ // a stub, a notice or the value itself, so a module that returns
19609
+ // blocks it already gave has them left as they are.
19610
+ if (fromModule) {
19611
+ try {
19612
+ content = JSON.parse(JSON.stringify(content, (_key, value) => shownToScript(value)));
19613
+ }
19614
+ catch (err) {
19615
+ return failed(err);
19616
+ }
19617
+ }
19010
19618
  }
19011
19619
  else {
19012
19620
  // As in history, a reference is its stub at any depth and inline data
@@ -19045,9 +19653,11 @@ export class AgentFramework {
19045
19653
  * RFC-007: with an `origin`, observers see the call as they see a model
19046
19654
  * call (register, dispatch with its refusal sites, open; the terminal at
19047
19655
  * the waiter intercept), under the inner tool's own name and class.
19048
- * `turnId` is the turn the call was made in (dispatchToolCall).
19656
+ * `turnId` is the turn the call was made in (dispatchToolCall). `route` is
19657
+ * the waiter's: the dispatch sets `toServer` when it sends the call to a
19658
+ * server.
19049
19659
  */
19050
- dispatchScriptToolCall(agentName, toolName, args, origin, turnId) {
19660
+ dispatchScriptToolCall(agentName, toolName, args, origin, turnId, route = { toServer: false }) {
19051
19661
  return new Promise((resolve) => {
19052
19662
  const callId = `pytc-${randomUUID()}`;
19053
19663
  // Host-side leak guard: the script's runner already gives the script a
@@ -19073,9 +19683,12 @@ export class AgentFramework {
19073
19683
  }
19074
19684
  }, safetyMs);
19075
19685
  safety.unref?.();
19076
- this.scriptToolWaiters.set(callId, (result) => {
19077
- clearTimeout(safety);
19078
- resolve(result);
19686
+ this.scriptToolWaiters.set(callId, {
19687
+ resolve: (result) => {
19688
+ clearTimeout(safety);
19689
+ resolve(result);
19690
+ },
19691
+ route,
19079
19692
  });
19080
19693
  const call = { id: callId, name: toolName, input: args };
19081
19694
  if (origin)
@@ -19606,6 +20219,11 @@ export class AgentFramework {
19606
20219
  // Route MCPL tool calls to the appropriate server via prefix map
19607
20220
  const mcplMatch = this.resolveMcplTool(enrichedCall.name);
19608
20221
  if (mcplMatch && this.mcplServerRegistry) {
20222
+ // A script's call: its waiter keeps that it went to a server, for the
20223
+ // script lane's exit (scriptToolWaiters).
20224
+ const scriptWaiter = this.scriptToolWaiters.get(enrichedCall.id);
20225
+ if (scriptWaiter)
20226
+ scriptWaiter.route.toServer = true;
19609
20227
  this.dispatchMcplToolCall(agentName, enrichedCall, mcplMatch[0], mcplMatch[1]);
19610
20228
  return;
19611
20229
  }
@@ -24205,8 +24823,8 @@ export class AgentFramework {
24205
24823
  });
24206
24824
  }
24207
24825
  /** An MCP tool call's trace: a result the server marked an error (or one
24208
- * the reading couldn't complete) is tool:failed, as on the other tool
24209
- * 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. */
24210
24828
  traceMcpToolOutcome(serverId, tool, callId, result, durationMs) {
24211
24829
  if (result.isError) {
24212
24830
  this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool, callId, durationMs, error: result.error ?? 'tool returned an error' });
@@ -24615,15 +25233,44 @@ export class AgentFramework {
24615
25233
  // generated JSON) stands in for what the server sent. An MCPL peer's
24616
25234
  // content blocks are the exception: what a script prints comes back to
24617
25235
  // the model, so each gets the treatment RFC-005 gives it on every lane
24618
- // the model reads. 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.
24619
25242
  const forScript = this.scriptToolWaiters?.has(call.id) ?? false;
24620
25243
  const reading = async (result, mcpl) => {
24621
25244
  if (!forScript || !Object.prototype.hasOwnProperty.call(result, 'structuredContent')) {
24622
25245
  return this.mcpToolResult(result, call.id, mcpl, serverId, call.name);
24623
25246
  }
24624
25247
  const direct = AgentFramework.directToolResult(result, serverId, call.name);
24625
- if (mcpl)
24626
- 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
+ }
24627
25274
  // A standard peer's content that isn't a list of blocks is a malformed
24628
25275
  // result on the model's reading (normalizeStandardToolResult), so the
24629
25276
  // script reads it that way too, its structured content kept.
@@ -24821,7 +25468,8 @@ export class AgentFramework {
24821
25468
  * direct path (a module's callTool, a channel's tool call) doesn't come
24822
25469
  * here: it returns the server's result itself (directToolResult), for
24823
25470
  * either engine, as does a script's call whose result is structured, with
24824
- * 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).
24825
25473
  */
24826
25474
  async mcpToolResult(result, callId, mcplPeer, serverId, callName) {
24827
25475
  const hasStructured = Object.prototype.hasOwnProperty.call(result, 'structuredContent');