@animalabs/agent-framework 0.33.0 → 0.34.0

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 (176) hide show
  1. package/README.md +136 -8
  2. package/dist/src/agent.d.ts +25 -4
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +68 -13
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/append-all.d.ts +5 -0
  7. package/dist/src/append-all.d.ts.map +1 -0
  8. package/dist/src/append-all.js +8 -0
  9. package/dist/src/append-all.js.map +1 -0
  10. package/dist/src/context-options.d.ts +9 -6
  11. package/dist/src/context-options.d.ts.map +1 -1
  12. package/dist/src/context-options.js +13 -8
  13. package/dist/src/context-options.js.map +1 -1
  14. package/dist/src/cut-off-round.js +4 -4
  15. package/dist/src/dendrite/registry.d.ts.map +1 -1
  16. package/dist/src/dendrite/registry.js +4 -2
  17. package/dist/src/dendrite/registry.js.map +1 -1
  18. package/dist/src/failure-text.d.ts +36 -0
  19. package/dist/src/failure-text.d.ts.map +1 -0
  20. package/dist/src/failure-text.js +89 -0
  21. package/dist/src/failure-text.js.map +1 -0
  22. package/dist/src/framework.d.ts +94 -30
  23. package/dist/src/framework.d.ts.map +1 -1
  24. package/dist/src/framework.js +987 -284
  25. package/dist/src/framework.js.map +1 -1
  26. package/dist/src/gate/event-gate.d.ts +40 -6
  27. package/dist/src/gate/event-gate.d.ts.map +1 -1
  28. package/dist/src/gate/event-gate.js +66 -25
  29. package/dist/src/gate/event-gate.js.map +1 -1
  30. package/dist/src/gate/reply-judge.d.ts +4 -0
  31. package/dist/src/gate/reply-judge.d.ts.map +1 -1
  32. package/dist/src/gate/reply-judge.js +12 -3
  33. package/dist/src/gate/reply-judge.js.map +1 -1
  34. package/dist/src/gate/suppression-review.js +2 -2
  35. package/dist/src/gate/suppression-review.js.map +1 -1
  36. package/dist/src/gate/types.d.ts +7 -0
  37. package/dist/src/gate/types.d.ts.map +1 -1
  38. package/dist/src/heartbeat/heartbeat.js +1 -1
  39. package/dist/src/heartbeat/heartbeat.js.map +1 -1
  40. package/dist/src/index.d.ts +1 -0
  41. package/dist/src/index.d.ts.map +1 -1
  42. package/dist/src/index.js +1 -0
  43. package/dist/src/index.js.map +1 -1
  44. package/dist/src/inference-bounds.d.ts +2 -1
  45. package/dist/src/inference-bounds.d.ts.map +1 -1
  46. package/dist/src/inference-bounds.js +9 -6
  47. package/dist/src/inference-bounds.js.map +1 -1
  48. package/dist/src/mcpl/channel-registry.d.ts +57 -13
  49. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  50. package/dist/src/mcpl/channel-registry.js +136 -24
  51. package/dist/src/mcpl/channel-registry.js.map +1 -1
  52. package/dist/src/mcpl/modern-connection.d.ts +109 -15
  53. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  54. package/dist/src/mcpl/modern-connection.js +593 -86
  55. package/dist/src/mcpl/modern-connection.js.map +1 -1
  56. package/dist/src/mcpl/protocol-family.d.ts +3 -1
  57. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  58. package/dist/src/mcpl/protocol-family.js +15 -9
  59. package/dist/src/mcpl/protocol-family.js.map +1 -1
  60. package/dist/src/mcpl/server-connection.d.ts +37 -18
  61. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  62. package/dist/src/mcpl/server-connection.js +31 -16
  63. package/dist/src/mcpl/server-connection.js.map +1 -1
  64. package/dist/src/mcpl/tool-presence.d.ts.map +1 -1
  65. package/dist/src/mcpl/tool-presence.js +10 -6
  66. package/dist/src/mcpl/tool-presence.js.map +1 -1
  67. package/dist/src/mcpl/toolset-host.d.ts.map +1 -1
  68. package/dist/src/mcpl/toolset-host.js +7 -6
  69. package/dist/src/mcpl/toolset-host.js.map +1 -1
  70. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  71. package/dist/src/mcpl/toolsets-module.js +2 -1
  72. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  73. package/dist/src/mcpl/types.d.ts +25 -4
  74. package/dist/src/mcpl/types.d.ts.map +1 -1
  75. package/dist/src/mcpl/types.js.map +1 -1
  76. package/dist/src/modules/discord/index.js +1 -1
  77. package/dist/src/modules/discord/index.js.map +1 -1
  78. package/dist/src/modules/health/index.js +2 -2
  79. package/dist/src/modules/health/index.js.map +1 -1
  80. package/dist/src/modules/history/semantic.js +3 -3
  81. package/dist/src/modules/history/semantic.js.map +1 -1
  82. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  83. package/dist/src/modules/workspace/index.js +4 -3
  84. package/dist/src/modules/workspace/index.js.map +1 -1
  85. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  86. package/dist/src/modules/workspace/observe.js +2 -1
  87. package/dist/src/modules/workspace/observe.js.map +1 -1
  88. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  89. package/dist/src/modules/workspace/reconcile.js +3 -2
  90. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  91. package/dist/src/notice-tags.d.ts +10 -8
  92. package/dist/src/notice-tags.d.ts.map +1 -1
  93. package/dist/src/notice-tags.js +10 -8
  94. package/dist/src/notice-tags.js.map +1 -1
  95. package/dist/src/presentation.d.ts +2 -1
  96. package/dist/src/presentation.d.ts.map +1 -1
  97. package/dist/src/presentation.js +4 -2
  98. package/dist/src/presentation.js.map +1 -1
  99. package/dist/src/reasoning-loss.d.ts +5 -0
  100. package/dist/src/reasoning-loss.d.ts.map +1 -0
  101. package/dist/src/reasoning-loss.js +8 -0
  102. package/dist/src/reasoning-loss.js.map +1 -0
  103. package/dist/src/recovery/offline-branch.d.ts.map +1 -1
  104. package/dist/src/recovery/offline-branch.js +5 -4
  105. package/dist/src/recovery/offline-branch.js.map +1 -1
  106. package/dist/src/recovery/recover-cli.js +2 -1
  107. package/dist/src/recovery/recover-cli.js.map +1 -1
  108. package/dist/src/sent-rows.js +1 -1
  109. package/dist/src/supplied-text.d.ts +1 -1
  110. package/dist/src/supplied-text.js +3 -3
  111. package/dist/src/supplied-text.js.map +1 -1
  112. package/dist/src/thinking-drops.d.ts +2 -0
  113. package/dist/src/thinking-drops.d.ts.map +1 -1
  114. package/dist/src/thinking-drops.js.map +1 -1
  115. package/dist/src/tool-presentation.d.ts.map +1 -1
  116. package/dist/src/tool-presentation.js +38 -9
  117. package/dist/src/tool-presentation.js.map +1 -1
  118. package/dist/src/tune-out/coordinator.d.ts +13 -4
  119. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  120. package/dist/src/tune-out/coordinator.js +33 -16
  121. package/dist/src/tune-out/coordinator.js.map +1 -1
  122. package/dist/src/tune-out/tools.d.ts.map +1 -1
  123. package/dist/src/tune-out/tools.js +2 -1
  124. package/dist/src/tune-out/tools.js.map +1 -1
  125. package/dist/src/types/agent.d.ts +39 -5
  126. package/dist/src/types/agent.d.ts.map +1 -1
  127. package/dist/src/types/framework.d.ts +43 -8
  128. package/dist/src/types/framework.d.ts.map +1 -1
  129. package/dist/src/types/inference-log.d.ts +1 -0
  130. package/dist/src/types/inference-log.d.ts.map +1 -1
  131. package/dist/src/types/trace.d.ts +64 -4
  132. package/dist/src/types/trace.d.ts.map +1 -1
  133. package/package.json +3 -3
  134. package/src/agent.ts +71 -14
  135. package/src/append-all.ts +6 -0
  136. package/src/context-options.ts +18 -8
  137. package/src/cut-off-round.ts +4 -4
  138. package/src/dendrite/registry.ts +4 -2
  139. package/src/failure-text.ts +88 -0
  140. package/src/framework.ts +1018 -293
  141. package/src/gate/event-gate.ts +90 -26
  142. package/src/gate/reply-judge.ts +12 -3
  143. package/src/gate/suppression-review.ts +2 -2
  144. package/src/gate/types.ts +7 -0
  145. package/src/heartbeat/heartbeat.ts +1 -1
  146. package/src/index.ts +1 -0
  147. package/src/inference-bounds.ts +11 -6
  148. package/src/mcpl/channel-registry.ts +160 -31
  149. package/src/mcpl/modern-connection.ts +629 -91
  150. package/src/mcpl/protocol-family.ts +15 -9
  151. package/src/mcpl/server-connection.ts +28 -15
  152. package/src/mcpl/tool-presence.ts +10 -5
  153. package/src/mcpl/toolset-host.ts +7 -6
  154. package/src/mcpl/toolsets-module.ts +2 -1
  155. package/src/mcpl/types.ts +25 -4
  156. package/src/modules/discord/index.ts +1 -1
  157. package/src/modules/health/index.ts +2 -2
  158. package/src/modules/history/semantic.ts +3 -3
  159. package/src/modules/workspace/index.ts +4 -3
  160. package/src/modules/workspace/observe.ts +2 -1
  161. package/src/modules/workspace/reconcile.ts +3 -2
  162. package/src/notice-tags.ts +11 -8
  163. package/src/presentation.ts +4 -2
  164. package/src/reasoning-loss.ts +7 -0
  165. package/src/recovery/offline-branch.ts +5 -4
  166. package/src/recovery/recover-cli.ts +2 -1
  167. package/src/sent-rows.ts +1 -1
  168. package/src/supplied-text.ts +3 -3
  169. package/src/thinking-drops.ts +2 -0
  170. package/src/tool-presentation.ts +34 -9
  171. package/src/tune-out/coordinator.ts +32 -15
  172. package/src/tune-out/tools.ts +2 -1
  173. package/src/types/agent.ts +36 -5
  174. package/src/types/framework.ts +44 -8
  175. package/src/types/inference-log.ts +1 -0
  176. package/src/types/trace.ts +66 -4
package/src/framework.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { isReasoningLoss } from './reasoning-loss.js';
2
+ export { residentViewFilter, subconsciousViewFilter } from './context-options.js';
1
3
  import { ToolPresentation, presentationTools, isPresentationTool, renderCatalogue, type PresentationSnapshot } from "./tool-presentation.js";
2
4
  import { presentAuthor } from './author-presentation.js';
3
5
  import { constants as bufferConstants } from 'node:buffer';
@@ -13,7 +15,8 @@ import type { Membrane, ContentBlock, NormalizedRequest, NormalizedResponse, Yie
13
15
  import { MembraneError } from '@animalabs/membrane';
14
16
  import { classifyFailure, retryDecision, MEMBRANE_RETRY_SETTINGS, type RetrySettings } from '@animalabs/membrane/retry';
15
17
  import { deadlineCut, isDeadlineExpiry, resolveInferenceBounds, wholeCallMs, type InferenceBounds } from './inference-bounds.js';
16
- import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy, OverBudgetError, UncoveredDropError, stripUnpairedToolBlocks, continuesBody, MalformedContentError, malformedContentPlaces, CompressionWithdrawn } from '@animalabs/context-manager';
18
+ import { boundFailureText, compileRefusalType, failureWords, FAILURE_TEXT_MAX_CHARS, houseReason, isMembraneError, PROVIDER_ERROR_CODE_MAX_CHARS } from './failure-text.js';
19
+ import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy, stripUnpairedToolBlocks, continuesBody, MalformedContentError, malformedContentPlaces, CompressionWithdrawn } from '@animalabs/context-manager';
17
20
  // Not named imports from context-manager: 0.24.0 has neither, and a named
18
21
  // import of either would fail this package's load there before create()
19
22
  // could name the release it needs.
@@ -229,6 +232,7 @@ import { AgentPresentation, asCompiled, bearingOf, heldFromResidents, suppressed
229
232
  import { coverTest, covers, readMarkThrough, type MarkedRow, type ReadMark } from './read-marks.js';
230
233
  import { contentAsStored, nameAsStored } from './text-as-stored.js';
231
234
  import { STORED_INJECTION_KEY, STORED_INJECTION_KIND, asStoredRow, hasRenderedContent, isAnotherAgentsInjection, isOwnInjection, isStoredKind, storedInjectionMarkOf, unitHash, type StoredInjectionMark } from './stored-injections.js';
235
+ import { appendAll } from './append-all.js';
232
236
 
233
237
  /** An MCPL tool call refused because its server isn't in the registry: one
234
238
  * closed for good, one removed, or one whose connect failed without
@@ -1295,18 +1299,29 @@ const TURN_RETRY: unique symbol = Symbol('turn-retry');
1295
1299
  */
1296
1300
  const MERGED_BATCH: unique symbol = Symbol('merged-batch');
1297
1301
  const REARMED: unique symbol = Symbol('rearmed');
1302
+ const UNPROMPTED: unique symbol = Symbol('unprompted');
1298
1303
  const TURN_CARRY: unique symbol = Symbol('turn-carry');
1304
+ const BUDGET_WAIT: unique symbol = Symbol('budget-wait');
1305
+ interface BudgetWait {
1306
+ since: number;
1307
+ row?: string;
1308
+ alerted?: true;
1309
+ }
1299
1310
  interface TurnCarry {
1300
1311
  silence: TurnSilence | null;
1301
1312
  sends: SendsReached | null;
1302
1313
  read: number;
1303
1314
  }
1304
- type MarkedRequest = InferenceRequest & { [TURN_RETRY]?: true; [MERGED_BATCH]?: InferenceRequest[]; [TURN_CARRY]?: TurnCarry; [REARMED]?: true };
1315
+ type MarkedRequest = InferenceRequest & { [TURN_RETRY]?: true; [MERGED_BATCH]?: InferenceRequest[]; [TURN_CARRY]?: TurnCarry; [REARMED]?: true; [UNPROMPTED]?: true; [BUDGET_WAIT]?: BudgetWait };
1305
1316
 
1306
1317
  function isRearmed(request: InferenceRequest | undefined): boolean {
1307
1318
  return request !== undefined && (request as MarkedRequest)[REARMED] === true;
1308
1319
  }
1309
1320
 
1321
+ function isUnprompted(request: InferenceRequest | undefined): boolean {
1322
+ return request !== undefined && (request as MarkedRequest)[UNPROMPTED] === true;
1323
+ }
1324
+
1310
1325
  interface StatedWaitRun { since: number; holds: number; alerted: boolean; model: string; reason: string; heldUntil: number }
1311
1326
 
1312
1327
  function waitAlertsOnItsOwn(wait: ProviderWait): boolean {
@@ -1362,6 +1377,10 @@ function continuesTurn(request: InferenceRequest): boolean {
1362
1377
  return resumesLogicalTurn(request.reason) || isTurnRetry(request);
1363
1378
  }
1364
1379
 
1380
+ function budgetWaitOf(request: InferenceRequest | undefined): BudgetWait | undefined {
1381
+ return request ? (request as MarkedRequest)[BUDGET_WAIT] : undefined;
1382
+ }
1383
+
1365
1384
  function turnCarryOf(request: InferenceRequest | undefined): TurnCarry | undefined {
1366
1385
  return request && continuesTurn(request) ? (request as MarkedRequest)[TURN_CARRY] : undefined;
1367
1386
  }
@@ -1459,6 +1478,8 @@ interface TurnRouting {
1459
1478
  * wake, so a heartbeat can't start a loop of turns.
1460
1479
  */
1461
1480
  heartbeat?: true;
1481
+ unprompted?: true;
1482
+ wakeChannels?: ChannelRef[];
1462
1483
  /**
1463
1484
  * Locked mode (AgentConfig.proseRouting 'locked'): the turn's locus is the
1464
1485
  * agent's prose lock, taken when the turn opens, and only prose_locus moves
@@ -1481,7 +1502,8 @@ interface TurnRouting {
1481
1502
  * lands. Locked mode's is the agent's lock (TurnRouting.locked); the rest
1482
1503
  * of this describes locus and hybrid mode.
1483
1504
  * Frozen when the turn opens (home channel for a fork, else the trigger
1484
- * channel, else the global default), so ambient chatter, reactions and
1505
+ * channel, else the global default, with the configured unprompted channel
1506
+ * in it where the turn takes it), so ambient chatter, reactions and
1485
1507
  * system markers can't move it (2026-07-21 Cairn lounge misroute). Since
1486
1508
  * 2026-07-31 a re-pin moves it (repinTurnLocus): an addressed message, or a
1487
1509
  * follow-up in a channel the turn engaged. The agent's own channel_open
@@ -1525,6 +1547,7 @@ interface TurnRouting {
1525
1547
  /** The turn asked for a ` !` continuation; taken once. */
1526
1548
  continuation: boolean;
1527
1549
  start?: { branch: string; sequence: number };
1550
+ firstRow?: string;
1528
1551
  roundsRan?: true;
1529
1552
  unflushedRounds?: number;
1530
1553
  unpostedSpeech?: true;
@@ -1868,15 +1891,20 @@ const RETRY_CANCELLED_BY_STOP = 'a retry waiting while the framework was stoppin
1868
1891
  * was dropped before it started, other than by an abandon or a stop. */
1869
1892
  const CONTINUATION_DROPPED = "a turn's continuation was dropped before it started";
1870
1893
 
1894
+ const BUDGET_HELD_TURN_REMOVED = 'a turn that waited for compression was dropped: what it stood on was removed';
1895
+
1871
1896
  /** One frame's window while it holds a turn with no stream and one of its
1872
1897
  * checks is ahead (AgentFramework.checkAhead). `abandoned` is an operator's
1873
1898
  * quiesce abandon that reached the turn there, which the frame's check
1874
1899
  * collects; it goes with the window. `wake`, set while the frame waits to
1875
1900
  * retry, ends that wait, so the check after it comes at once; called once
1876
- * the wait is over, it does nothing. */
1901
+ * the wait is over, it does nothing. `gateReleased` is set when a budget
1902
+ * hold gave back the frame's provider admission as its window closed, so
1903
+ * the frame's end doesn't give it back again. */
1877
1904
  interface CheckWindow {
1878
1905
  abandoned: boolean;
1879
1906
  wake?: () => void;
1907
+ gateReleased?: true;
1880
1908
  }
1881
1909
 
1882
1910
  function isTurnContinuation(reason: string): boolean {
@@ -1905,6 +1933,10 @@ function hasOwnCauseForRun(r: InferenceRequest): boolean {
1905
1933
  return !FRAMEWORK_WAKE_SOURCES.has(r.source) && r.suppressProse !== true;
1906
1934
  }
1907
1935
 
1936
+ function bringsOwnChannels(r: InferenceRequest): boolean {
1937
+ return hasOwnCauseForRun(r) && (r.wakeChannels?.length ?? 0) > 0;
1938
+ }
1939
+
1908
1940
  /** Whether a wake carries an autoContinue continuation: its own wake, or a
1909
1941
  * requeued turn trigger that kept the mark (InferenceRequest.autoContinueRun). */
1910
1942
  function carriesContinuation(r: InferenceRequest): boolean {
@@ -1952,6 +1984,11 @@ interface TurnCheckpoint {
1952
1984
  windowBranch?: string;
1953
1985
  }
1954
1986
 
1987
+ interface RecordedTurnCheckpoint {
1988
+ turnIndex: number;
1989
+ evicted: TurnCheckpoint[];
1990
+ }
1991
+
1955
1992
  /** What a report of writes kept in memory says of them: how many of each kind (`metadata.kind`, else `message`), how many are mail, and the sentences that say so. */
1956
1993
  function heldWritesTold(writes: readonly SurgeryHeldWrite[]): { kinds: Record<string, number>; mail: number; told: string } {
1957
1994
  const kinds: Record<string, number> = {};
@@ -2094,6 +2131,12 @@ function assertFrameworkSwitches(config: FrameworkConfig): void {
2094
2131
  'and false or leaving it out doesn\'t',
2095
2132
  );
2096
2133
  }
2134
+ const unprompted: unknown = config.unpromptedChannel;
2135
+ if (unprompted !== undefined && unprompted !== null && (typeof unprompted !== 'string' || unprompted.trim() === '')) {
2136
+ throw new Error(
2137
+ `unpromptedChannel must name a channel, such as "discord:<guildId>:<channelId>", not ${typeof unprompted === 'string' ? 'a blank string' : kindOf(unprompted)}`,
2138
+ );
2139
+ }
2097
2140
  // The heartbeat: true or an object turns it on; false, null or leaving it
2098
2141
  // out leaves it off. Any other value (a "false" read from text, a 0) is
2099
2142
  // refused rather than read as on.
@@ -2513,11 +2556,14 @@ export interface HostModeStatus {
2513
2556
  * gate while quiesced, or, while serving, until the scheduler's next pass
2514
2557
  * takes them), in a scheduler pass's hands, or held by a provider hold (an
2515
2558
  * acceleration or host hold, or a provider's stated wait). There, a start
2516
- * merged from several requests counts as those it took in. A start parked
2517
- * on provider admission behind an auxiliary call counts once in
2518
- * `parkedAdmissions` instead, however many wakes are merged into it.
2519
- * Continuations of a turn (budget restart, tool results) and a held turn's
2520
- * retry, which a quiesce lets through, are never counted. */
2559
+ * merged from several requests counts as those it took in. A start parked on
2560
+ * provider admission behind an auxiliary call counts once in
2561
+ * `parkedAdmissions` instead, however many wakes are merged into it. A turn
2562
+ * a budget hold holds counts once, continuation or not. The wakes merged
2563
+ * into its start are part of it. Wakes that joined it while it waited count
2564
+ * as other gated requests do. Other continuations (budget restart, tool
2565
+ * results, the tool-result guard's retry) and a held turn's retry, which a
2566
+ * quiesce lets through, are never counted. */
2521
2567
  gatedRequests: number;
2522
2568
  /** code_execution scripts still running that no call is waiting on: left
2523
2569
  * running after a yield, outside any turn. Quiesce does NOT stop them —
@@ -2597,8 +2643,9 @@ export class ProviderWaitNotSleptError extends Error {
2597
2643
  constructor(
2598
2644
  message: string,
2599
2645
  readonly retryAfterMs: number,
2646
+ options?: ErrorOptions,
2600
2647
  ) {
2601
- super(message);
2648
+ super(message, options);
2602
2649
  this.name = 'ProviderWaitNotSleptError';
2603
2650
  }
2604
2651
  }
@@ -2892,7 +2939,7 @@ export class DefaultErrorPolicy implements ErrorPolicy {
2892
2939
 
2893
2940
  constructor(settings: RetrySettings = TURN_RETRY_SETTINGS) {
2894
2941
  this.settings = settings;
2895
- this.maxRetries = Math.max(0, ...Object.values(settings).map((s) => (s?.maxAttempts ?? 1) - 1));
2942
+ this.maxRetries = Object.values(settings).reduce((max, s) => Math.max(max, (s?.maxAttempts ?? 1) - 1), 0);
2896
2943
  }
2897
2944
 
2898
2945
  onInferenceError(error: Error, _agentName: string, attempt: number): ErrorAction {
@@ -3062,6 +3109,33 @@ const TURN_RETRY_SETTINGS: RetrySettings = {
3062
3109
  deadline: { maxAttempts: DEADLINE_TURN_TRIES, firstDelayMs: 1_000, multiplier: 1, maxDelayMs: 1_000, statedWaitCeilingMs: null },
3063
3110
  } as RetrySettings;
3064
3111
  const PROVIDER_ACCELERATION_JITTER_MS = 5_000;
3112
+ const BUDGET_HOLD_NOTE_NAMESPACE = 'framework:budget-hold';
3113
+ type BudgetHoldOutcome = 'released' | 'stopped' | 'removed';
3114
+ function describeWait(ms: number): string {
3115
+ return ms < 60_000 ? `${Math.max(1, Math.round(ms / 1000))} s` : `${Math.round(ms / 60_000)} min`;
3116
+ }
3117
+ interface BudgetHoldBaseline {
3118
+ progress: string | undefined;
3119
+ settings: number;
3120
+ }
3121
+
3122
+ interface BudgetHold {
3123
+ startedAt: number;
3124
+ wait: BudgetWait;
3125
+ turn: InferenceRequest;
3126
+ standsOn: string[];
3127
+ wakes: InferenceRequest[];
3128
+ errorType: 'over_budget' | 'context_refusal';
3129
+ reason: string;
3130
+ budget: number | undefined;
3131
+ actual: number | undefined;
3132
+ recheck?: ReturnType<typeof setInterval>;
3133
+ managerCalls: number;
3134
+ progressAtHold: string | undefined;
3135
+ settingsAtHold: number;
3136
+ checkQueued?: boolean;
3137
+ drainKicked?: boolean;
3138
+ }
3065
3139
  /** Longest delay one setTimeout can represent (2^31-1 ms, about 24.8 days);
3066
3140
  * a longer timer fires almost at once, so longer waits re-arm in steps. */
3067
3141
  const MAX_TIMER_DELAY_MS = 2_147_483_647;
@@ -3077,17 +3151,6 @@ const WAITING_TRACE_EVERY_MS = 60_000;
3077
3151
  * waits in a row hold one turn longer than this (noteStatedWaitRun,
3078
3152
  * endStatedWaitRun). */
3079
3153
  const PROVIDER_WAIT_ALERT_MS = 60 * 60_000;
3080
- /**
3081
- * A MembraneError, from this framework's copy of membrane or from another: the
3082
- * host supplies the Membrane instance (config.membrane), so with two copies
3083
- * installed its errors are instances of a class this framework's `instanceof`
3084
- * doesn't match. Recognised by name as well, as context-manager's errors are;
3085
- * its fields are the same, so callers still check each one's type.
3086
- */
3087
- function isMembraneError(error: unknown): error is MembraneError {
3088
- return error instanceof MembraneError || (error instanceof Error && error.name === 'MembraneError');
3089
- }
3090
-
3091
3154
  /**
3092
3155
  * agent-framework#734. Membrane's own streams send a retryable failure as an
3093
3156
  * `error` event and send nothing after their terminal event. A stream of
@@ -3595,44 +3658,16 @@ const SCRIPT_TEXT_ANSWER_ENVELOPE_BYTES = Buffer.byteLength(
3595
3658
  const scriptResultMaxChars = (memoryMaxBytes: number): number =>
3596
3659
  Math.max(0, Math.floor((Math.min(maxResultBytes(memoryMaxBytes), bufferConstants.MAX_STRING_LENGTH) - SCRIPT_TEXT_ANSWER_ENVELOPE_BYTES) / 6));
3597
3660
 
3598
- /**
3599
- * Longest failure text the inference-failure pipeline writes to one sink: the
3600
- * reason in the exhaustion stderr line, a field of a logs/failures.log record,
3601
- * an ops alert's message, the inference log's `error`, and a trace event's
3602
- * `error` and `stack`. A failure's text is whatever its source put in
3603
- * `Error.message`, and a provider can echo the entire rejected request there
3604
- * (one OpenAI-compatible 400 echoed ~1.7 MB). The sinks with shorter bounds
3605
- * name them below, and every cut goes through boundFailureText. This bound
3606
- * doesn't depend on membrane bounding its own errors.
3607
- */
3608
- const FAILURE_TEXT_MAX_CHARS = 2_000;
3609
- /** Longest failure reason written into an agent's own context (the
3610
- * `[inference-failed]` marker): enough for a provider's explanation, not a
3611
- * self-dump the agent then carries in every later request. */
3661
+ /** Longest failure reason in the `[inference-failed]` marker: the reason its
3662
+ * text gives, and the excerpt its metadata keeps. */
3612
3663
  const FAILURE_MARKER_REASON_MAX_CHARS = 600;
3613
- /** Longest failure reason in the rewind marker, also the agent's own context:
3614
- * enough to identify the failure class without a wall of provider JSON. */
3664
+ /** Longest failure reason the rewind marker's metadata keeps as its category. */
3615
3665
  const REWIND_MARKER_REASON_MAX_CHARS = 160;
3616
3666
  /** Longest failure reason in an agent's status (`lastInference.lastError`) and
3617
3667
  * in the compile-refused ops alert. */
3618
3668
  const FAILURE_SUMMARY_MAX_CHARS = 300;
3619
3669
  /** Longest alert message posted to the ops webhook. */
3620
3670
  const OPS_WEBHOOK_MESSAGE_MAX_CHARS = 500;
3621
- /** Longest provider error code recorded verbatim; real codes are short tokens.
3622
- * A failure's name and type are bounded the same way. */
3623
- const PROVIDER_ERROR_CODE_MAX_CHARS = 128;
3624
-
3625
- const isHighSurrogate = (text: string, index: number): boolean => {
3626
- const code = text.charCodeAt(index);
3627
- return code >= 0xd800 && code <= 0xdbff;
3628
- };
3629
- const isLowSurrogate = (text: string, index: number): boolean => {
3630
- const code = text.charCodeAt(index);
3631
- return code >= 0xdc00 && code <= 0xdfff;
3632
- };
3633
- /** Whether a cut before `index` would separate the halves of a surrogate pair. */
3634
- const splitsPair = (text: string, index: number): boolean =>
3635
- index > 0 && index < text.length && isHighSurrogate(text, index - 1) && isLowSurrogate(text, index);
3636
3671
 
3637
3672
  /**
3638
3673
  * Whether an MCPL server's child ended the way a server ends when it's asked
@@ -3659,26 +3694,6 @@ function closeWords(reason: string | null | undefined, code: number | null | und
3659
3694
  return how.length > 0 ? ` (${how.join(', ')})` : '';
3660
3695
  }
3661
3696
 
3662
- /**
3663
- * `text` when it fits within `max` characters; otherwise its head and tail
3664
- * around a marker stating how many of how many characters were omitted, the
3665
- * whole within `max`. A cut that would separate a surrogate pair moves toward
3666
- * omission, so the result never holds half of a pair the text held whole, and
3667
- * bounding bounded text returns it as is. Each sink cuts the whole text, so
3668
- * its marker states the whole length.
3669
- */
3670
- function boundFailureText(text: string, max: number = FAILURE_TEXT_MAX_CHARS): string {
3671
- if (text.length <= max) return text;
3672
- const markerLength = ` …[${text.length} of ${text.length} characters omitted]… `.length;
3673
- const budget = Math.max(0, max - markerLength);
3674
- const tailLength = Math.floor(budget / 4);
3675
- let headEnd = budget - tailLength;
3676
- if (splitsPair(text, headEnd)) headEnd--;
3677
- let tailStart = text.length - tailLength;
3678
- if (splitsPair(text, tailStart)) tailStart++;
3679
- return `${text.slice(0, headEnd)} …[${tailStart - headEnd} of ${text.length} characters omitted]… ${text.slice(tailStart)}`;
3680
- }
3681
-
3682
3697
  export function opsWebhookText(message: string): string {
3683
3698
  return boundFailureText(message, OPS_WEBHOOK_MESSAGE_MAX_CHARS);
3684
3699
  }
@@ -3751,6 +3766,33 @@ function failureShown(text: string): string {
3751
3766
  return text === UNDESCRIBED_FAILURE ? text : suppliedValue(text);
3752
3767
  }
3753
3768
 
3769
+ function failedEntry(prefix: string, err: Error): { error: string; errorWords: string } {
3770
+ return { error: `${prefix}${err.message}`, errorWords: `${prefix}${failureWords(err)}` };
3771
+ }
3772
+
3773
+ function shownReasonOf(err: Error): { shownReason?: string } {
3774
+ const own = houseReason(err);
3775
+ return own === undefined ? {} : { shownReason: own };
3776
+ }
3777
+
3778
+ function boundedCause(err: Error): Error {
3779
+ const cause = new Error(boundFailureText(String(err.message)));
3780
+ cause.name = boundFailureText(String(err.name), PROVIDER_ERROR_CODE_MAX_CHARS);
3781
+ cause.stack = boundFailureText(String(err.stack ?? `${cause.name}: ${cause.message}`));
3782
+ if (isMembraneError(err)) {
3783
+ const fields = err as Error & { type?: unknown; retryable?: unknown; httpStatus?: unknown; providerErrorCode?: unknown; retryAfterMs?: unknown };
3784
+ const code = boundProviderErrorCode(fields.providerErrorCode);
3785
+ Object.assign(cause, {
3786
+ ...(typeof fields.type === 'string' ? { type: boundFailureText(fields.type, PROVIDER_ERROR_CODE_MAX_CHARS) } : {}),
3787
+ ...(typeof fields.retryable === 'boolean' ? { retryable: fields.retryable } : {}),
3788
+ ...(typeof fields.httpStatus === 'number' ? { httpStatus: fields.httpStatus } : {}),
3789
+ ...(code !== undefined ? { providerErrorCode: code } : {}),
3790
+ ...(typeof fields.retryAfterMs === 'number' ? { retryAfterMs: fields.retryAfterMs } : {}),
3791
+ });
3792
+ }
3793
+ return cause;
3794
+ }
3795
+
3754
3796
  /**
3755
3797
  * A bounded string describing a failure, for logs that would otherwise print
3756
3798
  * the error object; it is one line unless one of its parts has newlines, as a
@@ -3949,7 +3991,8 @@ type FrameworkCancelKind = 'turn_ended' | 'budget_restart' | 'quiesce_abandoned'
3949
3991
  * start; any other drop ends it as a start dropped before it sends anything.
3950
3992
  * `why` is said in the line its end logs. */
3951
3993
  type ContinuationDrop =
3952
- | { kind: 'quiesce_abandoned' | 'shutdown' | 'dropped'; why: string }
3994
+ | { kind: 'quiesce_abandoned' | 'shutdown'; why: string }
3995
+ | { kind: 'dropped'; why: string; reason?: string }
3953
3996
  /** A caller's stop (abortInference) of a turn whose continuation or retry
3954
3997
  * is on its way, with the reason it gave. */
3955
3998
  | { kind: 'caller'; reason?: string; why: string };
@@ -4128,6 +4171,14 @@ function mergeWakeLines(requests: readonly InferenceRequest[]): WakeLine[] | und
4128
4171
  return byEntry.size > 0 ? [...byEntry.values()] : undefined;
4129
4172
  }
4130
4173
 
4174
+ function wakeChannelsOf(requests: readonly ({ wakeChannels?: ChannelRef[] } | undefined)[]): ChannelRef[] | undefined {
4175
+ const channels: ChannelRef[] = [];
4176
+ for (const channel of requests.flatMap((r) => r?.wakeChannels ?? [])) {
4177
+ if (!channels.some((c) => sameChannel(c, channel))) channels.push(channel);
4178
+ }
4179
+ return channels.length > 0 ? channels : undefined;
4180
+ }
4181
+
4131
4182
  /** A batched-wake line's identity: its event's stored entry. */
4132
4183
  function wakeLineKey(line: WakeLine): string {
4133
4184
  const { eventType, serverId, channelId, messageId, eventId } = line.entry;
@@ -4299,6 +4350,9 @@ export class AgentFramework {
4299
4350
  private providerAccelerationCooldowns: Map<string, ProviderAccelerationCooldown> = new Map();
4300
4351
  private providerAccelerationRecoveries: Map<string, ProviderAccelerationRecovery> = new Map();
4301
4352
  private providerAccelerationLastRecovery: Map<string, ProviderAccelerationReceipt> = new Map();
4353
+ private budgetHolds: Map<string, BudgetHold> = new Map();
4354
+ private overBudgetDrainAfter: Map<string, Array<() => void>> = new Map();
4355
+ private budgetHoldRecheckMs = DEFAULT_MAINTENANCE_INTERVAL_MS;
4302
4356
  private providerAccelerationDefaultCooldownMs = PROVIDER_ACCELERATION_DEFAULT_COOLDOWN_MS;
4303
4357
  private providerAccelerationJitterMs = PROVIDER_ACCELERATION_JITTER_MS;
4304
4358
  private inferenceBounds: InferenceBounds | null = resolveInferenceBounds(undefined);
@@ -4828,11 +4882,11 @@ export class AgentFramework {
4828
4882
  private readonly managerReleases = new WeakMap<Agent, ManagerRelease>();
4829
4883
  private readonly releasedByCleanup = new WeakSet<Agent>();
4830
4884
  private readonly resumesPending = new Map<string, Agent | undefined>();
4831
- // context-manager 0.23.1: a strategy that keeps its context, as
4832
- // AutobiographicalStrategy does (hostContext, autobiographical.ts:2897),
4885
+ // context-manager 0.25.1: a strategy that keeps its context, as
4886
+ // AutobiographicalStrategy does (hostContext, autobiographical.ts:3156),
4833
4887
  // keeps its manager reachable while its work is pending, since the
4834
4888
  // context's tools and systemPrompt getters close over the manager
4835
- // (context-manager.ts:2343-2359). So such a released manager is collected
4889
+ // (context-manager.ts:4602-4621). So such a released manager is collected
4836
4890
  // only once nothing of its work can still read the store, and stop()
4837
4891
  // closes, before an owned store, each one alive.
4838
4892
  private readonly openManagers = new Set<WeakRef<ContextManager>>();
@@ -4958,7 +5012,8 @@ export class AgentFramework {
4958
5012
  private toolPatternKnownPrefixes: Map<string, string> = new Map();
4959
5013
  /** Maps serverId → McplServerConfig for prefix lookup. */
4960
5014
  private mcplServerConfigs: Map<string, import('./mcpl/types.js').McplServerConfig> = new Map();
4961
- /** Modern-MCP (2026-07-28) servers, by id; read through modernServers().
5015
+ /** Modern-MCP servers (2026-07-28, or a 2025 revision over HTTP), by id;
5016
+ * read through modernServers().
4962
5017
  * They are kept apart from the MCPL registry on purpose: they have no
4963
5018
  * grant, planes or server→host traffic, so the MCPL machinery (quiesce's
4964
5019
  * plane pauses, policy) never sees them. They take part
@@ -4995,6 +5050,7 @@ export class AgentFramework {
4995
5050
  * subsystem can be lazily initialized by connectMcplServer when the
4996
5051
  * framework started with zero configured servers. */
4997
5052
  private mcplInferenceRoutingConfig: import('./mcpl/types.js').InferenceRoutingPolicy | null = null;
5053
+ private mcplUnpromptedChannel: string | null = null;
4998
5054
  /** The awareness ledger; null for a framework with neither storePath nor
4999
5055
  * discordAwarenessOutboxPath, which refuses a marks choice. */
5000
5056
  private readonly discordAwarenessOutbox: DiscordAwarenessOutbox | null;
@@ -5130,13 +5186,16 @@ export class AgentFramework {
5130
5186
  assertAgentNamesUnique(config.agents);
5131
5187
  assertObservedAgentsConfigured(config.agents);
5132
5188
  // The presentation index (#291) needs a context-manager that keeps a
5133
- // window index per agent (presentRows): 0.25.0 on, since 0.23.x and
5134
- // 0.24.0 were published without one. It needs no store, so it is
5135
- // checked before one is opened.
5136
- if (typeof (ContextManager.prototype as { presentRows?: unknown }).presentRows !== 'function') {
5189
+ // window index per agent (presentRows), which 0.23.x and 0.24.x were
5190
+ // published without, and a stream tells it of dropped thinking
5191
+ // (noteThinkingDropped), which 0.25.0 was published without. It needs no
5192
+ // store, so it is checked before one is opened.
5193
+ const contextManagerProto = ContextManager.prototype as { presentRows?: unknown; noteThinkingDropped?: unknown };
5194
+ if (typeof contextManagerProto.presentRows !== 'function' || typeof contextManagerProto.noteThinkingDropped !== 'function') {
5137
5195
  throw new Error(
5138
- 'agent-framework needs @animalabs/context-manager with ContextManager.presentRows: a context-manager that keeps a window index per agent. ' +
5139
- 'Install @animalabs/context-manager ^0.25.0, which this package declares (0.23.x and 0.24.0 have no window index)',
5196
+ 'agent-framework needs @animalabs/context-manager with ContextManager.presentRows and ContextManager.noteThinkingDropped: ' +
5197
+ "a context-manager that keeps a window index per agent and can be told which stored replies' thinking the provider dropped. " +
5198
+ 'Install @animalabs/context-manager ^0.25.1, which this package declares (0.23.x and 0.24.x have no window index, and 0.25.0 has no noteThinkingDropped)',
5140
5199
  );
5141
5200
  }
5142
5201
 
@@ -5582,7 +5641,7 @@ export class AgentFramework {
5582
5641
  : undefined);
5583
5642
  },
5584
5643
  requestInference: (agentName, reason, source, provenance, after) => {
5585
- framework.pendingRequests.push({
5644
+ const request: MarkedRequest = {
5586
5645
  agentName, reason, source, timestamp: Date.now(),
5587
5646
  ...framework.readsRowFor(after, agentName),
5588
5647
  // gate-requested wakes carry where/who (EventGate wakeProvenance)
@@ -5610,11 +5669,18 @@ export class AgentFramework {
5610
5669
  const route = provenance?.routeChannelId !== undefined
5611
5670
  ? framework.channelNamed(provenance.routeChannelId, provenance.routeServerId)
5612
5671
  : undefined;
5613
- return route && framework.mayTakeGateRoute(agentName, route)
5614
- ? { channelId: route.channelId, channelServerId: route.serverId, addressed: true }
5615
- : {};
5672
+ if (route && framework.mayTakeGateRoute(agentName, route)) {
5673
+ return { channelId: route.channelId, channelServerId: route.serverId, addressed: true };
5674
+ }
5675
+ const came = (provenance?.batchChannels ?? [])
5676
+ .map((c) => framework.channelNamed(c.channelId, c.serverId))
5677
+ .filter((c): c is ChannelRef => c !== undefined)
5678
+ .filter((c, i, all) => all.findIndex((d) => sameChannel(d, c)) === i);
5679
+ return came.length > 0 ? { wakeChannels: came } : {};
5616
5680
  })(),
5617
- });
5681
+ };
5682
+ if (source === 'self-wake' || source === 'sleep') request[UNPROMPTED] = true;
5683
+ framework.pendingRequests.push(request);
5618
5684
  },
5619
5685
  getAgentNames: () => framework.registry.gateRecipients(),
5620
5686
  isHeld: (serverId, channelId) =>
@@ -5725,6 +5791,7 @@ export class AgentFramework {
5725
5791
  // Stored for lazy MCPL initialization (connectMcplServer on a framework
5726
5792
  // that started with zero configured servers).
5727
5793
  framework.mcplInferenceRoutingConfig = config.inferenceRouting ?? null;
5794
+ framework.mcplUnpromptedChannel = config.unpromptedChannel ?? null;
5728
5795
 
5729
5796
  // Client-side programmatic tool calling (code_execution). Made only when
5730
5797
  // enabled — everything downstream gates on the field. create() checked
@@ -6285,6 +6352,9 @@ export class AgentFramework {
6285
6352
  });
6286
6353
  // A stopped host must never hang behind its own cooldown.
6287
6354
  step(() => this.cancelProviderAdmission());
6355
+ for (const name of [...(this.budgetHolds?.keys() ?? [])]) {
6356
+ step(() => this.dropBudgetHold(name, { kind: 'shutdown', why: 'the framework is stopping' }));
6357
+ }
6288
6358
  // An open logical turn doesn't survive a stop.
6289
6359
  this.openLogical.clear();
6290
6360
 
@@ -6673,7 +6743,12 @@ export class AgentFramework {
6673
6743
  // A wait recorded while this call was parked binds it too.
6674
6744
  this.refuseHeldProviderModel(agentName, model);
6675
6745
  gate.auxiliaryInFlight++;
6676
- try { return await run(); }
6746
+ let landed = false;
6747
+ try {
6748
+ const result = await run();
6749
+ landed = true;
6750
+ return result;
6751
+ }
6677
6752
  catch (error) {
6678
6753
  // Compression can be the first call to find a spent quota (idle agent,
6679
6754
  // scheduled maintenance). Record a stated wait for this call's model,
@@ -6689,6 +6764,7 @@ export class AgentFramework {
6689
6764
  finally {
6690
6765
  gate.auxiliaryInFlight = Math.max(0, gate.auxiliaryInFlight - 1);
6691
6766
  if (gate.auxiliaryInFlight === 0) for (const resolve of gate.idleWaiters.splice(0)) resolve();
6767
+ if (landed) this.managerCallLanded(agentName);
6692
6768
  }
6693
6769
  }
6694
6770
  /**
@@ -6736,7 +6812,7 @@ export class AgentFramework {
6736
6812
  ? `a turn whose retries ran out, sent again (${cooldown.rearm.number} of ${cooldown.rearm.of})`
6737
6813
  : 'a turn held by a provider cooldown, when it ends' });
6738
6814
  }
6739
- wakes.push(...this.heartbeatWakesFor(agentName));
6815
+ appendAll(wakes, this.heartbeatWakesFor(agentName));
6740
6816
  return wakes;
6741
6817
  }
6742
6818
 
@@ -6926,6 +7002,14 @@ export class AgentFramework {
6926
7002
  private auxiliaryMembraneFor(agentName: string): Membrane {
6927
7003
  return this.membraneAdmitting((run, model) => this.withAuxiliaryAdmission(agentName, run, model));
6928
7004
  }
7005
+ private managerCallLanded(agentName: string): void {
7006
+ const hold = this.budgetHolds?.get(agentName);
7007
+ if (!hold) return;
7008
+ hold.managerCalls++;
7009
+ if (hold.checkQueued) return;
7010
+ const look = setTimeout(() => this.releaseBudgetHoldOnEvidence(agentName), 0);
7011
+ look.unref?.();
7012
+ }
6929
7013
  /**
6930
7014
  * The reply judge's calls. None waits: under the agent's holds a call is
6931
7015
  * refused without being made, and the agent's turn stops the judge. A call
@@ -6978,11 +7062,14 @@ export class AgentFramework {
6978
7062
  this.withdrawMaintenanceUnderSurgery();
6979
7063
  this.withdrawMaintenanceWhenStopping(agentName);
6980
7064
  this.refuseHeldProviderModel(agentName, model);
6981
- try { return await run(); }
7065
+ let result: Awaited<ReturnType<typeof run>>;
7066
+ try { result = await run(); }
6982
7067
  catch (error) {
6983
7068
  if (model !== undefined && error instanceof Error) this.recordProviderWait(agentName, model, error);
6984
7069
  throw error;
6985
7070
  }
7071
+ this.managerCallLanded(agentName);
7072
+ return result;
6986
7073
  });
6987
7074
  }
6988
7075
  private ephemeralAuxiliaryMembraneFor(agentName: string, release: ManagerRelease): Membrane {
@@ -7105,11 +7192,21 @@ export class AgentFramework {
7105
7192
  held.push(request);
7106
7193
  return;
7107
7194
  }
7195
+ // Read the kept request's carry before marking it.
7196
+ const keptCarry = turnCarryOf(same);
7108
7197
  if (isTurnRetry(request)) (same as MarkedRequest)[TURN_RETRY] = true;
7198
+ const wait = budgetWaitOf(request);
7199
+ if (wait && !budgetWaitOf(same)) (same as MarkedRequest)[BUDGET_WAIT] = wait;
7109
7200
  // A merged start's batch (MERGED_BATCH) stays recorded on the request
7110
7201
  // kept in its place, which then stands for it, as the start did.
7111
7202
  const batch = mergedBatch(request);
7112
7203
  if (batch) (same as MarkedRequest)[MERGED_BATCH] = [...new Set([...(mergedBatch(same) ?? []), ...batch])];
7204
+ const carry = keptCarry ?? turnCarryOf(request);
7205
+ if (carry) (same as MarkedRequest)[TURN_CARRY] = carry;
7206
+ else delete (same as MarkedRequest)[TURN_CARRY];
7207
+ if (!isRearmed(request)) delete (same as MarkedRequest)[REARMED];
7208
+ const channels = wakeChannelsOf([same, request]);
7209
+ if (channels) same.wakeChannels = channels;
7113
7210
  }
7114
7211
  private consultProviderHold(error: Error, agentName: string): { holdMs: number; reason: string } | undefined {
7115
7212
  if (!this.providerHoldHook) return undefined;
@@ -7162,7 +7259,7 @@ export class AgentFramework {
7162
7259
  // failure streak), and the wait applies to the calls that follow it.
7163
7260
  if (!acceleration && !hostHold) {
7164
7261
  return wait !== undefined && isMembraneError(error) && error.retryable === true
7165
- && this.holdForProviderWait(agent, wait, trigger ? [asTurnRetry(trigger)] : [], true);
7262
+ && this.holdForProviderWait(agent, wait, trigger ? [this.turnRetryOf(trigger, this.turnRouting.get(agent.name))] : [], true);
7166
7263
  }
7167
7264
  // As in holdForProviderWait: once stop() has closed admission, nothing is
7168
7265
  // held, and nothing started.
@@ -7174,7 +7271,7 @@ export class AgentFramework {
7174
7271
  const held = existing?.heldRequests ?? [];
7175
7272
  // The failed turn's own request, kept as its retry (TURN_RETRY). An
7176
7273
  // auxiliary call's failure passes no trigger: it holds no turn of its own.
7177
- if (trigger) this.holdRequest(held, asTurnRetry(trigger));
7274
+ if (trigger) this.holdRequest(held, this.turnRetryOf(trigger, this.turnRouting.get(agent.name)));
7178
7275
  if (existing) clearTimeout(existing.timer);
7179
7276
  const timer = setTimeout(() => this.releaseProviderAccelerationCooldown(agent.name), Math.min(delayMs, MAX_TIMER_DELAY_MS)); timer.unref?.();
7180
7277
  this.providerAccelerationCooldowns.set(agent.name, { startedAt: existing?.startedAt ?? now,
@@ -7234,6 +7331,8 @@ export class AgentFramework {
7234
7331
  const request: MarkedRequest = { ...trigger, [REARMED]: true };
7235
7332
  delete request[TURN_RETRY];
7236
7333
  delete request[MERGED_BATCH];
7334
+ delete request.wakeChannels;
7335
+ Object.assign(request, this.wakeChannelsMark(routing));
7237
7336
  if (continuation) {
7238
7337
  request.reason = REARM_REASON;
7239
7338
  delete request.counterparty;
@@ -7286,7 +7385,7 @@ export class AgentFramework {
7286
7385
  if (!isMembraneError(error) || typeof error.retryAfterMs !== 'number') return undefined;
7287
7386
  if (this.registry.homeChannel(agent.name) !== undefined) {
7288
7387
  const wait = this.recordProviderWait(agent.name, agent.model, error);
7289
- return error.retryable === true && wait !== undefined && this.holdForProviderWait(agent, wait, trigger ? [asTurnRetry(trigger)] : [], true)
7388
+ return error.retryable === true && wait !== undefined && this.holdForProviderWait(agent, wait, trigger ? [this.turnRetryOf(trigger, this.turnRouting.get(agent.name))] : [], true)
7290
7389
  ? 'held'
7291
7390
  : undefined;
7292
7391
  }
@@ -7306,18 +7405,468 @@ export class AgentFramework {
7306
7405
  const cap = `${PROVIDER_ACCELERATION_MAX_COOLDOWN_MS / 1000} s`;
7307
7406
  if (Number.isNaN(stated)) {
7308
7407
  return {
7309
- error: `${error.message} (the provider's stated wait is not a number, and an ephemeral run sleeps only a ` +
7408
+ error: `${failureWords(error)} (the provider's stated wait is not a number, and an ephemeral run sleeps only a ` +
7310
7409
  `stated wait of ${cap} or less, so it ends now)`,
7311
7410
  statedWaitMs: Number.NaN,
7312
7411
  };
7313
7412
  }
7314
7413
  const asked = Number.isFinite(stated) ? `${Math.ceil(stated / 1000)} s` : 'longer than can be represented';
7315
7414
  return {
7316
- error: `${error.message} (the provider asked to wait ${asked}; an ephemeral run doesn't sleep a stated wait ` +
7415
+ error: `${failureWords(error)} (the provider asked to wait ${asked}; an ephemeral run doesn't sleep a stated wait ` +
7317
7416
  `longer than ${cap}, so it ends now)`,
7318
7417
  statedWaitMs: stated,
7319
7418
  };
7320
7419
  }
7420
+ private holdForBudgetRefusal(
7421
+ agent: Agent,
7422
+ error: Error,
7423
+ retry: InferenceRequest | undefined,
7424
+ checkpoint: RecordedTurnCheckpoint | undefined,
7425
+ routing: TurnRouting,
7426
+ baseline: BudgetHoldBaseline | undefined,
7427
+ closeWindow: () => void,
7428
+ ): boolean {
7429
+ const { errorType } = this.classifyInferenceError(error);
7430
+ if (errorType !== 'over_budget' && errorType !== 'context_refusal') return false;
7431
+ if (!this.budgetHolds || !this.running || this.providerAdmissionClosed) return false;
7432
+ if (this.ephemeralRuns.has(agent.name) || this.agents.get(agent.name) !== agent || this.budgetHolds.has(agent.name)) return false;
7433
+ if (!this.compressionPending(agent)) return false;
7434
+ if (checkpoint !== undefined) {
7435
+ try {
7436
+ this.dropTurnCheckpoint(agent.name, checkpoint);
7437
+ } catch (error) {
7438
+ console.error(`[budget-hold] agent=${agent.name}: its refused start's checkpoint could not be dropped, so the refusal takes the error policy's path:`, error);
7439
+ return false;
7440
+ }
7441
+ }
7442
+ const turn = retry ?? { agentName: agent.name, reason: 'budget-hold-release', source: 'framework', timestamp: Date.now() };
7443
+ const carried = budgetWaitOf(turn);
7444
+ const startedAt = Date.now();
7445
+ const standsOn = !continuesTurn(turn) && carried
7446
+ ? (carried.row !== undefined ? [carried.row] : [])
7447
+ : this.rowsTurnStandsOn(agent, turn, routing);
7448
+ const wait: BudgetWait = {
7449
+ since: carried?.since ?? startedAt,
7450
+ ...(!continuesTurn(turn) && standsOn[0] !== undefined ? { row: standsOn[0] } : {}),
7451
+ ...(carried?.alerted ? { alerted: true } : {}),
7452
+ };
7453
+ (turn as MarkedRequest)[BUDGET_WAIT] = wait;
7454
+ const figures = error as Error & { budget?: unknown; actual?: unknown };
7455
+ const hold: BudgetHold = {
7456
+ startedAt,
7457
+ wait,
7458
+ turn,
7459
+ standsOn,
7460
+ wakes: [],
7461
+ errorType,
7462
+ reason: boundFailureText(String(error.message)),
7463
+ budget: typeof figures.budget === 'number' ? figures.budget : undefined,
7464
+ actual: typeof figures.actual === 'number' ? figures.actual : undefined,
7465
+ managerCalls: 0,
7466
+ progressAtHold: baseline ? baseline.progress : this.compressionProgressOf(agent),
7467
+ settingsAtHold: baseline ? baseline.settings : agent.contextSettingsChanges(),
7468
+ };
7469
+ this.budgetHolds.set(agent.name, hold);
7470
+ this.startBudgetHoldRecheck(agent, hold);
7471
+ closeWindow();
7472
+ const endGate = () => this.eventGate?.onInferenceEnded(agent.name);
7473
+ this.emitTrace({
7474
+ type: 'inference:held',
7475
+ agentName: agent.name,
7476
+ errorType,
7477
+ error: hold.reason,
7478
+ ...(hold.budget !== undefined ? { budget: hold.budget } : {}),
7479
+ ...(hold.actual !== undefined ? { actual: hold.actual } : {}),
7480
+ since: hold.startedAt,
7481
+ heldRequests: 1,
7482
+ });
7483
+ if (this.budgetHolds.get(agent.name) !== hold) {
7484
+ endGate();
7485
+ return true;
7486
+ }
7487
+ if (!wait.alerted) {
7488
+ wait.alerted = true;
7489
+ this.opsAlert('context-refusal', agent.name, this.budgetHoldOpenText(hold), {
7490
+ skipLog: true,
7491
+ key: String(hold.wait.since),
7492
+ data: {
7493
+ errorType,
7494
+ since: hold.wait.since,
7495
+ ...(hold.budget !== undefined ? { budget: hold.budget } : {}),
7496
+ ...(hold.actual !== undefined ? { actual: hold.actual } : {}),
7497
+ },
7498
+ });
7499
+ if (this.budgetHolds.get(agent.name) !== hold) {
7500
+ endGate();
7501
+ return true;
7502
+ }
7503
+ }
7504
+ endGate();
7505
+ if (this.budgetHolds.get(agent.name) !== hold) return true;
7506
+ this.retractUnsentInjections(agent);
7507
+ if (this.budgetHolds.get(agent.name) !== hold) return true;
7508
+ const readiness = this.contextReadiness(agent.getContextManager());
7509
+ const compression = 'pending' in readiness && readiness.pending ? ` Compression: ${boundFailureText(readiness.pending)}` : '';
7510
+ const again = carried ? `, as of a wait that began ${describeWait(startedAt - carried.since)} ago` : '';
7511
+ console.error(`[budget-hold] agent=${agent.name} compile refused (${errorType}): ${hold.reason} \u2014 Held${again}: ` +
7512
+ `the turn waits, idle, and runs once compression makes room.${compression}`);
7513
+ this.driveBudgetHoldSoon(agent);
7514
+ return true;
7515
+ }
7516
+
7517
+ private rowsTurnStandsOn(agent: Agent, turn: InferenceRequest, routing: TurnRouting): string[] {
7518
+ if (!continuesTurn(turn)) {
7519
+ const row = this.activeTurnTriggers.get(agent.name)?.readsRow;
7520
+ return row !== undefined ? [row] : [];
7521
+ }
7522
+ const rows = routing.firstRow !== undefined ? [routing.firstRow] : [];
7523
+ const from = routing.start?.sequence;
7524
+ if (from === undefined) return rows;
7525
+ try {
7526
+ const ownCalls = new Set<string>();
7527
+ for (const row of agent.getContextManager().getPresentedMessages()) {
7528
+ if (!ownWindowRow(row, agent, ownCalls) || row.sequence <= from) continue;
7529
+ if (storedInjectionMarkOf(row.metadata) !== undefined || clockStampOf(row.metadata) !== undefined) continue;
7530
+ rows.push(String(row.id));
7531
+ }
7532
+ } catch (error) {
7533
+ console.error(`[budget-hold] agent=${agent.name}: could not read the rows its held turn stands on, so a surgery checks its first row alone:`, error);
7534
+ }
7535
+ return rows;
7536
+ }
7537
+
7538
+ private compressionPending(agent: Agent): boolean {
7539
+ try {
7540
+ return !agent.getContextManager().isReady();
7541
+ } catch {
7542
+ return false;
7543
+ }
7544
+ }
7545
+
7546
+ private startBudgetHoldRecheck(agent: Agent, hold: BudgetHold): void {
7547
+ clearInterval(hold.recheck);
7548
+ hold.recheck = undefined;
7549
+ if (this.maintenanceIntervalMs > 0) return;
7550
+ hold.recheck = setInterval(() => this.driveBudgetHold(agent), this.budgetHoldRecheckMs);
7551
+ hold.recheck.unref?.();
7552
+ }
7553
+
7554
+ private driveBudgetHoldSoon(agent: Agent): void {
7555
+ const kick = setTimeout(() => this.driveBudgetHold(agent), 0);
7556
+ kick.unref?.();
7557
+ }
7558
+
7559
+ private driveBudgetHold(agent: Agent): void {
7560
+ const hold = this.budgetHolds?.get(agent.name);
7561
+ if (!hold || this.agents.get(agent.name) !== agent || hold.checkQueued) return;
7562
+ if (this.providerGateBlocked(agent.name)) return;
7563
+ const kicked = this.kickOverBudgetDrain(agent.name, agent, () => {
7564
+ hold.checkQueued = false;
7565
+ if (this.budgetHolds.get(agent.name) !== hold) return;
7566
+ if (this.budgetHoldMoved(agent, hold)) this.releaseBudgetHold(agent.name);
7567
+ else if (kicked === 'already draining') this.driveBudgetHold(agent);
7568
+ }, 'hold', hold.drainKicked);
7569
+ if (kicked === 'kicking' || kicked === 'already draining') hold.checkQueued = true;
7570
+ if (kicked === 'kicking') hold.drainKicked = true;
7571
+ }
7572
+
7573
+ private releaseBudgetHolds(): void {
7574
+ for (const name of [...(this.budgetHolds?.keys() ?? [])]) this.releaseBudgetHold(name);
7575
+ }
7576
+
7577
+ private releaseBudgetHoldsOnEvidence(): void {
7578
+ for (const name of [...(this.budgetHolds?.keys() ?? [])]) this.releaseBudgetHoldOnEvidence(name);
7579
+ }
7580
+
7581
+ private releaseBudgetHoldOnEvidence(agentName: string): void {
7582
+ const hold = this.budgetHolds?.get(agentName);
7583
+ const agent = this.agents.get(agentName);
7584
+ if (hold && agent && this.budgetHoldMoved(agent, hold)) this.releaseBudgetHold(agentName);
7585
+ }
7586
+
7587
+ private budgetHoldMoved(agent: Agent, hold: BudgetHold): boolean {
7588
+ if (!this.compressionPending(agent) || agent.contextSettingsChanges() !== hold.settingsAtHold) return true;
7589
+ const progress = this.compressionProgressOf(agent);
7590
+ if (progress !== undefined && hold.progressAtHold !== undefined) return progress !== hold.progressAtHold;
7591
+ if (progress !== undefined) hold.progressAtHold = progress;
7592
+ return hold.managerCalls !== 0;
7593
+ }
7594
+
7595
+ private compressionProgressOf(agent: Agent): string | undefined {
7596
+ const progress = this.contextProgress(agent.getContextManager());
7597
+ if (!progress) return undefined;
7598
+ try {
7599
+ const { chunksCompressed, summaryCounts } = progress;
7600
+ if (chunksCompressed === undefined && summaryCounts === undefined) return undefined;
7601
+ return JSON.stringify([chunksCompressed, summaryCounts]);
7602
+ } catch (error) {
7603
+ console.error(`[budget-hold] agent=${agent.name}: could not read its compression progress, so it reads as none:`, error);
7604
+ return undefined;
7605
+ }
7606
+ }
7607
+
7608
+ private closureWaits(agentName: string): boolean {
7609
+ const closure = (r: InferenceRequest): boolean => r.agentName === agentName
7610
+ && (mergedBatch(r) ?? [r]).some((m) => m.reason === 'conversation:closure');
7611
+ return this.pendingRequests.some(closure)
7612
+ || [...(this.passRequests ?? [])].some((pass) => (pass.get(agentName) ?? []).some(closure))
7613
+ || (this.providerAccelerationCooldowns.get(agentName)?.heldRequests ?? []).some(closure)
7614
+ || (this.budgetHolds?.get(agentName)?.wakes ?? []).some(closure);
7615
+ }
7616
+
7617
+ private releaseBudgetHold(agentName: string): void {
7618
+ const hold = this.budgetHolds?.get(agentName);
7619
+ const agent = this.agents.get(agentName);
7620
+ if (!hold || !agent || this.quiesced || this.surgeryHold) return;
7621
+ const requests = [hold.turn, ...hold.wakes];
7622
+ hold.wakes = [];
7623
+ this.budgetHolds!.delete(agentName);
7624
+ clearInterval(hold.recheck);
7625
+ hold.recheck = undefined;
7626
+ appendAll(this.pendingRequests, requests);
7627
+ console.error(`[budget-hold] agent=${agentName} released after ${Date.now() - hold.startedAt}ms: ` +
7628
+ `${requests.length} request(s) queued, and the start's own compile is the check`);
7629
+ const note = this.storeBudgetHoldNote(agent, hold);
7630
+ if (note !== undefined) this.emitTrace({ type: 'message:added', messageId: note, source: `context-injection:${BUDGET_HOLD_NOTE_NAMESPACE}` });
7631
+ this.finishBudgetHold(agentName, hold, 'released');
7632
+ }
7633
+
7634
+ private storeBudgetHoldNote(agent: Agent, hold: BudgetHold): MessageId | undefined {
7635
+ const size = hold.budget !== undefined && hold.actual !== undefined
7636
+ ? ` (${hold.actual.toLocaleString('en-US')} tokens against ${hold.budget.toLocaleString('en-US')})`
7637
+ : '';
7638
+ const text = `[budget-hold] Your context didn't fit its budget${size} when a turn came to start. ` +
7639
+ `That turn waited ${describeWait(Date.now() - hold.wait.since)}.`;
7640
+ const row = asStoredRow({ namespace: BUDGET_HOLD_NOTE_NAMESPACE, position: 'afterUser', content: [{ type: 'text', text }] });
7641
+ const mark: StoredInjectionMark = { agent: agent.name, namespace: row.namespace, contentHash: unitHash([row]), part: 0, parts: 1 };
7642
+ try {
7643
+ agent.markUnsentPoint();
7644
+ return storeOwnRow(agent, row.participant, row.content, {
7645
+ system: true, kind: STORED_INJECTION_KIND, [STORED_INJECTION_KEY]: mark,
7646
+ } as MessageMetadata, undefined, !this.logicalTurnAlive(agent));
7647
+ } catch (error) {
7648
+ console.error(`[budget-hold] agent=${agent.name}: the note for its released turn could not be stored:`, error);
7649
+ return undefined;
7650
+ }
7651
+ }
7652
+
7653
+ private finishBudgetHold(agentName: string, hold: BudgetHold, outcome: BudgetHoldOutcome): void {
7654
+ if (this.budgetHolds?.get(agentName) === hold) this.budgetHolds.delete(agentName);
7655
+ clearInterval(hold.recheck);
7656
+ hold.recheck = undefined;
7657
+ const waitedMs = Date.now() - hold.startedAt;
7658
+ console.error(`[budget-hold] agent=${agentName} hold ended (${outcome}) after ${waitedMs}ms`);
7659
+ this.emitTrace({ type: 'inference:hold_ended', agentName, outcome, waitedMs });
7660
+ if (outcome === 'released' || !hold.wait.alerted) return;
7661
+ const waitedFor = Date.now() - hold.wait.since;
7662
+ const after = describeWait(waitedFor);
7663
+ const words = outcome === 'removed'
7664
+ ? `what the waiting turn was for was removed or taken out of its window after ${after}, so its waiting start doesn't run`
7665
+ : `the waiting turn was stopped after ${after}, so its waiting start doesn't run`;
7666
+ this.opsAlert('context-refusal-clear', agentName, words, {
7667
+ skipLog: true,
7668
+ key: String(hold.wait.since),
7669
+ data: { outcome, waitedMs: waitedFor },
7670
+ });
7671
+ }
7672
+
7673
+ private budgetHoldOpenText(hold: BudgetHold): string {
7674
+ const head = `compile refused (${hold.errorType}): `;
7675
+ const tail = ' \u2014 the turn waits while compression makes room, and runs once its context fits';
7676
+ return head + boundFailureText(hold.reason, FAILURE_TEXT_MAX_CHARS - head.length - tail.length) + tail;
7677
+ }
7678
+
7679
+ private retractUnsentInjections(agent: Agent): void {
7680
+ if (!this.presents(agent)) return;
7681
+ try {
7682
+ const ids = agent.unsentInjectionRows();
7683
+ if (ids.length === 0) return;
7684
+ agent.getContextManager().retractRows(ids, { undone: true });
7685
+ this.emitTrace({ type: 'presentation:retracted', agentName: agent.name, messageIds: ids, reason: 'unsent-injection' });
7686
+ } catch (error) {
7687
+ console.error(`[budget-hold] agent=${agent.name}: the injection rows its refused start stored could not leave its window:`, error);
7688
+ }
7689
+ }
7690
+
7691
+ private endHeldTurn(agentName: string, turn: InferenceRequest, drop: ContinuationDrop): void {
7692
+ this.endContinuedTurn(agentName, drop, !continuesTurn(turn));
7693
+ }
7694
+
7695
+ private dropBudgetHold(agentName: string, drop: ContinuationDrop, wakes: 'back' | 'gone' = 'gone'): boolean {
7696
+ const hold = this.budgetHolds?.get(agentName);
7697
+ if (!hold) return false;
7698
+ this.budgetHolds.delete(agentName);
7699
+ clearInterval(hold.recheck);
7700
+ hold.recheck = undefined;
7701
+ if (wakes === 'back') appendAll(this.pendingRequests, hold.wakes);
7702
+ hold.wakes = [];
7703
+ try {
7704
+ this.endHeldTurn(agentName, hold.turn, drop);
7705
+ } finally {
7706
+ this.finishBudgetHold(agentName, hold, 'stopped');
7707
+ }
7708
+ return true;
7709
+ }
7710
+
7711
+ private heldContinuation(agentName: string): boolean {
7712
+ const hold = this.budgetHolds?.get(agentName);
7713
+ return hold !== undefined && continuesTurn(hold.turn);
7714
+ }
7715
+
7716
+ private dropHeldTurn(agentName: string, hold: BudgetHold, why: string): void {
7717
+ this.endDetachedHold(agentName, this.detachBudgetHold(agentName, hold), why);
7718
+ }
7719
+
7720
+ /** Take `hold` out of its agent's wait, with no trace yet. */
7721
+ private detachBudgetHold(agentName: string, hold: BudgetHold): BudgetHold {
7722
+ this.budgetHolds.delete(agentName);
7723
+ clearInterval(hold.recheck);
7724
+ hold.recheck = undefined;
7725
+ return hold;
7726
+ }
7727
+
7728
+ /** End a turn detachBudgetHold took out of its wait: its wakes back in the queue, then its end traced. */
7729
+ private endDetachedHold(agentName: string, hold: BudgetHold, why: string): void {
7730
+ appendAll(this.pendingRequests, hold.wakes);
7731
+ hold.wakes = [];
7732
+ try {
7733
+ this.endHeldTurn(agentName, hold.turn, { kind: 'dropped', why, reason: BUDGET_HELD_TURN_REMOVED });
7734
+ } finally {
7735
+ this.finishBudgetHold(agentName, hold, 'removed');
7736
+ }
7737
+ }
7738
+
7739
+ private rowGone(agent: Agent, row: string): boolean {
7740
+ try {
7741
+ return agent.getContextManager().getMessage(row as MessageId) === null;
7742
+ } catch (error) {
7743
+ console.error(`[budget-hold] agent=${agent.name}: could not read whether row ${row} is still there, so what waits on it stays:`, error);
7744
+ return false;
7745
+ }
7746
+ }
7747
+
7748
+ private rowLeftWindow(agent: Agent, row: string): boolean {
7749
+ if (this.rowGone(agent, row)) return true;
7750
+ if (!this.presents(agent)) return false;
7751
+ try {
7752
+ return agent.getContextManager().ordinalOf(row as MessageId) === undefined;
7753
+ } catch (error) {
7754
+ console.error(`[budget-hold] agent=${agent.name}: could not read whether row ${row} is still in its window, so what waits on it stays:`, error);
7755
+ return false;
7756
+ }
7757
+ }
7758
+
7759
+ private budgetWaitEnded(agent: Agent, trigger: InferenceRequest | undefined): boolean {
7760
+ const row = budgetWaitOf(trigger)?.row;
7761
+ if (row === undefined || !this.rowLeftWindow(agent, row)) return false;
7762
+ const batch = mergedBatch(trigger!);
7763
+ if (batch) appendAll(this.pendingRequests, batch.filter((r) => budgetWaitOf(r) === undefined));
7764
+ this.endContinuedTurn(agent.name, { kind: 'dropped', why: 'what its held turn stood on was removed', reason: BUDGET_HELD_TURN_REMOVED }, true);
7765
+ return true;
7766
+ }
7767
+
7768
+ private checkBudgetHoldsAfterWindowChange(only?: string, release = true): void {
7769
+ for (const [agentName, hold] of [...(this.budgetHolds?.entries() ?? [])]) {
7770
+ const agent = this.agents.get(agentName);
7771
+ if (!agent || (only !== undefined && agentName !== only) || this.budgetHolds?.get(agentName) !== hold) continue;
7772
+ const wakes = hold.wakes.filter((wake) => wake.readsRow === undefined || !this.rowGone(agent, wake.readsRow));
7773
+ if (wakes.length < hold.wakes.length) {
7774
+ console.error(`[budget-hold] agent=${agentName}: ${hold.wakes.length - wakes.length} held wake(s) dropped, their rows removed`);
7775
+ }
7776
+ hold.wakes = wakes;
7777
+ if (hold.standsOn.some((row) => this.rowLeftWindow(agent, row))) {
7778
+ this.dropHeldTurn(agentName, hold, 'a surgery or an undo removed what its held turn stood on');
7779
+ } else if (release) {
7780
+ this.releaseBudgetHold(agentName);
7781
+ }
7782
+ }
7783
+ }
7784
+
7785
+ private kickOverBudgetDrain(
7786
+ agentName: string,
7787
+ agent: Agent,
7788
+ after?: () => void,
7789
+ by: 'breaker' | 'hold' = 'breaker',
7790
+ quiet = false,
7791
+ ): 'not kicked: the framework is stopping' | 'not kicked: a surgery holds the store' | 'already draining' | 'kicking' {
7792
+ // Like a maintenance pass: none starts once stop() has begun, it starts
7793
+ // no tick while a surgery holds the store or once its agent has been
7794
+ // disposed, and a surgery, a switchBranch or stop() waits for the tick
7795
+ // in flight. It joins the drains they wait for before the breaker's alert is
7796
+ // raised and before its body runs, so the alert's listeners find it:
7797
+ // one that begins stop() or a surgery waits for it, and its body then
7798
+ // ticks none and says why. So does a stop() made from inside its first
7799
+ // tick, before that tick's first await.
7800
+ if (this.providerAdmissionClosed) return 'not kicked: the framework is stopping';
7801
+ if (this.surgeryHold) return 'not kicked: a surgery holds the store';
7802
+ if (after) {
7803
+ const waiting = this.overBudgetDrainAfter.get(agentName);
7804
+ if (waiting) waiting.push(after);
7805
+ else this.overBudgetDrainAfter.set(agentName, [after]);
7806
+ }
7807
+ if (this.overBudgetDrainInFlight.has(agentName)) return 'already draining';
7808
+ this.overBudgetDrainInFlight.add(agentName);
7809
+ const tag = by === 'hold' ? '[budget-hold]' : '[inference-failed]';
7810
+ const label = by === 'hold' ? 'budget hold' : 'OverBudget breaker';
7811
+ const drain = Promise.resolve().then(async () => {
7812
+ let ticks = 0;
7813
+ let record: ContextMaintenanceAgentRun | undefined;
7814
+ try {
7815
+ const cm = agent.getContextManager();
7816
+ if (by === 'hold') {
7817
+ const readiness = this.contextReadiness(cm);
7818
+ const progress = this.contextProgress(cm);
7819
+ record = {
7820
+ agentName, startedAt: Date.now(), ticks: 0, readyBefore: false,
7821
+ ...('pending' in readiness && readiness.pending ? { pendingBefore: readiness.pending } : {}),
7822
+ ...(progress ? { progressBefore: progress } : {}),
7823
+ };
7824
+ }
7825
+ const holding = () => this.budgetHolds.has(agentName);
7826
+ while ((by === 'hold' ? holding() : ticks < 8) && !this.providerAdmissionClosed && !this.surgeryHold && this.agents.get(agentName) === agent) {
7827
+ const calls = by === 'hold' ? this.budgetHolds.get(agentName)?.managerCalls : undefined;
7828
+ await cm.tick();
7829
+ ticks++;
7830
+ if (by === 'breaker') continue;
7831
+ const held = this.budgetHolds.get(agentName);
7832
+ if (held && this.budgetHoldMoved(agent, held)) {
7833
+ this.releaseBudgetHold(agentName);
7834
+ if (this.quiesced) break;
7835
+ } else if (held?.managerCalls === calls || this.compressionProgressOf(agent) !== undefined) {
7836
+ break;
7837
+ }
7838
+ }
7839
+ if (by === 'hold' && ticks > 0) this.maintenanceFailureReported.delete(agentName);
7840
+ const stopped = by === 'breaker' && ticks >= 8 ? ''
7841
+ : this.agents.get(agentName) !== agent ? ', stopped as the agent was disposed'
7842
+ : this.providerAdmissionClosed ? ', stopped as the framework stops'
7843
+ : this.surgeryHold ? ', stopped while a surgery holds the store'
7844
+ : '';
7845
+ if (!quiet || stopped) console.error(`${tag} drain kicked for ${agentName} (${label}, ${ticks} ticks${stopped})`);
7846
+ } catch (err) {
7847
+ if (err instanceof MaintenanceWithdrawnError) {
7848
+ console.error(`${tag} drain kicked for ${agentName} (${label}, ${ticks} ticks, stopped: ${err.message})`);
7849
+ } else if (record) {
7850
+ record.ticks = ticks;
7851
+ record.finishedAt = Date.now();
7852
+ this.reportMaintenanceFailure(agentName, record, err instanceof Error ? String(err.message) : failureDescription(err));
7853
+ } else {
7854
+ console.error(`${tag} drain kick failed for ${agentName} after ${ticks} ticks: ${describeFailureWithFrames(err)}`);
7855
+ }
7856
+ } finally {
7857
+ this.overBudgetDrainInFlight.delete(agentName);
7858
+ }
7859
+ const waiting = this.overBudgetDrainAfter?.get(agentName);
7860
+ if (waiting) {
7861
+ this.overBudgetDrainAfter.delete(agentName);
7862
+ for (const run of waiting) run();
7863
+ }
7864
+ });
7865
+ this.overBudgetDrains.add(drain);
7866
+ void drain.finally(() => { this.overBudgetDrains.delete(drain); });
7867
+ return 'kicking';
7868
+ }
7869
+
7321
7870
  /** When a hold's timer next fires: at its deadline, stepped past what one
7322
7871
  * timer can hold. A hold with no deadline (a wait held until released, or
7323
7872
  * the stand-in for unreadable records) asks again as often as unreadable
@@ -7370,7 +7919,7 @@ export class AgentFramework {
7370
7919
  if (recovery) { recovery.releasedAt = Date.now(); recovery.heldRequests = cooldown.heldRequests.length; }
7371
7920
  const requests = cooldown.heldRequests.length > 0 ? cooldown.heldRequests : [{ agentName,
7372
7921
  reason: 'provider-acceleration-retry', source: 'framework', timestamp: Date.now() } satisfies InferenceRequest];
7373
- this.pendingRequests.push(...requests);
7922
+ appendAll(this.pendingRequests, requests);
7374
7923
  console.error(cooldown.rearm
7375
7924
  ? `[inference-rearm] agent=${agentName} turn queued again (${cooldown.rearm.number} of ${cooldown.rearm.of})`
7376
7925
  : `[provider-cooldown] agent=${agentName} released after ${Date.now() - cooldown.startedAt}ms — ` +
@@ -7523,7 +8072,7 @@ export class AgentFramework {
7523
8072
  // (releaseProviderWait) before anything was held: nothing binds now, so
7524
8073
  // the requests start, as the release of an installed hold starts them.
7525
8074
  if (report && !this.providerWaits?.active(agent.name, wait.model)) {
7526
- this.pendingRequests.push(...requests);
8075
+ appendAll(this.pendingRequests, requests);
7527
8076
  return true;
7528
8077
  }
7529
8078
  const existing = this.providerAccelerationCooldowns.get(agent.name);
@@ -7705,6 +8254,7 @@ export class AgentFramework {
7705
8254
  this.maintenancePass = pass;
7706
8255
  void pass.finally(() => {
7707
8256
  if (this.maintenancePass === pass) this.maintenancePass = null;
8257
+ this.releaseBudgetHoldsOnEvidence();
7708
8258
  });
7709
8259
  begin(this.runQueuedMaintenance().catch((error) => {
7710
8260
  // The alert cuts its own copies from the whole message, so the
@@ -8242,6 +8792,9 @@ export class AgentFramework {
8242
8792
  this.dropContinuations(agentName, { kind: 'caller', reason, why: 'a stop ended the turn' });
8243
8793
  return true;
8244
8794
  }
8795
+ if (!result && this.budgetHolds?.has(agentName)) {
8796
+ return this.dropBudgetHold(agentName, { kind: 'caller', reason, why: 'a stop ended the turn' }, 'back');
8797
+ }
8245
8798
  return !!result;
8246
8799
  }
8247
8800
 
@@ -8370,6 +8923,7 @@ export class AgentFramework {
8370
8923
  log({
8371
8924
  success: false,
8372
8925
  error: `Stream cancelled (${reason})`,
8926
+ errorWords: `Stream cancelled (${reason})`,
8373
8927
  request: compiledRequest ?? { note: 'streaming request aborted' },
8374
8928
  durationMs,
8375
8929
  });
@@ -8450,6 +9004,7 @@ export class AgentFramework {
8450
9004
  log({
8451
9005
  success: false,
8452
9006
  error: `Stream aborted: ${kind}`,
9007
+ errorWords: `Stream aborted: ${kind}`,
8453
9008
  request: compiledRequest ?? { note: `streaming request aborted by ${kind}` },
8454
9009
  durationMs,
8455
9010
  });
@@ -9115,7 +9670,7 @@ export class AgentFramework {
9115
9670
  const extKeys: string[] = [];
9116
9671
  for (const ext of extensions.values()) {
9117
9672
  Object.assign(extProperties, ext.properties);
9118
- extKeys.push(...ext.keys);
9673
+ appendAll(extKeys, ext.keys);
9119
9674
  }
9120
9675
  const settingsProp = baseSchema.properties.settings as { items?: { enum?: string[] } };
9121
9676
  return {
@@ -9505,7 +10060,9 @@ export class AgentFramework {
9505
10060
  ...this.pendingRequests,
9506
10061
  ...[...this.passRequests].flatMap((pass) => [...pass.values()].flat()),
9507
10062
  ...[...this.providerAccelerationCooldowns.values()].flatMap((c) => c.heldRequests),
9508
- ].flatMap((r) => mergedBatch(r) ?? [r]).filter((r) => !passesQuiesce(r)).length,
10063
+ ].flatMap((r) => mergedBatch(r) ?? [r]).filter((r) => !passesQuiesce(r)).length
10064
+ + [...(this.budgetHolds?.values() ?? [])].reduce((n, h) =>
10065
+ n + 1 + h.wakes.flatMap((r) => mergedBatch(r) ?? [r]).filter((r) => !passesQuiesce(r)).length, 0),
9509
10066
  backgroundScripts: this.codeExecution?.scripts.leftRunning() ?? 0,
9510
10067
  // Deprecated (#291): no queue is kept. What a live surgery's hold
9511
10068
  // keeps in memory, to land at its release, and what waits in memory
@@ -9900,6 +10457,7 @@ export class AgentFramework {
9900
10457
  console.error(this.queue?.isClosed
9901
10458
  ? `[host-mode] resumed — ${releasedRequests} parked wake(s) won't run because the framework is stopping`
9902
10459
  : `[host-mode] resumed — ${releasedRequests} parked wake(s) released`);
10460
+ this.releaseBudgetHolds();
9903
10461
  }
9904
10462
  return this.getHostModeStatus();
9905
10463
  }
@@ -11429,10 +11987,12 @@ export class AgentFramework {
11429
11987
  store: this.store,
11430
11988
  namespace,
11431
11989
  isolate: true,
11990
+ viewFilter: (message) => !isReasoningLoss(message.metadata),
11432
11991
  strategy: config.strategy ?? new PassthroughStrategy(),
11433
11992
  membrane: this.ephemeralAuxiliaryMembraneFor(config.name, release),
11434
11993
  debugLogContext: !!process.env.DEBUG_CONTEXT,
11435
11994
  });
11995
+ this.storeSees.set(contextManager, (metadata) => !isReasoningLoss(metadata));
11436
11996
  ownSlot = { namespace };
11437
11997
  }
11438
11998
  // Either manager is the run's to release (main's manager release), the derived one included.
@@ -11842,7 +12402,8 @@ export class AgentFramework {
11842
12402
  * The returned manager uses a passthrough strategy, so nothing is
11843
12403
  * folded: its compile is the agent's window as the agent's view shows it
11844
12404
  * (the rows the window holds, through its view filter), and in legacy
11845
- * mode, where the window is the record, the record so. getAllMessages()
12405
+ * mode, where the window is the record, the record so. These compiled
12406
+ * views omit legacy reasoning-loss notices. getAllMessages()
11846
12407
  * lists the record. An ended agent's context is the real one — treat it as
11847
12408
  * read-only unless you mean to change what a later resume starts from.
11848
12409
  */
@@ -11966,9 +12527,9 @@ export class AgentFramework {
11966
12527
  * and what it observes (context-options.ts): a resident's, on the
11967
12528
  * residents' slot, or for an isolated agent that watches that slot, the
11968
12529
  * residents' view when its edge leaves held traffic out, and the
11969
- * subconscious's when it shows it. Undefined for an isolated agent that
12530
+ * subconscious's when it shows it. For an isolated agent that
11970
12531
  * watches no residents' slot on its branch (a worker, a conversation
11971
- * fork), whose context was opened with no filter.
12532
+ * fork), whose view excludes only legacy reasoning-loss notices.
11972
12533
  */
11973
12534
  private liveViewFilterOf(record: AgentRecord): ((message: StoredMessage) => boolean) | undefined {
11974
12535
  if (record.ownSlot?.namespace === null) return residentViewFilter(record.name);
@@ -11976,7 +12537,7 @@ export class AgentFramework {
11976
12537
  const slot = this.registry.observedSlotOf(edge);
11977
12538
  return slot !== undefined && slot.branch === undefined && sameSlot(slot, RESIDENTS_SLOT);
11978
12539
  });
11979
- if (residents.length === 0) return undefined;
12540
+ if (residents.length === 0) return (message) => !isReasoningLoss(message.metadata);
11980
12541
  return residents.some((edge) => edge.includeHeld === true) ? subconsciousViewFilter(record.name) : residentViewFilter(record.name);
11981
12542
  }
11982
12543
 
@@ -12003,7 +12564,7 @@ export class AgentFramework {
12003
12564
  * (deriveSharedContext): what the agent its chain of derivations started
12004
12565
  * from sees (liveViewFilterOf), and the stored injections of each agent in
12005
12566
  * the chain, which carry that agent's name and would otherwise read as
12006
- * another's. Undefined when the owner's context has no filter.
12567
+ * another's. Legacy notices remain excluded even for an own injection.
12007
12568
  */
12008
12569
  private derivedViewFilterOf(record: AgentRecord): ((message: StoredMessage) => boolean) | undefined {
12009
12570
  const owner = this.viewOwnerOf(record);
@@ -12018,7 +12579,8 @@ export class AgentFramework {
12018
12579
  current = parent;
12019
12580
  }
12020
12581
  if (names.length === 0) return ownerSees;
12021
- return (message) => ownerSees(message) || names.some((name) => isOwnInjection(message.metadata, name));
12582
+ return (message) => !isReasoningLoss(message.metadata)
12583
+ && (ownerSees(message) || names.some((name) => isOwnInjection(message.metadata, name)));
12022
12584
  }
12023
12585
 
12024
12586
  /** Open a derived agent's context on a branch of its own (see deriveAgent). */
@@ -12065,13 +12627,11 @@ export class AgentFramework {
12065
12627
  // reads through it, and is recorded beside the manager (storeSees), so
12066
12628
  // main's checks of whether an agent sees a row (agentSees: a tool
12067
12629
  // boundary's injection, an arrival, a reader of an unread message) ask
12068
- // what its compile shows. A source with no view recorded has no filter
12069
- // (an isolated run's): it sees every row, so its fork keeps none, and
12070
- // sees its own.
12630
+ // what its compile shows. The global legacy-notice exclusion applies
12631
+ // even when the source has no additional view recorded.
12071
12632
  const sourceSees = this.storeSees.get(source);
12072
- const sees = sourceSees
12073
- ? (metadata: unknown): boolean => sourceSees(metadata) || isOwnInjection(metadata, config.name)
12074
- : undefined;
12633
+ const sees = (metadata: unknown): boolean => !isReasoningLoss(metadata)
12634
+ && (!sourceSees || sourceSees(metadata) || isOwnInjection(metadata, config.name));
12075
12635
  const contextManager = await source.derive({
12076
12636
  branch: ownBranch,
12077
12637
  strategy: config.strategy ?? new PassthroughStrategy(),
@@ -12081,10 +12641,10 @@ export class AgentFramework {
12081
12641
  solve: inherit.solve ?? 'reuse',
12082
12642
  inherit: { refusals: inherit.refusals === true },
12083
12643
  ...(inherit.atSequence !== undefined ? { atSequence: inherit.atSequence } : {}),
12084
- ...(sees ? { viewFilter: (message: StoredMessage) => sees(message.metadata) } : {}),
12644
+ viewFilter: (message) => sees(message.metadata),
12085
12645
  debugLogContext: !!process.env.DEBUG_CONTEXT,
12086
12646
  });
12087
- if (sees) this.storeSees.set(contextManager, sees);
12647
+ this.storeSees.set(contextManager, sees);
12088
12648
  const derivation = (contextManager as ContextManager & Partial<DerivingContextManager>).getDerivation?.();
12089
12649
  return {
12090
12650
  contextManager,
@@ -12633,6 +13193,7 @@ export class AgentFramework {
12633
13193
  this.closingConversationAgents.delete(name);
12634
13194
  // A continuation of its turn still on its way goes with it.
12635
13195
  this.dropContinuations(name, { kind: 'dropped', why });
13196
+ this.dropBudgetHold(name, { kind: 'dropped', why });
12636
13197
  // A batch its guard still holds is settled as `aborted`: the stream's
12637
13198
  // own finally does neither once the agent has gone.
12638
13199
  if (agent.toolResultGuard.hasPending) {
@@ -13303,6 +13864,7 @@ export class AgentFramework {
13303
13864
  stopReason: AgentSettleResult['stopReason'];
13304
13865
  speech: string;
13305
13866
  error?: string;
13867
+ cause?: Error;
13306
13868
  /** An ephemeral run that ended rather than sleep the provider's stated
13307
13869
  * wait: that wait (statedWaitNotSlept). */
13308
13870
  statedWaitMs?: number;
@@ -13346,9 +13908,10 @@ export class AgentFramework {
13346
13908
  if (outcome.stopReason === 'exhausted') {
13347
13909
  // The caller may log or relay the message, which can echo the whole request.
13348
13910
  const message = boundFailureText(outcome.error ?? 'Unknown error');
13911
+ const options = outcome.cause !== undefined ? { cause: boundedCause(outcome.cause) } : undefined;
13349
13912
  run.settle.reject(outcome.statedWaitMs === undefined
13350
- ? new Error(message)
13351
- : new ProviderWaitNotSleptError(message, outcome.statedWaitMs));
13913
+ ? new Error(message, options)
13914
+ : new ProviderWaitNotSleptError(message, outcome.statedWaitMs, options));
13352
13915
  return;
13353
13916
  }
13354
13917
  run.settle.resolve({
@@ -13367,7 +13930,19 @@ export class AgentFramework {
13367
13930
  * Rejects with ProviderWaitNotSleptError when the run ends rather than sleep
13368
13931
  * a retryable failure's stated wait longer than the framework's ten-minute
13369
13932
  * cap, or one it can't represent, and with a plain Error for any other
13370
- * ending, a stated wait its error policy declines among them.
13933
+ * ending, a stated wait its error policy declines among them. A failed
13934
+ * turn's rejection never carries a provider's message: a provider's failure
13935
+ * is told by membrane's type and the HTTP status, a safety failure only as
13936
+ * a request that couldn't be completed, a progress bound's cut, a stream's
13937
+ * abort and context-manager's refusal of a compile in their own words, and
13938
+ * any other failure as the turn failing. Where an error ended the turn, the
13939
+ * rejection's `cause` is a bounded copy of it: a plain `Error` with its
13940
+ * `name`, its message and stack cut to 2,000 characters, and, from a
13941
+ * MembraneError, its `type`, `retryable`, `httpStatus`, `providerErrorCode`
13942
+ * and `retryAfterMs`, which a host reads by name. It carries no `rawError`
13943
+ * or `rawRequest`, and `instanceof MembraneError` doesn't hold on it. Its
13944
+ * message and stack are the failure's own, a provider's words included, for
13945
+ * classifying the failure and for logs, and aren't for showing to an agent.
13371
13946
  *
13372
13947
  * The caller provides a pre-created agent + contextManager (from createEphemeralAgent).
13373
13948
  * The task message should already be in the context manager.
@@ -14432,7 +15007,7 @@ export class AgentFramework {
14432
15007
  const turnMembers = all.slice(turn.from, turn.to + 1);
14433
15008
  if (turnMembers.some((m) => hasBlock(m, 'tool_use'))) {
14434
15009
  from = turn.from;
14435
- removedIds.push(...turnMembers.map((m) => m.id).reverse());
15010
+ appendAll(removedIds, turnMembers.map((m) => m.id).reverse());
14436
15011
  heads.unshift(all[turn.from]!.id);
14437
15012
  }
14438
15013
  }
@@ -14475,17 +15050,10 @@ export class AgentFramework {
14475
15050
  const cm = agent.getContextManager();
14476
15051
  const ep = this.rewindEpisode.get(agent.name);
14477
15052
  const count = (ep?.count ?? 0) + 1;
14478
- const why = cause === 'refusal'
14479
- ? `the model refused on them (content filter: ${failureShown(category)}). Their content is ` +
14480
- `not reproduced here (so this note can't re-trigger the filter); the ` +
14481
- `originals remain in the raw record`
14482
- : `the model API kept refusing the requests that carried them (${failureShown(category)}), ` +
14483
- `so that the conversation could go on. Their content is not reproduced ` +
14484
- `here (so this note can't trigger the same refusal); the originals remain ` +
14485
- `in the raw record`;
14486
15053
  const text =
14487
15054
  `[${NOTICE_TAG.refusalRewind}] The ${count} most recent turn(s) were set aside because ` +
14488
- `${why}. You are clear to continue — if you ` +
15055
+ `the requests that carried them couldn't be completed. Their content isn't shown here, ` +
15056
+ `and the originals remain in the raw record. You are clear to continue — if you ` +
14489
15057
  `were mid-task, take a different approach; otherwise carry on with whatever ` +
14490
15058
  `is now in front of you, or briefly acknowledge the gap and ask what's next.`;
14491
15059
  const blocks: ContentBlock[] = [{ type: 'text', text }];
@@ -14533,10 +15101,10 @@ export class AgentFramework {
14533
15101
  : '';
14534
15102
  const content = success
14535
15103
  ? `🔧 Unstuck **${agentName}** — shed ${n} turn(s); the model responded.${list}`
14536
- : `⚠️ Couldn't unstick **${agentName}** — still refusing after ${n} rewind(s)` +
14537
- `${category ? ` (category=${category})` : ''}.${list}`;
15104
+ : `⚠️ Couldn't unstick **${agentName}** — its requests still couldn't be completed after ${n} rewind(s).${list}`;
14538
15105
  console.error(
14539
- `[unstick] agent=${agentName} ${success ? 'succeeded' : 'gave up'} after ${n} rewind(s)`,
15106
+ `[unstick] agent=${agentName} ${success ? 'succeeded' : 'gave up'} after ${n} rewind(s)` +
15107
+ `${category ? ` category=${logValue(category)}` : ''}`,
14540
15108
  );
14541
15109
  if (s.channelId) void this.postToChannel(s.serverId, s.channelId, content);
14542
15110
  }
@@ -14573,8 +15141,9 @@ export class AgentFramework {
14573
15141
  /** Agent state at queue time — 'idle' means no turn holds the agent, so
14574
15142
  * the turn starts on the next scheduler pass unless a provider hold (a
14575
15143
  * provider's stated wait, or a capacity cooldown) holds it until the hold
14576
- * ends, or a start of the agent's that waits for an auxiliary call to
14577
- * finish goes first; anything else means it runs after the current turn. */
15144
+ * ends, or a budget hold keeps it until compression makes room, or a
15145
+ * start of the agent's that waits for an auxiliary call to finish goes
15146
+ * first; anything else means it runs after the current turn. */
14578
15147
  agentStatus?: string;
14579
15148
  /** True when the host is quiesced: the wake is PARKED, not running, until resume(). */
14580
15149
  quiesced?: boolean;
@@ -15225,6 +15794,7 @@ export class AgentFramework {
15225
15794
  requestId: entry.requestId,
15226
15795
  success: entry.success,
15227
15796
  error: entry.error,
15797
+ errorWords: entry.errorWords,
15228
15798
  durationMs: entry.durationMs,
15229
15799
  tokenUsage: entry.tokenUsage,
15230
15800
  stopReason: entry.stopReason,
@@ -15571,7 +16141,7 @@ export class AgentFramework {
15571
16141
  let failures: ManagerReinitFailure[] = [];
15572
16142
  try {
15573
16143
  this.subconsciousAnchor?.branchMoved(this.tuneOutCoordinator?.activeEpochIds());
15574
- this.tuneOutCoordinator?.branchMoved();
16144
+ this.tuneOutCoordinator?.branchMoved(sameBranch);
15575
16145
  if (!sameBranch) this.redoStacks.clear();
15576
16146
  failures = await this.reinitializeManagers(except);
15577
16147
  // The agent the move was made through initialized its own manager,
@@ -15700,6 +16270,27 @@ export class AgentFramework {
15700
16270
  fromBranch?: string;
15701
16271
  toBranch?: string;
15702
16272
  window?: { retracted: number; represented: number };
16273
+ } {
16274
+ // A turn under way that waits for compression stands on what the undo
16275
+ // takes out. It leaves its wait first, so the undo finds the agent idle,
16276
+ // and it ends once the undo has kept its record, so a listener of its
16277
+ // end finds the undo done.
16278
+ const held = this.budgetHolds?.get(agent.name);
16279
+ const taken = held && continuesTurn(held.turn) && !this.hasTurn(agent.name) && !this.turnAlive(agent)
16280
+ ? this.detachBudgetHold(agent.name, held) : undefined;
16281
+ try {
16282
+ return this.undoTurnOnWindow(agent, requester);
16283
+ } finally {
16284
+ if (taken) this.endDetachedHold(agent.name, taken, 'an undo took out what its held turn stood on');
16285
+ }
16286
+ }
16287
+
16288
+ private undoTurnOnWindow(agent: Agent, requester?: OperatorRequester): {
16289
+ undone: boolean;
16290
+ turnIndex?: number;
16291
+ fromBranch?: string;
16292
+ toBranch?: string;
16293
+ window?: { retracted: number; represented: number };
15703
16294
  } {
15704
16295
  const agentName = agent.name;
15705
16296
  // A turn alive (its start, its stream, its tools, a continuation or a
@@ -15746,6 +16337,7 @@ export class AgentFramework {
15746
16337
  }
15747
16338
  this.emitTrace({ type: 'window:undone', agentName, retracted: 0 });
15748
16339
  record(window);
16340
+ this.checkBudgetHoldsAfterWindowChange(agentName);
15749
16341
  return { undone: true, turnIndex: checkpoint.turnIndex, fromBranch: branch, toBranch: branch, window };
15750
16342
  }
15751
16343
  const entry: RedoEntry = {
@@ -15775,6 +16367,7 @@ export class AgentFramework {
15775
16367
  const reason = error instanceof Error ? error.message : String(error);
15776
16368
  this.emitTrace({ type: 'window:undone', agentName, retracted: left });
15777
16369
  record({ retracted: left, represented: 0 }, reason);
16370
+ this.checkBudgetHoldsAfterWindowChange(agentName, false);
15778
16371
  throw new OperatorActionError(
15779
16372
  'failed',
15780
16373
  `Undo of ${agentName}'s turn ${checkpoint.turnIndex} was cut short: ${left} of the ${tail.length} rows it takes out of the window left it ` +
@@ -15798,6 +16391,7 @@ export class AgentFramework {
15798
16391
  const reason = error instanceof Error ? error.message : String(error);
15799
16392
  this.emitTrace({ type: 'window:undone', agentName, retracted: tail.length });
15800
16393
  record(window, reason);
16394
+ this.checkBudgetHoldsAfterWindowChange(agentName, false);
15801
16395
  const rows = tail.length === 1 ? '1 row' : `${tail.length} rows`;
15802
16396
  throw new OperatorActionError(
15803
16397
  'failed',
@@ -15808,6 +16402,7 @@ export class AgentFramework {
15808
16402
  }
15809
16403
  this.emitTrace({ type: 'window:undone', agentName, retracted: tail.length });
15810
16404
  record(window);
16405
+ this.checkBudgetHoldsAfterWindowChange(agentName);
15811
16406
  return { undone: true, turnIndex: checkpoint.turnIndex, fromBranch: branch, toBranch: branch, window };
15812
16407
  }
15813
16408
 
@@ -16007,7 +16602,7 @@ export class AgentFramework {
16007
16602
  this.retakeMarks(agent, cm, entry, back, below);
16008
16603
  const ordinals = back.map((id) => cm.ordinalOf(id)).filter((ordinal): ordinal is number => ordinal !== undefined);
16009
16604
  const boundary = {
16010
- indexLengthBefore: ordinals.length > 0 ? Math.min(...ordinals) : cm.indexLength(),
16605
+ indexLengthBefore: ordinals.length > 0 ? ordinals.reduce((min, ordinal) => Math.min(min, ordinal), Infinity) : cm.indexLength(),
16011
16606
  windowBranch: cm.currentBranch().name,
16012
16607
  };
16013
16608
  const checkpoints = this.getTurnCheckpoints(agentName);
@@ -16290,6 +16885,7 @@ export class AgentFramework {
16290
16885
  // turn's end (a rollback's returned arrivals wait: holdUntilStart).
16291
16886
  this.releaseSurgeryHeldWrites();
16292
16887
  for (const name of reserved.keys()) this.presentAtEndOf(this.agents.get(name));
16888
+ this.checkBudgetHoldsAfterWindowChange();
16293
16889
  };
16294
16890
  }
16295
16891
 
@@ -16345,14 +16941,16 @@ export class AgentFramework {
16345
16941
  `[surgery] agent=${agentName}: its context manager is re-initialized, so the ${writes.length} write(s) kept in ` +
16346
16942
  "memory for it while it refused its work go to the move's release, ahead of the writes its hold keeps",
16347
16943
  );
16348
- recovered.push(...writes);
16944
+ appendAll(recovered, writes);
16349
16945
  }
16350
16946
  if (recovered.length === 0) return;
16351
16947
  // In arrival order across the agents, as each agent's are: writes for
16352
16948
  // two agents whose slot one record holds (residents) land there in the
16353
16949
  // order they came.
16354
16950
  recovered.sort((x, y) => x.heldOrder - y.heldOrder);
16355
- this.surgeryHeldWrites.unshift(...recovered);
16951
+ const later = this.surgeryHeldWrites.splice(0);
16952
+ appendAll(this.surgeryHeldWrites, recovered);
16953
+ appendAll(this.surgeryHeldWrites, later);
16356
16954
  if (!this.surgeryHold) this.releaseSurgeryHeldWrites();
16357
16955
  }
16358
16956
 
@@ -16419,7 +17017,7 @@ export class AgentFramework {
16419
17017
  return found;
16420
17018
  };
16421
17019
  for (const [agentName, writes] of this.heldForRecovery ?? []) {
16422
- entry(agentName).recovery.push(...writes.filter((write) => !told.has(write)));
17020
+ appendAll(entry(agentName).recovery, writes.filter((write) => !told.has(write)));
16423
17021
  }
16424
17022
  for (const write of this.surgeryHeldWrites ?? []) {
16425
17023
  if (told.has(write)) continue;
@@ -17866,7 +18464,7 @@ export class AgentFramework {
17866
18464
  // Turn checkpoint internals
17867
18465
  // ==========================================================================
17868
18466
 
17869
- private recordTurnCheckpoint(agentName: string): void {
18467
+ private recordTurnCheckpoint(agentName: string): RecordedTurnCheckpoint {
17870
18468
  const turnIndex = this.turnCounters.get(agentName) ?? 0;
17871
18469
  this.turnCounters.set(agentName, turnIndex + 1);
17872
18470
 
@@ -17889,11 +18487,20 @@ export class AgentFramework {
17889
18487
  checkpoints.push(checkpoint);
17890
18488
 
17891
18489
  // Trim to max depth
17892
- if (checkpoints.length > MAX_TURN_CHECKPOINTS) {
17893
- checkpoints.splice(0, checkpoints.length - MAX_TURN_CHECKPOINTS);
17894
- }
18490
+ const evicted = checkpoints.length > MAX_TURN_CHECKPOINTS
18491
+ ? checkpoints.splice(0, checkpoints.length - MAX_TURN_CHECKPOINTS)
18492
+ : [];
17895
18493
 
17896
18494
  this.saveTurnCheckpoints(agentName, checkpoints);
18495
+ return { turnIndex, evicted };
18496
+ }
18497
+
18498
+ private dropTurnCheckpoint(agentName: string, recorded: RecordedTurnCheckpoint): void {
18499
+ const checkpoints = this.getTurnCheckpoints(agentName);
18500
+ if (checkpoints.at(-1)?.turnIndex !== recorded.turnIndex) return;
18501
+ checkpoints.pop();
18502
+ this.saveTurnCheckpoints(agentName, recorded.evicted.concat(checkpoints));
18503
+ this.turnCounters.set(agentName, recorded.turnIndex);
17897
18504
  }
17898
18505
 
17899
18506
  /**
@@ -18830,7 +19437,7 @@ export class AgentFramework {
18830
19437
  // reads the time it's written (clock-rows.ts).
18831
19438
  const clock = injects ? this.storeDueClock(agent, 'tool-boundary') : undefined;
18832
19439
  if (presentsWindow) {
18833
- heard.push(...this.presentation!.presentAtBoundary(agent, [replyId, resultsId, clock?.id], { injects }).heard);
19440
+ appendAll(heard, this.presentation!.presentAtBoundary(agent, [replyId, resultsId, clock?.id], { injects }).heard);
18834
19441
  }
18835
19442
  // The clock's store and the boundary's presentations run their
18836
19443
  // listeners synchronously too, and one may have stopped the turn,
@@ -19115,6 +19722,7 @@ export class AgentFramework {
19115
19722
  timestamp: Date.now(),
19116
19723
  suppressProse: this.activeTurnTriggers.get(agent.name)?.suppressProse,
19117
19724
  silentHeartbeat: this.activeTurnTriggers.get(agent.name)?.silentHeartbeat,
19725
+ ...this.wakeChannelsMark(this.turnRouting.get(agent.name)),
19118
19726
  }, currentState.stream ? this.streamCarries.get(currentState.stream)?.() : undefined);
19119
19727
  this.pendingRequests.push(continuation);
19120
19728
  // Context budget exceeded: break the stream, let compile() compress.
@@ -19252,6 +19860,7 @@ export class AgentFramework {
19252
19860
  timestamp: Date.now(),
19253
19861
  suppressProse: this.activeTurnTriggers.get(agent.name)?.suppressProse,
19254
19862
  silentHeartbeat: this.activeTurnTriggers.get(agent.name)?.silentHeartbeat,
19863
+ ...this.wakeChannelsMark(this.turnRouting.get(agent.name)),
19255
19864
  });
19256
19865
  }
19257
19866
  }
@@ -20100,6 +20709,7 @@ export class AgentFramework {
20100
20709
  store: this.store,
20101
20710
  namespace: `conversations/${name}`,
20102
20711
  isolate: true,
20712
+ viewFilter: (message) => !isReasoningLoss(message.metadata),
20103
20713
  // Strategy instances are stateful — never share the template's.
20104
20714
  strategy: router.strategyFactory?.() ?? new PassthroughStrategy(),
20105
20715
  // Provider cooldown ownership belongs to the persistent resident only;
@@ -20110,6 +20720,7 @@ export class AgentFramework {
20110
20720
  });
20111
20721
  release.manager = contextManager;
20112
20722
  this.trackManager(contextManager);
20723
+ this.storeSees.set(contextManager, (metadata) => !isReasoningLoss(metadata));
20113
20724
 
20114
20725
  // Seed with the template's compiled context, renaming the template
20115
20726
  // participant so the fork reads its inheritance as its own history.
@@ -20374,6 +20985,7 @@ export class AgentFramework {
20374
20985
  this.thinkingLossIncidents.forget(agentName);
20375
20986
  // A continuation of its turn still on its way goes with it.
20376
20987
  this.dropContinuations(agentName, { kind: 'dropped', why: 'its agent was disposed' });
20988
+ this.dropBudgetHold(agentName, { kind: 'dropped', why: 'its agent was disposed' });
20377
20989
  this.turnRouting.delete(agentName);
20378
20990
  this.proseSpeechChains.delete(agentName);
20379
20991
  this.openLogical.delete(agentName);
@@ -20483,8 +21095,8 @@ export class AgentFramework {
20483
21095
 
20484
21096
  // Fallback reaper: a closing fork whose closure inference never ran
20485
21097
  // (request dropped as stale, inference policy veto) would otherwise stay
20486
- // registered forever. No turn, no active stream and no pending request =
20487
- // done. A turn holds its token from dequeue, through its hooks and
21098
+ // registered forever. No turn, no active stream, no pending request, no
21099
+ // closure wake waiting and no budget hold = done. A turn holds its token from dequeue, through its hooks and
20488
21100
  // compile, before its stream registers, so a fork mid-compile is busy;
20489
21101
  // and a turn is alive while its continuation or a provider hold's retry
20490
21102
  // of it is on its way (hasTurn), so a closure turn held for its retry
@@ -20493,7 +21105,9 @@ export class AgentFramework {
20493
21105
  if (
20494
21106
  !this.hasTurn(agentName) &&
20495
21107
  !this.activeStreams.has(agentName) &&
20496
- !this.pendingRequests.some((r) => r.agentName === agentName)
21108
+ !this.pendingRequests.some((r) => r.agentName === agentName) &&
21109
+ !this.closureWaits(agentName) &&
21110
+ !this.budgetHolds?.has(agentName)
20497
21111
  ) {
20498
21112
  this.disposeConversationAgent(agentName, 'idle-ttl');
20499
21113
  }
@@ -21449,7 +22063,7 @@ export class AgentFramework {
21449
22063
  eventType: 'mcpl:push-event',
21450
22064
  ...(occ.tags ? { tags: occ.tags } : {}),
21451
22065
  },
21452
- }, { suppressible, ...remembered });
22066
+ }, { suppressible, ...remembered, folds: true });
21453
22067
  // Its batch's row, written later, reads this decision, since a rule's
21454
22068
  // script answers once per event. (A copy of the occurrence, as a wake to
21455
22069
  // read a row has, was decided before it was copied.)
@@ -21522,6 +22136,7 @@ export class AgentFramework {
21522
22136
  };
21523
22137
  for (const r of this.pendingRequests) unrecord(r);
21524
22138
  for (const cooldown of this.providerAccelerationCooldowns.values()) cooldown.heldRequests.forEach(unrecord);
22139
+ for (const hold of this.budgetHolds?.values() ?? []) hold.wakes.forEach(unrecord);
21525
22140
  for (const r of this.parkedContinuations ?? []) unrecord(r);
21526
22141
  for (const pass of this.passRequests ?? []) for (const requests of pass.values()) requests.forEach(unrecord);
21527
22142
  const withdrawn = (r: InferenceRequest): boolean => r.coalescingSubject === subject || emptied.has(r);
@@ -21531,12 +22146,13 @@ export class AgentFramework {
21531
22146
  cooldown.heldRequests = cooldown.heldRequests.filter((r) => !withdrawn(r));
21532
22147
  if (held > 0 && cooldown.heldRequests.length === 0) cooldown.emptiedBy = 'withdrawal';
21533
22148
  }
22149
+ for (const hold of this.budgetHolds?.values() ?? []) hold.wakes = hold.wakes.filter((r) => !withdrawn(r));
21534
22150
  for (const pass of this.passRequests ?? []) {
21535
22151
  for (const requests of pass.values()) {
21536
22152
  if (!requests.some((r) => emptied.has(r))) continue;
21537
22153
  const left = requests.filter((r) => !emptied.has(r));
21538
22154
  requests.length = 0;
21539
- requests.push(...left);
22155
+ appendAll(requests, left);
21540
22156
  }
21541
22157
  }
21542
22158
  for (const start of emptied) {
@@ -22086,7 +22702,7 @@ export class AgentFramework {
22086
22702
  const agent = this.agents.get(agentName);
22087
22703
  if (!agent) {
22088
22704
  // Agent not found — request is orphaned. Emit warning and drop.
22089
- const oldest = Math.min(...requests.map(r => r.timestamp));
22705
+ const oldest = requests.reduce((min, r) => Math.min(min, r.timestamp), Infinity);
22090
22706
  this.emitTrace({
22091
22707
  type: 'inference:request_dropped',
22092
22708
  agentName,
@@ -22102,6 +22718,11 @@ export class AgentFramework {
22102
22718
  continue;
22103
22719
  }
22104
22720
 
22721
+ const budgetHold = this.budgetHolds?.get(agentName);
22722
+ if (budgetHold) {
22723
+ for (const req of requests) this.holdRequest(budgetHold.wakes, req);
22724
+ continue;
22725
+ }
22105
22726
  const providerCooldown = this.providerAccelerationCooldowns?.get(agentName);
22106
22727
  if (providerCooldown) {
22107
22728
  // A provider wait binds its own model; with a different primary model
@@ -22135,7 +22756,22 @@ export class AgentFramework {
22135
22756
  // passes, and the read check and the policy decide on them all; the
22136
22757
  // merge below records the batch anew.
22137
22758
  if (requests.some((r) => mergedBatch(r) !== undefined)) {
22138
- requests = requests.flatMap((r) => mergedBatch(r) ?? [r]);
22759
+ requests = requests.flatMap((r) => {
22760
+ const batch = mergedBatch(r);
22761
+ if (!batch?.length) return [r];
22762
+ const wait = budgetWaitOf(r);
22763
+ const waitMarked = wait !== undefined && !batch.some((m) => budgetWaitOf(m) !== undefined);
22764
+ if (waitMarked && !batch.some((m) => this.sameInferenceRequest(m, r))) {
22765
+ const { [MERGED_BATCH]: _batch, ...folded } = r as MarkedRequest;
22766
+ return [...batch, folded];
22767
+ }
22768
+ const own = r.wakeChannels?.filter((c) => !batch.some((m) => m.wakeChannels?.some((x) => sameChannel(x, c))));
22769
+ const channels = own?.length ? wakeChannelsOf([batch[0], { wakeChannels: own }]) : undefined;
22770
+ const members = channels ? [{ ...batch[0]!, wakeChannels: channels }, ...batch.slice(1)] : batch;
22771
+ if (!waitMarked) return members;
22772
+ const at = members.findIndex((m) => this.sameInferenceRequest(m, r));
22773
+ return members.map((m, i) => (i === at ? { ...m, [BUDGET_WAIT]: wait } as MarkedRequest : m));
22774
+ });
22139
22775
  }
22140
22776
 
22141
22777
  // A provider's stated wait on this agent's primary model, including one
@@ -22204,22 +22840,25 @@ export class AgentFramework {
22204
22840
  // A heartbeat's wake keeps its own key, so a later channel-less wake
22205
22841
  // can't absorb it: resumed together, they make a heartbeat's batch
22206
22842
  // (heartbeatTurn), as they would have without the quiesce.
22207
- const key = `${request.reason}|${request.addressed ? 'a' : 'n'}|${isHeartbeatWake(request) ? 'h' : ''}|${request.coalescingSubject ?? ''}|${request.readsRow ?? ''}`;
22843
+ const key = `${request.reason}|${request.addressed ? 'a' : 'n'}|${isHeartbeatWake(request) ? 'h' : ''}|${isUnprompted(request) ? 'u' : ''}|${request.coalescingSubject ?? ''}|${request.readsRow ?? ''}|${budgetWaitOf(request) ? 'waited' : ''}`;
22208
22844
  const prev = newestByKey.get(key);
22209
22845
  const kept = !prev || request.timestamp >= prev.timestamp ? request : prev;
22210
22846
  // The wake it absorbs hands on its lines (WakeLine): each event's
22211
22847
  // line stays to be decided when the host resumes.
22212
22848
  const lines = prev ? mergeWakeLines([prev, request]) : undefined;
22213
22849
  if (lines) kept.wakeLines = lines;
22850
+ const pair = prev ? [prev, request] : undefined;
22851
+ const channels = pair ? wakeChannelsOf(pair) : undefined;
22852
+ if (channels) kept.wakeChannels = channels;
22214
22853
  newestByKey.set(key, kept);
22215
22854
  }
22216
- this.pendingRequests.push(...newestByKey.values());
22855
+ appendAll(this.pendingRequests, [...newestByKey.values()]);
22217
22856
  if (passThrough.length === 0) continue;
22218
22857
  // The continuation continues its held turn; its parked siblings must
22219
22858
  // NOT be consumed by that turn — they were re-parked above, so narrow
22220
22859
  // the batch to the continuation(s) alone before the trigger selection.
22221
22860
  requests.length = 0;
22222
- requests.push(...passThrough);
22861
+ appendAll(requests, passThrough);
22223
22862
  }
22224
22863
 
22225
22864
  // A batch that carries a continuation which resumes its logical turn
@@ -22237,10 +22876,10 @@ export class AgentFramework {
22237
22876
  if (requests.some((r) => resumesLogicalTurn(r.reason))) {
22238
22877
  const siblings = requests.filter((r) => !isTurnContinuation(r.reason));
22239
22878
  if (siblings.length > 0) {
22240
- this.pendingRequests.push(...siblings);
22879
+ appendAll(this.pendingRequests, siblings);
22241
22880
  const continuations = requests.filter((r) => isTurnContinuation(r.reason));
22242
22881
  requests.length = 0;
22243
- requests.push(...continuations);
22882
+ appendAll(requests, continuations);
22244
22883
  }
22245
22884
  }
22246
22885
 
@@ -22263,7 +22902,7 @@ export class AgentFramework {
22263
22902
  // Re-queue requests, but warn if they've been pending too long.
22264
22903
  // Suppressed while quiesced: a drain-phase busy requeue is expected,
22265
22904
  // not a wedge tell.
22266
- const oldest = Math.min(...requests.map(r => r.timestamp));
22905
+ const oldest = requests.reduce((min, r) => Math.min(min, r.timestamp), Infinity);
22267
22906
  if (
22268
22907
  !this.quiesced &&
22269
22908
  now - oldest > STALE_REQUEST_MS &&
@@ -22295,7 +22934,7 @@ export class AgentFramework {
22295
22934
  `${requests.length} request(s) waiting ${Math.round((now - oldest) / 1000)}s`,
22296
22935
  );
22297
22936
  }
22298
- this.pendingRequests.push(...requests);
22937
+ appendAll(this.pendingRequests, requests);
22299
22938
  continue;
22300
22939
  }
22301
22940
 
@@ -22323,7 +22962,7 @@ export class AgentFramework {
22323
22962
  if (read.length > 0) {
22324
22963
  const left = requests.filter((r) => !read.includes(r));
22325
22964
  requests.length = 0;
22326
- requests.push(...left);
22965
+ appendAll(requests, left);
22327
22966
  console.error(`[wake] ${agentName}: dropped ${read.length} wake(s) for writes an answered request carried, and that it spoke after in the wake's channel where it carries one not marked ambient, or that were removed` +
22328
22967
  (requests.length === 0 ? '; no turn starts' : ''));
22329
22968
  if (requests.length === 0) {
@@ -22495,6 +23134,7 @@ export class AgentFramework {
22495
23134
  // channel is that message's, never one server's id with another's.
22496
23135
  channelId: channelReq?.channelId,
22497
23136
  channelServerId: channelReq?.channelServerId,
23137
+ wakeChannels: wakeChannelsOf(requests.some(bringsOwnChannels) ? requests.filter(hasOwnCauseForRun) : requests),
22498
23138
  // Only the mark this batch decided, never one requests[0] carried.
22499
23139
  autoContinueRun: continuationMark,
22500
23140
  addressed: addressedReq !== undefined,
@@ -22517,8 +23157,13 @@ export class AgentFramework {
22517
23157
  const carry = requests.map(turnCarryOf).find((c) => c !== undefined);
22518
23158
  if (carry) start[TURN_CARRY] = carry;
22519
23159
  else delete start[TURN_CARRY];
23160
+ const wait = requests.map(budgetWaitOf).find((w) => w !== undefined);
23161
+ if (wait) start[BUDGET_WAIT] = wait;
23162
+ else delete start[BUDGET_WAIT];
22520
23163
  if (requests.every(isRearmed)) start[REARMED] = true;
22521
23164
  else delete start[REARMED];
23165
+ if (requests.every(isUnprompted)) start[UNPROMPTED] = true;
23166
+ else delete start[UNPROMPTED];
22522
23167
  // A start merged from more than one request, any of them not the
22523
23168
  // turn's, records the batch it was merged from (MERGED_BATCH): until
22524
23169
  // it takes its token it stands for them all.
@@ -22546,7 +23191,7 @@ export class AgentFramework {
22546
23191
  */
22547
23192
  private publishInputWithTurnLocus(input: unknown, agentName: string, callingTurn: TurnRouting | undefined): unknown {
22548
23193
  const given = (input as { channelId?: unknown } | null | undefined)?.channelId;
22549
- if (typeof given === 'string') return input;
23194
+ if (given !== undefined && given !== null) return input;
22550
23195
  // A locked turn's default is its lock, or none: never what the registry
22551
23196
  // resolves now, which reads the agent's latest routing (a silent start's).
22552
23197
  const locus = callingTurn
@@ -22835,7 +23480,7 @@ export class AgentFramework {
22835
23480
  logical.draftFailures = { count: (logical.draftFailures?.count ?? 0) + notHeld.count, error: notHeld.error };
22836
23481
  }
22837
23482
  if (held.length === 0) return held;
22838
- logical.drafts.push(...held);
23483
+ appendAll(logical.drafts, held);
22839
23484
  console.error(`[drafts] ${agent.name}: held ${held.length} segment(s) as ${held.map((d) => d.id).join(', ')} (${reason})`);
22840
23485
  this.emitTrace({
22841
23486
  type: 'prose:drafts-held',
@@ -23570,7 +24215,13 @@ export class AgentFramework {
23570
24215
  this.holdHeartbeatProse(agent, [text], round);
23571
24216
  return null;
23572
24217
  }
23573
- return this.channelRegistry!.routeSpeech(agent.name, text, locus);
24218
+ return this.channelRegistry!.routeSpeech(agent.name, text, locus, this.cameFrom(round.routing, locus));
24219
+ }
24220
+
24221
+ private cameFrom(routing: TurnRouting, locus: ChannelRef | null): { cameFrom: Array<{ channel: ChannelRef; shown: string }> } | undefined {
24222
+ return locus === null && !routing.noChannel && routing.wakeChannels
24223
+ ? { cameFrom: routing.wakeChannels.map((c) => ({ channel: c, shown: this.channelShown(c) })) }
24224
+ : undefined;
23574
24225
  }
23575
24226
 
23576
24227
  /**
@@ -23595,6 +24246,20 @@ export class AgentFramework {
23595
24246
  return routing.heartbeat && (!routing.locus || routing.locked) ? { heartbeatTurn: true } : {};
23596
24247
  }
23597
24248
 
24249
+ private unpromptedTurnMark(routing: TurnRouting): { [UNPROMPTED]?: true } {
24250
+ return routing.unprompted && !routing.locus ? { [UNPROMPTED]: true } : {};
24251
+ }
24252
+
24253
+ private wakeChannelsMark(routing: TurnRouting | undefined): { wakeChannels?: ChannelRef[] } {
24254
+ return routing && !routing.noChannel && routing.wakeChannels ? { wakeChannels: routing.wakeChannels } : {};
24255
+ }
24256
+
24257
+ private turnRetryOf(trigger: InferenceRequest, routing: TurnRouting | undefined): InferenceRequest {
24258
+ const retry: MarkedRequest = asTurnRetry(trigger);
24259
+ delete retry.wakeChannels;
24260
+ return Object.assign(retry, this.wakeChannelsMark(routing));
24261
+ }
24262
+
23598
24263
  /** Hold a heartbeat turn's plain speech as drafts ('no-destination'), named by `round`'s notice. */
23599
24264
  private holdHeartbeatProse(agent: Agent, texts: string[], round: ProseRound): Draft[] {
23600
24265
  console.error(`[prose] ${agent.name}: heartbeat turn — ${texts.length} plain-speech segment(s) held as drafts (no destination)`);
@@ -24245,7 +24910,7 @@ export class AgentFramework {
24245
24910
  * and no undo or redo is made.
24246
24911
  */
24247
24912
  private logicalTurnAlive(agent: Agent): boolean {
24248
- return this.hasTurn(agent.name) || this.turnAlive(agent);
24913
+ return this.hasTurn(agent.name) || this.turnAlive(agent) || this.heldContinuation(agent.name);
24249
24914
  }
24250
24915
 
24251
24916
  /** Whether `agent` has a turn alive: starting, streaming or waiting on its tools. */
@@ -24977,7 +25642,7 @@ export class AgentFramework {
24977
25642
 
24978
25643
  private agentSees(agent: Agent, metadata: unknown): boolean {
24979
25644
  const sees = this.storeSees.get(agent.getContextManager());
24980
- return !sees || sees(metadata);
25645
+ return !isReasoningLoss(metadata) && (!sees || sees(metadata));
24981
25646
  }
24982
25647
 
24983
25648
  /**
@@ -25628,6 +26293,8 @@ export class AgentFramework {
25628
26293
  source: 'framework',
25629
26294
  timestamp: Date.now(),
25630
26295
  ...this.heartbeatTurnMark(routing),
26296
+ ...this.unpromptedTurnMark(routing),
26297
+ ...this.wakeChannelsMark(routing),
25631
26298
  });
25632
26299
  }
25633
26300
 
@@ -25724,6 +26391,8 @@ export class AgentFramework {
25724
26391
  ...channel,
25725
26392
  autoContinueRun: { stop, ...(opts.heldAfterSend ? { heldAfterSend: true } : {}) },
25726
26393
  ...this.heartbeatTurnMark(routing),
26394
+ ...this.unpromptedTurnMark(routing),
26395
+ ...this.wakeChannelsMark(routing),
25727
26396
  });
25728
26397
  }
25729
26398
 
@@ -26191,7 +26860,7 @@ export class AgentFramework {
26191
26860
  }
26192
26861
  const target = sticky ?? locus;
26193
26862
  try {
26194
- const outcome = await this.channelRegistry!.routeSpeech(agent.name, envelope, target);
26863
+ const outcome = await this.channelRegistry!.routeSpeech(agent.name, envelope, target, this.cameFrom(routing, target));
26195
26864
  this.recordProseDelivery(routing, outcome);
26196
26865
  if (outcome?.delivered) {
26197
26866
  routing.liveSpeech = true;
@@ -26566,6 +27235,8 @@ export class AgentFramework {
26566
27235
  source: 'framework',
26567
27236
  timestamp: Date.now(),
26568
27237
  ...this.heartbeatTurnMark(round.routing),
27238
+ ...this.unpromptedTurnMark(round.routing),
27239
+ ...this.wakeChannelsMark(round.routing),
26569
27240
  });
26570
27241
  }
26571
27242
  }
@@ -26700,7 +27371,7 @@ export class AgentFramework {
26700
27371
  const batch = trigger && mergedBatch(trigger);
26701
27372
  if (this.quiesced && batch) {
26702
27373
  this.releasePrimaryProviderGate(agent.name);
26703
- this.pendingRequests.push(...batch);
27374
+ appendAll(this.pendingRequests, batch);
26704
27375
  console.error(`[provider-admission] ${agent.name}: host quiesced while parked — the merged start goes back as its ${batch.length} requests`);
26705
27376
  return;
26706
27377
  }
@@ -26797,8 +27468,9 @@ export class AgentFramework {
26797
27468
  // one's routing, and an ephemeral run fails with the error rather than
26798
27469
  // waiting out its watchdog. A silent start has no part in an open turn.
26799
27470
  if (!trigger?.suppressProse) {
27471
+ const err = error instanceof Error ? error : new Error(String(error));
26800
27472
  this.settleAgent(agent.name, {
26801
- stopReason: 'exhausted', speech: '', error: error instanceof Error ? error.message : String(error),
27473
+ stopReason: 'exhausted', speech: '', error: failureWords(err), cause: err,
26802
27474
  }, this.openLogical.get(agent.name));
26803
27475
  }
26804
27476
  this.endStatedWaitRun(agent.name);
@@ -26822,7 +27494,7 @@ export class AgentFramework {
26822
27494
  this.activeTurnTokens.delete(agent.name);
26823
27495
  this.activeTurnTriggers.delete(agent.name);
26824
27496
  }
26825
- if (!tokenHandedOff && ownsProviderGate) this.releasePrimaryProviderGate(agent.name);
27497
+ if (!tokenHandedOff && ownsProviderGate && !checkWindow.gateReleased) this.releasePrimaryProviderGate(agent.name);
26826
27498
  // A start that ends before its stream exists is its turn's end: what
26827
27499
  // arrived during it enters its window, as at a stream's teardown,
26828
27500
  // unless its turn lives on (a retry on its way). Left out, it would
@@ -26854,7 +27526,7 @@ export class AgentFramework {
26854
27526
  if (!isContinuation(r)) left.push(r);
26855
27527
  else {
26856
27528
  dropped = true;
26857
- if (putsBack) back.push(...mergedWakes(r));
27529
+ if (putsBack) appendAll(back, mergedWakes(r));
26858
27530
  }
26859
27531
  }
26860
27532
  return left;
@@ -26871,16 +27543,16 @@ export class AgentFramework {
26871
27543
  this.parkedContinuations.delete(parked);
26872
27544
  this.releasePrimaryProviderGate(agentName);
26873
27545
  dropped = true;
26874
- if (putsBack) back.push(...mergedWakes(parked));
27546
+ if (putsBack) appendAll(back, mergedWakes(parked));
26875
27547
  }
26876
27548
  for (const pass of this.passRequests) {
26877
27549
  const requests = pass.get(agentName);
26878
27550
  if (!requests) continue;
26879
27551
  const left = take(requests);
26880
27552
  requests.length = 0;
26881
- requests.push(...left);
27553
+ appendAll(requests, left);
26882
27554
  }
26883
- this.pendingRequests.push(...back);
27555
+ appendAll(this.pendingRequests, back);
26884
27556
  if (!dropped || !drop) return;
26885
27557
  // A primary a provider hold left pending for what it released won't run
26886
27558
  // now, when nothing else of the agent's waits (endPendingPrimary).
@@ -26902,18 +27574,19 @@ export class AgentFramework {
26902
27574
  * ends on its own and settles nothing. What was stored for the agent
26903
27575
  * meanwhile is presented now (presentAtEndOf), or at that frame's end.
26904
27576
  */
26905
- private endContinuedTurn(agentName: string, drop: ContinuationDrop): void {
26906
- console.error(`[turn-continuation] ${agentName}: its turn ends without its continuation: ${drop.why}`);
27577
+ private endContinuedTurn(agentName: string, drop: ContinuationDrop, opensTurn = false): void {
27578
+ const ends = opensTurn ? 'its waiting start ends' : 'its turn ends without its continuation';
27579
+ console.error(`[turn-continuation] ${agentName}: ${ends}: ${drop.why}`);
26907
27580
  const agent = this.agents.get(agentName);
26908
27581
  const startedAt = this.lastInferenceAt.get(agentName)?.startedAt ?? Date.now();
26909
27582
  if (agent && drop.kind !== 'dropped') {
26910
27583
  const routing = this.openLogical.get(agentName) ?? this.routingOf(agentName);
26911
27584
  this.endStoppedStart(agent, drop.kind === 'caller' ? { kind: 'caller', reason: drop.reason } : { kind: drop.kind },
26912
- routing, startedAt, undefined, false);
27585
+ routing, startedAt, undefined, opensTurn);
26913
27586
  } else {
26914
27587
  const owner = this.activeTurnTokens.get(agentName);
26915
27588
  this.abortDroppedTurn(agentName, owner ?? -1,
26916
- drop.kind === 'dropped' ? CONTINUATION_DROPPED : drop.kind === 'caller' ? (drop.reason ?? 'user') : drop.kind, startedAt);
27589
+ drop.kind === 'dropped' ? (drop.reason ?? CONTINUATION_DROPPED) : drop.kind === 'caller' ? (drop.reason ?? 'user') : drop.kind, startedAt);
26917
27590
  this.channelRegistry?.stopTyping();
26918
27591
  if (agent) {
26919
27592
  agent.toolResultGuard.recovering = false;
@@ -26931,7 +27604,7 @@ export class AgentFramework {
26931
27604
  // A registered ephemeral run's caller waits on the run's settle, which
26932
27605
  // no frame gives once its continuation is gone: the turn's end is the
26933
27606
  // run's, with the drop's reason.
26934
- this.settleAgent(agentName, { stopReason: 'exhausted', speech: '', error: `${agentName}'s turn ended without its continuation: ${drop.why}` }, undefined);
27607
+ this.settleAgent(agentName, { stopReason: 'exhausted', speech: '', error: `${agentName}'s ${opensTurn ? 'waiting start ended' : 'turn ended without its continuation'}: ${drop.why}` }, undefined);
26935
27608
  }
26936
27609
  // The turn's end; a frame that still holds its token presents at its
26937
27610
  // own end instead.
@@ -27166,6 +27839,7 @@ export class AgentFramework {
27166
27839
  * operator's abandon. */
27167
27840
  checkWindow: CheckWindow,
27168
27841
  ): Promise<boolean> {
27842
+ if (attempt === 0 && this.budgetWaitEnded(agent, trigger)) return false;
27169
27843
  const resumesTurn = resumesLogicalTurn(trigger?.reason);
27170
27844
  let carry = turnCarryOf(trigger);
27171
27845
  const turnProseRouting = trigger?.suppressProse ? 'disabled' : this.proseRoutingOf(agent);
@@ -27328,8 +28002,10 @@ export class AgentFramework {
27328
28002
  }
27329
28003
 
27330
28004
  // Record turn checkpoint before inference (only on first attempt, not retries)
28005
+ let checkpoint: RecordedTurnCheckpoint | undefined;
28006
+ let compileBaseline: BudgetHoldBaseline | undefined;
27331
28007
  if (attempt === 0 && trigger?.reason !== 'tool_result_guard_retry') {
27332
- this.recordTurnCheckpoint(agent.name);
28008
+ checkpoint = this.recordTurnCheckpoint(agent.name);
27333
28009
  this.redoStacks.delete(agent.name); // new work invalidates redo
27334
28010
  }
27335
28011
 
@@ -27369,6 +28045,8 @@ export class AgentFramework {
27369
28045
  routing.silent = true;
27370
28046
  if (continued?.roundsRan) routing.roundsRan = true;
27371
28047
  if (continued?.unflushedRounds) routing.unflushedRounds = continued.unflushedRounds;
28048
+ if (continued?.start) routing.start = continued.start;
28049
+ if (continued?.firstRow !== undefined) routing.firstRow = continued.firstRow;
27372
28050
  opensTurn = true;
27373
28051
  } else {
27374
28052
  const open = this.openLogical.get(agent.name);
@@ -27382,7 +28060,9 @@ export class AgentFramework {
27382
28060
  }
27383
28061
  if (opensTurn) {
27384
28062
  const head = agent.branchHead();
27385
- if (head) routing.start = head;
28063
+ if (head && !routing.start) routing.start = head;
28064
+ const firstRow = this.activeTurnTriggers.get(agent.name)?.readsRow;
28065
+ if (firstRow !== undefined) routing.firstRow = firstRow;
27386
28066
  // A silent start, and an agent whose configuration turns the pin off
27387
28067
  // (turnInputsPin: false), pin nothing, and a pin of the name left from
27388
28068
  // before goes.
@@ -27419,12 +28099,16 @@ export class AgentFramework {
27419
28099
  // no longer holds are still on disk goes in before this start's compile.
27420
28100
  this.noticeHeadMoves(agent);
27421
28101
 
28102
+ const wakeChannels = wakeChannelsOf([routing, trigger]);
28103
+ if (wakeChannels) routing.wakeChannels = wakeChannels;
28104
+
27422
28105
  if (opensTurn) {
27423
28106
  // The turn's trigger channel (item-3 redux): the channel of a
27424
28107
  // channel/DM-triggered turn, with its server, and none for a
27425
28108
  // no-trigger turn, which doesn't inherit a previous turn's channel: a
27426
28109
  // heartbeat's takes no locus at all (TurnRouting.heartbeat), and any
27427
- // other falls back to the global default. A wake whose channel,
28110
+ // other falls back to the global default, with the configured
28111
+ // unprompted channel in it where the turn takes it. A wake whose channel,
27428
28112
  // given without its server, names no one registered channel (a host's
27429
28113
  // id that two servers' channels answer to, or that none does) has none
27430
28114
  // either, and takes no fallback: it was for a channel, and the latest
@@ -27443,9 +28127,12 @@ export class AgentFramework {
27443
28127
  // speech is held as a draft (TurnRouting.heartbeat).
27444
28128
  if (isHeartbeatWake(trigger)) routing.heartbeat = true;
27445
28129
  else delete routing.heartbeat;
28130
+ if (isUnprompted(trigger)) routing.unprompted = true;
28131
+ else delete routing.unprompted;
27446
28132
  // Resolve the turn's outbound locus now: the home channel for forks,
27447
28133
  // else the trigger channel, else the global default, which an
27448
- // ephemeral run never takes (takesGlobalFallback). Ambient mid-turn
28134
+ // ephemeral run never takes (takesGlobalFallback), with the configured
28135
+ // unprompted channel in it where the turn takes it. Ambient mid-turn
27449
28136
  // injections must not redirect the agent's plain prose (ambient
27450
28137
  // chatter and reactions hijacked it before, 2026-07-21); only an
27451
28138
  // addressed message, a follow-up in a channel the turn engaged
@@ -27523,7 +28210,9 @@ export class AgentFramework {
27523
28210
  ? 'at your budget restart'
27524
28211
  : isTurnContinuation(trigger?.reason ?? '')
27525
28212
  ? 'before this re-inference on your tool results'
27526
- : 'while your turn waited on a provider hold';
28213
+ : budgetWaitOf(trigger)
28214
+ ? 'while your turn waited'
28215
+ : 'while your turn waited on a provider hold';
27527
28216
  this.noticeAddressedArrivals(agent, routing, arrivals, when);
27528
28217
  }
27529
28218
  }
@@ -27681,6 +28370,7 @@ export class AgentFramework {
27681
28370
 
27682
28371
  if (carry && this.heardSince(agent, routing, carry.read)) carry = undefined;
27683
28372
  const streamCarry: TurnCarry = { silence: carry?.silence ?? null, sends: carry?.sends ?? null, read: lastHeardAt };
28373
+ compileBaseline = { progress: this.compressionProgressOf(agent), settings: agent.contextSettingsChanges() };
27684
28374
  const {
27685
28375
  stream,
27686
28376
  request: compiledRequest,
@@ -27691,6 +28381,7 @@ export class AgentFramework {
27691
28381
  thinkingRecordedAfter,
27692
28382
  thinkingBranch,
27693
28383
  noteRequestSent,
28384
+ noteThinkingDropped,
27694
28385
  carriedBodies,
27695
28386
  } = await agent.startStreamWithInjections(tools, injections, undefined, compressionTools, {
27696
28387
  silentHeartbeat: trigger?.silentHeartbeat,
@@ -27699,6 +28390,7 @@ export class AgentFramework {
27699
28390
  membraneRetries: false,
27700
28391
  inferenceBounds: this.inferenceBounds,
27701
28392
  });
28393
+ if (trigger) delete (trigger as MarkedRequest)[BUDGET_WAIT];
27702
28394
  // Each later round this stream sends is noted as it goes (noteRoundSent).
27703
28395
  if (noteRequestSent) this.streamRequestNotes.set(stream, { note: noteRequestSent, rounds: [], stopped: false });
27704
28396
  if (carriedBodies) this.streamCarried.set(stream, new Set(carriedBodies));
@@ -27751,7 +28443,7 @@ export class AgentFramework {
27751
28443
  if (this.registry.homeChannel(agent.name) !== undefined) {
27752
28444
  const wait = this.providerWaits?.active(agent.name, agent.model);
27753
28445
  // The turn has begun (inference:started), so the hold keeps its retry.
27754
- if (wait && this.holdForProviderWait(agent, wait, trigger ? [asTurnRetry(withTurnCarry(trigger, carry))] : [], false, () => {
28446
+ if (wait && this.holdForProviderWait(agent, wait, trigger ? [this.turnRetryOf(withTurnCarry(trigger, carry), routing)] : [], false, () => {
27755
28447
  // The stream the compile made won't be sent: it's cancelled, and
27756
28448
  // this start's window closes, before the hold is reported, so a stop
27757
28449
  // made while inference:failed is delivered finds neither, and the
@@ -27812,6 +28504,7 @@ export class AgentFramework {
27812
28504
  place: placeDrops,
27813
28505
  ...(thinkingRecordedAfter !== undefined ? { recordedAfter: thinkingRecordedAfter } : {}),
27814
28506
  ...(thinkingBranch !== undefined ? { branch: thinkingBranch } : {}),
28507
+ ...(noteThinkingDropped ? { noteDropped: noteThinkingDropped } : {}),
27815
28508
  }
27816
28509
  : undefined,
27817
28510
  streamCarry,
@@ -27862,6 +28555,17 @@ export class AgentFramework {
27862
28555
 
27863
28556
  const retry = trigger && withTurnCarry(trigger, carry);
27864
28557
  const asTransient = this.takeAsTransient(agent.name, err, trigger);
28558
+ if (this.holdForBudgetRefusal(agent, err, retry, checkpoint, routing, compileBaseline, () => {
28559
+ if (this.checkAhead.get(agent) === checkWindow) this.checkAhead.delete(agent);
28560
+ if (this.activeTurnTokens.get(agent.name) === turnToken) {
28561
+ this.activeTurnTokens.delete(agent.name);
28562
+ this.activeTurnTriggers.delete(agent.name);
28563
+ }
28564
+ if (ownsProviderGate && !checkWindow.gateReleased) {
28565
+ checkWindow.gateReleased = true;
28566
+ this.releasePrimaryProviderGate(agent.name);
28567
+ }
28568
+ })) return false;
27865
28569
  if (ownsProviderGate && this.holdProviderAcceleration(agent, err, retry)) {
27866
28570
  // Capacity scheduling is not poisoned history. The cooldown timer owns
27867
28571
  // one later fresh compile; no immediate retry or exhaustion marker.
@@ -27921,7 +28625,7 @@ export class AgentFramework {
27921
28625
  // once the retry is held finds the held retry (abortInference's
27922
28626
  // drop), not a window nothing will collect.
27923
28627
  if (this.checkAhead.get(agent) === checkWindow) this.checkAhead.delete(agent);
27924
- await this.startAgentStream(agent, retry && asTurnRetry(retry), attempt + 1);
28628
+ await this.startAgentStream(agent, retry && this.turnRetryOf(retry, routing), attempt + 1);
27925
28629
  } else {
27926
28630
  this.noteFailedTurnEnd(agent, routing);
27927
28631
  // Terminal failure with no driveStream: this frame owns teardown.
@@ -27938,13 +28642,15 @@ export class AgentFramework {
27938
28642
  this.settleAgent(agent.name, {
27939
28643
  stopReason: 'exhausted',
27940
28644
  speech: '',
27941
- ...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
28645
+ ...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: failureWords(err) }),
28646
+ cause: err,
27942
28647
  }, routing);
27943
28648
  if (isRearmed(trigger) && asTransient) this.exhaustingRearms.add(agent.name);
27944
28649
  this.emitTrace({
27945
28650
  type: 'inference:exhausted',
27946
28651
  agentName: agent.name,
27947
28652
  error: err.message,
28653
+ ...shownReasonOf(err),
27948
28654
  // Drives the poison-history breaker (shedMayHelp: an
27949
28655
  // `invalid_request` at HTTP 400, 413 or 422 or with no status, or a
27950
28656
  // `context_length` at 413, suggests the provider refused the
@@ -28939,7 +29645,7 @@ export class AgentFramework {
28939
29645
  this.noteRefusal(agent.name, category, tokenUsage);
28940
29646
  countUsage(abandoned);
28941
29647
  logStream({
28942
- success: false, error: 'Tool output withheld by the guard',
29648
+ success: false, error: 'Tool output withheld by the guard', errorWords: 'Tool output withheld by the guard',
28943
29649
  request: compiledRequest ?? {}, response, durationMs, tokenUsage, stopReason: 'refusal',
28944
29650
  });
28945
29651
  console.error(`[tool-result-guard] agent=${agent.name} withheld ${withheld.length} result(s); retrying inference`);
@@ -28998,10 +29704,12 @@ export class AgentFramework {
28998
29704
  // a postscript to a message already delivered.
28999
29705
  // Restart inference inside this logical turn. No tool is
29000
29706
  // executed again and no settle/checkpoint/locus reset occurs.
29001
- await this.startAgentStream(agent, withTurnCarry({
29707
+ const retried: InferenceRequest = {
29002
29708
  ...trigger, agentName: agent.name, reason: 'tool_result_guard_retry',
29003
29709
  source: trigger?.source ?? 'framework', timestamp: Date.now(),
29004
- }, { silence, sends: turnSends, read: carry.read }), attempt);
29710
+ };
29711
+ delete retried.wakeChannels;
29712
+ await this.startAgentStream(agent, withTurnCarry({ ...retried, ...this.wakeChannelsMark(streamRouting) }, { silence, sends: turnSends, read: carry.read }), attempt);
29005
29713
  return;
29006
29714
  }
29007
29715
  const lastResult = response.content.reduce((last, block, index) =>
@@ -29406,6 +30114,7 @@ export class AgentFramework {
29406
30114
  reason: forced ? 'unstick-retry' : 'refusal-rewind-retry',
29407
30115
  source: 'framework',
29408
30116
  timestamp: Date.now(),
30117
+ ...this.wakeChannelsMark(streamRouting),
29409
30118
  });
29410
30119
  handledByRewind = true;
29411
30120
  };
@@ -29798,7 +30507,7 @@ export class AgentFramework {
29798
30507
 
29799
30508
  logStream({
29800
30509
  success: false,
29801
- error: err.message,
30510
+ ...failedEntry('', err),
29802
30511
  request: compiledRequest ?? { note: 'streaming request failed' },
29803
30512
  durationMs,
29804
30513
  });
@@ -29862,7 +30571,7 @@ export class AgentFramework {
29862
30571
  return;
29863
30572
  }
29864
30573
  // Marked as its turn's retry, as at the start's failure.
29865
- await this.startAgentStream(agent, retry && asTurnRetry(retry), attempt + 1);
30574
+ await this.startAgentStream(agent, retry && this.turnRetryOf(retry, streamRouting), attempt + 1);
29866
30575
  } else {
29867
30576
  // The turn ends here, and its inference:failed doesn't say so,
29868
30577
  // since a retry usually follows one: an inference:aborted
@@ -29884,13 +30593,15 @@ export class AgentFramework {
29884
30593
  this.settleAgent(agent.name, {
29885
30594
  stopReason: 'exhausted',
29886
30595
  speech: '',
29887
- ...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
30596
+ ...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: failureWords(err) }),
30597
+ cause: err,
29888
30598
  }, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting : undefined, streamRouting);
29889
30599
  if (isRearmed(trigger) && asTransient) this.exhaustingRearms.add(agent.name);
29890
30600
  this.emitTrace({
29891
30601
  type: 'inference:exhausted',
29892
30602
  agentName: agent.name,
29893
30603
  error: err.message,
30604
+ ...shownReasonOf(err),
29894
30605
  // Drives the poison-history breaker. `retryable` is kept for
29895
30606
  // observability, but the breaker gates on
29896
30607
  // shedMayHelp(errorType, httpStatus): only an `invalid_request`
@@ -29978,6 +30689,7 @@ export class AgentFramework {
29978
30689
  type: 'inference:exhausted',
29979
30690
  agentName: agent.name,
29980
30691
  error: terminal,
30692
+ shownReason: terminal,
29981
30693
  });
29982
30694
  this.rearmExhaustedTurn(agent, new Error(terminal), trigger, ownsProviderGate, streamRouting, false);
29983
30695
  // Postmortem 2026-05-28 P2 #7: persist the abort to the
@@ -29987,6 +30699,7 @@ export class AgentFramework {
29987
30699
  logStream({
29988
30700
  success: false,
29989
30701
  error: terminal,
30702
+ errorWords: terminal,
29990
30703
  request: compiledRequest ?? { note: 'streaming request aborted' },
29991
30704
  durationMs,
29992
30705
  });
@@ -30093,16 +30806,33 @@ export class AgentFramework {
30093
30806
  // carries what the stream appended, so once a call has dropped
30094
30807
  // blocks, none of the stream's later calls is given either. A
30095
30808
  // compile whose first call dropped blocks gives no sample, and the
30096
- // calibration keeps its value. If the provider drops an earlier
30097
- // model's blocks after a model switch (`model_binding_mismatch`),
30098
- // they drop on every call until they leave the context, and the
30099
- // calibration takes up the new model's tokenizer only after that.
30809
+ // calibration keeps its value. Without `binds`, if the provider
30810
+ // drops an earlier model's blocks after a model switch
30811
+ // (`model_binding_mismatch`), they drop on every call until they
30812
+ // leave the context, and the calibration takes up the new model's
30813
+ // tokenizer only after that. With `binds`, the switch changes the
30814
+ // request's identity, so the compile leaves the replies' thinking
30815
+ // out itself, and a drop that does come is noted below.
30100
30816
  const thinkingReport = event.round?.thinking;
30101
30817
  const thinkingDropped = (thinkingReport?.dropped.length ?? 0) > 0;
30102
30818
  if (thinkingDropped) thinkingDroppedInStream = true;
30103
30819
  // Against the branch's binding record, when the context manager keeps one: a drop it can't account
30104
30820
  // for tells the operators (noteThinkingLoss), and a call with no report shows no such drop.
30105
30821
  const placedDrops = thinkingDrops && thinkingReport ? thinkingDrops.place(thinkingReport.dropped) : [];
30822
+ // The stored replies' bodies among them, as placed, go to the record, so the next compile sends them
30823
+ // without their thinking and the provider drops none of them again.
30824
+ const droppedBodies = placedDrops.flatMap((drop) => drop.kind === 'reply' ? drop.bodies.map((body) => body.messageId) : []);
30825
+ if (droppedBodies.length > 0 && thinkingDrops?.noteDropped) {
30826
+ try {
30827
+ thinkingDrops.noteDropped(droppedBodies);
30828
+ } catch (error) {
30829
+ console.error(
30830
+ `[thinking-binding] ${agent.name}: couldn't note the stored replies whose thinking the provider dropped, ` +
30831
+ 'so the next compile still sends them with it:',
30832
+ error,
30833
+ );
30834
+ }
30835
+ }
30106
30836
  if (thinkingDrops?.recordedAfter !== undefined) {
30107
30837
  this.noteThinkingLoss(agent.name, placedDrops, thinkingDrops.recordedAfter);
30108
30838
  }
@@ -30243,7 +30973,7 @@ export class AgentFramework {
30243
30973
  // ends here (endFailureAtStop).
30244
30974
  this.endFailureAtStop(agent.name, myTurnToken ?? -1, startTime, streamRouting);
30245
30975
  logStream({ success: false,
30246
- error: `Provider acceleration cooldown: ${err.message}`,
30976
+ ...failedEntry('Provider acceleration cooldown: ', err),
30247
30977
  request: compiledRequest ?? { note: 'streaming request rate-limited' }, durationMs });
30248
30978
  this.abandonAgentScripts(agent.name, 'provider acceleration cooldown', streamRouting);
30249
30979
  lifecyclePhase = 'failed'; agent.reset(); if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
@@ -30257,7 +30987,7 @@ export class AgentFramework {
30257
30987
  if (statedWait === 'held') {
30258
30988
  this.endFailureAtStop(agent.name, myTurnToken ?? -1, startTime, streamRouting);
30259
30989
  logStream({ success: false,
30260
- error: `Provider wait: ${err.message}`,
30990
+ ...failedEntry('Provider wait: ', err),
30261
30991
  request: compiledRequest ?? { note: 'streaming request rate-limited' }, durationMs });
30262
30992
  this.abandonAgentScripts(agent.name, 'provider wait', streamRouting);
30263
30993
  lifecyclePhase = 'failed'; agent.reset(); if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
@@ -30267,13 +30997,15 @@ export class AgentFramework {
30267
30997
  this.settleAgent(agent.name, {
30268
30998
  stopReason: 'exhausted',
30269
30999
  speech: '',
30270
- ...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
31000
+ ...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: failureWords(err) }),
31001
+ cause: err,
30271
31002
  }, streamRouting);
30272
31003
  if (isRearmed(trigger) && asTransient) this.exhaustingRearms.add(agent.name);
30273
31004
  this.emitTrace({
30274
31005
  type: 'inference:exhausted',
30275
31006
  agentName: agent.name,
30276
31007
  error: err.message,
31008
+ ...shownReasonOf(err),
30277
31009
  ...this.classifyInferenceError(err),
30278
31010
  });
30279
31011
  this.rearmExhaustedTurn(agent, err, trigger, ownsProviderGate, streamRouting, asTransient);
@@ -30282,7 +31014,7 @@ export class AgentFramework {
30282
31014
  // Persist to inference log for forensic attribution.
30283
31015
  logStream({
30284
31016
  success: false,
30285
- error: `Stream threw: ${err.message}`,
31017
+ ...failedEntry('Stream threw: ', err),
30286
31018
  request: compiledRequest ?? { note: 'streaming request threw' },
30287
31019
  durationMs,
30288
31020
  });
@@ -30306,13 +31038,15 @@ export class AgentFramework {
30306
31038
  if (countedUsage && !usageLogged) {
30307
31039
  const replaced = this.agents.get(agent.name) !== agent;
30308
31040
  const superseded = generationLost || agent.streamId !== myStreamId;
31041
+ const ended = replaced
31042
+ ? 'Stream abandoned: its agent was disposed or replaced'
31043
+ : superseded
31044
+ ? 'Stream superseded: a newer stream of the agent took over'
31045
+ : 'Stream ended without a final response';
30309
31046
  logStream({
30310
31047
  success: false,
30311
- error: replaced
30312
- ? 'Stream abandoned: its agent was disposed or replaced'
30313
- : superseded
30314
- ? 'Stream superseded: a newer stream of the agent took over'
30315
- : 'Stream ended without a final response',
31048
+ error: ended,
31049
+ errorWords: ended,
30316
31050
  request: compiledRequest ?? { note: 'streaming request' },
30317
31051
  durationMs: Date.now() - startTime,
30318
31052
  tokenUsage: tokenUsageOf(countedUsage),
@@ -30468,7 +31202,7 @@ export class AgentFramework {
30468
31202
  // so does one whose failure a provider hold keeps for its retry: the
30469
31203
  // turn isn't over (pendingContinuationAgents).
30470
31204
  if (frameReachedTerminal && ownsPhysicalStream && this.closingConversationAgents.has(agent.name)
30471
- && !this.pendingContinuationAgents().has(agent.name)) {
31205
+ && !this.pendingContinuationAgents().has(agent.name) && !this.closureWaits(agent.name)) {
30472
31206
  this.disposeConversationAgent(agent.name, 'idle-ttl');
30473
31207
  }
30474
31208
  }
@@ -30573,7 +31307,7 @@ export class AgentFramework {
30573
31307
  `puppet refused: the store is under live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName} — retry when it completes`,
30574
31308
  );
30575
31309
  }
30576
- if (agent.state.status !== 'idle' || this.hasTurn(agentName)) {
31310
+ if (agent.state.status !== 'idle' || this.hasTurn(agentName) || this.heldContinuation(agentName)) {
30577
31311
  const shown = agent.state.status === 'idle' ? 'idle+turn-alive' : agent.state.status;
30578
31312
  // A turn a provider hold keeps for its retry has no stream: the refusal
30579
31313
  // names the hold, and how to go on sooner, as surgery's does.
@@ -30581,6 +31315,9 @@ export class AgentFramework {
30581
31315
  throw new Error(held
30582
31316
  ? `puppet refused: agent ${agentName} is idle+turn-alive (${held.says}; requires idle); ` +
30583
31317
  heldTurnWayOn(held.statedWait)
31318
+ : agent.state.status === 'idle' && !this.hasTurn(agentName)
31319
+ ? `puppet refused: agent ${agentName} is idle+turn-alive (its turn waits for compression to make room; ` +
31320
+ 'requires idle); abortInference() ends that turn'
30584
31321
  : `puppet refused: agent ${agentName} is ${shown} (requires idle — ` +
30585
31322
  `injecting a turn under an active stream corrupts wire ordering)`,
30586
31323
  );
@@ -32430,6 +33167,7 @@ export class AgentFramework {
32430
33167
  // The failure text is stored inline in every record; the request that
32431
33168
  // produced it is already stored separately as a blob.
32432
33169
  if (typeof entryToStore.error === 'string') entryToStore.error = boundFailureText(entryToStore.error);
33170
+ if (typeof entryToStore.errorWords === 'string') entryToStore.errorWords = boundFailureText(entryToStore.errorWords);
32433
33171
 
32434
33172
  // Blob threshold: 10KB - typical context-heavy requests exceed this
32435
33173
  const BLOB_THRESHOLD = 10000;
@@ -32659,19 +33397,21 @@ export class AgentFramework {
32659
33397
 
32660
33398
  /**
32661
33399
  * Why this host refuses a skip_reply call, as the model will read it, or
32662
- * undefined when it doesn't (skipReplyWakeRefusal): without an EventGate,
32663
- * any wake_in_seconds; with one, a wake_in_seconds that isn't a number of
32664
- * seconds. Only when the channel registry handles skip_reply. The dispatch
32665
- * that refuses the call reads it here, and so does the turn's bookkeeping of
32666
- * a refused round (roundCallOf), for the model's calls and a script's calls
32667
- * that reach the host while a call of the round watches it: the silence
32668
- * before it, which comes back after its round, and in XML mode which round's
32669
- * prose stays private. A skip_reply round's own prose stays private by the
32670
- * tool's name, refused or not.
33400
+ * undefined when it doesn't: a reason that isn't a string
33401
+ * (ChannelRegistry.nonStringRefusal), or (skipReplyWakeRefusal) without an
33402
+ * EventGate, any wake_in_seconds, and with one, a wake_in_seconds that isn't
33403
+ * a number of seconds. Only when the channel registry handles skip_reply.
33404
+ * The dispatch that refuses the call reads it here, and so does the turn's
33405
+ * bookkeeping of a refused round (roundCallOf), for the model's calls and a
33406
+ * script's calls that reach the host while a call of the round watches it:
33407
+ * the silence before it, which comes back after its round, and in XML mode
33408
+ * which round's prose stays private. A skip_reply round's own prose stays
33409
+ * private by the tool's name, refused or not.
32671
33410
  */
32672
33411
  private skipReplyRefusal(call: { name?: string; input?: unknown }): string | undefined {
32673
33412
  if (call.name !== 'skip_reply' || !this.channelRegistry) return undefined;
32674
- return skipReplyWakeRefusal(readSkipReplyWake(call.input), !!this.eventGate);
33413
+ return this.channelRegistry.nonStringRefusal('skip_reply', call.input)?.error
33414
+ ?? skipReplyWakeRefusal(readSkipReplyWake(call.input), !!this.eventGate);
32675
33415
  }
32676
33416
 
32677
33417
  private refusesSelfWake(call: { name?: string; input?: unknown }): boolean {
@@ -32682,8 +33422,8 @@ export class AgentFramework {
32682
33422
  * A call as its round's routing counts it, made directly, or by a script
32683
33423
  * and reaching the host while a run or wait call of the round watches it
32684
33424
  * (ScriptRound). A skip_reply refused, because the agent's allowedTools
32685
- * doesn't name it, because its arguments are refused (markedInputRefusal)
32686
- * or because this host can't set the wake it asks for, is
33425
+ * doesn't name it, because its arguments are refused (markedInputRefusal),
33426
+ * or because this host refuses its arguments (skipReplyRefusal), is
32687
33427
  * marked refused: it keeps its own round private and silences nothing after
32688
33428
  * it. A refusal withholds the call's effect, not the model's choice to keep
32689
33429
  * its words private. Any other call the list refuses posted nothing, so it
@@ -32934,7 +33674,7 @@ export class AgentFramework {
32934
33674
  const error = this.skipReplyRefusal(enrichedCall);
32935
33675
  if (error !== undefined) {
32936
33676
  const wakeValue = (enrichedCall.input as { wake_in_seconds?: unknown } | null | undefined)?.wake_in_seconds;
32937
- console.error(`[self-wake] agent=${agentName} wake_in_seconds=${logValue(wakeValue)} skip_reply refused: ${logDetail(this.eventGate ? "wake_in_seconds isn't a number of seconds" : 'No EventGate configured (FrameworkConfig.gate is unset)')}`);
33677
+ console.error(`[self-wake] agent=${agentName} wake_in_seconds=${logValue(wakeValue)} skip_reply refused: ${logDetail(error)}`);
32938
33678
  const result = this.refusedResult(agentName, enrichedCall.id, error);
32939
33679
  this.emitTrace({ type: 'tool:failed', module: 'channels', tool: enrichedCall.name, callId: enrichedCall.id, error });
32940
33680
  this.pushEvent({ type: 'tool-result', callId: enrichedCall.id, toolName: enrichedCall.name, agentName, moduleName: 'channels', result });
@@ -34538,6 +35278,7 @@ export class AgentFramework {
34538
35278
  {
34539
35279
  httpStatus: event.httpStatus as number | undefined,
34540
35280
  providerErrorCode: event.providerErrorCode as string | undefined,
35281
+ shownReason: event.shownReason as string | undefined,
34541
35282
  },
34542
35283
  );
34543
35284
  } else if (event.type === 'inference:completed') {
@@ -34600,6 +35341,7 @@ export class AgentFramework {
34600
35341
  const bounded = traceEvent as unknown as Record<string, unknown>;
34601
35342
  if (typeof bounded.error === 'string') bounded.error = boundFailureText(bounded.error);
34602
35343
  if (typeof bounded.stack === 'string') bounded.stack = boundFailureText(bounded.stack);
35344
+ if (typeof bounded.shownReason === 'string') bounded.shownReason = boundFailureText(bounded.shownReason);
34603
35345
 
34604
35346
  if (this.earlyTraces) {
34605
35347
  if (this.earlyTraces.length < 1000) this.earlyTraces.push(traceEvent);
@@ -34696,6 +35438,19 @@ export class AgentFramework {
34696
35438
  };
34697
35439
  })(),
34698
35440
  lastInference: this.lastInferenceAt.get(name) ?? null,
35441
+ budgetHold: (() => {
35442
+ const hold = this.budgetHolds?.get(name);
35443
+ if (!hold) return null;
35444
+ const readiness = this.contextReadiness(agent.getContextManager());
35445
+ return {
35446
+ since: hold.startedAt,
35447
+ pending: 'pending' in readiness && readiness.pending ? boundFailureText(readiness.pending) : null,
35448
+ heldRequests: 1 + hold.wakes.flatMap((r) => mergedBatch(r) ?? [r]).filter((r) => !passesQuiesce(r)).length,
35449
+ errorType: hold.errorType,
35450
+ ...(hold.budget !== undefined ? { budget: hold.budget } : {}),
35451
+ ...(hold.actual !== undefined ? { actual: hold.actual } : {}),
35452
+ };
35453
+ })(),
34699
35454
  refusalStats: this.refusalStats.get(name) ?? null,
34700
35455
  ...this.upcomingWakesHealth(name),
34701
35456
  })),
@@ -34723,7 +35478,8 @@ export class AgentFramework {
34723
35478
 
34724
35479
  /**
34725
35480
  * Ops alert — the one escalation path for "a human should hear about this":
34726
- * (1) durable failures.log record (unless the caller already wrote one),
35481
+ * (1) durable failures.log record (unless the caller already wrote one, or
35482
+ * the alert keeps none, as a budget hold's open and clear don't),
34727
35483
  * (2) an `ops:alert` trace so authorized observers get it on the wire,
34728
35484
  * (3) if CONNECTOME_OPS_WEBHOOK is set, a Discord post throttled to one per
34729
35485
  * (agent, kind) per cooldown window — a persistent failure re-posts
@@ -34759,7 +35515,7 @@ export class AgentFramework {
34759
35515
  const notes = this.streamRequestNotes.get(stream);
34760
35516
  if (!notes || notes.stopped) return;
34761
35517
  if (unvouched) { notes.stopped = true; return; }
34762
- notes.rounds.push(...rows);
35518
+ appendAll(notes.rounds, rows);
34763
35519
  this.noteStreamRows(agent, notes);
34764
35520
  }
34765
35521
 
@@ -35000,17 +35756,8 @@ export class AgentFramework {
35000
35756
  ...(providerErrorCode !== undefined ? { providerErrorCode } : {}),
35001
35757
  };
35002
35758
  }
35003
- if (err instanceof OverBudgetError || err.name === 'OverBudgetError') {
35004
- return { errorType: 'over_budget' };
35005
- }
35006
- // context-manager's fatal coverage invariant (fix/coverage-invariant-fatal,
35007
- // 2026-07-26): a compile REFUSES rather than shipping a context with
35008
- // silently-dropped messages. Recovery is identical to over_budget — kick
35009
- // the compression drain so summaries cover the un-represented span.
35010
- if (err instanceof UncoveredDropError || err.name === 'UncoveredDropError') {
35011
- return { errorType: 'context_refusal' };
35012
- }
35013
- return {};
35759
+ const refusal = compileRefusalType(err);
35760
+ return refusal !== undefined ? { errorType: refusal } : {};
35014
35761
  }
35015
35762
 
35016
35763
  /**
@@ -35029,7 +35776,7 @@ export class AgentFramework {
35029
35776
  rawReason: string,
35030
35777
  retryable?: boolean,
35031
35778
  rawErrorType?: string,
35032
- detail?: { httpStatus?: number; providerErrorCode?: string },
35779
+ detail?: { httpStatus?: number; providerErrorCode?: string; shownReason?: string },
35033
35780
  ): void {
35034
35781
  const ended = this.exhaustedTurns?.get(agentName);
35035
35782
  this.exhaustedTurns?.delete(agentName);
@@ -35041,10 +35788,12 @@ export class AgentFramework {
35041
35788
  // sink below gets an excerpt cut from the whole reason at its own bound
35042
35789
  // (boundFailureText, so its marker states the whole length): the stderr
35043
35790
  // line, failures.log and the hard-down alert FAILURE_TEXT_MAX_CHARS, the
35044
- // [inference-failed] marker FAILURE_MARKER_REASON_MAX_CHARS, the status's
35045
- // lastError and the compile-refused alert FAILURE_SUMMARY_MAX_CHARS, and
35046
- // the rewind marker REWIND_MARKER_REASON_MAX_CHARS. The classification
35047
- // travels as structured fields beside the text.
35791
+ // [inference-failed] marker's metadata FAILURE_MARKER_REASON_MAX_CHARS, the
35792
+ // status's lastError and the compile-refused alert FAILURE_SUMMARY_MAX_CHARS,
35793
+ // and the rewind marker's metadata REWIND_MARKER_REASON_MAX_CHARS. The
35794
+ // classification travels as structured fields beside the text. The
35795
+ // [inference-failed] marker's text quotes only `shownReason`, which holds
35796
+ // no provider's text, and the rewind marker's quotes no reason.
35048
35797
  //
35049
35798
  // A reason that isn't a string (the stream paths always pass a message)
35050
35799
  // is described rather than allowed to throw before anything is recorded.
@@ -35092,9 +35841,12 @@ export class AgentFramework {
35092
35841
  const agent = this.agents.get(agentName);
35093
35842
  if (agent && !(rearmed && !ended) && process.env.SUPPRESS_INFERENCE_FAILED_MARKER !== '1') {
35094
35843
  const timedOut = errorType === 'timeout';
35095
- const causeParts = [errorType, httpStatus !== undefined ? `HTTP ${httpStatus}` : undefined]
35844
+ const causeParts = errorType === 'safety' ? [] : [errorType, httpStatus !== undefined ? `HTTP ${httpStatus}` : undefined]
35096
35845
  .filter((part): part is string => part !== undefined);
35097
35846
  const cause = timedOut && causeParts.length === 1 ? '' : causeParts.join(', ');
35847
+ const shownText = typeof detail?.shownReason === 'string'
35848
+ ? failureShown(boundFailureText(detail.shownReason, FAILURE_MARKER_REASON_MAX_CHARS)) : undefined;
35849
+ const shown = shownText !== undefined ? ` Reason: ${shownText}${/[.!?]$/.test(shownText) ? '' : '.'}` : '';
35098
35850
  try {
35099
35851
  storeOwnRow(
35100
35852
  agent,
@@ -35109,11 +35861,11 @@ export class AgentFramework {
35109
35861
  `${ended.roundsRan ? ' Its earlier rounds ran, and their tool calls ran as their results show.' : ''}` +
35110
35862
  `${ended.callsCutOff ? ` ${CALLS_CUT_OFF}` : ''}` +
35111
35863
  `${ended.liveSpeech ? ` ${LIVE_SPEECH_UNRECEIPTED}` : ''}` +
35112
- `${ended.speechRouting ? ` ${SPEECH_STILL_ROUTING}` : ''} Reason: `
35864
+ `${ended.speechRouting ? ` ${SPEECH_STILL_ROUTING}` : ''}`
35113
35865
  : `[${NOTICE_TAG.inferenceFailed}] Your previous turn did not complete: ` +
35114
35866
  `the model call ${timedOut ? 'timed out' : 'failed'}${cause ? ` (${suppliedValue(cause)})` : ''} ` +
35115
- `and produced no response, so nothing was sent.${madeAgain ? ` ${MADE_AGAIN}` : ''} Reason: `) +
35116
- `${failureShown(markerReason)}. ${timedOut ? TIMEOUT_ADVICE : FAILURE_ADVICE}`,
35867
+ `and produced no response, so nothing was sent.${madeAgain ? ` ${MADE_AGAIN}` : ''}`) +
35868
+ `${shown} ${timedOut ? TIMEOUT_ADVICE : FAILURE_ADVICE}`,
35117
35869
  }],
35118
35870
  { system: true, kind: 'inference-failed', reason: markerReason, consecutive: streak, ...classification, ...cutAt(markerReason) },
35119
35871
  );
@@ -35157,46 +35909,7 @@ export class AgentFramework {
35157
35909
  // runUntilIdle() has no maintenance timer, so a drain is its recovery.
35158
35910
  const drainState = !overBudget ? undefined
35159
35911
  : !agent ? 'unavailable (no agent handle)'
35160
- : this.providerAdmissionClosed ? 'not kicked: the framework is stopping'
35161
- : this.surgeryHold ? 'not kicked: a surgery holds the store'
35162
- : this.overBudgetDrainInFlight.has(agentName) ? 'already draining'
35163
- : 'kicking';
35164
- if (agent && drainState === 'kicking') {
35165
- this.overBudgetDrainInFlight.add(agentName);
35166
- // Like a maintenance pass: none starts once stop() has begun, it starts
35167
- // no tick while a surgery holds the store or once its agent has been
35168
- // disposed, and a surgery, a switchBranch or stop() waits for the tick
35169
- // in flight. It joins the drains they wait for before the alert is
35170
- // raised and before its body runs, so the alert's listeners find it:
35171
- // one that begins stop() or a surgery waits for it, and its body then
35172
- // ticks none and says why. So does a stop() made from inside its first
35173
- // tick, before that tick's first await.
35174
- const drain = Promise.resolve().then(async () => {
35175
- let ticks = 0;
35176
- try {
35177
- const cm = agent.getContextManager();
35178
- while (ticks < 8 && !this.providerAdmissionClosed && !this.surgeryHold && this.agents.get(agentName) === agent) {
35179
- await cm.tick();
35180
- ticks++;
35181
- }
35182
- const stopped = ticks >= 8 ? ''
35183
- : this.agents.get(agentName) !== agent ? ', stopped as the agent was disposed'
35184
- : this.providerAdmissionClosed ? ', stopped as the framework stops'
35185
- : ', stopped while a surgery holds the store';
35186
- console.error(`[inference-failed] drain kicked for ${agentName} (OverBudget breaker, ${ticks} ticks${stopped})`);
35187
- } catch (err) {
35188
- if (err instanceof MaintenanceWithdrawnError) {
35189
- console.error(`[inference-failed] drain kicked for ${agentName} (OverBudget breaker, ${ticks} ticks, stopped: ${err.message})`);
35190
- } else {
35191
- console.error(`[inference-failed] drain kick failed for ${agentName} after ${ticks} ticks: ${describeFailureWithFrames(err)}`);
35192
- }
35193
- } finally {
35194
- this.overBudgetDrainInFlight.delete(agentName);
35195
- }
35196
- });
35197
- this.overBudgetDrains.add(drain);
35198
- void drain.finally(() => { this.overBudgetDrains.delete(drain); });
35199
- }
35912
+ : this.kickOverBudgetDrain(agentName, agent);
35200
35913
  if (overBudget) {
35201
35914
  this.opsAlert(
35202
35915
  'context-refusal',
@@ -35296,6 +36009,7 @@ export class AgentFramework {
35296
36009
  reason: 'inference-failure-rewind-retry',
35297
36010
  source: 'framework',
35298
36011
  timestamp: Date.now(),
36012
+ ...this.wakeChannelsMark(this.turnRouting?.get(agent.name)),
35299
36013
  });
35300
36014
  }
35301
36015
 
@@ -35502,7 +36216,8 @@ export class AgentFramework {
35502
36216
  // a later start of the turn is told where its unprefixed speech lands
35503
36217
  // unless a hybrid `>>>` destination takes it. Empty for a no-trigger
35504
36218
  // turn until its locus is resolved, so that resolution takes the
35505
- // global fallback (none for an ephemeral run), and the turn then keeps
36219
+ // global fallback, the configured unprompted channel among it (none
36220
+ // for an ephemeral run), and the turn then keeps
35506
36221
  // that channel; null for a heartbeat's turn, which takes none
35507
36222
  // (TurnRouting.heartbeat).
35508
36223
  // A turn with no channel of its own (a heartbeat's, or a wake for a
@@ -35521,7 +36236,9 @@ export class AgentFramework {
35521
36236
  // message's channel is nobody's choice for it (#551). By name, so a
35522
36237
  // speech dispatch that comes after the run's release still counts.
35523
36238
  takesGlobalFallback: (conversationId) => this.usedEphemeralAgentNames.get(conversationId) !== 'run',
36239
+ takesUnpromptedChannel: (conversationId) => this.openLogical.get(conversationId)?.unprompted === true,
35524
36240
  eventGatePresent: this.eventGate !== null,
36241
+ unpromptedChannel: this.mcplUnpromptedChannel ?? undefined,
35525
36242
  // A channel tool reads the connect and restart marks a tool call's
35526
36243
  // "connecting" text reads (mcplConnectInFlight, mcplServerAbsentText).
35527
36244
  // This host is connecting to a server while a connect or restart of
@@ -35541,7 +36258,7 @@ export class AgentFramework {
35541
36258
  // or wasn't confirmed to. addMessage() alone does not request
35542
36259
  // inference, so this never wakes her (matching the
35543
36260
  // `discord-send-failed-skip` gate intent: context yes, wake no).
35544
- onRouteFailure: ({ conversationId, channelId, serverId, reason, textLen, outcome, detail }) => {
36261
+ onRouteFailure: ({ conversationId, channelId, serverId, reason, textLen, outcome, detail, byDesign }) => {
35545
36262
  try {
35546
36263
  // The marker is `system: true`, so it is never conversational and
35547
36264
  // never influences routing. The channel is named by its label when
@@ -35571,7 +36288,7 @@ export class AgentFramework {
35571
36288
  // have been posted (in part or whole). Never claim it wasn't.
35572
36289
  ? `[${NOTICE_TAG.discordSendFailed}] Delivery of your previous reply (${textLen} chars) to ${where} was not confirmed (${suppliedValue(reason)}). It may or may not have been posted, so sending it again could post it twice. It is saved in your archive.`
35573
36290
  : `[${NOTICE_TAG.discordSendFailed}] Your previous reply (${textLen} chars) could not be delivered to ${where} (${suppliedValue(reason)}). Nothing was posted; it is saved in your archive.`)
35574
- : `[${NOTICE_TAG.sendUndeliverable}] Your previous reply (${textLen} chars) had no channel to go to — ${suppliedValue(reason)}. This is a routing/configuration situation, not a channel failure. This route delivered it nowhere; if another surface showed it (a reply thread, the console), that copy stands. It is saved in your archive.`;
36291
+ : `[${NOTICE_TAG.sendUndeliverable}] Your previous reply (${textLen} chars) had no channel to go to — ${suppliedValue(reason)}. This is a ${byDesign ? 'routing' : 'routing/configuration'} situation, not a channel failure. This route delivered it nowhere; if another surface showed it (a reply thread, the console), that copy stands. It is saved in your archive.`;
35575
36292
  // A registered speaker's notice is addressed to it (noticeTargetFor).
35576
36293
  this.addMessage(
35577
36294
  'user',
@@ -37884,8 +38601,9 @@ export class AgentFramework {
37884
38601
  closedTab: boolean;
37885
38602
  retrying: boolean;
37886
38603
  /** Which engine serves it: `legacy` (MCP 2024-11-05 + MCPL) or `modern`
37887
- * (MCP 2026-07-28), as its configuration selects; null when the
37888
- * configuration names no usable transport (see `refused`). */
38604
+ * (the SDK's client: MCP 2026-07-28, or over HTTP a 2025 revision), as
38605
+ * its configuration selects; null when the configuration names no
38606
+ * usable transport (see `refused`). */
37889
38607
  family: 'legacy' | 'modern' | null;
37890
38608
  /** The MCP revision its last handshake established; null until one has. */
37891
38609
  protocolVersion: string | null;
@@ -38942,7 +39660,7 @@ export class AgentFramework {
38942
39660
  .filter(([, owner]) => owner === serverId)
38943
39661
  .map(([name]) => name);
38944
39662
  const known = new Set(names);
38945
- tools.push(...(this.mcplTools ?? []).filter((tool) => known.has(tool.name)));
39663
+ appendAll(tools, (this.mcplTools ?? []).filter((tool) => known.has(tool.name)));
38946
39664
  for (const name of names) {
38947
39665
  toolServers.set(name, serverId);
38948
39666
  const featureSet = this.mcplToolFeatureSets?.get(name);
@@ -38952,7 +39670,7 @@ export class AgentFramework {
38952
39670
  }
38953
39671
  kept.set(serverId, names.length);
38954
39672
  } else if (listedServers.has(serverId)) {
38955
- tools.push(...staged.tools);
39673
+ appendAll(tools, staged.tools);
38956
39674
  for (const [name, featureSet] of staged.featureSets) toolFeatureSets.set(name, featureSet);
38957
39675
  for (const [name, classes] of staged.classes) toolClasses.set(name, classes);
38958
39676
  for (const [name, owner] of staged.servers) toolServers.set(name, owner);
@@ -38965,7 +39683,7 @@ export class AgentFramework {
38965
39683
  console.error(`[mcpl] server "${serverId}": tools/list failed (${error}); ${count > 0 ? `keeping its ${count} known tool(s)` : 'none of its tools are offered'}`);
38966
39684
  }
38967
39685
  }
38968
- tools.push(...(over.size > 0 ? modernTools.filter((tool) => toolServers.has(tool.name)) : modernTools));
39686
+ appendAll(tools, over.size > 0 ? modernTools.filter((tool) => toolServers.has(tool.name)) : modernTools);
38969
39687
  this.mcplToolsRefreshPublished = generation;
38970
39688
  this.mcplTools = tools;
38971
39689
  this.mcplPublishedConnections = publishedConnections;
@@ -39943,6 +40661,11 @@ export class AgentFramework {
39943
40661
  return;
39944
40662
  }
39945
40663
  if (call.name === 'channel_publish' || call.name === 'channel_close') {
40664
+ const refusal = this.channelRegistry?.nonStringRefusal(call.name, call.input);
40665
+ if (refusal) {
40666
+ reject(refusal.error!);
40667
+ return;
40668
+ }
39946
40669
  const input = (call.input ?? {}) as { channelId?: string; serverId?: string };
39947
40670
  // An empty serverId is read as none given, as the channel tools and
39948
40671
  // channelNamed read it.
@@ -40484,7 +41207,7 @@ export class AgentFramework {
40484
41207
  .filter(([field]) => !(field.startsWith('behavior.') && typeof input.behavior === 'object')));
40485
41208
  const policy = this.eventGate!.addPolicy(rawPolicy, {
40486
41209
  ...(position ? { position } : {}),
40487
- onIgnoredFields: (notes) => { ignored.push(...notes); },
41210
+ onIgnoredFields: (notes) => { appendAll(ignored, notes); },
40488
41211
  fieldNames,
40489
41212
  });
40490
41213
  const after = this.eventGate!.probeTable();
@@ -40816,8 +41539,9 @@ export class AgentFramework {
40816
41539
  /**
40817
41540
  * Whether `agent`'s view leaves suppressed rows out, asked of the view
40818
41541
  * itself (storeSees): every resident's, the subconscious's and a view
40819
- * derived from one does; an isolated agent's own store has no view filter,
40820
- * so a row stamped there would render.
41542
+ * derived from one does. An isolated agent's own view excludes legacy
41543
+ * reasoning-loss notices, not suppressed rows, so a suppressed row stamped
41544
+ * there would render.
40821
41545
  */
40822
41546
  private viewHidesSuppressed(agent: Agent | undefined): boolean {
40823
41547
  if (!agent) return false;
@@ -40864,7 +41588,7 @@ export class AgentFramework {
40864
41588
  * this occurrence earlier (a coalesced push's, at its wake), used instead
40865
41589
  * of asking again, since a rule's script answers once per event.
40866
41590
  */
40867
- opts: { suppressible?: boolean; decided?: string | null } = {},
41591
+ opts: { suppressible?: boolean; decided?: string | null; folds?: boolean } = {},
40868
41592
  ): AdmissionDecision & { gateDecision?: GateDecision } {
40869
41593
  // The gate's whole decision, for wake onboarding (which rule let the event
40870
41594
  // go by); admission itself reads only whether it wakes.
@@ -40882,6 +41606,7 @@ export class AgentFramework {
40882
41606
  tags: Array.isArray(e.gateMetadata.tags) ? (e.gateMetadata.tags as string[]) : undefined,
40883
41607
  ...(e.targetAgents !== undefined ? { recipients: e.targetAgents.filter((name) => this.agents.has(name)) } : {}),
40884
41608
  ...(asked ? { suppressionAsked: true } : {}),
41609
+ ...(opts.folds ? { folds: true as const } : {}),
40885
41610
  });
40886
41611
  const decision = admit(env, {
40887
41612
  untargetedRecipients: () => this.registry.untargetedRecipients(),
@@ -41241,7 +41966,7 @@ export class AgentFramework {
41241
41966
  console.error(`[sleep] ${agentName}: a heartbeat turn has no channel — sleep announcement not posted`);
41242
41967
  } else {
41243
41968
  const locus = callingTurn.locus ?? null;
41244
- registry.routeSpeech(agentName, announcement(locus), locus).then(noted).catch((err) => {
41969
+ registry.routeSpeech(agentName, announcement(locus), locus, this.cameFrom(callingTurn, locus)).then(noted).catch((err) => {
41245
41970
  console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
41246
41971
  });
41247
41972
  }