@animalabs/agent-framework 0.25.0 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +1 -1
  2. package/dist/src/framework.d.ts +98 -9
  3. package/dist/src/framework.d.ts.map +1 -1
  4. package/dist/src/framework.js +422 -91
  5. package/dist/src/framework.js.map +1 -1
  6. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  7. package/dist/src/mcpl/channel-registry.js +24 -3
  8. package/dist/src/mcpl/channel-registry.js.map +1 -1
  9. package/dist/src/mcpl/push-coalescer.d.ts +34 -11
  10. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  11. package/dist/src/mcpl/push-coalescer.js +61 -27
  12. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  13. package/dist/src/mcpl/references.d.ts +11 -5
  14. package/dist/src/mcpl/references.d.ts.map +1 -1
  15. package/dist/src/mcpl/references.js +21 -6
  16. package/dist/src/mcpl/references.js.map +1 -1
  17. package/dist/src/tool-image-ledger.d.ts +26 -3
  18. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  19. package/dist/src/tool-image-ledger.js +31 -4
  20. package/dist/src/tool-image-ledger.js.map +1 -1
  21. package/dist/src/tool-result-history.d.ts +12 -0
  22. package/dist/src/tool-result-history.d.ts.map +1 -1
  23. package/dist/src/tool-result-history.js +23 -1
  24. package/dist/src/tool-result-history.js.map +1 -1
  25. package/dist/src/types/events.d.ts +3 -1
  26. package/dist/src/types/events.d.ts.map +1 -1
  27. package/package.json +3 -3
  28. package/src/framework.ts +427 -93
  29. package/src/mcpl/channel-registry.ts +23 -4
  30. package/src/mcpl/push-coalescer.ts +83 -34
  31. package/src/mcpl/references.ts +22 -7
  32. package/src/tool-image-ledger.ts +31 -4
  33. package/src/tool-result-history.ts +22 -1
  34. package/src/types/events.ts +3 -1
@@ -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, 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';
@@ -712,6 +712,9 @@ const RETRY_CANCELLED_BY_STOP = 'a retry waiting while the framework was stoppin
712
712
  * retry a failed compile across a stop() and a start(): the retry would
713
713
  * revive the earlier lifecycle's turn in the next one. */
714
714
  const RETRY_ACROSS_RESTART = 'a retry waiting while the framework was stopped and started again is dropped before it sends anything';
715
+ /** The reason on the inference:aborted that ends a turn whose continuation
716
+ * was dropped before it started, other than by an abandon or a stop. */
717
+ const CONTINUATION_DROPPED = "a turn's continuation was dropped before it started";
715
718
  function isTurnContinuation(reason) {
716
719
  return reason === 'context_budget_restart' || reason === 'tool_results_ready'
717
720
  || reason === 'tool_result_guard_retry';
@@ -1221,8 +1224,8 @@ const SCRIPT_TEXT_ANSWER_ENVELOPE_BYTES = Buffer.byteLength(JSON.stringify({ con
1221
1224
  * memoryMaxBytes. So the figure moves with memoryMaxBytes. What it can't
1222
1225
  * count: a result's `structured` value, which the measure leaves out and the
1223
1226
  * 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
1227
+ * their text, which the answer carries as the script lane gives them (each
1228
+ * further block's keys, an image's data); nesting past 2,000 levels; and a
1226
1229
  * parse that needs more of the script's memory than is free, as for JSON
1227
1230
  * dense in small objects or a script already holding most of its memory.
1228
1231
  * Such a result, or one past the figure that doesn't fit, reaches the script
@@ -1918,6 +1921,15 @@ export class AgentFramework {
1918
1921
  // and its settle except that turn's own blocks, and, before its checkpoint,
1919
1922
  // the inputs its start writes (flushForStart).
1920
1923
  activeTurnTokens = new Map();
1924
+ /** Each running scheduler pass's requests, which it took from
1925
+ * pendingRequests: it holds those of the agents it hasn't taken up yet
1926
+ * while it awaits an earlier agent's start, so a continuation among them
1927
+ * is still on its way (pendingContinuationAgents). */
1928
+ passRequests = new Set();
1929
+ /** Continuations whose start waits on provider admission behind an
1930
+ * auxiliary call, in no queue meanwhile. A drop takes one out and gives
1931
+ * its admission back; the park, woken, then finds it gone. */
1932
+ parkedContinuations = new Set();
1921
1933
  /** Each turn whose settle has ended it (settleAgent, endDroppedTurn), for
1922
1934
  * its agent's scripts: its start's teardown can still hold the agent's
1923
1935
  * token, and a turn that has ended isn't live (liveTurns). A later start
@@ -2050,7 +2062,15 @@ export class AgentFramework {
2050
2062
  // config.codeExecution.enabled: its settings, and every agent's scripts,
2051
2063
  // each in a process of its own on the engine (AgentScripts). Waiters
2052
2064
  // resolve script-inner tool calls dispatched through the normal
2053
- // dispatchToolCall path.
2065
+ // dispatchToolCall path. A waiter's route is where the dispatch sent its
2066
+ // call: `toServer` is set when it went to a server's own tool (an MCPL or
2067
+ // MCP-only peer's), so the script lane's exit treats every other result as
2068
+ // a module's (scriptResultAnswer), by where the call went rather than by a
2069
+ // name lookup a connect or disconnect can change while it runs. A
2070
+ // framework tool that talks to a server itself, such as a channel tool,
2071
+ // builds its own result, which counts as a module's. A caller of
2072
+ // dispatchScriptToolCall that passes no route gets one left false, the
2073
+ // reading that withholds more.
2054
2074
  codeExecution = null;
2055
2075
  scriptToolWaiters = new Map();
2056
2076
  /** Per agent, a round that called code_execution and waits for those calls'
@@ -3026,6 +3046,16 @@ export class AgentFramework {
3026
3046
  // Retained tool images are per-process by design; a retained stopped
3027
3047
  // framework must not keep up to the whole ledger budget referenced.
3028
3048
  this.toolImageLedgers.clear();
3049
+ // A turn whose continuation is still on its way ends with the stop, as
3050
+ // a turn's start a stop reaches does: the continuation would start only
3051
+ // after a start(), in a lifecycle where its logical turn is gone
3052
+ // (openLogical, below). Before the holds and the parks go, so the
3053
+ // continuation leaves them with its turn.
3054
+ step(() => {
3055
+ for (const agentName of this.pendingContinuationAgents()) {
3056
+ this.dropContinuations(agentName, { kind: 'shutdown', why: 'the framework is stopping' });
3057
+ }
3058
+ });
3029
3059
  // A stopped host must never hang behind its own cooldown.
3030
3060
  step(() => this.cancelProviderAdmission());
3031
3061
  // An open logical turn doesn't survive a stop: a start after a restart
@@ -3695,7 +3725,8 @@ export class AgentFramework {
3695
3725
  // The stated wait (recorded above for agent.model) set this hold's
3696
3726
  // length: an operator release of it returns the hold to AF's own pacing.
3697
3727
  ...(pacing?.stated ? { statedWait: { model: agent.model, ownUntil: now + pacing.ownMs } } : {}),
3698
- ...(auxiliary && (!existing || existing.auxiliaryOrigin) ? { auxiliaryOrigin: true } : {}) });
3728
+ ...(auxiliary && (!existing || existing.auxiliaryOrigin) ? { auxiliaryOrigin: true } : {}),
3729
+ ...(existing?.emptiedByDrop && held.length === 0 ? { emptiedByDrop: true } : {}) });
3699
3730
  if (auxiliary && !existing) {
3700
3731
  console.error(`[provider-cooldown] agent=${agent.name} host hold (${hostHold.reason}) from an auxiliary call — ` +
3701
3732
  `holding primary/auxiliary for ${delayMs}ms`);
@@ -3808,9 +3839,13 @@ export class AgentFramework {
3808
3839
  ? `no longer holds primary turns: the primary model is now ${model} (the wait stays recorded)`
3809
3840
  : `primary hold ended after ${Date.now() - cooldown.startedAt}ms`));
3810
3841
  }
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`);
3842
+ if ((cooldown.auxiliaryOrigin || cooldown.emptiedByDrop) && cooldown.heldRequests.length === 0) {
3843
+ // Nobody asked for an inference while parked, or what was held was a
3844
+ // continuation since dropped with its turn: just reopen the gate. A
3845
+ // primary the hold left pending won't run, so its recovery ends.
3846
+ console.error(`[provider-cooldown] agent=${agentName} ${cooldown.auxiliaryOrigin ? 'auxiliary hold' : 'hold, its held continuation dropped,'} released after ${Date.now() - cooldown.startedAt}ms`);
3847
+ if (cooldown.emptiedByDrop)
3848
+ this.endPendingPrimary(agentName);
3814
3849
  this.flushAuxiliaryAdmission(agentName);
3815
3850
  return;
3816
3851
  }
@@ -5393,14 +5428,60 @@ export class AgentFramework {
5393
5428
  }
5394
5429
  return n;
5395
5430
  }
5431
+ /**
5432
+ * A continuation the framework makes for a turn (a budget restart's, which
5433
+ * it queues, or a tool-result guard's retry, which its frame starts at
5434
+ * once) holds no turn token until its start takes one. Once its turn's
5435
+ * frame has let go of its token, its start can wait in the queue, a
5436
+ * provider hold (a retry too, when a wait holds it), a park on admission
5437
+ * or a scheduler pass's hands. Its turn is alive meanwhile. These are the
5438
+ * agents with one on its way. A
5439
+ * `tool_results_ready` continuation follows a tool round left waiting with
5440
+ * no stream, which only the direct Agent API makes (Agent's
5441
+ * runInferenceWithInjections; the framework's own rounds wait with their
5442
+ * stream, enterWaitingForTools). Its caller owns that turn's timing, so it
5443
+ * isn't counted.
5444
+ */
5445
+ pendingContinuationAgents() {
5446
+ const agents = new Set();
5447
+ const add = (r) => { if (resumesLogicalTurn(r.reason))
5448
+ agents.add(r.agentName); };
5449
+ // Read defensively: a framework built from the prototype, as the
5450
+ // operator tests build one, sets none of these.
5451
+ for (const r of this.pendingRequests ?? [])
5452
+ add(r);
5453
+ for (const cooldown of this.providerAccelerationCooldowns?.values() ?? [])
5454
+ for (const r of cooldown.heldRequests)
5455
+ add(r);
5456
+ for (const r of this.parkedContinuations ?? [])
5457
+ add(r);
5458
+ for (const pass of this.passRequests ?? [])
5459
+ for (const requests of pass.values())
5460
+ for (const r of requests)
5461
+ add(r);
5462
+ return agents;
5463
+ }
5464
+ /** The agents with a turn alive: holding a turn token, or with a
5465
+ * continuation of their turn on its way. */
5466
+ turnHolders() {
5467
+ const holders = this.pendingContinuationAgents();
5468
+ for (const name of this.activeTurnTokens.keys())
5469
+ holders.add(name);
5470
+ return holders;
5471
+ }
5472
+ hasTurn(agentName) {
5473
+ return this.activeTurnTokens.has(agentName) || this.pendingContinuationAgents().has(agentName);
5474
+ }
5396
5475
  getHostModeStatus() {
5397
5476
  const parkedAdmissions = this.parkedAdmissionCount();
5477
+ const holders = this.turnHolders();
5398
5478
  return {
5399
5479
  quiesced: this.quiesced,
5400
5480
  ...(this.quiesceReason ? { reason: this.quiesceReason } : {}),
5401
5481
  ...(this.quiescedAt !== undefined ? { since: this.quiescedAt } : {}),
5402
- drained: this.activeTurnTokens.size === 0 && parkedAdmissions === 0,
5403
- activeTurns: this.activeTurnTokens.size,
5482
+ drained: holders.size === 0 && parkedAdmissions === 0,
5483
+ activeTurns: holders.size,
5484
+ activeTurnAgents: [...holders].sort(),
5404
5485
  parkedAdmissions,
5405
5486
  gatedRequests: this.pendingRequests.filter((r) => !isTurnContinuation(r.reason)).length,
5406
5487
  backgroundScripts: this.codeExecution?.scripts.leftRunning() ?? 0,
@@ -5417,8 +5498,9 @@ export class AgentFramework {
5417
5498
  /**
5418
5499
  * Enter quiesce mode: write the deferred-write queue, persist the flag,
5419
5500
  * 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
5501
+ * in-flight turns to settle (turn-alive is a turn token, which spans
5502
+ * dequeue → settled teardown, or a continuation of the turn on its way to
5503
+ * its start: turnHolders). Idempotent, but for the queue's write, which
5422
5504
  * every call makes first: when the queue can't reach disk, because its
5423
5505
  * write fails or it's over the cap, the call rejects with a
5424
5506
  * QuiesceBlockedError and the host's mode as it was, unless it has
@@ -5428,7 +5510,10 @@ export class AgentFramework {
5428
5510
  * On drain timeout the host STAYS quiesced (`drained: false` in the
5429
5511
  * result); with `abandon: true` the undrained streams are cancelled via a
5430
5512
  * dedicated cancel kind that settles the turn without feeding the
5431
- * inference-failure accounting.
5513
+ * inference-failure accounting, and a turn whose continuation hasn't
5514
+ * started ends with the continuation dropped. Drain covers the framework's
5515
+ * own turns: a caller of the direct Agent API (`Agent.runInference`) owns
5516
+ * its turns' timing.
5432
5517
  */
5433
5518
  async quiesce(opts) {
5434
5519
  this.refuseOperatorAfterStop('quiesce');
@@ -5445,13 +5530,13 @@ export class AgentFramework {
5445
5530
  // out undrained must be escalatable with a second quiesce({abandon})
5446
5531
  // without resume()+re-quiesce (which would reopen data planes and
5447
5532
  // release parked wakes mid-surgery).
5448
- if (opts?.abandon && this.activeTurnTokens.size > 0) {
5533
+ if (opts?.abandon && this.turnHolders().size > 0) {
5449
5534
  const unabandonable = await this.abandonActiveTurns();
5450
5535
  this.emitTrace({
5451
5536
  type: 'host:quiesce',
5452
5537
  ...(this.quiesceReason ? { reason: this.quiesceReason } : {}),
5453
- drained: this.activeTurnTokens.size === 0,
5454
- activeTurns: this.activeTurnTokens.size,
5538
+ drained: this.turnHolders().size === 0,
5539
+ activeTurns: this.turnHolders().size,
5455
5540
  abandoned: true,
5456
5541
  ...(unabandonable.length > 0 ? { unabandonable } : {}),
5457
5542
  });
@@ -5476,7 +5561,7 @@ export class AgentFramework {
5476
5561
  this.quiescedAt = since;
5477
5562
  this.lastUnabandonable = [];
5478
5563
  console.error(`[host-mode] quiescing${opts?.reason ? ` (${opts.reason})` : ''}: parking wakes, ` +
5479
- `pausing MCPL data planes, draining ${this.activeTurnTokens.size} in-flight turn(s)`);
5564
+ `pausing MCPL data planes, draining ${this.turnHolders().size} in-flight turn(s)`);
5480
5565
  this.eventGate?.setQuiesced(true);
5481
5566
  for (const connection of this.mcplServerRegistry?.getAllServers() ?? []) {
5482
5567
  connection.pauseDataPlane();
@@ -5496,7 +5581,7 @@ export class AgentFramework {
5496
5581
  // continuation's quiesce recheck requeues it — wait for that to happen).
5497
5582
  while (this.quiesced &&
5498
5583
  !this.queue?.isClosed &&
5499
- (this.activeTurnTokens.size > 0 || this.parkedAdmissionCount() > 0) &&
5584
+ (this.turnHolders().size > 0 || this.parkedAdmissionCount() > 0) &&
5500
5585
  Date.now() < deadline) {
5501
5586
  await new Promise((resolve) => setTimeout(resolve, 250));
5502
5587
  }
@@ -5517,7 +5602,7 @@ export class AgentFramework {
5517
5602
  }
5518
5603
  let abandoned = false;
5519
5604
  let unabandonable = [];
5520
- if (this.activeTurnTokens.size > 0 && opts?.abandon) {
5605
+ if (this.turnHolders().size > 0 && opts?.abandon) {
5521
5606
  abandoned = true;
5522
5607
  unabandonable = await this.abandonActiveTurns();
5523
5608
  if (!this.quiesced)
@@ -5547,7 +5632,19 @@ export class AgentFramework {
5547
5632
  * the already-quiesced escalation path. */
5548
5633
  async abandonActiveTurns() {
5549
5634
  const unabandonable = [];
5635
+ // A turn whose continuation is still on its way ends with the
5636
+ // continuation dropped. Its stream has ended; a frame still letting go of
5637
+ // its token ends on its own, and settles nothing, since it handed the turn
5638
+ // to the continuation.
5639
+ const handedOff = new Set();
5640
+ for (const agentName of this.pendingContinuationAgents()) {
5641
+ console.error(`[host-mode] abandoning ${agentName}'s turn before its continuation starts: the continuation is dropped`);
5642
+ this.dropContinuations(agentName, { kind: 'quiesce_abandoned', why: 'its turn was abandoned' });
5643
+ handedOff.add(agentName);
5644
+ }
5550
5645
  for (const agentName of [...this.activeTurnTokens.keys()]) {
5646
+ if (handedOff.has(agentName))
5647
+ continue;
5551
5648
  const agent = this.agents.get(agentName);
5552
5649
  const state = agent?.state;
5553
5650
  const stream = state && 'stream' in state ? state.stream : undefined;
@@ -7496,6 +7593,9 @@ export class AgentFramework {
7496
7593
  this.activeTurnTokens.delete(agent.name);
7497
7594
  this.activeTurnTriggers.delete(agent.name);
7498
7595
  this.logicalTurnToolCalls.delete(agent);
7596
+ // A continuation of its turn still on its way goes too: the run, and
7597
+ // with it the turn, has ended above.
7598
+ this.dropContinuations(agent.name, null);
7499
7599
  }
7500
7600
  }
7501
7601
  /**
@@ -9104,7 +9204,7 @@ export class AgentFramework {
9104
9204
  for (const [name, a] of this.agents) {
9105
9205
  if (a.state.status !== 'idle')
9106
9206
  busy.push(`${name} is ${a.state.status}`);
9107
- else if (this.activeTurnTokens.has(name))
9207
+ else if (this.hasTurn(name))
9108
9208
  busy.push(`${name} is idle+turn-alive`);
9109
9209
  }
9110
9210
  for (const name of this.activeTurnTokens.keys()) {
@@ -10087,7 +10187,7 @@ export class AgentFramework {
10087
10187
  // was killed while the call ran: the result reaches no one, but the
10088
10188
  // call did finish.
10089
10189
  this.toolLifecycleEmitter?.onResult(event.agentName, event.callId, event.result);
10090
- scriptWaiter(event.result);
10190
+ scriptWaiter.resolve(event.result);
10091
10191
  return;
10092
10192
  }
10093
10193
  // RFC-007: the call's terminal (completed / failed) — the result
@@ -10513,7 +10613,27 @@ export class AgentFramework {
10513
10613
  }
10514
10614
  this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name });
10515
10615
  }
10616
+ else if ((overBudget || overPhysical) && this.providerAdmissionClosed) {
10617
+ // A stopping framework starts no continuation: the turn ends with
10618
+ // its stream, as stop() ends a live stream's turn.
10619
+ if (currentState.stream)
10620
+ this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'shutdown');
10621
+ agent.cancelStream();
10622
+ }
10516
10623
  else if (overBudget || overPhysical) {
10624
+ // The continuation is queued, and the stream cancelled, before the
10625
+ // boundary's usage report and the restart's trace, so a listener
10626
+ // that stops or quiesces the host on either finds the turn waiting
10627
+ // for its continuation, with nothing left of the stream to end.
10628
+ const continuation = {
10629
+ agentName: agent.name,
10630
+ reason: 'context_budget_restart',
10631
+ source: 'framework',
10632
+ timestamp: Date.now(),
10633
+ suppressProse: this.activeTurnTriggers.get(agent.name)?.suppressProse,
10634
+ silentHeartbeat: this.activeTurnTriggers.get(agent.name)?.silentHeartbeat,
10635
+ };
10636
+ this.pendingRequests.push(continuation);
10517
10637
  // Context budget exceeded: break the stream, let compile() compress.
10518
10638
  // Mark the cancel as framework-initiated BEFORE cancelling: the
10519
10639
  // membrane delivers `aborted` before the restart bumps streamId,
@@ -10521,13 +10641,23 @@ export class AgentFramework {
10521
10641
  // a terminal failure — rejecting an ephemeral's promise mid-run
10522
10642
  // and emitting a spurious inference:exhausted (same race shape as
10523
10643
  // endTurn above).
10524
- if (currentState.stream) {
10644
+ if (currentState.stream)
10525
10645
  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');
10646
+ try {
10647
+ agent.cancelStream();
10648
+ }
10649
+ catch (error) {
10650
+ // The cancel failed and the stream is still live, so the turn
10651
+ // has no continuation: take it back out of the queue.
10652
+ this.pendingRequests = this.pendingRequests.filter((r) => r !== continuation);
10653
+ throw error;
10654
+ }
10655
+ finally {
10656
+ // Its rounds were billed, even when the cancel throws; the
10657
+ // restart opens a fresh stream with its own usage.
10658
+ if (currentState.stream)
10659
+ this.streamBoundaryEnds.get(currentState.stream)?.(overBudget ? 'context_budget' : 'physical_window');
10529
10660
  }
10530
- agent.cancelStream();
10531
10661
  this.emitTrace({
10532
10662
  type: 'inference:stream_restarted',
10533
10663
  agentName: agent.name,
@@ -10538,14 +10668,6 @@ export class AgentFramework {
10538
10668
  inputTokens: overBudget ? agent.lastStreamInputTokens : projectedRealTokens,
10539
10669
  budget: overBudget ? agent.maxStreamTokens : (agent.physicalWindowTokens ?? 0),
10540
10670
  });
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
10671
  }
10550
10672
  else if (currentState.stream) {
10551
10673
  // What this round appends is in the next call's prompt. If that
@@ -11306,6 +11428,8 @@ export class AgentFramework {
11306
11428
  */
11307
11429
  disposeConversationAgent(agentName) {
11308
11430
  this.closingConversationAgents.delete(agentName);
11431
+ // A continuation of its turn still on its way goes with it.
11432
+ this.dropContinuations(agentName, { kind: 'dropped', why: 'its agent was disposed' });
11309
11433
  const channelId = this.conversationAgentHomes.get(agentName);
11310
11434
  const agent = this.agents.get(agentName);
11311
11435
  // A fork stop() ends can still be running: its stream, its tool round.
@@ -11780,9 +11904,19 @@ export class AgentFramework {
11780
11904
  recordReceipt: (record) => {
11781
11905
  // Written now, not at the throttled flush: an acknowledged acceptance
11782
11906
  // 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.
11907
+ // accepted work itself to a crash. A failure here fails the answer:
11908
+ // the coalescer keeps the receipt, and the producer's retry writes it
11909
+ // again, with the subject's state as it is then. So a record whose
11910
+ // write failed leaves the list: a later write would carry it after
11911
+ // the batch it holds had moved on.
11784
11912
  this.coalescingRecentReceipts.push(record);
11785
- this.store.setStateJson(COALESCING_RECENT_ID, this.coalescingRecentReceipts);
11913
+ try {
11914
+ this.store.setStateJson(COALESCING_RECENT_ID, this.coalescingRecentReceipts);
11915
+ }
11916
+ catch (error) {
11917
+ this.coalescingRecentReceipts.pop();
11918
+ throw error;
11919
+ }
11786
11920
  },
11787
11921
  // The acceptance of coalesced work (deferred included) is observed
11788
11922
  // here, once; its later delivery reuses the frozen envelope.
@@ -11904,6 +12038,9 @@ export class AgentFramework {
11904
12038
  }
11905
12039
  catch (err) {
11906
12040
  console.error('[coalescing] could not persist coalescing state:', err);
12041
+ // Still unsaved: the next persist, or the stop's flush, writes it. No
12042
+ // timer of its own, so a store that keeps failing isn't retried here.
12043
+ this.coalescingSnapshotDirty ??= snapshot;
11907
12044
  }
11908
12045
  }
11909
12046
  /** Serialize both lanes' admissions: create, edit and delete of one subject stay ordered. */
@@ -12532,15 +12669,26 @@ export class AgentFramework {
12532
12669
  }
12533
12670
  return undefined;
12534
12671
  }
12672
+ /** One scheduler pass, whose requests stay visible as on their way until
12673
+ * it takes their agent up (passRequests). */
12535
12674
  async processInferenceRequests() {
12536
12675
  if (this.pendingRequests.length === 0) {
12537
12676
  return;
12538
12677
  }
12678
+ const requestsByAgent = new Map();
12679
+ this.passRequests?.add(requestsByAgent);
12680
+ try {
12681
+ await this.runInferencePass(requestsByAgent);
12682
+ }
12683
+ finally {
12684
+ this.passRequests?.delete(requestsByAgent);
12685
+ }
12686
+ }
12687
+ async runInferencePass(requestsByAgent) {
12539
12688
  const STALE_REQUEST_MS = 30_000;
12540
12689
  const now = Date.now();
12541
12690
  const state = this.createFrameworkState();
12542
12691
  // Group requests by agent
12543
- const requestsByAgent = new Map();
12544
12692
  for (const req of this.pendingRequests) {
12545
12693
  const existing = requestsByAgent.get(req.agentName) ?? [];
12546
12694
  existing.push(req);
@@ -12551,6 +12699,14 @@ export class AgentFramework {
12551
12699
  // Check each agent
12552
12700
  for (const [agentName, groupedRequests] of requestsByAgent) {
12553
12701
  let requests = groupedRequests;
12702
+ // Taken up now: from here to its start's turn token (or its hold or
12703
+ // park) nothing awaits, so the requests leave the pass's map, which
12704
+ // shows only what the pass still holds while it awaits a start.
12705
+ requestsByAgent.delete(agentName);
12706
+ // A drop while this pass awaited an earlier agent's start can have
12707
+ // taken every request of this agent's (dropContinuations).
12708
+ if (requests.length === 0)
12709
+ continue;
12554
12710
  const agent = this.agents.get(agentName);
12555
12711
  if (!agent) {
12556
12712
  // Agent not found — request is orphaned. Emit warning and drop.
@@ -12563,6 +12719,10 @@ export class AgentFramework {
12563
12719
  oldestRequestAge: now - oldest,
12564
12720
  });
12565
12721
  console.error(`[inference-dropped] agent=${agentName} reason=agent_not_found requests=${requests.length}`);
12722
+ // A continuation among them leaves its turn to end here.
12723
+ if (requests.some((r) => resumesLogicalTurn(r.reason))) {
12724
+ this.endContinuedTurn(agentName, { kind: 'dropped', why: 'its agent is gone' });
12725
+ }
12566
12726
  continue;
12567
12727
  }
12568
12728
  const providerCooldown = this.providerAccelerationCooldowns?.get(agentName);
@@ -12652,7 +12812,10 @@ export class AgentFramework {
12652
12812
  requests.length = 0;
12653
12813
  requests.push(...passThrough);
12654
12814
  }
12655
- const turnAlive = !budgetRestart && this.activeTurnTokens.has(agentName);
12815
+ // Turn-alive includes a continuation of the agent's turn still on its
12816
+ // way elsewhere (a park, another pass's hands): a fresh wake waits for
12817
+ // it, as for a turn holding its token.
12818
+ const turnAlive = !budgetRestart && this.hasTurn(agentName);
12656
12819
  const providerGate = this.providerGates?.get(agentName);
12657
12820
  // A primary can own provider admission while yielding to an already in-flight
12658
12821
  // auxiliary call. Keep this agent's later wakes queued, but do not block the
@@ -12722,10 +12885,12 @@ export class AgentFramework {
12722
12885
  // exactly this class of silent drop. One stderr line per drop.
12723
12886
  console.error(`[inference-dropped] agent=${agentName} reason=policy-skip ` +
12724
12887
  `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);
12888
+ // A continuation inherits its predecessor's EventGate liveness and
12889
+ // logical turn. If policy drops it, no driveStream remains to end them,
12890
+ // so its turn ends here (endContinuedTurn).
12891
+ if (requests.some((r) => resumesLogicalTurn(r.reason))) {
12892
+ this.endContinuedTurn(agentName, { kind: 'dropped', why: 'the inference policy dropped its continuation' });
12893
+ }
12729
12894
  this.endPendingPrimary(agentName);
12730
12895
  continue;
12731
12896
  }
@@ -15078,7 +15243,14 @@ export class AgentFramework {
15078
15243
  // Do not await inside processInferenceRequests: one slow auxiliary call for
15079
15244
  // this resident must not block events or other residents. Admission stays
15080
15245
  // owned and the continuation receives it after the in-flight call settles.
15246
+ const parkedContinuation = trigger !== undefined && resumesLogicalTurn(trigger.reason);
15247
+ if (parkedContinuation)
15248
+ this.parkedContinuations.add(trigger);
15081
15249
  void this.waitForAuxiliaryIdle(agent.name).then(async () => {
15250
+ // A continuation dropped while it was parked left the set then, with
15251
+ // its admission given back.
15252
+ if (parkedContinuation && !this.parkedContinuations.delete(trigger))
15253
+ return;
15082
15254
  if (this.providerAdmissionClosed) {
15083
15255
  this.releasePrimaryProviderGate(agent.name);
15084
15256
  return;
@@ -15243,6 +15415,99 @@ export class AgentFramework {
15243
15415
  }
15244
15416
  }
15245
15417
  }
15418
+ /**
15419
+ * Drop every continuation of `agentName`'s turn still on its way, wherever
15420
+ * it waits: the queue, a provider hold (which, left empty, then releases
15421
+ * with nothing to run), a park on admission (whose admission it gives
15422
+ * back), or a scheduler pass's hands (which then never reach it). If one
15423
+ * was there, the turn ends (endContinuedTurn), unless `drop` is null: the
15424
+ * caller has ended the turn already.
15425
+ */
15426
+ dropContinuations(agentName, drop) {
15427
+ const isContinuation = (r) => r.agentName === agentName && resumesLogicalTurn(r.reason);
15428
+ let dropped = false;
15429
+ const take = (requests) => {
15430
+ const left = requests.filter((r) => !isContinuation(r));
15431
+ if (left.length < requests.length)
15432
+ dropped = true;
15433
+ return left;
15434
+ };
15435
+ this.pendingRequests = take(this.pendingRequests);
15436
+ const hold = this.providerAccelerationCooldowns.get(agentName);
15437
+ if (hold) {
15438
+ const held = hold.heldRequests.length;
15439
+ hold.heldRequests = take(hold.heldRequests);
15440
+ if (held > 0 && hold.heldRequests.length === 0)
15441
+ hold.emptiedByDrop = true;
15442
+ }
15443
+ for (const parked of [...this.parkedContinuations]) {
15444
+ if (!isContinuation(parked))
15445
+ continue;
15446
+ this.parkedContinuations.delete(parked);
15447
+ this.releasePrimaryProviderGate(agentName);
15448
+ dropped = true;
15449
+ }
15450
+ for (const pass of this.passRequests) {
15451
+ const requests = pass.get(agentName);
15452
+ if (!requests)
15453
+ continue;
15454
+ const left = take(requests);
15455
+ requests.length = 0;
15456
+ requests.push(...left);
15457
+ }
15458
+ if (!dropped || !drop)
15459
+ return;
15460
+ // A primary a provider hold left pending for what it released won't run
15461
+ // now, when nothing else of the agent's waits (endPendingPrimary).
15462
+ if (!this.pendingRequests.some((r) => r.agentName === agentName) && !this.providerAccelerationCooldowns.get(agentName)?.heldRequests.length) {
15463
+ this.endPendingPrimary(agentName);
15464
+ }
15465
+ this.endContinuedTurn(agentName, drop);
15466
+ }
15467
+ /**
15468
+ * End a turn whose continuation won't run. The frame that handed the turn
15469
+ * to it kept the turn open for it: its logical turn, the EventGate's
15470
+ * window, the tool-result guard's batch and recovery, its typing. An
15471
+ * abandon or a stop of a registered agent settles the turn as it settles a
15472
+ * turn's start (endStoppedStart: one inference:aborted, the turn settled,
15473
+ * the gate released). Any other drop ends it as a start dropped before it
15474
+ * sends anything ends. That frame, if it hasn't let go of its token yet,
15475
+ * ends on its own and settles nothing. What was deferred for the agent
15476
+ * lands now, or at that frame's end.
15477
+ */
15478
+ endContinuedTurn(agentName, drop) {
15479
+ console.error(`[turn-continuation] ${agentName}: its turn ends without its continuation: ${drop.why}`);
15480
+ const agent = this.agents.get(agentName);
15481
+ const startedAt = this.lastInferenceAt.get(agentName)?.startedAt ?? Date.now();
15482
+ if (agent && drop.kind !== 'dropped') {
15483
+ const routing = this.openLogical.get(agentName) ?? this.routingOf(agentName);
15484
+ this.endStoppedStart(agent, { kind: drop.kind }, routing, startedAt, undefined, false);
15485
+ }
15486
+ else {
15487
+ const owner = this.activeTurnTokens.get(agentName);
15488
+ this.abortDroppedTurn(agentName, owner ?? -1, drop.kind === 'dropped' ? CONTINUATION_DROPPED : drop.kind, startedAt);
15489
+ this.channelRegistry?.stopTyping();
15490
+ if (agent) {
15491
+ agent.toolResultGuard.recovering = false;
15492
+ if (agent.toolResultGuard.hasPending) {
15493
+ try {
15494
+ agent.toolResultGuard.abandon('aborted');
15495
+ }
15496
+ catch (error) {
15497
+ console.error(`[tool-result-guard] agent=${agentName} failed to record aborted batch:`, error);
15498
+ }
15499
+ }
15500
+ }
15501
+ const routing = this.openLogical.get(agentName) ?? this.turnRouting.get(agentName);
15502
+ if (routing)
15503
+ this.endDroppedTurn(agentName, routing);
15504
+ this.eventGate?.onInferenceEnded(agentName);
15505
+ }
15506
+ if (!this.activeTurnTokens.has(agentName) && this.deferredMessages.length > 0 && !this.pendingAssistantBlocks.has(agentName)) {
15507
+ this.readdDrained(this.drainDeferredFor(agentName), 'continuation-drop flush');
15508
+ this.ackDeferredWrites();
15509
+ }
15510
+ }
15246
15511
  /**
15247
15512
  * End a turn that beginAgentTurn drops before it sends anything. The turn
15248
15513
  * emitted inference:started, and listeners that bracket a turn, such as a
@@ -15297,7 +15562,7 @@ export class AgentFramework {
15297
15562
  * continues one (a restart or a retry) may follow rounds already sent,
15298
15563
  * so the note says it was stopped before it finished. */
15299
15564
  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`);
15565
+ 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
15566
  this.channelRegistry?.stopTyping();
15302
15567
  // A start that continues a turn (a budget or guard restart) follows a
15303
15568
  // frame that kept the turn's tool-result batch, and the guard's recovery,
@@ -15325,7 +15590,7 @@ export class AgentFramework {
15325
15590
  this.settleDeliberateCancel({ ...common, reason: stop.reason ?? 'user', before: opensTurn ? 'sending' : 'finishing' });
15326
15591
  }
15327
15592
  else {
15328
- this.settleFrameworkCancel({ ...common, kind: 'quiesce_abandoned' });
15593
+ this.settleFrameworkCancel({ ...common, kind: stop.kind });
15329
15594
  }
15330
15595
  const last = this.lastInferenceAt.get(agent.name);
15331
15596
  if (last?.startedAt === turnStartedAt)
@@ -16019,8 +16284,8 @@ export class AgentFramework {
16019
16284
  this.recordProviderAccelerationRecovery(agent, compiledRequest, end);
16020
16285
  // The accounting writes to the store, and a write that fails must not
16021
16286
  // 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.
16287
+ // end, or a restart's trace (its cancel and its request come before
16288
+ // this call), and then the event's dispatch to modules.
16024
16289
  try {
16025
16290
  if (turnEnded) {
16026
16291
  completeTurn(latestUsage, durationMs);
@@ -17933,7 +18198,11 @@ export class AgentFramework {
17933
18198
  // its ids when a successor turn has already taken the agent. A fork or
17934
18199
  // run deregistered while the host is quiesced leaves its queue to its
17935
18200
  // window's release at resume (drainDeferredFor).
17936
- if (frameReachedTerminal && this.deferredMessages.length > 0 && !this.pendingAssistantBlocks.has(agent.name)) {
18201
+ // A frame that handed its turn to a continuation since dropped
18202
+ // (endContinuedTurn, which couldn't land it while this frame held the
18203
+ // token) lands it too: the turn is over.
18204
+ const handedOffTurnOver = preserveEventGateForSuccessor && !this.hasTurn(agent.name);
18205
+ if ((frameReachedTerminal || handedOffTurnOver) && this.deferredMessages.length > 0 && !this.pendingAssistantBlocks.has(agent.name)) {
17937
18206
  this.readdDrained(this.drainDeferredFor(agent.name), 'turn-end flush');
17938
18207
  this.ackDeferredWrites();
17939
18208
  }
@@ -18036,7 +18305,7 @@ export class AgentFramework {
18036
18305
  if (this.surgeryHold) {
18037
18306
  throw new Error(`puppet refused: the store is under live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName} — retry when it completes`);
18038
18307
  }
18039
- if (agent.state.status !== 'idle' || this.activeTurnTokens.has(agentName)) {
18308
+ if (agent.state.status !== 'idle' || this.hasTurn(agentName)) {
18040
18309
  const shown = agent.state.status === 'idle' ? 'idle+turn-alive' : agent.state.status;
18041
18310
  throw new Error(`puppet refused: agent ${agentName} is ${shown} (requires idle — ` +
18042
18311
  `injecting a turn under an active stream corrupts wire ordering)`);
@@ -18607,7 +18876,7 @@ export class AgentFramework {
18607
18876
  for (const tc of toolResults) {
18608
18877
  const { text, shown, stored } = alone.get(tc.id);
18609
18878
  spilled.set(tc.id, byShare
18610
- ? await this.spillOrTruncate(text, share.cap, `${dateLabel}-${tc.id}`, shown, share.bound, stored.written)
18879
+ ? await this.spillOrTruncate(text, share.cap, `${dateLabel}-${tc.id}`, shown, share.bound, stored.written, { readCap: maxChars })
18611
18880
  : stored);
18612
18881
  }
18613
18882
  const blocks = toolResults.map(tc => ({
@@ -18814,7 +19083,12 @@ export class AgentFramework {
18814
19083
  bound = undefined,
18815
19084
  /** The spill file an earlier cut of this text wrote: reused, not written
18816
19085
  * again. Undefined when none was cut. */
18817
- written) {
19086
+ written,
19087
+ /** Set where `cap` and `bound` are this text's share of one turn's
19088
+ * bound (buildStoredToolResultContent): `readCap` is the inline cap,
19089
+ * which a later read of the file comes back under alone in its own turn
19090
+ * and its paging advice is sized to. */
19091
+ share) {
18818
19092
  if (!cap || content.length <= cap)
18819
19093
  return { text: content, filePath: null };
18820
19094
  // The stored text is the only place save_recent_image discovers tool
@@ -18824,45 +19098,63 @@ export class AgentFramework {
18824
19098
  // newest slots past the cut that the live copy showed are re-appended
18825
19099
  // after the notice. Otherwise the live copy is this text, and nothing
18826
19100
  // past the cut is kept (#403). The stored copy, notice and kept slots
18827
- // included, fits `bound`, the most a reader's strategy keeps of it, so
18828
- // no reader's cut takes the notice (cutStoredToolResultToFit), and the
18829
- // spill notices name the head they kept.
18830
- const cut = cutStoredToolResultToFit(content, cap, shown, bound);
19101
+ // included, fits `bound`, the most a reader's strategy keeps of it,
19102
+ // where `bound` leaves the notice its room, so no reader's cut takes the
19103
+ // notice (cutStoredToolResultToFit), and the spill notices name the head
19104
+ // they kept. Under a share too small for that, a cut keeps no head and
19105
+ // stores its notice alone, and a text no longer than that notice is
19106
+ // stored whole, with no file written: each result at its least.
19107
+ const readCap = share?.readCap ?? cap;
19108
+ const cut = cutStoredToolResultToFit(content, cap, shown, bound, share ? this.spillNoticeLength(label, content, shown, bound, readCap) : undefined);
19109
+ if (cut.head === content)
19110
+ return { text: content, filePath: null };
18831
19111
  const { head } = cut;
18832
19112
  const kept = formatKeptImageSlots(cut);
18833
19113
  if (written === undefined)
18834
19114
  written = await this.writeToolResultFile(label, content);
19115
+ const notice = this.spillNotice(head.length, content, shown, bound, readCap, written);
19116
+ // The notice was measured as a written file's reads. A write that failed
19117
+ // keeps nothing, and its notice, naming the failure, can be longer: under
19118
+ // a share, a cut that keeps no head and would store no less than the
19119
+ // text is stored as the text itself, where the text holds no image slot.
19120
+ // One that does is stored as it was measured, cut, since save_recent_image
19121
+ // counted it so before the write.
19122
+ if (share && head.length === 0 && notice.length >= content.length && parseImagePlaceholders(content).length === 0) {
19123
+ return { text: content, filePath: null, written };
19124
+ }
19125
+ return { text: head + notice + kept, filePath: written && written.error === undefined ? written.path : null, written };
19126
+ }
19127
+ /**
19128
+ * The notice a cut copy carries after its head (spillOrTruncate): the file
19129
+ * holding the whole result and how to read it back, or why there is none.
19130
+ */
19131
+ spillNotice(headLength, content, shown, bound, readCap, written) {
18835
19132
  if (written && written.error === undefined) {
18836
- const path = written.path;
18837
- return {
18838
- text: head
18839
- + `\n\n[truncated — showing ${head.length} of ${content.length} chars; full content: workspace file ${path}`
18840
- + `. ${this.spillPageAdvice(path, cap, bound === undefined || cutStoredToolResultToFit(content, bound, shown, bound).head.length > head.length)}]`
18841
- + kept,
18842
- filePath: path,
18843
- written,
18844
- };
19133
+ const canRaise = bound === undefined || cutStoredToolResultToFit(content, bound, shown, bound).head.length > headLength;
19134
+ return `\n\n[truncated — showing ${headLength} of ${content.length} chars; full content: workspace file ${written.path}`
19135
+ + `. ${this.spillPageAdvice(written.path, readCap, canRaise)}]`;
18845
19136
  }
18846
19137
  if (written) {
18847
19138
  // A workspace was there and the write FAILED — say that, loudly and
18848
19139
  // distinctly. Telling the agent "no writable workspace" would teach
18849
19140
  // them their residence lacks a capability it actually has.
18850
- return {
18851
- text: head
18852
- + `\n\n[truncated — showing ${head.length} of ${content.length} chars; spill to workspace file ${written.path} FAILED`
18853
- + ` (${written.error}); content over the cap was not retained]`
18854
- + kept,
18855
- filePath: null,
18856
- written,
18857
- };
19141
+ return `\n\n[truncated — showing ${headLength} of ${content.length} chars; spill to workspace file ${written.path} FAILED`
19142
+ + ` (${written.error}); content over the cap was not retained]`;
18858
19143
  }
18859
- return {
18860
- text: head
18861
- + '\n\n[truncated — original was ' + content.length + ' chars; no writable workspace mount without an include, full content not retained]'
18862
- + kept,
18863
- filePath: null,
18864
- written,
18865
- };
19144
+ return '\n\n[truncated — original was ' + content.length + ' chars; no writable workspace mount without an include, full content not retained]';
19145
+ }
19146
+ /**
19147
+ * The length of the notice a cut of `content` that keeps no head would
19148
+ * carry, before any file is written, as it reads once its file is
19149
+ * (toolResultFilePlace, where any earlier cut of the text wrote it too), so
19150
+ * the cut and save_recent_image's count of the result before it measure
19151
+ * alike. For keeping whole, under a share, a text no longer than its notice
19152
+ * (cutStoredToolResultToFit). A write that fails carries a notice naming
19153
+ * the failure instead (spillOrTruncate).
19154
+ */
19155
+ spillNoticeLength(label, content, shown, bound, readCap) {
19156
+ const place = this.toolResultFilePlace(label);
19157
+ return this.spillNotice(0, content, shown, bound, readCap, place ? { path: place.path } : null).length;
18866
19158
  }
18867
19159
  /**
18868
19160
  * The bridge: one of a script's calls, as its agent's, through the ordinary
@@ -18911,7 +19203,8 @@ export class AgentFramework {
18911
19203
  }
18912
19204
  if (refusal !== undefined)
18913
19205
  return refused(refusal);
18914
- const result = await this.dispatchScriptToolCall(agentName, toolName, input, context.origin, context.watch?.turnId);
19206
+ const route = { toServer: false };
19207
+ const result = await this.dispatchScriptToolCall(agentName, toolName, input, context.origin, context.watch?.turnId, route);
18915
19208
  // Deferred: applied to the result of the code_execution call watching
18916
19209
  // the script — ending the turn mid-script would cancel the stream while
18917
19210
  // the script still runs and wedge the tool round. A call a left script
@@ -18923,7 +19216,7 @@ export class AgentFramework {
18923
19216
  watching.endTurn = true;
18924
19217
  if (result.refused)
18925
19218
  return refused(result.error ?? `${toolName} was refused; nothing was done`);
18926
- const answer = await this.scriptResultAnswer(result, toolName);
19219
+ const answer = await this.scriptResultAnswer(result, toolName, route.toServer);
18927
19220
  return SCRIPT_CALL_NO_RESULT.has(result) ? { ...answer, held: true } : answer;
18928
19221
  }
18929
19222
  /**
@@ -18931,14 +19224,18 @@ export class AgentFramework {
18931
19224
  * isError }`, one shape for every tool. `content` is
18932
19225
  * what the model would read: a structured result's blocks as the dispatch
18933
19226
  * gave them (the direct reading, with RFC-005's treatment of an MCPL peer's
18934
- * blocks), and otherwise one text block of the model's text, which for a
18935
- * failure is its error. `structuredContent` is the tool's own structured
19227
+ * blocks there, bound to the server that sent them), a module's under
19228
+ * history's reading of RFC-005 at every depth (shownToScript), and
19229
+ * otherwise one text block of the model's text, which for a failure is its
19230
+ * error. A module's is any result whose call the dispatch didn't send to
19231
+ * a server (`toServer`, from the call's waiter). `structuredContent` is the
19232
+ * tool's own structured
18936
19233
  * content where it sent one, `false`, `0` and `null` included, else null.
18937
19234
  * A result that can't be converted to JSON (a cyclic object, a BigInt),
18938
19235
  * which the dispatch's own check makes unlikely here, is a failure naming
18939
19236
  * the tool and saying the call itself completed.
18940
19237
  */
18941
- async scriptResultAnswer(result, toolName) {
19238
+ async scriptResultAnswer(result, toolName, toServer) {
18942
19239
  const structured = Object.prototype.hasOwnProperty.call(result, 'structured');
18943
19240
  const isError = result.isError === true;
18944
19241
  // RFC-005 "eagerly lazy": a script receiving a result is the strongest
@@ -18955,7 +19252,10 @@ export class AgentFramework {
18955
19252
  // which isn't fetched.
18956
19253
  // Whenever the result's blocks reach the script: a structured failure's
18957
19254
  // included, as a module's error can carry content.
18958
- if ((!isError || structured) && Array.isArray(result.data) && this.resolveMcplTool(toolName) === null) {
19255
+ // Not a server's result: a module's, whose blocks no dispatch has given
19256
+ // RFC-005's treatment.
19257
+ const fromModule = !toServer;
19258
+ if ((!isError || structured) && Array.isArray(result.data) && fromModule) {
18959
19259
  const fetcher = this.getReferenceFetcher();
18960
19260
  for (const block of result.data) {
18961
19261
  const c = classifyBlock(block);
@@ -18984,6 +19284,21 @@ export class AgentFramework {
18984
19284
  // no blocks reads as its error text.
18985
19285
  if (structured && (!isError || (Array.isArray(result.data) && result.data.length > 0))) {
18986
19286
  content = Array.isArray(result.data) ? result.data : [];
19287
+ // A module's blocks, such as an MCPL peer's it got from ctx.callTool
19288
+ // and returns whole, are read as history reads a module's data: what
19289
+ // RFC-005 governs, at any depth, as the script lane shows it, and
19290
+ // anything else as sent. What a script prints comes back to the model,
19291
+ // so no uri the model's lanes withhold reaches it. What this gives is
19292
+ // a stub, a notice or the value itself, so a module that returns
19293
+ // blocks it already gave has them left as they are.
19294
+ if (fromModule) {
19295
+ try {
19296
+ content = JSON.parse(JSON.stringify(content, (_key, value) => shownToScript(value)));
19297
+ }
19298
+ catch (err) {
19299
+ return failed(err);
19300
+ }
19301
+ }
18987
19302
  }
18988
19303
  else {
18989
19304
  // As in history, a reference is its stub at any depth and inline data
@@ -19022,9 +19337,11 @@ export class AgentFramework {
19022
19337
  * RFC-007: with an `origin`, observers see the call as they see a model
19023
19338
  * call (register, dispatch with its refusal sites, open; the terminal at
19024
19339
  * the waiter intercept), under the inner tool's own name and class.
19025
- * `turnId` is the turn the call was made in (dispatchToolCall).
19340
+ * `turnId` is the turn the call was made in (dispatchToolCall). `route` is
19341
+ * the waiter's: the dispatch sets `toServer` when it sends the call to a
19342
+ * server.
19026
19343
  */
19027
- dispatchScriptToolCall(agentName, toolName, args, origin, turnId) {
19344
+ dispatchScriptToolCall(agentName, toolName, args, origin, turnId, route = { toServer: false }) {
19028
19345
  return new Promise((resolve) => {
19029
19346
  const callId = `pytc-${randomUUID()}`;
19030
19347
  // Host-side leak guard: the script's runner already gives the script a
@@ -19050,9 +19367,12 @@ export class AgentFramework {
19050
19367
  }
19051
19368
  }, safetyMs);
19052
19369
  safety.unref?.();
19053
- this.scriptToolWaiters.set(callId, (result) => {
19054
- clearTimeout(safety);
19055
- resolve(result);
19370
+ this.scriptToolWaiters.set(callId, {
19371
+ resolve: (result) => {
19372
+ clearTimeout(safety);
19373
+ resolve(result);
19374
+ },
19375
+ route,
19056
19376
  });
19057
19377
  const call = { id: callId, name: toolName, input: args };
19058
19378
  if (origin)
@@ -19583,6 +19903,11 @@ export class AgentFramework {
19583
19903
  // Route MCPL tool calls to the appropriate server via prefix map
19584
19904
  const mcplMatch = this.resolveMcplTool(enrichedCall.name);
19585
19905
  if (mcplMatch && this.mcplServerRegistry) {
19906
+ // A script's call: its waiter keeps that it went to a server, for the
19907
+ // script lane's exit (scriptToolWaiters).
19908
+ const scriptWaiter = this.scriptToolWaiters.get(enrichedCall.id);
19909
+ if (scriptWaiter)
19910
+ scriptWaiter.route.toServer = true;
19586
19911
  this.dispatchMcplToolCall(agentName, enrichedCall, mcplMatch[0], mcplMatch[1]);
19587
19912
  return;
19588
19913
  }
@@ -24592,7 +24917,9 @@ export class AgentFramework {
24592
24917
  // generated JSON) stands in for what the server sent. An MCPL peer's
24593
24918
  // content blocks are the exception: what a script prints comes back to
24594
24919
  // the model, so each gets the treatment RFC-005 gives it on every lane
24595
- // the model reads. A standard peer's blocks reach the script as sent.
24920
+ // the model reads, here, where the server that sent them is known. A
24921
+ // module's blocks get it at the script lane's exit (scriptResultAnswer).
24922
+ // A standard peer's blocks reach the script as sent.
24596
24923
  const forScript = this.scriptToolWaiters?.has(call.id) ?? false;
24597
24924
  const reading = async (result, mcpl) => {
24598
24925
  if (!forScript || !Object.prototype.hasOwnProperty.call(result, 'structuredContent')) {
@@ -25692,11 +26019,15 @@ export class AgentFramework {
25692
26019
  // the round's calls, finished or not.
25693
26020
  const { cap, bound } = this.resolveToolResultInlineCap(agent);
25694
26021
  const share = toolResultShare(cap, bound, state.pending.size + state.completed.length);
26022
+ const dateLabel = new Date().toISOString().slice(0, 10);
25695
26023
  for (let i = state.completed.length - 1; i >= 0 && collecting(); i--) {
25696
26024
  const tc = state.completed[i];
25697
26025
  const { text, shown } = this.storedToolResultText(agentName, tc, cap);
25698
26026
  const alone = storedImageSlots(text, cap, shown, bound);
25699
- const byShare = storedImageSlots(text, share.cap, shown, share.bound);
26027
+ // By its share, with the notice the commit's cut would carry, so a
26028
+ // result no longer than its notice counts as kept whole, as the
26029
+ // commit stores it.
26030
+ const byShare = storedImageSlots(text, share.cap, shown, share.bound, this.spillNoticeLength(`${dateLabel}-${tc.id}`, text, shown, share.bound, cap));
25700
26031
  if (alone.length !== byShare.length || alone.some((slot, j) => slot.text !== byShare[j].text)) {
25701
26032
  this.toolRoundsByShare.add(state.completed);
25702
26033
  }