@animalabs/agent-framework 0.30.0 → 0.32.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 (189) hide show
  1. package/README.md +71 -8
  2. package/dist/src/agent.d.ts +29 -9
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +82 -17
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/mcp-server-core.d.ts.map +1 -1
  7. package/dist/src/api/mcp-server-core.js +10 -3
  8. package/dist/src/api/mcp-server-core.js.map +1 -1
  9. package/dist/src/code-execution/isolate/render.d.ts.map +1 -1
  10. package/dist/src/code-execution/isolate/render.js +7 -6
  11. package/dist/src/code-execution/isolate/render.js.map +1 -1
  12. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  13. package/dist/src/code-execution/isolate/scripts.js +5 -4
  14. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  15. package/dist/src/framework.d.ts +138 -102
  16. package/dist/src/framework.d.ts.map +1 -1
  17. package/dist/src/framework.js +1133 -752
  18. package/dist/src/framework.js.map +1 -1
  19. package/dist/src/gate/event-gate.d.ts +24 -17
  20. package/dist/src/gate/event-gate.d.ts.map +1 -1
  21. package/dist/src/gate/event-gate.js +198 -144
  22. package/dist/src/gate/event-gate.js.map +1 -1
  23. package/dist/src/gate/recipe-reconcile.d.ts.map +1 -1
  24. package/dist/src/gate/recipe-reconcile.js +6 -5
  25. package/dist/src/gate/recipe-reconcile.js.map +1 -1
  26. package/dist/src/host-presentation.d.ts.map +1 -1
  27. package/dist/src/host-presentation.js +5 -7
  28. package/dist/src/host-presentation.js.map +1 -1
  29. package/dist/src/index.d.ts +1 -0
  30. package/dist/src/index.d.ts.map +1 -1
  31. package/dist/src/index.js +1 -0
  32. package/dist/src/index.js.map +1 -1
  33. package/dist/src/inference-bounds.d.ts +37 -0
  34. package/dist/src/inference-bounds.d.ts.map +1 -0
  35. package/dist/src/inference-bounds.js +84 -0
  36. package/dist/src/inference-bounds.js.map +1 -0
  37. package/dist/src/line-reader.d.ts +7 -1
  38. package/dist/src/line-reader.d.ts.map +1 -1
  39. package/dist/src/line-reader.js +17 -6
  40. package/dist/src/line-reader.js.map +1 -1
  41. package/dist/src/mcpl/errors.d.ts +1 -0
  42. package/dist/src/mcpl/errors.d.ts.map +1 -1
  43. package/dist/src/mcpl/errors.js +3 -0
  44. package/dist/src/mcpl/errors.js.map +1 -1
  45. package/dist/src/mcpl/inbound-source.d.ts +2 -2
  46. package/dist/src/mcpl/inbound-source.js +2 -2
  47. package/dist/src/mcpl/inference-router.d.ts +11 -1
  48. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  49. package/dist/src/mcpl/inference-router.js +65 -9
  50. package/dist/src/mcpl/inference-router.js.map +1 -1
  51. package/dist/src/mcpl/modern-connection.d.ts +10 -7
  52. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  53. package/dist/src/mcpl/modern-connection.js +85 -74
  54. package/dist/src/mcpl/modern-connection.js.map +1 -1
  55. package/dist/src/mcpl/protocol-family.d.ts +1 -1
  56. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  57. package/dist/src/mcpl/protocol-family.js +45 -3
  58. package/dist/src/mcpl/protocol-family.js.map +1 -1
  59. package/dist/src/mcpl/push-coalescer.d.ts +2 -7
  60. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  61. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  62. package/dist/src/mcpl/references.d.ts +1 -2
  63. package/dist/src/mcpl/references.d.ts.map +1 -1
  64. package/dist/src/mcpl/references.js +3 -3
  65. package/dist/src/mcpl/references.js.map +1 -1
  66. package/dist/src/mcpl/server-connection.d.ts +14 -0
  67. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  68. package/dist/src/mcpl/server-connection.js +26 -22
  69. package/dist/src/mcpl/server-connection.js.map +1 -1
  70. package/dist/src/mcpl/tool-signature.d.ts +1 -0
  71. package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
  72. package/dist/src/mcpl/tool-signature.js +16 -11
  73. package/dist/src/mcpl/tool-signature.js.map +1 -1
  74. package/dist/src/mcpl/transport.d.ts +44 -21
  75. package/dist/src/mcpl/transport.d.ts.map +1 -1
  76. package/dist/src/mcpl/transport.js +283 -64
  77. package/dist/src/mcpl/transport.js.map +1 -1
  78. package/dist/src/mcpl/types.d.ts +17 -1
  79. package/dist/src/mcpl/types.d.ts.map +1 -1
  80. package/dist/src/mcpl/types.js.map +1 -1
  81. package/dist/src/module-registry.d.ts +32 -4
  82. package/dist/src/module-registry.d.ts.map +1 -1
  83. package/dist/src/module-registry.js +189 -25
  84. package/dist/src/module-registry.js.map +1 -1
  85. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  86. package/dist/src/modules/discord/discord-js-client.js +24 -16
  87. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  88. package/dist/src/modules/sender-name.d.ts +5 -4
  89. package/dist/src/modules/sender-name.d.ts.map +1 -1
  90. package/dist/src/modules/sender-name.js +7 -5
  91. package/dist/src/modules/sender-name.js.map +1 -1
  92. package/dist/src/modules/workspace/disk-agreement.d.ts +16 -8
  93. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  94. package/dist/src/modules/workspace/disk-agreement.js +33 -12
  95. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  96. package/dist/src/modules/workspace/index.d.ts +6 -3
  97. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  98. package/dist/src/modules/workspace/index.js +61 -15
  99. package/dist/src/modules/workspace/index.js.map +1 -1
  100. package/dist/src/modules/workspace/observe.d.ts +9 -1
  101. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  102. package/dist/src/modules/workspace/observe.js +50 -10
  103. package/dist/src/modules/workspace/observe.js.map +1 -1
  104. package/dist/src/modules/workspace/reconcile.d.ts +11 -1
  105. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  106. package/dist/src/modules/workspace/reconcile.js +19 -8
  107. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  108. package/dist/src/modules/workspace/types.d.ts +8 -0
  109. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  110. package/dist/src/modules/workspace/types.js.map +1 -1
  111. package/dist/src/modules/workspace/watcher.d.ts +7 -0
  112. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  113. package/dist/src/modules/workspace/watcher.js +7 -5
  114. package/dist/src/modules/workspace/watcher.js.map +1 -1
  115. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
  116. package/dist/src/recovery/discord-awareness-outbox.js +1 -9
  117. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
  118. package/dist/src/supplied-text.d.ts +2 -0
  119. package/dist/src/supplied-text.d.ts.map +1 -1
  120. package/dist/src/supplied-text.js +16 -7
  121. package/dist/src/supplied-text.js.map +1 -1
  122. package/dist/src/tool-result-guard.js +1 -1
  123. package/dist/src/tool-result-guard.js.map +1 -1
  124. package/dist/src/tune-out/coordinator.d.ts +2 -20
  125. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  126. package/dist/src/tune-out/coordinator.js +175 -70
  127. package/dist/src/tune-out/coordinator.js.map +1 -1
  128. package/dist/src/tune-out/tools.d.ts +14 -2
  129. package/dist/src/tune-out/tools.d.ts.map +1 -1
  130. package/dist/src/tune-out/tools.js.map +1 -1
  131. package/dist/src/types/agent.d.ts +6 -12
  132. package/dist/src/types/agent.d.ts.map +1 -1
  133. package/dist/src/types/framework.d.ts +65 -24
  134. package/dist/src/types/framework.d.ts.map +1 -1
  135. package/dist/src/types/module.d.ts +42 -3
  136. package/dist/src/types/module.d.ts.map +1 -1
  137. package/dist/src/types/trace.d.ts +4 -3
  138. package/dist/src/types/trace.d.ts.map +1 -1
  139. package/package.json +3 -3
  140. package/src/agent.ts +90 -25
  141. package/src/api/mcp-server-core.ts +10 -3
  142. package/src/code-execution/isolate/render.ts +7 -6
  143. package/src/code-execution/isolate/scripts.ts +5 -4
  144. package/src/framework.ts +1163 -770
  145. package/src/gate/event-gate.ts +220 -145
  146. package/src/gate/recipe-reconcile.ts +7 -5
  147. package/src/host-presentation.ts +4 -7
  148. package/src/index.ts +1 -0
  149. package/src/inference-bounds.ts +111 -0
  150. package/src/line-reader.ts +22 -5
  151. package/src/mcpl/errors.ts +4 -0
  152. package/src/mcpl/inbound-source.ts +2 -2
  153. package/src/mcpl/inference-router.ts +67 -10
  154. package/src/mcpl/modern-connection.ts +83 -68
  155. package/src/mcpl/protocol-family.ts +37 -4
  156. package/src/mcpl/push-coalescer.ts +2 -7
  157. package/src/mcpl/references.ts +3 -4
  158. package/src/mcpl/server-connection.ts +30 -21
  159. package/src/mcpl/tool-signature.ts +18 -11
  160. package/src/mcpl/transport.ts +261 -60
  161. package/src/mcpl/types.ts +18 -1
  162. package/src/module-registry.ts +178 -25
  163. package/src/modules/discord/discord-js-client.ts +33 -29
  164. package/src/modules/sender-name.ts +8 -5
  165. package/src/modules/workspace/disk-agreement.ts +39 -15
  166. package/src/modules/workspace/index.ts +65 -14
  167. package/src/modules/workspace/observe.ts +51 -10
  168. package/src/modules/workspace/reconcile.ts +22 -8
  169. package/src/modules/workspace/types.ts +5 -0
  170. package/src/modules/workspace/watcher.ts +8 -6
  171. package/src/recovery/discord-awareness-outbox.ts +1 -11
  172. package/src/supplied-text.ts +17 -6
  173. package/src/tool-result-guard.ts +1 -1
  174. package/src/tune-out/coordinator.ts +187 -85
  175. package/src/tune-out/tools.ts +14 -2
  176. package/src/types/agent.ts +6 -12
  177. package/src/types/framework.ts +66 -24
  178. package/src/types/module.ts +42 -3
  179. package/src/types/trace.ts +4 -3
  180. package/dist/src/reasoning-loss-notice.d.ts +0 -23
  181. package/dist/src/reasoning-loss-notice.d.ts.map +0 -1
  182. package/dist/src/reasoning-loss-notice.js +0 -52
  183. package/dist/src/reasoning-loss-notice.js.map +0 -1
  184. package/dist/src/reasoning-loss.d.ts +0 -24
  185. package/dist/src/reasoning-loss.d.ts.map +0 -1
  186. package/dist/src/reasoning-loss.js +0 -102
  187. package/dist/src/reasoning-loss.js.map +0 -1
  188. package/src/reasoning-loss-notice.ts +0 -64
  189. package/src/reasoning-loss.ts +0 -120
package/src/framework.ts CHANGED
@@ -12,6 +12,7 @@ import { JsStore } from '@animalabs/chronicle';
12
12
  import type { Membrane, ContentBlock, NormalizedRequest, NormalizedResponse, YieldingStream, DetailedUsage, ToolParameter, ToolResult as MembraneToolResult, ToolResultContentBlock, StreamEvent } from '@animalabs/membrane';
13
13
  import { MembraneError } from '@animalabs/membrane';
14
14
  import { classifyFailure, retryDecision, MEMBRANE_RETRY_SETTINGS, type RetrySettings } from '@animalabs/membrane/retry';
15
+ import { deadlineCut, isDeadlineExpiry, resolveInferenceBounds, wholeCallMs, type InferenceBounds } from './inference-bounds.js';
15
16
  import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy, OverBudgetError, UncoveredDropError, stripUnpairedToolBlocks, continuesBody, MalformedContentError, malformedContentPlaces, CompressionWithdrawn } from '@animalabs/context-manager';
16
17
  import type { CacheWireReceipt } from './kv-unified-wire.js';
17
18
  import { SILENT_HEARTBEAT_SEPARATOR, type RequestOnlyPrompt, type RequestOnlyStamps } from './silent-heartbeat.js';
@@ -85,9 +86,9 @@ import { asModuleRow, heldHandle, heldWriteId, matchesMessageQuery } from './hel
85
86
  import { ProcessQueueImpl } from './queue.js';
86
87
  import { DEFAULT_SHUTDOWN_TIMEOUT_MS, validateShutdownTimeout, shutdownErrorMessage, waitForShutdown, ShutdownTimeoutError } from './shutdown.js';
87
88
  import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK, REFUSAL_REACTION_BASELINE } from './refusal-reactions.js';
88
- import { Agent, assertAgentSwitches, dryCompileStatsOf, type RenderStats } from './agent.js';
89
+ import { Agent, assertAgentSwitches, assertThinkingBinding, dryCompileStatsOf, type RenderStats } from './agent.js';
89
90
  import { assertBooleanSetting, holdsSettings, kindOf } from './config-checks.js';
90
- import { ModuleRegistry, checkedToolResult, ensureStateRegistered, storableToolResult, thrownResult } from './module-registry.js';
91
+ import { ModuleRegistry, checkedToolResult, droppedToolResultLine, ensureStateRegistered, retiredPushLine, storableToolResult, thrownResult } from './module-registry.js';
91
92
  import { McplServerRegistry } from './mcpl/server-registry.js';
92
93
  import { prefixesMeet, prefixesMeetError } from './mcpl/prefix-rule.js';
93
94
  import { logDetail, logValue } from './mcpl/log-value.js';
@@ -198,7 +199,6 @@ import { cumulativeDelta } from './usage-accounting.js';
198
199
  import { stampThinkingTokenEstimates } from './thinking-token-stamp.js';
199
200
  import { splitThinkingDrops, ThinkingLossIncidents, type PlacedDrop, type StreamThinkingDrops } from './thinking-drops.js';
200
201
  import { asNextRequestCarries, roundRows, type RoundRow, type SentRow } from './sent-rows.js';
201
- import { ReasoningLossTeller } from './reasoning-loss.js';
202
202
  import { contentAsStored, nameAsStored } from './text-as-stored.js';
203
203
  import { STORED_INJECTION_KEY, STORED_INJECTION_KIND, asStoredRow, hasRenderedContent, isAnotherAgentsInjection, isOwnInjection, isStoredKind, storedInjectionMarkOf, unitHash, type StoredInjectionMark } from './stored-injections.js';
204
204
 
@@ -815,6 +815,37 @@ export const BUILTIN_TOOLSET_TOOLS: ReadonlySet<string> = new Set([
815
815
  'workspace--status',
816
816
  ]);
817
817
 
818
+ type ToolRoute =
819
+ | { kind: 'presentation' }
820
+ | { kind: 'host-act'; host: HostActTarget }
821
+ | { kind: 'toolset-act' }
822
+ | { kind: 'mcpl'; serverId: string; prefix: string }
823
+ | { kind: 'channel' }
824
+ | { kind: 'agent-settings' }
825
+ | { kind: 'save-image' }
826
+ | { kind: 'read-image' }
827
+ | { kind: 'fetch-reference' }
828
+ | { kind: 'gate-status' }
829
+ | { kind: 'tune-out' }
830
+ | { kind: 'subconscious' }
831
+ | { kind: 'sleep' }
832
+ | { kind: 'heartbeat' }
833
+ | { kind: 'wake-rule' }
834
+ | { kind: 'event-tags' }
835
+ | { kind: 'agent-verb' }
836
+ | { kind: 'code-execution' }
837
+ | { kind: 'drafts' }
838
+ | { kind: 'prose-help' }
839
+ | { kind: 'module' };
840
+
841
+ function agentsOwnVerbRefusal(toolName: string): ToolResult {
842
+ return {
843
+ success: false,
844
+ error: `${toolName} is the agent's own, which a module's call can't make for it, so nothing was done.`,
845
+ isError: true,
846
+ };
847
+ }
848
+
818
849
  type SettingsReading = {
819
850
  core: Record<string, string>;
820
851
  exts: Map<AgentSettingsExtension, { touched: readonly string[]; fields: Record<string, string> }>;
@@ -867,7 +898,7 @@ import { HEARTBEAT_CHANGING_TOOLS, Heartbeat, isHeartbeatMcplServer, type Heartb
867
898
  import type { GateEntryRef, GateEventInfo, WakeLine } from './gate/types.js';
868
899
  import { EPHEMERAL_USAGE_NAME, UsageTracker, persistedUsageProblem, type PersistedUsageState } from './usage/usage-tracker.js';
869
900
  import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js';
870
- import { McplProtocolVersionError, McplRequestError, McplUnreapedLaunchError, stderrTailOf, toolsetDeclarationsOf, withStderr, type McplConnectionCloseInfo, type McplServerConnection } from './mcpl/server-connection.js';
901
+ import { McplRequestError, McplUnreapedLaunchError, stderrTailOf, toolsetDeclarationsOf, withStderr, type McplConnectionCloseInfo, type McplServerConnection } from './mcpl/server-connection.js';
871
902
  import { ToolsetHub, TOOLSETS_MODULE, META_TOOL_NAMES, type ToolsetController } from './mcpl/toolset-host.js';
872
903
  import { HostPresentation, BUILTIN_TOOLSET, type HostActTarget } from './host-presentation.js';
873
904
  import { isEmptyChange, type Presence } from './mcpl/tool-presence.js';
@@ -875,7 +906,7 @@ import { ToolsetsModule, resolveAct, withLookHint, type TakenChange } from './mc
875
906
  import { ModernMcpConnection } from './mcpl/modern-connection.js';
876
907
  import { MAX_TIMER_MS, WITHHELD_TEXT, asText, credentialRedactor, redactUrl, redactServerText, resolveServerBinding, serverConfigProblems, serverCredentialPatterns, serverTextRedactor, type CredentialPattern } from './mcpl/protocol-family.js';
877
908
  import { MALFORMED_CONTENT_NOTICE, normalizeStandardToolResult } from './mcpl/tool-result-normalize.js';
878
- import { boundedForLog, describeCause } from './mcpl/errors.js';
909
+ import { boundedForLog, describeCause, hostStoppedWhileHandling } from './mcpl/errors.js';
879
910
  import { assertServerConfigBooleans, buildWebSocketUrl, isWebSocketTransport } from './mcpl/transport.js';
880
911
  import type {
881
912
  McplServerConfig,
@@ -1198,8 +1229,8 @@ function resumesLogicalTurn(reason: string | undefined): boolean {
1198
1229
  */
1199
1230
  const TURN_RETRY: unique symbol = Symbol('turn-retry');
1200
1231
  /**
1201
- * The batch a start that carries a turn on (continuesTurn) was merged from,
1202
- * when the scheduler merged wakes that aren't the turn's into it: every
1232
+ * The batch a start was merged from, when the scheduler merged more than
1233
+ * one request into it and any of them isn't the turn's: every
1203
1234
  * request it took in, as each was. Until the start takes its turn token it
1204
1235
  * stands for them all. A scheduler pass that takes it back puts the batch in
1205
1236
  * its place, each request with its own identity and flags; a quiesce at the
@@ -1230,14 +1261,9 @@ function waitAlertsOnItsOwn(wait: ProviderWait): boolean {
1230
1261
  return wait.until === null || wait.until - wait.setAt > PROVIDER_WAIT_ALERT_MS;
1231
1262
  }
1232
1263
 
1233
- // membrane 0.8.0 marks a deadline it reports as run out with this registered
1234
- // symbol (on a sent request, or the Anthropic SDK's timeout, which covers its
1235
- // connect timeout too), and doesn't export its reader.
1236
- const MEMBRANE_DEADLINE_EXPIRY: unique symbol = Symbol.for('membrane.deadlineExpiry');
1237
-
1238
1264
  function isTransientFailure(error: Error): boolean {
1239
1265
  if (!isMembraneError(error) || error.retryable !== true) return false;
1240
- if ((error as { [MEMBRANE_DEADLINE_EXPIRY]?: unknown })[MEMBRANE_DEADLINE_EXPIRY] === true) return false;
1266
+ if (isDeadlineExpiry(error)) return false;
1241
1267
  return error.type === 'server' || error.type === 'network' || error.type === 'timeout' || error.type === 'rate_limit';
1242
1268
  }
1243
1269
 
@@ -1492,6 +1518,7 @@ interface TurnRouting {
1492
1518
  */
1493
1519
  addressedElsewhere: Set<string>;
1494
1520
  heard?: Map<MessageId, number>;
1521
+ inputsPin?: { agent: Agent; id: string };
1495
1522
  }
1496
1523
 
1497
1524
  /** A message that would re-pin its turn's locus (TurnRouting.arrivals). */
@@ -1522,6 +1549,29 @@ function freshTurnRouting(): TurnRouting {
1522
1549
  };
1523
1550
  }
1524
1551
 
1552
+ const TURN_INPUTS_SCAN = 64;
1553
+
1554
+ function turnInputs(
1555
+ cm: ContextManager, floor: number, agentName: string, sees: (metadata: unknown) => boolean,
1556
+ ): { first: MessageId; last: MessageId } | undefined {
1557
+ let first: MessageId | undefined;
1558
+ let last: MessageId | undefined;
1559
+ scan: for (let end = cm.getMessageCount(); end > 0; end -= TURN_INPUTS_SCAN) {
1560
+ const { messages } = cm.getMessageWindow(Math.max(0, end - TURN_INPUTS_SCAN), Math.min(TURN_INPUTS_SCAN, end), { resolveBlobs: false });
1561
+ for (let i = messages.length - 1; i >= 0; i--) {
1562
+ const message = messages[i];
1563
+ if (message.sequence <= floor) break scan;
1564
+ if (!sees(message.metadata)) continue;
1565
+ if (message.participant === agentName) break scan;
1566
+ last ??= message.id;
1567
+ const answers = Array.isArray(message.content) && message.content.length > 0
1568
+ && message.content.every((block) => block.type === 'tool_result');
1569
+ if (!answers) first = message.id;
1570
+ }
1571
+ }
1572
+ return first !== undefined && last !== undefined ? { first, last } : undefined;
1573
+ }
1574
+
1525
1575
  /**
1526
1576
  * What a prose delivery tells its stream once a publish succeeds: the
1527
1577
  * destination, its server and channel, and the text it published. The
@@ -1727,11 +1777,6 @@ const LIVE_SPEECH_UNRECEIPTED = `Plain speech its rounds routed live was handed
1727
1777
  * and this says no retry follows it. */
1728
1778
  const RETRY_CANCELLED_BY_STOP = 'a retry waiting while the framework was stopping is dropped before it sends anything';
1729
1779
 
1730
- /** The reason on the inference:aborted that ends a turn whose start waited to
1731
- * retry a failed compile across a stop() and a start(): the retry would
1732
- * revive the earlier lifecycle's turn in the next one. */
1733
- const RETRY_ACROSS_RESTART = 'a retry waiting while the framework was stopped and started again is dropped before it sends anything';
1734
-
1735
1780
  /** The reason on the inference:aborted that ends a turn whose continuation
1736
1781
  * was dropped before it started, other than by an abandon or a stop. */
1737
1782
  const CONTINUATION_DROPPED = "a turn's continuation was dropped before it started";
@@ -1822,6 +1867,26 @@ interface DiscordAwarenessDrainPass {
1822
1867
  drain?: Promise<DiscordAwarenessDrainOutcome>;
1823
1868
  }
1824
1869
 
1870
+ /** Whether a synthesized tool's nullable field is in use: omission, null and
1871
+ * the empty string all mean it isn't, so a caller whose provider presents
1872
+ * every property as required can still leave one unset. Any other value is
1873
+ * in use, and its field checks it. */
1874
+ function fieldInUse(value: unknown): boolean {
1875
+ return value !== undefined && value !== null && value !== '';
1876
+ }
1877
+
1878
+ /** A synthesized tool's integer field: an integer number, or a string of ASCII
1879
+ * digits once trimmed (a model may write an integer as a string), within the
1880
+ * integers a number holds exactly; NaN for anything else. Number() would read
1881
+ * " ", false and [] as 0, and "2.0" or "+2" as 2, and a longer run of digits
1882
+ * as a different, rounded number. */
1883
+ function integerField(value: unknown): number {
1884
+ const n = typeof value === 'number' ? value
1885
+ : typeof value === 'string' && /^[0-9]+$/.test(value.trim()) ? Number(value.trim())
1886
+ : Number.NaN;
1887
+ return Number.isSafeInteger(n) ? n : Number.NaN;
1888
+ }
1889
+
1825
1890
  function normalizeDiscordAwarenessDeadline(value: number | undefined): number {
1826
1891
  if (value === undefined || !Number.isFinite(value) || value <= 0) {
1827
1892
  return DEFAULT_DISCORD_AWARENESS_DEADLINE_MS;
@@ -1835,9 +1900,10 @@ function normalizeDiscordAwarenessDeadline(value: number | undefined): number {
1835
1900
  /**
1836
1901
  * Refuse a config whose `subconscious`, `watchdog` or `processLogging` is
1837
1902
  * anything but an object, null or false (holdsSettings), or whose switch in
1838
- * one holds anything but true or false (assertBooleanSetting). A subconscious
1839
- * that isn't on has its `allowChannelSpeech` left unchecked, since nothing
1840
- * reads it then.
1903
+ * one holds anything but true or false (assertBooleanSetting), or whose
1904
+ * subconscious's `thinkingBinding` assertThinkingBinding refuses, whichever
1905
+ * its reader. A subconscious that isn't on has its `allowChannelSpeech` and
1906
+ * `thinkingBinding` left unchecked, since nothing reads them then.
1841
1907
  */
1842
1908
  function assertFrameworkSwitches(config: FrameworkConfig): void {
1843
1909
  if (holdsSettings('subconscious', config.subconscious, '{ enabled: true }')) {
@@ -1853,6 +1919,7 @@ function assertFrameworkSwitches(config: FrameworkConfig): void {
1853
1919
  config.subconscious.allowChannelSpeech,
1854
1920
  'true lets the subconscious speak in channels with speak_in_channel, and false or leaving it out refuses it',
1855
1921
  );
1922
+ assertThinkingBinding('subconscious.thinkingBinding', config.subconscious.thinkingBinding);
1856
1923
  }
1857
1924
  }
1858
1925
  if (holdsSettings('watchdog', config.watchdog, '{ enabled: true }')) {
@@ -2180,15 +2247,16 @@ export interface HostModeStatus {
2180
2247
  activeTurnAgents: string[];
2181
2248
  /** Wakes that own provider admission while waiting for an in-flight
2182
2249
  * auxiliary call — no turn token yet, but a turn is one settle away. The
2183
- * admission continuation rechecks quiesce and requeues them; `drained`
2184
- * is false until it has. A parked continuation of a turn is counted here
2185
- * and, as a turn alive, in `activeTurns` and `activeTurnAgents`. */
2250
+ * admission continuation rechecks quiesce and provider holds, and requeues
2251
+ * a wake either keeps from starting; `drained` is false until it has. A
2252
+ * parked continuation of a turn is counted here and, as a turn alive, in
2253
+ * `activeTurns` and `activeTurnAgents`. */
2186
2254
  parkedAdmissions: number;
2187
2255
  /** Inference requests waiting to start: queued (parked by the quiesce wake
2188
2256
  * gate while quiesced, or, while serving, until the scheduler's next pass
2189
2257
  * takes them), in a scheduler pass's hands, or held by a provider hold (an
2190
2258
  * acceleration or host hold, or a provider's stated wait). There, a start
2191
- * that carries a turn on counts as the wakes merged into it. A start parked
2259
+ * merged from several requests counts as those it took in. A start parked
2192
2260
  * on provider admission behind an auxiliary call counts once in
2193
2261
  * `parkedAdmissions` instead, however many wakes are merged into it.
2194
2262
  * Continuations of a turn (budget restart, tool results) and a held turn's
@@ -2356,9 +2424,15 @@ function hostCommandRequester(serverId: string, params: HostCommandParams): Oper
2356
2424
  * schedule, jittered, 10 s doubling to 5 min (by default 13 tries, where
2357
2425
  * membrane's settings have 7: about 30 min).
2358
2426
  * - rate limited (429): the base schedule with at least 5 tries.
2359
- * - any other retryable failure, a stream deadline on a sent request
2360
- * included (membrane leaves those to its caller): the base schedule (by
2361
- * default 3 tries, 1 s then 2 s).
2427
+ * - a deadline expiry, which membrane leaves to its caller: a progress
2428
+ * bound's cut (FrameworkConfig.inferenceBounds), the adapter's own
2429
+ * watchdogs, the SDK's request timeout or a runtime's fetch timeout. The
2430
+ * deadline schedule: by default 2 tries, 1 s apart, so one fresh call. For
2431
+ * one, the framework's `attempt` counts that call's consecutive expiries,
2432
+ * not the turn's other failures, so each call a deadline cuts gets its
2433
+ * fresh call. Settings with no deadline schedule give it their base one.
2434
+ * - any other retryable failure: the base schedule (by default 3 tries, 1 s
2435
+ * then 2 s).
2362
2436
  * A provider's stated wait (`retryAfterMs`) is waited when it is longer than
2363
2437
  * the backoff, up to the schedule's ceiling, which by default is at least the
2364
2438
  * framework's own cap (PROVIDER_ACCELERATION_MAX_COOLDOWN_MS, ten minutes);
@@ -2380,6 +2454,10 @@ export class DefaultErrorPolicy implements ErrorPolicy {
2380
2454
  }
2381
2455
 
2382
2456
  onInferenceError(error: Error, _agentName: string, attempt: number): ErrorAction {
2457
+ if (isDeadlineExpiry(error)) {
2458
+ const decision = retryDecision(this.settings.deadline ? 'deadline' : 'retryable', attempt + 1, undefined, this.settings);
2459
+ return decision.retry ? { retry: true, delayMs: decision.delayMs } : { retry: false };
2460
+ }
2383
2461
  if (!isMembraneError(error)) {
2384
2462
  // Not a provider failure membrane classified: retried as any other
2385
2463
  // retryable failure, on the base schedule.
@@ -2388,9 +2466,7 @@ export class DefaultErrorPolicy implements ErrorPolicy {
2388
2466
  }
2389
2467
  if (error.retryable === false) return { retry: false };
2390
2468
  // The facts membrane classifies by, read off the error itself (from either
2391
- // copy of membrane, as isMembraneError takes one). No `deadline` fact: a
2392
- // stream deadline is a retryable timeout here, as it was before membrane
2393
- // 0.7, where membrane itself gives it no retry.
2469
+ // copy of membrane, as isMembraneError takes one).
2394
2470
  const facts = error as { retryable?: boolean; type?: string; httpStatus?: number; message?: string };
2395
2471
  // A failure that doesn't say is retried as an unknown one is; only false refuses (handled above).
2396
2472
  const cls = classifyFailure({ retryable: facts.retryable !== false, type: facts.type, httpStatus: facts.httpStatus, message: facts.message });
@@ -2420,6 +2496,19 @@ const MAINTENANCE_TICKS_PER_PASS = 8;
2420
2496
  * together, and then on awareness drains still running, such as its
2421
2497
  * startup's delivery. */
2422
2498
  const FAILED_CREATE_TEARDOWN_TIMEOUT_MS = 30_000;
2499
+ const ABANDON_GRACE_MS = 10_000;
2500
+
2501
+ const testAbandonGraces = new WeakMap<AgentFramework, number>();
2502
+
2503
+ /**
2504
+ * For tests only: `framework`'s abandon takes `ms` as its grace in place of
2505
+ * {@link ABANDON_GRACE_MS}. Set it only where the test holds every turn the
2506
+ * abandon reaches past the grace, so no turn can end within it and a short
2507
+ * grace only brings the abandon's report sooner.
2508
+ */
2509
+ export function setAbandonGraceForTests(framework: AgentFramework, ms: number): void {
2510
+ testAbandonGraces.set(framework, ms);
2511
+ }
2423
2512
 
2424
2513
  /** Local per-residence provider admission (AF #114 bounded first slice). */
2425
2514
  interface LocalProviderGate {
@@ -2443,9 +2532,10 @@ interface ProviderAccelerationCooldown {
2443
2532
  /** Armed by an auxiliary (compression) failure with no primary involved:
2444
2533
  * release reopens auxiliary admission without synthesising an inference. */
2445
2534
  auxiliaryOrigin?: boolean;
2446
- /** Its held requests were taken out by dropContinuations: release reopens
2447
- * admission without synthesising an inference in their place. */
2448
- emptiedByDrop?: boolean;
2535
+ /** Its held requests were all taken out, by dropContinuations ('drop') or
2536
+ * by a withdrawal of their coalescing subject ('withdrawal'): release
2537
+ * reopens admission without synthesising an inference in their place. */
2538
+ emptiedBy?: 'drop' | 'withdrawal';
2449
2539
  rearm?: { number: number; of: number };
2450
2540
  /** Set when this hold is a provider wait (a stated retry-after) for the
2451
2541
  * agent's primary model: it holds primary turns only. Auxiliary calls are
@@ -2484,13 +2574,21 @@ const PROVIDER_ACCELERATION_DEFAULT_COOLDOWN_MS = 65_000;
2484
2574
  * longest stated wait an ephemeral run sleeps in its turn: a longer one ends
2485
2575
  * the run instead (nonOwningProviderWait). */
2486
2576
  const PROVIDER_ACCELERATION_MAX_COOLDOWN_MS = 10 * 60_000;
2577
+ const FAILURE_ADVICE = 'If this recurs with the same cause, change approach rather than retrying identically ' +
2578
+ '(e.g. drop an oversized attachment or an unsupported setting).';
2579
+ const TIMEOUT_ADVICE = 'If a reply is still owed from that turn, you can give it now.';
2580
+ const MADE_AGAIN = 'The call was made again, and it timed out again.';
2487
2581
  const OVERLOADED_TURN_TRIES = 13;
2488
- const TURN_RETRY_SETTINGS: RetrySettings = Object.fromEntries(
2489
- Object.entries(MEMBRANE_RETRY_SETTINGS as RetrySettings).map(([cls, s]) => [cls, s && {
2490
- ...s,
2491
- ...(cls === 'overloaded' ? { maxAttempts: OVERLOADED_TURN_TRIES } : {}),
2492
- statedWaitCeilingMs: s.statedWaitCeilingMs === null ? null : Math.max(s.statedWaitCeilingMs, PROVIDER_ACCELERATION_MAX_COOLDOWN_MS),
2493
- }])) as RetrySettings;
2582
+ const DEADLINE_TURN_TRIES = 2;
2583
+ const TURN_RETRY_SETTINGS: RetrySettings = {
2584
+ ...Object.fromEntries(
2585
+ Object.entries(MEMBRANE_RETRY_SETTINGS as RetrySettings).map(([cls, s]) => [cls, s && {
2586
+ ...s,
2587
+ ...(cls === 'overloaded' ? { maxAttempts: OVERLOADED_TURN_TRIES } : {}),
2588
+ statedWaitCeilingMs: s.statedWaitCeilingMs === null ? null : Math.max(s.statedWaitCeilingMs, PROVIDER_ACCELERATION_MAX_COOLDOWN_MS),
2589
+ }])),
2590
+ deadline: { maxAttempts: DEADLINE_TURN_TRIES, firstDelayMs: 1_000, multiplier: 1, maxDelayMs: 1_000, statedWaitCeilingMs: null },
2591
+ } as RetrySettings;
2494
2592
  const PROVIDER_ACCELERATION_JITTER_MS = 5_000;
2495
2593
  /** Longest delay one setTimeout can represent (2^31-1 ms, about 24.8 days);
2496
2594
  * a longer timer fires almost at once, so longer waits re-arm in steps. */
@@ -2540,6 +2638,11 @@ async function* turnStreamEvents(stream: YieldingStream, agentName: string): Asy
2540
2638
  console.error(`[stream] ${agentName}: the stream sent an event (${event.type}) after its ${decidedBy} event, which decided its turn; it and the rest of the stream decide nothing`);
2541
2639
  return;
2542
2640
  }
2641
+ if (event.type === 'aborted' && event.reason === 'timeout') {
2642
+ decidedBy = 'error';
2643
+ yield { type: 'error', error: deadlineCut(event.message) } as StreamEvent;
2644
+ continue;
2645
+ }
2543
2646
  if (event.type === 'complete' || event.type === 'error' || event.type === 'aborted') decidedBy = event.type;
2544
2647
  yield event;
2545
2648
  }
@@ -2684,6 +2787,7 @@ interface EphemeralRun {
2684
2787
  endReason?: AgentEndReason;
2685
2788
  /** Who ended it, when it was stopped from outside. */
2686
2789
  endedBy?: string;
2790
+ endedFromOutside?: true;
2687
2791
  }
2688
2792
 
2689
2793
  /**
@@ -3026,6 +3130,31 @@ const isLowSurrogate = (text: string, index: number): boolean => {
3026
3130
  const splitsPair = (text: string, index: number): boolean =>
3027
3131
  index > 0 && index < text.length && isHighSurrogate(text, index - 1) && isLowSurrogate(text, index);
3028
3132
 
3133
+ /**
3134
+ * Whether an MCPL server's child ended the way a server ends when it's asked
3135
+ * to: exit 0, death by a signal that asks a process to end (SIGTERM, SIGINT,
3136
+ * SIGHUP), or a shell's report of such a death by its convention, 128 plus the
3137
+ * signal's number (143, 130, 129). A service manager's stop signals the host
3138
+ * and its children together, so a child can go before the host's stop()
3139
+ * closes it: its loss is that stop, not a failure, and raises no mcpl-down.
3140
+ * A child the stdio transport ended itself, after a pipe error or a line on
3141
+ * its stdout longer than the bound, was ended by that, whatever its code or
3142
+ * signal. A WebSocket's close codes are 1000 and up, so they never match.
3143
+ */
3144
+ function endedAsAsked(ended: { code?: number | null; signal?: string | null; pipeFailed?: boolean }): boolean {
3145
+ if (ended.pipeFailed) return false;
3146
+ const { code, signal } = ended;
3147
+ return code === 0 || code === 143 || code === 130 || code === 129 ||
3148
+ signal === 'SIGTERM' || signal === 'SIGINT' || signal === 'SIGHUP';
3149
+ }
3150
+
3151
+ /** A lost connection's words for an alert: the transport's reason, then its
3152
+ * exit or close code and signal, each when there is one. */
3153
+ function closeWords(reason: string | null | undefined, code: number | null | undefined, signal: string | null | undefined): string {
3154
+ const how = [reason ?? null, code != null ? `code ${code}` : null, signal ? `signal ${signal}` : null].filter((part) => part !== null);
3155
+ return how.length > 0 ? ` (${how.join(', ')})` : '';
3156
+ }
3157
+
3029
3158
  /**
3030
3159
  * `text` when it fits within `max` characters; otherwise its head and tail
3031
3160
  * around a marker stating how many of how many characters were omitted, the
@@ -3261,6 +3390,17 @@ export function shedMayHelp(errorType: string | undefined, httpStatus: number |
3261
3390
  return errorType === 'invalid_request' && (httpStatus === undefined || httpStatus === 400 || httpStatus === 422);
3262
3391
  }
3263
3392
 
3393
+ function responseParts(response: EventResponse | undefined): string {
3394
+ return [
3395
+ response?.addMessages?.length ? `${response.addMessages.length} message(s) to add` : '',
3396
+ response?.editMessages?.length ? `${response.editMessages.length} edit(s)` : '',
3397
+ response?.removeMessages?.length ? `${response.removeMessages.length} removal(s)` : '',
3398
+ response?.stateUpdate !== undefined ? 'a state update' : '',
3399
+ response?.requestInference ? 'an inference request' : '',
3400
+ response?.toolsChanged ? 'a tools change' : '',
3401
+ ].filter(Boolean).join(', ');
3402
+ }
3403
+
3264
3404
  /**
3265
3405
  * What a thrown value says, safely and briefly, as boundedErrorMessage gives
3266
3406
  * it, with `recorded` applied before the cut, so no part of a credential
@@ -3423,6 +3563,13 @@ interface StreamRequestNotes {
3423
3563
 
3424
3564
  const SUBCONSCIOUS_MODEL_FIELDS = ['physicalWindowTokens', 'thinkingBinding'] as const satisfies ReadonlyArray<keyof AgentConfig>;
3425
3565
 
3566
+ function readerModelFields(cfg: SubconsciousConfig): Pick<AgentConfig, (typeof SUBCONSCIOUS_MODEL_FIELDS)[number]> {
3567
+ return {
3568
+ ...(cfg.physicalWindowTokens != null ? { physicalWindowTokens: cfg.physicalWindowTokens } : {}),
3569
+ ...(holdsSettings('subconscious.thinkingBinding', cfg.thinkingBinding, '{ binds: true, dropBlock: true }') ? { thinkingBinding: cfg.thinkingBinding } : {}),
3570
+ };
3571
+ }
3572
+
3426
3573
  const JUDGE_PARAMETER: ToolParameter = {
3427
3574
  type: 'object',
3428
3575
  description:
@@ -3491,9 +3638,10 @@ export class AgentFramework {
3491
3638
  private store: JsStore;
3492
3639
  private ownsStore: boolean;
3493
3640
  private stopPromise: Promise<void> | null = null;
3494
- private stopCompleted = false;
3641
+ // Nothing sets it back to 'none': a stop ends its instance (#315).
3642
+ private stopState: 'none' | 'stopping' | 'completed' | 'failed' = 'none';
3643
+ private closedStopFailure: { error: unknown } | undefined;
3495
3644
  private ownedStoreClosed = false;
3496
- private ownedStoreManagersClosed = false;
3497
3645
  /** Whether the latest stop() got through its teardown with nothing failed:
3498
3646
  * every step and every wait up to and including the one on awareness
3499
3647
  * drains, whatever failed after that, in the storage work that finishes
@@ -3509,8 +3657,8 @@ export class AgentFramework {
3509
3657
  * promise: a stream it cancelled or an event loop still in a call; and a
3510
3658
  * stream whose cancel threw, which stop() doesn't wait for. A later
3511
3659
  * stop() waits for each again, beside what it finds, whatever has happened
3512
- * since to the entry it was found under (a run's end removes its stream's,
3513
- * a start() replaces the loop), so neither a budget nor a cancel that
3660
+ * since to the entry it was found under (a run's end removes its stream's),
3661
+ * so neither a budget nor a cancel that
3514
3662
  * threw lets the store close under work still running. Each leaves once it
3515
3663
  * settles. */
3516
3664
  private readonly leftRunning = new Map<Promise<unknown>, { label: string; kind: 'stream' | 'loop' }>();
@@ -3576,6 +3724,9 @@ export class AgentFramework {
3576
3724
  private providerAccelerationLastRecovery: Map<string, ProviderAccelerationReceipt> = new Map();
3577
3725
  private providerAccelerationDefaultCooldownMs = PROVIDER_ACCELERATION_DEFAULT_COOLDOWN_MS;
3578
3726
  private providerAccelerationJitterMs = PROVIDER_ACCELERATION_JITTER_MS;
3727
+ private inferenceBounds: InferenceBounds | null = resolveInferenceBounds(undefined);
3728
+ private readonly deadlineCuts = new Map<string, number>();
3729
+ private readonly freshCallsCut = new Set<string>();
3579
3730
  private providerHoldHook: ProviderHoldHook | undefined;
3580
3731
  /** Provider waits (stated retry-after) per (agent, model), kept across restarts. */
3581
3732
  private providerWaits: ProviderWaits | undefined;
@@ -3583,8 +3734,6 @@ export class AgentFramework {
3583
3734
  private retryWaitWakers = new Set<() => void>();
3584
3735
  private waitingTraceEveryMs = WAITING_TRACE_EVERY_MS;
3585
3736
  private providerAdmissionClosed = false;
3586
- /** Invalidates framework turns still assembling when stop begins. */
3587
- private lifecycleGeneration = 0;
3588
3737
  /** Last time we reported stale (busy-requeued) inference requests, per agent. */
3589
3738
  private staleWarnAt = new Map<string, number>();
3590
3739
  /** Per-agent last inference activity (epoch ms), for /healthz + doctor tooling. */
@@ -3640,7 +3789,6 @@ export class AgentFramework {
3640
3789
  * next turn.
3641
3790
  */
3642
3791
  private turnRouting: Map<string, TurnRouting> = new Map();
3643
- private readonly reasoningLoss = new ReasoningLossTeller();
3644
3792
  /**
3645
3793
  * The latest link of each agent's chain of queued prose deliveries. Every
3646
3794
  * stream queues its own behind it, a fresh turn's included, so the agent's
@@ -3736,6 +3884,7 @@ export class AgentFramework {
3736
3884
  private turnRearmMs: readonly number[] = DEFAULT_TURN_REARM_MS;
3737
3885
  private turnRearms: Map<string, number> = new Map();
3738
3886
  private exhaustingRearms: Set<string> = new Set();
3887
+ private rearmDeadlineCuts: Set<string> = new Set();
3739
3888
  private statedWaitRuns: Map<string, StatedWaitRun> = new Map();
3740
3889
  /** N consecutive failed inferences ⇒ the agent is treated as hard-down and
3741
3890
  * escalated loudly to stderr. */
@@ -4034,7 +4183,7 @@ export class AgentFramework {
4034
4183
  private collapseDerivationNoticesAfter = 10;
4035
4184
  /** The host's say over each derivation, ephemeral spawn and resume (config.dendrite.beforeDerive). */
4036
4185
  private beforeDerive?: (spec: AgentSpec) => void | { refuse: string };
4037
- /** What a source is told of a derivation (config.dendrite.derivationNotice); absent, the default line. */
4186
+ /** What a source is told of a derivation (config.dendrite.derivationNotice); absent, nothing. */
4038
4187
  private derivationNoticeHook?: (record: AgentRecord, from: string, defaultText: string) => string | null;
4039
4188
  /** Per source agent, per kind: how many derivations, since when, and at which count the last note went. */
4040
4189
  private derivationTallies: Map<string, Map<string, { count: number; since: number }>> = new Map();
@@ -4314,6 +4463,7 @@ export class AgentFramework {
4314
4463
  getMessage: (id) => this.getMessage(id),
4315
4464
  queryMessages: (filter, opts) => this.queryMessages(filter, opts),
4316
4465
  pushEvent: (event, moduleName) => this.pushModuleEvent(event, moduleName, true),
4466
+ dropRetiredPush: (event, moduleName) => this.dropRetiredModuleEvent(event, moduleName),
4317
4467
  onTrace: (listener) => this.onTrace(listener),
4318
4468
  // The ONLY entry that confers module origin — a private closure the
4319
4469
  // framework hands to its own module registry. The public
@@ -4337,6 +4487,7 @@ export class AgentFramework {
4337
4487
  static async create(config: FrameworkConfig): Promise<AgentFramework> {
4338
4488
  // Before anything opens or starts: a refused config must leave nothing behind.
4339
4489
  if (config.codeExecution != null) validateCodeExecutionConfig(config.codeExecution);
4490
+ resolveInferenceBounds(config.inferenceBounds);
4340
4491
  assertFrameworkSwitches(config);
4341
4492
  for (const agentConfig of config.agents) assertAgentSwitches(agentConfig);
4342
4493
  assertAgentNamesUnique(config.agents);
@@ -4538,6 +4689,7 @@ export class AgentFramework {
4538
4689
  );
4539
4690
  framework.providerHoldHook = config.providerHold;
4540
4691
  framework.turnRearmMs = turnRearmDelays(config.exhaustedTurnRearmMs);
4692
+ framework.inferenceBounds = resolveInferenceBounds(config.inferenceBounds);
4541
4693
 
4542
4694
  // Before any delivery.
4543
4695
  await framework.reconcileDiscordAwarenessAtStartup();
@@ -5062,9 +5214,7 @@ export class AgentFramework {
5062
5214
  * maintenance, event loop and operator operations, so a dump shows what
5063
5215
  * those waits are on, and before its modules and MCPL servers stop, so a
5064
5216
  * stop() whose teardown hangs, fails or times out doesn't keep it listed.
5065
- * With none in the set, one line says no framework is running. A framework
5066
- * started again after a completed stop() doesn't rejoin; whether that
5067
- * lifecycle is supported is #315.
5217
+ * With none in the set, one line says no framework is running.
5068
5218
  *
5069
5219
  * One listener serves the process, installed by the first create() to
5070
5220
  * reach the join and kept: once a process's last SIGUSR2 listener is
@@ -5108,36 +5258,39 @@ export class AgentFramework {
5108
5258
 
5109
5259
  /**
5110
5260
  * Start the event loop. A start() on a running framework does nothing.
5111
- * Refused, with an error, from a stop() call until that stop() settles,
5112
- * since that stop would wait on the lifecycle a start() begins, or tear it
5113
- * down under it. What a start() after a settled stop() should do is #315's
5114
- * question.
5261
+ * A stop ends its instance (#315): once a stop() is admitted, start() and
5262
+ * addModule() are refused with an error, for good. While that stop() runs,
5263
+ * the error says the framework is stopping. Once it has settled, the error
5264
+ * says what to do: create a new AgentFramework on the store, and after a
5265
+ * failed stop(), call stop() again first, until one resolves. When a
5266
+ * failed stop() left its owned store closed, so that it can't be tried
5267
+ * again, the error carries that failure as its cause.
5268
+ *
5269
+ * A new instance on the store keeps what the store holds. With the
5270
+ * heartbeat on and the same heartbeat state file (heartbeat.statePath, or
5271
+ * its default), it keeps the heartbeat's schedule as it reads it from that
5272
+ * file: a tick or a reminder that fell due meanwhile fires once, shortly
5273
+ * after it starts. What an instance holds only in memory is lost with it,
5274
+ * as on a process restart: an agent's sleep and its wake, a self-wake, the
5275
+ * inference requests still queued (a budget restart, a heartbeat's wake, a
5276
+ * nudge), a re-armed turn's wait and count, and the redo stack. #627 is
5277
+ * about keeping a sleep and a self-wake across a restart. To pause and
5278
+ * resume one framework in place, use quiesce() and resume().
5115
5279
  */
5116
5280
  start(): void {
5117
5281
  // Before the running check: stop() claims its attempt before it clears
5118
5282
  // running, so a start() made in its first steps, such as from a trace
5119
5283
  // listener, is refused too.
5120
- this.resetCompletedStop('start()');
5284
+ this.refuseOnceStopped('start()');
5121
5285
  if (this.running) {
5122
5286
  return;
5123
5287
  }
5124
5288
 
5125
5289
  this.running = true;
5126
- this.providerAdmissionClosed = false;
5127
5290
  this.earlyTraces = null;
5128
- // A new lifecycle: a turn compiled before an earlier stop() never drives,
5129
- // and withheld spills, closed for the generation a stop ended, are open
5130
- // for this one.
5131
- this.lifecycleGeneration++;
5132
- // Its servers' addresses, which an earlier stop() took out of the
5133
- // reference registry, for its stubs.
5134
5291
  for (const config of this.mcplServerConfigs.values()) {
5135
5292
  referenceRegistry.setServerAddress(config.id, serverAddressOf(config), this);
5136
5293
  }
5137
- // A sleep kept through a stop() wakes this lifecycle at its end, or now
5138
- // if it ended while stopped. Before the loop's first pass, so that a wake
5139
- // owed now joins the requests already pending in one turn.
5140
- this.eventGate?.resumeSleep();
5141
5294
  this.heartbeat?.start();
5142
5295
  this.loopPromise = this.runLoop();
5143
5296
 
@@ -5164,11 +5317,14 @@ export class AgentFramework {
5164
5317
  }
5165
5318
 
5166
5319
  /**
5167
- * Stop the event loop. Until it settles, start() and addModule() are
5168
- * refused. Each step of the teardown runs whatever an earlier one did: one
5169
- * that throws, such as a host stream's cancel(), fails the stop() once the
5170
- * rest has run. When a step or a wait up to and including
5171
- * the wait on awareness drains fails or times out, storage stays open and
5320
+ * Stop the event loop, and with it this framework, for good: once a stop()
5321
+ * is admitted, start() and addModule() are refused, as start() says. A
5322
+ * stop() whose budget is refused, which it rejects with a RangeError, is
5323
+ * not admitted and changes nothing. Each step of the teardown runs
5324
+ * whatever an earlier one did: one that throws, such as a host stream's
5325
+ * cancel(), fails the stop() once the rest has run. When a step or a wait
5326
+ * up to and including the wait on awareness drains fails or times out,
5327
+ * storage stays open and
5172
5328
  * stop() can be called again; one that fails after it, in the storage work
5173
5329
  * that finishes the stop, fails the stop() without keeping storage open.
5174
5330
  * A timed-out wait lets teardown go on while what it waited for may still
@@ -5176,7 +5332,10 @@ export class AgentFramework {
5176
5332
  * @param teardownTimeoutMs Optional budget for each of stop()'s waits, in
5177
5333
  * turn: on the streams it cancels, on maintenance in flight (a pass or an
5178
5334
  * over-budget drain kick), on the event loop, on an operator's operations
5179
- * already running, on modules and MCPL together, then on Discord awareness
5335
+ * already running, on modules and MCPL (its servers and the inference
5336
+ * requests they made) together, where a module whose start() is still
5337
+ * running counts until that start() has settled and its stop() returned,
5338
+ * then on Discord awareness
5180
5339
  * drains, which send nothing once stop() has begun, recording the call the
5181
5340
  * close ended. With a budget, a stop() can take up to six of them, besides
5182
5341
  * the withheld-spill deadline of a guard's write still under way. Omit to
@@ -5199,9 +5358,7 @@ export class AgentFramework {
5199
5358
  * dropped, though a write the stream already had under way can still
5200
5359
  * land in the run's namespace after them.
5201
5360
  * - an event loop still in a call, such as a module's onProcess, finishes
5202
- * that call and the rest of its iteration, and then exits. A start()
5203
- * made once that stop() has failed runs its own loop beside that last
5204
- * iteration.
5361
+ * that call and the rest of its iteration, and then exits.
5205
5362
  * - a later stop() waits again, within its own budget, for any stream or
5206
5363
  * loop this one left running, so the store never closes under it.
5207
5364
  *
@@ -5224,11 +5381,13 @@ export class AgentFramework {
5224
5381
  let settle!: { resolve: () => void; reject: (error: unknown) => void };
5225
5382
  const attempt = new Promise<void>((resolve, reject) => { settle = { resolve, reject }; });
5226
5383
  this.stopPromise = attempt;
5227
- void attempt.then(() => { this.stopCompleted = true; }, () => {});
5228
- // Failed cleanup can be retried while its original store is open. A
5229
- // terminal storage failure after successful close must remain a failure.
5230
- void attempt.catch(() => {
5231
- if (!this.ownedStoreClosed && this.stopPromise === attempt) this.stopPromise = null;
5384
+ this.stopState = 'stopping';
5385
+ void attempt.then(() => { this.stopState = 'completed'; }, (error: unknown) => {
5386
+ this.stopState = 'failed';
5387
+ // Failed cleanup can be retried while its original store is open. A
5388
+ // terminal storage failure after successful close must remain a failure.
5389
+ if (this.ownedStoreClosed) this.closedStopFailure = { error };
5390
+ else if (this.stopPromise === attempt) this.stopPromise = null;
5232
5391
  });
5233
5392
  // The body still starts in this call, so its synchronous steps (closing
5234
5393
  // the queue among them) happen before stop() returns.
@@ -5236,22 +5395,29 @@ export class AgentFramework {
5236
5395
  return attempt;
5237
5396
  }
5238
5397
 
5239
- /** What start() and addModule() pass before either begins work of a
5240
- * lifecycle. A stop() still running refuses both, from the moment it
5241
- * claims its attempt until that attempt settles. A failed stop() has
5242
- * cleared its attempt, unless it closed an owned store, or the context
5243
- * managers it closes before that store, which are refused first. A
5244
- * completed stop() is cleared, and the call goes on as before;
5245
- * whether a lifecycle should begin after a stop() is #315's question. */
5246
- private resetCompletedStop(verb: 'start()' | 'addModule()'): void {
5247
- if (this.ownedStoreClosed) throw new Error('Cannot start a new lifecycle: owned store is closed');
5248
- if (this.ownedStoreManagersClosed) {
5249
- throw new Error('Cannot start a new lifecycle: stop() closed its context managers, and then failed to close its owned store');
5250
- }
5251
- if (this.stopPromise && !this.stopCompleted) throw new Error(`${verb} refused: the framework is stopping`);
5252
- if (this.stopCompleted) {
5253
- this.stopPromise = null;
5254
- this.stopCompleted = false;
5398
+ private refuseOnceStopped(verb: 'start()' | 'addModule()'): void {
5399
+ switch (this.stopState) {
5400
+ case 'none':
5401
+ return;
5402
+ case 'stopping':
5403
+ throw new Error(`${verb} refused: the framework is stopping`);
5404
+ case 'completed':
5405
+ throw new Error(`${verb} refused: this framework has stopped, and a stopped framework doesn't start again. ` +
5406
+ 'Create a new AgentFramework on its store to go on, with its storePath if it opened the store itself.');
5407
+ case 'failed': {
5408
+ const closed = this.closedStopFailure;
5409
+ if (closed) {
5410
+ throw new Error(`${verb} refused: this framework's stop() failed and its owned store is closed, so stop() can't be tried again: ` +
5411
+ `it rejects with that same failure, this error's cause: ${shutdownErrorMessage(closed.error)}. ` +
5412
+ "A stopped framework doesn't start again, and a new AgentFramework on its store doesn't finish what that stop() left unfinished.",
5413
+ { cause: closed.error });
5414
+ }
5415
+ throw new Error(`${verb} refused: this framework's stop() failed, and a stopped framework doesn't start again. ` +
5416
+ 'A failed stop() may have left its cleanup or final persistence unfinished. ' +
5417
+ "Call stop() again: once a stop() of this one resolves, they're done, and only then should a new " +
5418
+ 'AgentFramework use its store, with its storePath if this one opened the store itself, ' +
5419
+ 'or another instance use anything this one shares.');
5420
+ }
5255
5421
  }
5256
5422
  }
5257
5423
 
@@ -5373,6 +5539,13 @@ export class AgentFramework {
5373
5539
  // holding the wait for operator operations below until its handshake's
5374
5540
  // deadline.
5375
5541
  step(() => this.endConnectsInFlight());
5542
+ // Nor does a server lost before or during the stop launch or dial again
5543
+ // on its own: a retry already scheduled starts nothing, and none is
5544
+ // scheduled after.
5545
+ step(() => {
5546
+ for (const connection of this.mcplServerRegistry?.getAllServers() ?? []) connection.stopReconnecting();
5547
+ for (const connection of this.modernServers().values()) connection.stopReconnecting();
5548
+ });
5376
5549
  step(() => this.pushCoalescer?.suspend());
5377
5550
  step(() => this.flushCoalescingSnapshot());
5378
5551
  this.running = false;
@@ -5386,11 +5559,10 @@ export class AgentFramework {
5386
5559
  step(() => this.ackDeferredWrites());
5387
5560
  this.providerAdmissionClosed = true;
5388
5561
  for (const wake of [...this.retryWaitWakers]) step(wake);
5389
- this.lifecycleGeneration++;
5390
- // The generation this stop ends; withheld spills close for it alone, so a
5391
- // start() after this stop has settled reopens them for its own.
5392
- const stoppingGeneration = this.lifecycleGeneration;
5562
+ this.guardSpillsClosed = false;
5393
5563
  step(() => this.queue.close());
5564
+ let inferences: Array<{ label: string; promise: Promise<void> }> = [];
5565
+ step(() => { inferences = this.inferenceRouter?.stopAll() ?? []; });
5394
5566
  step(() => this.tuneOutCoordinator?.stop());
5395
5567
 
5396
5568
  // Stop running code_execution scripts before cancelling streams: a
@@ -5405,9 +5577,8 @@ export class AgentFramework {
5405
5577
  this.toolImageLedgers.clear();
5406
5578
 
5407
5579
  // A turn whose continuation is still on its way ends with the stop, as
5408
- // a turn's start a stop reaches does: the continuation would start only
5409
- // after a start(), in a lifecycle where its logical turn is gone
5410
- // (openLogical, below). Before the holds and the parks go, so the
5580
+ // a turn's start a stop reaches does: the continuation never starts.
5581
+ // Before the holds and the parks go, so the
5411
5582
  // continuation leaves them with its turn. Each drop is a step of its
5412
5583
  // own, so one that throws leaves the next agent's turn to end all the
5413
5584
  // same, as stop()'s other loops do.
@@ -5418,11 +5589,7 @@ export class AgentFramework {
5418
5589
  });
5419
5590
  // A stopped host must never hang behind its own cooldown.
5420
5591
  step(() => this.cancelProviderAdmission());
5421
- // An open logical turn doesn't survive a stop: a start after a restart
5422
- // on this instance opens a new one rather than continuing the old turn's
5423
- // routing (openLogical). A resume still continues its turn, with its
5424
- // routing state (turnRouting), since it carries that turn's own request
5425
- // on: beginAgentTurn opens the turn again for it.
5592
+ // An open logical turn doesn't survive a stop.
5426
5593
  this.openLogical.clear();
5427
5594
 
5428
5595
  // Cancel the stream of every agent whose state holds one: streaming,
@@ -5541,8 +5708,9 @@ export class AgentFramework {
5541
5708
  // its idle watchdog. Its end stores its queue, as at any run's end; that
5542
5709
  // stream's late events are dropped once the run has left, and a later
5543
5710
  // stop() waits for the stream itself (leftRunning). (A start() is refused
5544
- // until this stop() settles, so no new lifecycle begins beside it.)
5711
+ // once a stop() is admitted, so no new lifecycle begins beside it.)
5545
5712
  for (const [name, run] of this.ephemeralRuns) run.settle.reject(ephemeralRunStopped(name));
5713
+ for (const agent of this.agents.values()) step(() => { agent.unpinTurnInputs(); });
5546
5714
 
5547
5715
  // Admission closed when stop() began. What admitted operations start now,
5548
5716
  // such as a surgery's config materialization, joins the set, so wait until
@@ -5572,11 +5740,9 @@ export class AgentFramework {
5572
5740
  // generation and nothing reads the old fork's window again. So each live
5573
5741
  // fork ends here as a closing fork's end disposes it, and its release
5574
5742
  // passes on what it left unread, as any release does. Its channel unbinds
5575
- // first, as the idle sweep's does: a start() after this stop() would
5576
- // otherwise route a message still queued for it to a name that's gone. A
5577
- // fork whose spawn is still waiting for its creation to return has no
5578
- // binding yet, and its spawn binds nothing once it has ended
5579
- // (spawnConversationAgent).
5743
+ // first, as the idle sweep's does. A fork whose spawn is still waiting
5744
+ // for its creation to return has no binding yet, and its spawn binds
5745
+ // nothing once it has ended (spawnConversationAgent).
5580
5746
  // Each is a step of its own, and each fork ends whatever its stream or
5581
5747
  // turn is doing, as each run does above: its end settles a tool-result
5582
5748
  // batch its stream still held and removes the stream's entries
@@ -5664,17 +5830,17 @@ export class AgentFramework {
5664
5830
  // workspace: its stub names the audit record. A write that was past its
5665
5831
  // deadline isn't waited for here, but the workspace's own stop waits for
5666
5832
  // the mount turn it holds, bounded only by teardownTimeoutMs.
5667
- this.guardSpillsClosedFor = stoppingGeneration;
5833
+ this.guardSpillsClosed = true;
5668
5834
 
5669
5835
  // Stop modules and both engines' MCP servers in parallel
5670
- const shutdownPromises: Promise<void>[] = [this.moduleRegistry.stopAll(teardownTimeoutMs)];
5836
+ const shutdownPromises: Promise<void>[] = [this.moduleRegistry.stopAll(teardownTimeoutMs), waitFor(inferences)];
5671
5837
  if (this.mcplServerRegistry) {
5672
5838
  shutdownPromises.push(this.mcplServerRegistry.closeAll(teardownTimeoutMs));
5673
5839
  }
5674
5840
  shutdownPromises.push(this.closeModernMcpConnections(teardownTimeoutMs));
5675
5841
  // A stopped framework's servers' addresses leave the reference registry,
5676
5842
  // so no other framework's stub reads them and the registry doesn't keep
5677
- // this one reachable. start() records them again.
5843
+ // this one reachable.
5678
5844
  step(() => { for (const id of this.mcplServerConfigs.keys()) referenceRegistry.forgetServerAddress(id, this); });
5679
5845
  // Timed-out or failed teardown retains its context and may still write.
5680
5846
  // Sync available state best-effort, but leave its store open for a retry.
@@ -5734,8 +5900,8 @@ export class AgentFramework {
5734
5900
  // No stream is left, unless its wait above ran out or its cancel threw:
5735
5901
  // from the start of stop() none registers and no agent turn starts
5736
5902
  // (providerAdmissionClosed, checked for every agent), and every stream
5737
- // registered before was cancelled and awaited above. (A start(), which
5738
- // would reopen admission, is refused until this stop() settles.)
5903
+ // registered before was cancelled and awaited above. (A start() is
5904
+ // refused once a stop() is admitted, so admission stays closed.)
5739
5905
  // What is still deferred (a message held for a turn's end whose flush
5740
5906
  // found the target busy again, or one deferred behind its target's own
5741
5907
  // state) would wait for a flush that may never come. Land each at its
@@ -5820,7 +5986,7 @@ export class AgentFramework {
5820
5986
  }
5821
5987
 
5822
5988
  if (this.ownsStore && teardownSucceeded) {
5823
- if (this.closeOpenManagers()) this.ownedStoreManagersClosed = true;
5989
+ this.closeOpenManagers();
5824
5990
  try {
5825
5991
  this.store.close();
5826
5992
  this.ownedStoreClosed = true;
@@ -6253,10 +6419,9 @@ export class AgentFramework {
6253
6419
  }
6254
6420
  contextManager.close();
6255
6421
  }
6256
- private closeOpenManagers(): boolean {
6422
+ private closeOpenManagers(): void {
6257
6423
  const alive = [...this.openManagers].flatMap((ref) => ref.deref() ?? []);
6258
6424
  for (const contextManager of alive) this.closeManager(contextManager);
6259
- return alive.length > 0;
6260
6425
  }
6261
6426
  /** The framework's membrane, with each complete() call passed through
6262
6427
  * `admit` along with the model it names. */
@@ -6266,8 +6431,17 @@ export class AgentFramework {
6266
6431
  const value = Reflect.get(obj, prop, receiver);
6267
6432
  if (prop === 'complete' && typeof value === 'function') {
6268
6433
  return (...args: unknown[]) => {
6269
- const model = (args[0] as { config?: { model?: unknown } } | undefined)?.config?.model;
6270
- return admit(() => Reflect.apply(value, this.membrane, args) as Promise<unknown>,
6434
+ const config = (args[0] as { config?: { model?: unknown; maxTokens?: unknown } } | undefined)?.config;
6435
+ const model = config?.model;
6436
+ const options = args[1] as { progressTimeoutMs?: unknown } | undefined;
6437
+ const bounds = this.inferenceBounds;
6438
+ const sent = bounds !== null && options?.progressTimeoutMs === undefined
6439
+ ? [args[0], {
6440
+ ...options,
6441
+ progressTimeoutMs: wholeCallMs(bounds, typeof config?.maxTokens === 'number' ? config.maxTokens : undefined),
6442
+ }, ...args.slice(2)]
6443
+ : args;
6444
+ return admit(() => Reflect.apply(value, this.membrane, sent) as Promise<unknown>,
6271
6445
  typeof model === 'string' ? model : undefined);
6272
6446
  };
6273
6447
  }
@@ -6392,7 +6566,7 @@ export class AgentFramework {
6392
6566
  // length: an operator release of it returns the hold to AF's own pacing.
6393
6567
  ...(pacing?.stated ? { statedWait: { model: agent.model, ownUntil: now + pacing.ownMs } } : {}),
6394
6568
  ...(auxiliary && (!existing || existing.auxiliaryOrigin) ? { auxiliaryOrigin: true } : {}),
6395
- ...(existing?.emptiedByDrop && held.length === 0 ? { emptiedByDrop: true } : {}) });
6569
+ ...(existing?.emptiedBy && held.length === 0 ? { emptiedBy: existing.emptiedBy } : {}) });
6396
6570
  if (auxiliary && !existing) {
6397
6571
  console.error(`[provider-cooldown] agent=${agent.name} host hold (${hostHold!.reason}) from an auxiliary call — ` +
6398
6572
  `holding primary/auxiliary for ${delayMs}ms`);
@@ -6407,11 +6581,22 @@ export class AgentFramework {
6407
6581
  if (wait && trigger && (wait.until === null || wait.until > now)) this.noteStatedWaitRun(agent.name, wait, now);
6408
6582
  return true;
6409
6583
  }
6410
- private rearmExhaustedTurn(agent: Agent, error: Error, trigger: InferenceRequest | undefined, ownsProviderGate: boolean, routing: TurnRouting | undefined): void {
6584
+ private takeAsTransient(agentName: string, error: Error, trigger: InferenceRequest | undefined): boolean {
6585
+ if (isTransientFailure(error)) {
6586
+ this.rearmDeadlineCuts.delete(agentName);
6587
+ return true;
6588
+ }
6589
+ if (!isRearmed(trigger) || !isDeadlineExpiry(error)) return false;
6590
+ const first = !this.rearmDeadlineCuts.has(agentName);
6591
+ this.rearmDeadlineCuts.add(agentName);
6592
+ return first;
6593
+ }
6594
+
6595
+ private rearmExhaustedTurn(agent: Agent, error: Error, trigger: InferenceRequest | undefined, ownsProviderGate: boolean, routing: TurnRouting | undefined, asTransient: boolean): void {
6411
6596
  if (!ownsProviderGate || trigger === undefined || this.providerAdmissionClosed || this.turnRearmMs.length === 0) return;
6412
6597
  const done = this.turnRearms.get(agent.name) ?? 0;
6413
6598
  const notSent = (why: string) => console.error(`[inference-rearm] agent=${agent.name} turn not re-armed: ${why}`);
6414
- if (!isTransientFailure(error)) {
6599
+ if (!asTransient) {
6415
6600
  if (done > 0) notSent(`its failure isn't transient (${isMembraneError(error) ? error.type : boundFailureText(error.message, FAILURE_SUMMARY_MAX_CHARS)})`);
6416
6601
  return;
6417
6602
  }
@@ -6551,12 +6736,15 @@ export class AgentFramework {
6551
6736
  ? `no longer holds primary turns: the primary model is now ${model} (the wait stays recorded)`
6552
6737
  : `primary hold ended after ${Date.now() - cooldown.startedAt}ms`));
6553
6738
  }
6554
- if ((cooldown.auxiliaryOrigin || cooldown.emptiedByDrop) && cooldown.heldRequests.length === 0) {
6739
+ if ((cooldown.auxiliaryOrigin || cooldown.emptiedBy) && cooldown.heldRequests.length === 0) {
6555
6740
  // Nobody asked for an inference while parked, or what was held was a
6556
- // continuation since dropped with its turn: just reopen the gate. A
6557
- // primary the hold left pending won't run, so its recovery ends.
6558
- console.error(`[provider-cooldown] agent=${agentName} ${cooldown.auxiliaryOrigin ? 'auxiliary hold' : 'hold, its held continuation dropped,'} released after ${Date.now() - cooldown.startedAt}ms`);
6559
- if (cooldown.emptiedByDrop) this.endPendingPrimary(agentName);
6741
+ // continuation since dropped with its turn, or wakes since withdrawn:
6742
+ // just reopen the gate. A primary the hold left pending won't run, so
6743
+ // its recovery ends.
6744
+ const which = cooldown.emptiedBy === 'drop' ? 'hold, its held continuation dropped,'
6745
+ : cooldown.emptiedBy === 'withdrawal' ? 'hold, its held wakes withdrawn,' : 'auxiliary hold';
6746
+ console.error(`[provider-cooldown] agent=${agentName} ${which} released after ${Date.now() - cooldown.startedAt}ms`);
6747
+ if (cooldown.emptiedBy) this.endPendingPrimary(agentName);
6560
6748
  this.flushAuxiliaryAdmission(agentName);
6561
6749
  return;
6562
6750
  }
@@ -7212,12 +7400,7 @@ export class AgentFramework {
7212
7400
  */
7213
7401
  pushEvent(event: ProcessEvent): void {
7214
7402
  if (event.type === 'tool-result' && this.queue?.isClosed) {
7215
- // What an operator reconciling a dropped result needs (the resident
7216
- // may run the tool again): which tool, and whether it succeeded.
7217
- const outcome = event.result.success && !event.result.isError
7218
- ? 'succeeded'
7219
- : `failed${event.result.error ? `: ${thrownText(event.result.error)}` : ''}`;
7220
- console.error(`[framework] ${event.agentName}: result of tool call ${event.callId} (${event.toolName ? `${event.toolName}, ` : ''}${event.moduleName}) arrived after stop — dropped; the tool ${outcome}`);
7403
+ console.error(droppedToolResultLine(event, 'after stop'));
7221
7404
  return;
7222
7405
  }
7223
7406
  this.queue.push(event);
@@ -7247,6 +7430,14 @@ export class AgentFramework {
7247
7430
  else this.queue.push(event);
7248
7431
  }
7249
7432
 
7433
+ private dropRetiredModuleEvent(event: ProcessEvent, moduleName: string): void {
7434
+ if (this.queue.isClosed) {
7435
+ this.pushModuleEvent(event, moduleName, true);
7436
+ return;
7437
+ }
7438
+ console.error(retiredPushLine(event, moduleName));
7439
+ }
7440
+
7250
7441
  /**
7251
7442
  * Add a trace event listener for observability.
7252
7443
  */
@@ -7298,12 +7489,17 @@ export class AgentFramework {
7298
7489
  }
7299
7490
 
7300
7491
  /**
7301
- * Add a module at runtime. Refused, with a rejection, while a stop() is
7302
- * still running: a module added then would either be stopped by that stop
7303
- * or, once the stop has taken its list of modules to stop, stay started
7304
- * past it.
7492
+ * Add a module at runtime. Refused, with a rejection, once a stop() has
7493
+ * been admitted, before anything else is checked: while that stop() runs,
7494
+ * a module added then would either be stopped by it or, once it has taken
7495
+ * its list of modules to stop, stay started past it; once it has settled,
7496
+ * the framework has stopped for good, as start() says. A stop() admitted
7497
+ * while the module's start() runs stops the module once that start() has
7498
+ * settled; if that start() fulfils, this rejects the same way then, with
7499
+ * no `module:added`, and if it rejects, this rejects with its error.
7305
7500
  */
7306
7501
  async addModule(module: Module): Promise<void> {
7502
+ this.refuseOnceStopped('addModule()');
7307
7503
  // A server's tool prefix and a module's name share one namespace, as
7308
7504
  // create() checks at startup: a module isn't added under a prefix a
7309
7505
  // server holds, or one a restart is moving its server to.
@@ -7321,8 +7517,8 @@ export class AgentFramework {
7321
7517
  throw new Error(`Module "${module.name}" collides with MCPL server "${id}"'s toolPrefix "${module.name}" (a closed tab)`);
7322
7518
  }
7323
7519
  }
7324
- this.resetCompletedStop('addModule()');
7325
7520
  await this.moduleRegistry.addModule(module);
7521
+ this.refuseOnceStopped('addModule()');
7326
7522
  // The workspace judges whether an agent's history holds a message as the
7327
7523
  // framework judges its own notices, for its head-move notice
7328
7524
  // (noticeHeadMoves) and the writes it weighs against it. A module taken
@@ -7337,7 +7533,9 @@ export class AgentFramework {
7337
7533
  }
7338
7534
 
7339
7535
  /**
7340
- * Remove a module at runtime.
7536
+ * Remove a module at runtime. A module whose start() is still running is
7537
+ * stopped once that start() has settled; this waits for that and for its
7538
+ * stop(), with no time limit.
7341
7539
  */
7342
7540
  async removeModule(name: string): Promise<void> {
7343
7541
  await this.moduleRegistry.removeModule(name);
@@ -7686,7 +7884,7 @@ export class AgentFramework {
7686
7884
  ];
7687
7885
  }
7688
7886
 
7689
- /** The registered workspace module, if any (used by save_image). */
7887
+ /** The registered workspace module, if any (used by save_recent_image). */
7690
7888
  private getWorkspaceModule(): WorkspaceModule | undefined {
7691
7889
  const module = this.moduleRegistry.getAllModules().find((m) => m.name === 'workspace');
7692
7890
  return module && typeof (module as WorkspaceModule).writeBinary === 'function'
@@ -8165,15 +8363,11 @@ export class AgentFramework {
8165
8363
  private stallSkips = 0;
8166
8364
  /** See WITHHELD_SPILL_DEADLINE_MS; an instance field so tests can shorten it. */
8167
8365
  private withheldSpillDeadlineMs = WITHHELD_SPILL_DEADLINE_MS;
8168
- /** The lifecycle generation whose stop() has let the running annotations
8169
- * finish: a withheld original is then no longer written to a workspace
8170
- * that is stopping, and its stub names the audit record. A start() after
8171
- * that stop() has finished has a generation of its own, so the spill gate
8172
- * is open again for an instance kept with a store it doesn't own and that
8173
- * is still open (start() doesn't restore stopped modules or MCPL
8174
- * connections, and refuses an owned store that stop() closed). A start()
8175
- * made while a stop() is still running is refused. */
8176
- private guardSpillsClosedFor: number | null = null;
8366
+ /** Set once a stop() has let the running annotations finish, and cleared
8367
+ * as each stop() begins: while set, a withheld original is no longer
8368
+ * written to a workspace that is stopping, and its stub names the audit
8369
+ * record. */
8370
+ private guardSpillsClosed = false;
8177
8371
 
8178
8372
  /** A new agent's guard: its persisted setting, the tool-results spill its
8179
8373
  * withheld originals are written to, and the set stop() waits on
@@ -8904,7 +9098,7 @@ export class AgentFramework {
8904
9098
  // Bounded grace for cancelled streams. Stop cancels streams too; a turn
8905
9099
  // with no stream settles on its own. Report the abandon that already ran,
8906
9100
  // even if stop cuts its grace short.
8907
- const grace = Date.now() + 10_000;
9101
+ const grace = Date.now() + (testAbandonGraces.get(this) ?? ABANDON_GRACE_MS);
8908
9102
  while (this.quiesced && !this.queue?.isClosed && this.activeTurnTokens.size > 0 && Date.now() < grace) {
8909
9103
  await new Promise((resolve) => setTimeout(resolve, 250));
8910
9104
  }
@@ -9334,8 +9528,9 @@ export class AgentFramework {
9334
9528
  * memory-only: they land within the turn, at a tool boundary or at its
9335
9529
  * end, and a crash before then loses them. That window runs to the turn's
9336
9530
  * end for a message held there and what is queued behind it, and, on a
9337
- * stream that declares it can't carry injected messages (membrane's XML
9338
- * prefill path, from 0.6.0), for every message deferred during the turn.
9531
+ * stream that doesn't declare it carries injected messages (membrane's XML
9532
+ * prefill path declares it can't), for every message deferred during the
9533
+ * turn.
9339
9534
  *
9340
9535
  * A write that fails, or a queue over the cap, which writes nothing,
9341
9536
  * leaves the record as it last held it, except that store only, a write
@@ -9353,20 +9548,21 @@ export class AgentFramework {
9353
9548
  * itself before it changes the host's mode, and refuses instead, unless
9354
9549
  * the call has `abandon` (writeDeferredWritesForQuiesce).
9355
9550
  */
9356
- private persistDeferredWrites(): void {
9551
+ private persistDeferredWrites(): boolean {
9357
9552
  let outcome: DeferredWritesOutcome;
9358
9553
  try {
9359
9554
  outcome = this.writeDeferredWrites(this.quiesced);
9360
9555
  } catch (err) {
9361
9556
  console.error('[host-mode] failed to persist deferred context writes:', err);
9362
9557
  this.deferredWritesFellBehind({ error: err });
9363
- return;
9558
+ return false;
9364
9559
  }
9365
9560
  if (outcome === 'over-cap') {
9366
9561
  this.deferredWritesFellBehind('over-cap');
9367
- } else if (outcome !== 'nothing') {
9368
- this.deferredWritesCaughtUp();
9562
+ return false;
9369
9563
  }
9564
+ if (outcome !== 'nothing') this.deferredWritesCaughtUp();
9565
+ return true;
9370
9566
  }
9371
9567
 
9372
9568
  /**
@@ -10757,8 +10953,9 @@ export class AgentFramework {
10757
10953
  * **Inheritance.** `mode: 'shared'` (the default) gives the child the
10758
10954
  * parent's messages and fold state through shared storage: nothing is
10759
10955
  * copied, the child folds with the parent's memory tree, and neither can
10760
- * alter what the other reads. It needs branch-bound store handles and is
10761
- * refused, not silently downgraded, where they are missing. `mode: 'copy'`
10956
+ * alter what the other reads. It needs a store that supports derivation
10957
+ * (`ContextManager.supportsDerivation`), and is refused, not silently
10958
+ * downgraded, without one. `mode: 'copy'`
10762
10959
  * is the historical mechanism — the parent's compiled context re-added as
10763
10960
  * the child's own messages — and works everywhere.
10764
10961
  *
@@ -11347,6 +11544,7 @@ export class AgentFramework {
11347
11544
  if (run) {
11348
11545
  run.endReason ??= 'stopped';
11349
11546
  run.endedBy ??= options.by;
11547
+ run.endedFromOutside = true;
11350
11548
  // The awaiting caller is told who stopped the job and why, whether or
11351
11549
  // not a stream was in flight: settled first, so the aborted stream's own
11352
11550
  // settle ('Stream cancelled') finds the run already settled. The caller's
@@ -11597,13 +11795,14 @@ export class AgentFramework {
11597
11795
  /**
11598
11796
  * The creation ("consent") event: names what kind of agent this is, what
11599
11797
  * ends it and what happens to it when its spawner ends. A derived agent's
11600
- * creation is also told to the agent it was derived from (#270, Linn:
11601
- * "told when the body changes" as a property, not a courtesy): one
11602
- * host-framed line in the source's context, built from the same facts as
11603
- * the trace, with no wake; a host's derivationNotice can replace the line
11604
- * or send none. Both derivation paths announce after the
11605
- * fork's branch or copy exists, so a fork never inherits the notice about
11606
- * itself; a source that has ended is told nothing (lifecycleNotice).
11798
+ * creation is told to the agent it was derived from only when the host
11799
+ * asks for it with a derivationNotice and that returns text: one
11800
+ * host-framed line in the source's context, with no wake, given the
11801
+ * framework's line built from the same facts as the trace. Without one the
11802
+ * source is told nothing; the trace is emitted either way. Both derivation
11803
+ * paths announce after the fork's branch or copy exists, so a fork never
11804
+ * inherits the notice about itself; a source that has ended is told
11805
+ * nothing (lifecycleNotice).
11607
11806
  */
11608
11807
  private announceAgentCreated(record: AgentRecord): void {
11609
11808
  this.emitTrace({
@@ -11620,7 +11819,8 @@ export class AgentFramework {
11620
11819
  ...(record.inherit ? { inherit: record.inherit } : {}),
11621
11820
  });
11622
11821
  const from = record.inherit?.from;
11623
- if (!from || from === record.name) return;
11822
+ if (!from || from === record.name || !this.derivationNoticeHook) return;
11823
+ const hook = this.derivationNoticeHook;
11624
11824
  const detail = {
11625
11825
  event: 'derived',
11626
11826
  agent: record.name,
@@ -11631,15 +11831,16 @@ export class AgentFramework {
11631
11831
  ...(record.relationships.resultTo ? { resultTo: record.relationships.resultTo } : {}),
11632
11832
  lifetime: record.lifetime,
11633
11833
  };
11634
- // A resumption is rare and always said. A first incarnation is tallied:
11635
- // past the threshold, a reader loop that forks a hundred times a session
11636
- // would fill its source's context with stamps (Linn, 2026-10-15), so
11637
- // the source hears a count line every `threshold` forks instead, with
11638
- // the latest described; every derivation still emits its trace.
11834
+ // With the host's hook: a resumption is rare and always passed to it. A
11835
+ // first incarnation is tallied: past the threshold, a reader loop that
11836
+ // forks a hundred times a session would fill its source's context with
11837
+ // stamps, so the hook is given a count line every `threshold` forks
11838
+ // instead, with the latest described; every derivation still emits its
11839
+ // trace.
11639
11840
  const threshold = this.collapseDerivationNoticesAfter;
11640
11841
  const tell = (defaultText: string, extra: Record<string, unknown> = {}) => {
11641
11842
  try {
11642
- const text = this.derivationNoticeHook ? this.derivationNoticeHook(record, from, defaultText) : defaultText;
11843
+ const text = hook(record, from, defaultText);
11643
11844
  if (text !== null) this.lifecycleNotice(from, text, { ...detail, ...extra });
11644
11845
  } catch (error) {
11645
11846
  console.error(`[dendrite] ${record.name} was created, but ${from} could not be told:`, error);
@@ -11725,6 +11926,7 @@ export class AgentFramework {
11725
11926
  if (run) {
11726
11927
  run.endReason = reason;
11727
11928
  run.endedBy = record.ended?.by;
11929
+ run.endedFromOutside = true;
11728
11930
  run.settle.reject(new AgentStoppedError(name, reason, record.ended?.by, detail));
11729
11931
  return;
11730
11932
  }
@@ -11733,6 +11935,15 @@ export class AgentFramework {
11733
11935
  this.releaseAgentRuntime(agent, this.channelOfHome(record.homeChannel), undefined, record.lifetime.kind === 'persistent');
11734
11936
  }
11735
11937
 
11938
+ private forgetFailureAccounting(name: string): void {
11939
+ this.consecutiveInferenceFailures.delete(name);
11940
+ this.exhaustionRewinds.delete(name);
11941
+ this.refusalStreak.delete(name);
11942
+ this.refusalRewinds.delete(name);
11943
+ this.rewindEpisode.delete(name);
11944
+ if (this.forcedRewind.delete(name)) console.error(`[unstick] agent=${name} its /unstick session ended with its agent`);
11945
+ }
11946
+
11736
11947
  /**
11737
11948
  * Everything the process holds for a live, non-ephemeral agent that has
11738
11949
  * ended, released once, on every path an agent ends by — stopAgent, the
@@ -11776,8 +11987,10 @@ export class AgentFramework {
11776
11987
  this.eventGate?.agentGone(name);
11777
11988
  this.agents.delete(name);
11778
11989
  this.agentConfigs.delete(name);
11990
+ this.deadlineCuts.delete(name);
11991
+ this.freshCallsCut.delete(name);
11992
+ this.forgetFailureAccounting(name);
11779
11993
  this.thinkingLossIncidents.forget(name);
11780
- this.reasoningLoss.forget(name);
11781
11994
  // main's manager release: the context manager an ephemeral run or a fork
11782
11995
  // opened is released with the agent (releaseManager).
11783
11996
  this.releaseManager(agent);
@@ -11817,6 +12030,7 @@ export class AgentFramework {
11817
12030
  // waits ends here as at a turn's end, alerted if it ran long
11818
12031
  // (endStatedWaitRun).
11819
12032
  this.turnRearms.delete(name);
12033
+ this.rearmDeadlineCuts.delete(name);
11820
12034
  this.exhaustingRearms.delete(name);
11821
12035
  this.exhaustedTurns.delete(name);
11822
12036
  this.endStatedWaitRun(name);
@@ -12221,13 +12435,12 @@ export class AgentFramework {
12221
12435
 
12222
12436
  /**
12223
12437
  * A start dropped before its stream sends anything (its agent was replaced,
12224
- * or stop() closed admission or ended the lifecycle the start began in)
12225
- * ends the logical turn it was running. stop() ends every open turn
12226
- * (openLogical), but a fresh start that awaited its batches' assembly
12227
- * across a stop() reaches its routing after it and opens its turn. That
12228
- * turn would then stay open, and the agent's first start after a restart
12229
- * would continue the dropped turn's routing. The identity check leaves any
12230
- * other turn open.
12438
+ * or stop() closed admission) ends the logical turn it was running. stop()
12439
+ * ends every open turn (openLogical), but a fresh start that awaited its
12440
+ * batches' assembly across a stop() reaches its routing after it and opens
12441
+ * its turn. That turn would then stay open, and the next start of the
12442
+ * agent's name would continue the dropped turn's routing. The identity
12443
+ * check leaves any other turn open.
12231
12444
  */
12232
12445
  private endDroppedTurn(agentName: string, routing: TurnRouting): void {
12233
12446
  if (this.openLogical.get(agentName) === routing) this.openLogical.delete(agentName);
@@ -12236,9 +12449,36 @@ export class AgentFramework {
12236
12449
  // Its scripts take it as a settle's end of the turn: the start sent
12237
12450
  // nothing, so none of its own calls is waiting on a script.
12238
12451
  this.settledTurns.add(routing);
12452
+ this.releaseTurnInputs(routing);
12239
12453
  this.codeExecution?.scripts.turnEnded(agentName, { id: routing.turnId, live: this.liveTurns(agentName, routing) }, false);
12240
12454
  }
12241
12455
 
12456
+ private pinTurnInputs(agent: Agent, routing: TurnRouting): void {
12457
+ let range: { first: MessageId; last: MessageId } | undefined;
12458
+ try {
12459
+ const cm = agent.getContextManager();
12460
+ const watermark = agent.getConsumedWatermark();
12461
+ if (watermark && watermark.branch === cm.currentBranch().name) {
12462
+ range = turnInputs(cm, watermark.sequence, agent.name, (metadata) => this.agentSees(agent, metadata));
12463
+ }
12464
+ } catch (error) {
12465
+ console.error(`[turn-inputs] ${agent.name}: could not read the turn's inputs, so none is pinned:`, error);
12466
+ }
12467
+ if (!range) {
12468
+ agent.unpinTurnInputs();
12469
+ return;
12470
+ }
12471
+ const id = agent.pinTurnInputs(range.first, range.last);
12472
+ if (id !== undefined) routing.inputsPin = { agent, id };
12473
+ }
12474
+
12475
+ private releaseTurnInputs(routing: TurnRouting): void {
12476
+ const pin = routing.inputsPin;
12477
+ if (!pin) return;
12478
+ routing.inputsPin = undefined;
12479
+ pin.agent.unpinTurnInputs(pin.id);
12480
+ }
12481
+
12242
12482
  /** A logical turn's end, for every agent: it closes the agent's open
12243
12483
  * logical turn when it is the settling stream's own (openLogical), and
12244
12484
  * settles an active ephemeral run. The run's tool-call count comes from
@@ -12276,7 +12516,10 @@ export class AgentFramework {
12276
12516
  // or of the stream that now holds the agent, are left alone.
12277
12517
  if (stream) {
12278
12518
  // Ended, unless it's the logical turn this settle left open.
12279
- if (this.openLogical.get(agentName) !== stream) this.settledTurns.add(stream);
12519
+ if (this.openLogical.get(agentName) !== stream) {
12520
+ this.settledTurns.add(stream);
12521
+ this.releaseTurnInputs(stream);
12522
+ }
12280
12523
  this.codeExecution?.scripts.turnEnded(
12281
12524
  agentName,
12282
12525
  { id: stream.turnId, live: this.liveTurns(agentName, stream) },
@@ -12543,8 +12786,10 @@ export class AgentFramework {
12543
12786
  // deregistered, so disposal must release EventGate liveness itself.
12544
12787
  this.eventGate?.onInferenceEnded(agent.name);
12545
12788
  this.agents.delete(agent.name);
12789
+ this.deadlineCuts.delete(agent.name);
12790
+ this.freshCallsCut.delete(agent.name);
12791
+ if (run.endedFromOutside) this.forgetFailureAccounting(agent.name);
12546
12792
  this.thinkingLossIncidents.forget(agent.name);
12547
- this.reasoningLoss.forget(agent.name);
12548
12793
  // Its pending self-wake ends with it, as a fork's does.
12549
12794
  this.eventGate?.agentGone(agent.name);
12550
12795
  this.releaseWindowNowOrAfterResume(agent);
@@ -12717,6 +12962,15 @@ export class AgentFramework {
12717
12962
  },
12718
12963
  ];
12719
12964
 
12965
+ private static readonly WAKE_ADD_RULE_FIELDS: Readonly<Record<string, string>> = {
12966
+ 'behavior.debounce': 'debounceMs',
12967
+ 'behavior.rate_limit': 'rateLimit',
12968
+ 'behavior.passive_sample': 'passiveSample',
12969
+ 'behavior.judge': 'judge',
12970
+ 'position.before': 'insertBefore',
12971
+ 'position.after': 'insertAfter',
12972
+ };
12973
+
12720
12974
  /** Synthesized wake-rule tools: add/remove a gate.json policy at runtime.
12721
12975
  * Present when a gate is wired. They write validated policies into the
12722
12976
  * hot-reloaded gate.json (same validation as load), so a rule takes effect
@@ -12909,10 +13163,10 @@ export class AgentFramework {
12909
13163
  /** Synthesized drafts tool — present whenever MCPL channels are
12910
13164
  * configured. A resident's held drafts (src/prose-drafts.ts): plain speech
12911
13165
  * that was not sent, never published except by its resident's resend, and
12912
- * kept as that module's retention says. Nullable
12913
- * fields, cast like save_recent_image's: a caller whose provider presents
12914
- * every property as required can still say "not in use". */
12915
- private static readonly DRAFTS_TOOL = {
13166
+ * kept as that module's retention says. Its optional fields are nullable,
13167
+ * as save_recent_image's selectors are: a caller whose provider presents
13168
+ * every property as required can still say "not in use" (fieldInUse). */
13169
+ private static readonly DRAFTS_TOOL: import('./types/index.js').ToolDefinition = {
12916
13170
  name: 'drafts',
12917
13171
  description:
12918
13172
  'Your held drafts: plain speech of yours that was NOT sent — held back because an explicit send in its turn ' +
@@ -12951,12 +13205,12 @@ export class AgentFramework {
12951
13205
  },
12952
13206
  required: ['action'],
12953
13207
  },
12954
- } as unknown as import('./types/index.js').ToolDefinition;
13208
+ };
12955
13209
 
12956
- /** Synthesized save_image tool — present when a workspace module is
13210
+ /** Synthesized save_recent_image tool — present when a workspace module is
12957
13211
  * registered. Lets the agent persist an image it has already seen in its
12958
13212
  * own context (Discord attachments arrive inlined as base64 and are
12959
- * otherwise unreachable as files). */
13213
+ * otherwise unreachable as files). Its three selectors are nullable. */
12960
13214
  private static readonly SAVE_IMAGE_TOOL: import('./types/index.js').ToolDefinition = {
12961
13215
  name: 'save_recent_image',
12962
13216
  description:
@@ -12972,7 +13226,8 @@ export class AgentFramework {
12972
13226
  'restart) the call fails and writes nothing — it never substitutes an older ' +
12973
13227
  'image. When other tool calls in the same batch are still running, the save ' +
12974
13228
  'waits for them so their images are counted. Receipts carry source tool call, MIME, byte size and SHA-256. Saved ' +
12975
- 'files are visible via workspace tools.',
13229
+ 'files are visible via workspace tools. Select by `ref` or by ' +
13230
+ '`index`/`count`; a selector you are not using may be omitted or passed as null.',
12976
13231
  inputSchema: {
12977
13232
  type: 'object',
12978
13233
  properties: {
@@ -12980,19 +13235,27 @@ export class AgentFramework {
12980
13235
  type: 'string',
12981
13236
  description: 'Mount-prefixed destination path, e.g. "project/photos/name.png".',
12982
13237
  },
13238
+ // Nullable selectors: a caller whose provider presents every property
13239
+ // as required must still be able to say "not this selector". Omission,
13240
+ // null and "" all mean it isn't used (fieldInUse); any other value is
13241
+ // checked. The descriptions name null, the form to ask for.
12983
13242
  index: {
12984
- type: 'number',
12985
- description: 'Which image, counting back from the most recent (0 = most recent). Default 0.',
13243
+ type: ['integer', 'null'],
13244
+ description:
13245
+ 'Which image, counting back from the most recent (0 = most recent). Default 0. ' +
13246
+ 'null, like omitting it, means the default.',
12986
13247
  },
12987
13248
  count: {
12988
- type: 'number',
12989
- description: 'How many images to save, starting at `index` and going further back. Default 1.',
13249
+ type: ['integer', 'null'],
13250
+ description:
13251
+ 'How many images to save, starting at `index` and going further back. Default 1. ' +
13252
+ 'null, like omitting it, means the default.',
12990
13253
  },
12991
13254
  ref: {
12992
- type: 'string',
13255
+ type: ['string', 'null'],
12993
13256
  description:
12994
13257
  'Save a specific tool-result image by its history ref (e.g. "img_k7x3q2_7", as shown in its placeholder in your context) instead of by position. ' +
12995
- 'Mutually exclusive with `index`/`count`.',
13258
+ 'Mutually exclusive with `index`/`count`: pass null for the selector you are not using. null, like omitting it, means no ref.',
12996
13259
  },
12997
13260
  },
12998
13261
  required: ['path'],
@@ -15808,8 +16071,9 @@ export class AgentFramework {
15808
16071
  // idiom): reasoning effort (thinking), provider params, token ceilings,
15809
16072
  // caching, refusal handling, timezone — a same-model side-process must
15810
16073
  // not silently run under different inference conditions than the
15811
- // resident it serves. Only identity- and surface-defining fields are
15812
- // overridden below.
16074
+ // resident it serves. Below, identity- and surface-defining fields are
16075
+ // overridden, and the window and thinking binding where the
16076
+ // subconscious's config sets its own.
15813
16077
  const model = cfg.model ?? primaryConfig.model;
15814
16078
  const inherited: AgentConfig = { ...primaryConfig };
15815
16079
  if (model !== primaryConfig.model) {
@@ -15817,6 +16081,7 @@ export class AgentFramework {
15817
16081
  }
15818
16082
  const agentConfig: AgentConfig = {
15819
16083
  ...inherited,
16084
+ ...readerModelFields(cfg),
15820
16085
  name,
15821
16086
  model,
15822
16087
  systemPrompt: cfg.systemPrompt,
@@ -15924,6 +16189,7 @@ export class AgentFramework {
15924
16189
  config: {
15925
16190
  model: cfg.model,
15926
16191
  proseRouting: 'disabled',
16192
+ ...readerModelFields(cfg),
15927
16193
  },
15928
16194
  inheritRefusals: true,
15929
16195
  onParentEnd: 'end',
@@ -16037,13 +16303,7 @@ export class AgentFramework {
16037
16303
  }
16038
16304
 
16039
16305
  private async runLoop(): Promise<void> {
16040
- // A loop ends with its lifecycle. A stop() whose budget ran out while
16041
- // this loop was in a call (a module's onProcess) returns before the call
16042
- // does, and a start() after it begins a lifecycle with a loop of its own;
16043
- // this one finishes its iteration and exits, rather than running beside
16044
- // that loop for good.
16045
- const lifecycle = this.lifecycleGeneration;
16046
- while (this.running && this.lifecycleGeneration === lifecycle) {
16306
+ while (this.running) {
16047
16307
  try {
16048
16308
  await this.processNextEvent();
16049
16309
  } catch (error) {
@@ -16331,18 +16591,21 @@ export class AgentFramework {
16331
16591
  // compiled prefix and the live request still match without it
16332
16592
  // (#232 gap 5).
16333
16593
  //
16334
- // A stream that declares it can't carry injected messages
16335
- // (supportsInjectedMessages false: membrane's XML prefill path,
16336
- // from membrane 0.6.0) gets none, so nothing is drained here.
16337
- // Everything queued for the agent waits for the turn-end flush, as
16338
- // a held message does, rather than sitting in the window between
16339
- // rounds the stream never showed it.
16594
+ // Only a stream that declares it carries injected messages
16595
+ // (supportsInjectedMessages true: membrane's native path) gets
16596
+ // them. Membrane's XML prefill path declares false, and a stream
16597
+ // that doesn't say is taken as one that doesn't, so nothing is
16598
+ // drained for either: everything queued for the agent waits for
16599
+ // the turn-end flush, as a held message does, rather than sitting
16600
+ // in the window between rounds the stream never showed it. A
16601
+ // boundary with no stream, the direct API's, drains: its
16602
+ // re-inference compiles from the store.
16340
16603
  const midTurnInjections: Array<{ participant: string; content: ContentBlock[]; metadata?: MessageMetadata; id?: MessageId }> = [];
16341
16604
  // What this boundary stored for the agent to hear, in store order,
16342
16605
  // for a boundary that leaves it to the turn's next start as
16343
16606
  // arrivals: that start's compile sends it.
16344
16607
  const heard: Array<{ msg: DeferredWrite; id: MessageId; content: ContentBlock[] }> = [];
16345
- if (currentState.supportsInjectedMessages !== false) {
16608
+ if (currentState.supportsInjectedMessages === true || !currentState.stream) {
16346
16609
  // A clock row due (storeDueClock) goes right after the round's
16347
16610
  // results, ahead of what is drained below, and joins the stream
16348
16611
  // the same way. A boundary that passed during the round can't
@@ -16443,12 +16706,10 @@ export class AgentFramework {
16443
16706
 
16444
16707
  // A live stream handed these messages after the results follows
16445
16708
  // them in its next round, not the tool loop of a reply to
16446
- // [Continue]: that round's rows carry no prompt. One that doesn't
16447
- // say whether it carries them (membrane 0.5.x, whose native path
16448
- // carries them and whose XML path drops them) counts as carrying
16449
- // them here, as at the drain above. A boundary that ends the turn,
16450
- // restarts the stream or falls back to a new start below hands
16451
- // them to no live stream, and a new start reads its own prompt from
16709
+ // [Continue]: that round's rows carry no prompt. A boundary that
16710
+ // ends the turn, restarts the stream or falls back to a new start
16711
+ // below hands them to no live stream, and a new start reads its own
16712
+ // prompt from
16452
16713
  // what it sends. (The routing notice below rides only an injection
16453
16714
  // already here.)
16454
16715
  if (midTurnInjections.length > 0) {
@@ -16683,11 +16944,10 @@ export class AgentFramework {
16683
16944
  }
16684
16945
  // Streaming path: convert results and resume the stream.
16685
16946
  // Mid-turn messages collected above ride along as injected user
16686
- // messages. Membrane's native path (from 0.5.72) appends them
16687
- // after the tool_result envelope, so the next round of THIS turn
16688
- // hears them. Its XML prefill path drops them with a warning on
16689
- // 0.5.x, and from 0.6.0 declares it can't carry them, so none are
16690
- // collected for it. Each goes as its participant and content
16947
+ // messages. Membrane's native path appends them after the
16948
+ // tool_result envelope, so the next round of THIS turn hears
16949
+ // them. Its XML prefill path declares it can't carry them, so
16950
+ // none are collected for it. Each goes as its participant and content
16691
16951
  // alone: a stored row's metadata is the host's record, not input
16692
16952
  // for membrane. Each goes with its name and text as the store
16693
16953
  // keeps them, whether it's a heard message, rendered or not, or a
@@ -16718,15 +16978,11 @@ export class AgentFramework {
16718
16978
  // the watermark of the turn's compile and read as unread, so a
16719
16979
  // coalesced replacement could remove a message the agent heard
16720
16980
  // and answered (and that its later replies were minted after).
16721
- // Only a stream that declares it carries them is marked read
16722
- // (supportsInjectedMessages true: membrane's native path, from
16723
- // membrane 0.6.0). One that doesn't say may be membrane 0.5.87's
16724
- // XML path, which drops them with a warning, and a message marked
16725
- // read there could no longer be replaced though the agent never
16726
- // saw it. So without the declaration they stay unread, as before
16727
- // this mark.
16728
- // The shown mark moves at every boundary that injects, declared
16729
- // or not, since there a doubt counts as shown.
16981
+ // Only a stream that declares it carries them gets them, and so
16982
+ // is marked read (supportsInjectedMessages true: membrane's native
16983
+ // path).
16984
+ // The shown mark moves at every boundary that injects into its
16985
+ // stream.
16730
16986
  // The mark is the store's head, a scalar, as a compile's is: it
16731
16987
  // also covers what was stored since the compile without being
16732
16988
  // carried, such as another slot's messages, and a write that
@@ -16738,8 +16994,7 @@ export class AgentFramework {
16738
16994
  // replies after it go without their thinking, whatever the mark
16739
16995
  // says; deferring those writers is the fix.
16740
16996
  if (midTurnInjections.length > 0) {
16741
- // Possibly shown, whatever the stream declares: the first-view
16742
- // mark (CoalescerHost.isUnshown) counts a doubt as shown.
16997
+ // Shown: the stream carries them (CoalescerHost.isUnshown).
16743
16998
  agent.markContextShown();
16744
16999
  if (currentState.supportsInjectedMessages === true) agent.markContextConsumed();
16745
17000
  }
@@ -16799,14 +17054,22 @@ export class AgentFramework {
16799
17054
  private async dispatchProcessEventToModules(event: ProcessEvent, startTime = Date.now()): Promise<void> {
16800
17055
  // Dispatch to all modules, tracking responses with module names
16801
17056
  const responses: ModuleProcessResponse[] = [];
16802
- for (const module of this.moduleRegistry.getAllModules()) {
17057
+ const registrations = new Map<ModuleProcessResponse, object | undefined>();
17058
+ const stillRegistered = (entry: ModuleProcessResponse): boolean =>
17059
+ this.moduleRegistry.registrationOf(entry.moduleName) === registrations.get(entry);
17060
+ const dispatched = this.moduleRegistry.getAllModules()
17061
+ .map((module) => ({ module, registration: this.moduleRegistry.registrationOf(module.name) }));
17062
+ for (const { module, registration } of dispatched) {
16803
17063
  // The framework's own toolsets module answers no event; every agent has
16804
17064
  // it now, so it isn't listed beside the modules that do.
16805
17065
  if (module instanceof ToolsetsModule) continue;
17066
+ if (this.moduleRegistry.registrationOf(module.name) !== registration) continue;
16806
17067
  try {
16807
17068
  const processState = this.moduleRegistry.createProcessState(module.name);
16808
17069
  const response = await module.onProcess(event, processState);
16809
- responses.push({ moduleName: module.name, response });
17070
+ const entry = { moduleName: module.name, response };
17071
+ responses.push(entry);
17072
+ registrations.set(entry, registration);
16810
17073
  } catch (error) {
16811
17074
  console.error(`Module ${module.name} error handling process event:`, error);
16812
17075
  }
@@ -16832,7 +17095,17 @@ export class AgentFramework {
16832
17095
  // responses, a tool call's dispatch and the trace go on, since a module
16833
17096
  // can't catch a throw on this path, and one module's mustn't cost the
16834
17097
  // event.
16835
- const applying = responses.filter(({ moduleName, response }) => {
17098
+ const dropUnregistered = (entry: ModuleProcessResponse): boolean => {
17099
+ if (stillRegistered(entry)) return false;
17100
+ const parts = responseParts(entry.response);
17101
+ if (parts) {
17102
+ console.error(
17103
+ `[process] module ${entry.moduleName} stopped before its response to ${event.type} was applied, so none of it was: ${parts}`,
17104
+ );
17105
+ }
17106
+ return true;
17107
+ };
17108
+ const applying = responses.filter((entry) => !dropUnregistered(entry)).filter(({ moduleName, response }) => {
16836
17109
  const refusal = malformedResponseContent(response);
16837
17110
  if (refusal === null) return true;
16838
17111
  const { where, places } = refusal;
@@ -16855,7 +17128,7 @@ export class AgentFramework {
16855
17128
  const { moduleName, response } = entry;
16856
17129
  const step = { at: 'its start' };
16857
17130
  try {
16858
- await this.applyProcessResponse(response, event, moduleName, surface, step);
17131
+ this.applyProcessResponse(response, event, moduleName, surface, step);
16859
17132
  } catch (error) {
16860
17133
  entry.threw = { at: step.at, error: error instanceof Error ? error.message : String(error) };
16861
17134
  console.error(
@@ -16883,7 +17156,7 @@ export class AgentFramework {
16883
17156
  }
16884
17157
  }
16885
17158
 
16886
- private async applyProcessResponse(
17159
+ private applyProcessResponse(
16887
17160
  response: EventResponse,
16888
17161
  event: ProcessEvent,
16889
17162
  moduleName: string,
@@ -16891,7 +17164,7 @@ export class AgentFramework {
16891
17164
  surface: InboundSource | undefined,
16892
17165
  /** Names the operation under way, for the line when one throws. */
16893
17166
  step: { at: string },
16894
- ): Promise<void> {
17167
+ ): void {
16895
17168
  // Add messages. Only the framework writes the inbound envelope: one a
16896
17169
  // module's metadata carries (echoed from a server's event, say) is
16897
17170
  // dropped, and a surface event's messages carry the framework's own.
@@ -17695,8 +17968,10 @@ export class AgentFramework {
17695
17968
  } else {
17696
17969
  // Nothing live under the name: only what the process keeps by name.
17697
17970
  this.closingConversationAgents.delete(agentName);
17971
+ this.deadlineCuts.delete(agentName);
17972
+ this.freshCallsCut.delete(agentName);
17973
+ this.forgetFailureAccounting(agentName);
17698
17974
  this.thinkingLossIncidents.forget(agentName);
17699
- this.reasoningLoss.forget(agentName);
17700
17975
  // A continuation of its turn still on its way goes with it.
17701
17976
  this.dropContinuations(agentName, { kind: 'dropped', why: 'its agent was disposed' });
17702
17977
  this.turnRouting.delete(agentName);
@@ -18728,25 +19003,45 @@ export class AgentFramework {
18728
19003
  }
18729
19004
  }
18730
19005
 
18731
- /** Withdraw unstarted wakes whose sole cause is this subject (§4.2). */
19006
+ /** Withdraw unstarted wakes whose sole cause is this subject (§4.2), and
19007
+ * a merged start whose every request is withdrawn. */
18732
19008
  private cancelCoalescedWakes(subject: string): void {
18733
- this.pendingRequests = this.pendingRequests.filter((r) => r.coalescingSubject !== subject);
18734
- for (const cooldown of this.providerAccelerationCooldowns.values()) {
18735
- cooldown.heldRequests = cooldown.heldRequests.filter((r) => r.coalescingSubject !== subject);
18736
- }
18737
- // A wake of the subject's merged into a start that carries a turn on
19009
+ // A wake of the subject's merged into a start
18738
19010
  // (MERGED_BATCH) is withdrawn from the start's batch, so no stop puts
18739
19011
  // it back and no pass unpacks it.
19012
+ const emptied = new Set<InferenceRequest>();
18740
19013
  const unrecord = (r: InferenceRequest): void => {
18741
19014
  const batch = mergedBatch(r);
18742
19015
  if (batch?.some((w) => w.coalescingSubject === subject)) {
18743
- (r as MarkedRequest)[MERGED_BATCH] = batch.filter((w) => w.coalescingSubject !== subject);
19016
+ const left = batch.filter((w) => w.coalescingSubject !== subject);
19017
+ (r as MarkedRequest)[MERGED_BATCH] = left;
19018
+ if (left.length === 0) emptied.add(r);
18744
19019
  }
18745
19020
  };
18746
19021
  for (const r of this.pendingRequests) unrecord(r);
18747
19022
  for (const cooldown of this.providerAccelerationCooldowns.values()) cooldown.heldRequests.forEach(unrecord);
18748
19023
  for (const r of this.parkedContinuations ?? []) unrecord(r);
18749
19024
  for (const pass of this.passRequests ?? []) for (const requests of pass.values()) requests.forEach(unrecord);
19025
+ const withdrawn = (r: InferenceRequest): boolean => r.coalescingSubject === subject || emptied.has(r);
19026
+ this.pendingRequests = this.pendingRequests.filter((r) => !withdrawn(r));
19027
+ for (const cooldown of this.providerAccelerationCooldowns.values()) {
19028
+ const held = cooldown.heldRequests.length;
19029
+ cooldown.heldRequests = cooldown.heldRequests.filter((r) => !withdrawn(r));
19030
+ if (held > 0 && cooldown.heldRequests.length === 0) cooldown.emptiedBy = 'withdrawal';
19031
+ }
19032
+ for (const pass of this.passRequests ?? []) {
19033
+ for (const requests of pass.values()) {
19034
+ if (!requests.some((r) => emptied.has(r))) continue;
19035
+ const left = requests.filter((r) => !emptied.has(r));
19036
+ requests.length = 0;
19037
+ requests.push(...left);
19038
+ }
19039
+ }
19040
+ for (const start of emptied) {
19041
+ if (!start.coalescingBatch || start.coalescingSubject !== undefined) continue;
19042
+ console.error(`[coalescing] ${start.agentName}: its deferred batches were withdrawn while its wake waited; turn not started (no wake cause left)`);
19043
+ this.emitTrace({ type: 'mcpl:coalescing', kind: 'turn-withdrawn', agentName: start.agentName, subjects: start.coalescingBatchSubjects ?? [] });
19044
+ }
18750
19045
  }
18751
19046
 
18752
19047
  /**
@@ -19261,7 +19556,7 @@ export class AgentFramework {
19261
19556
  if (recovery) recovery.releasedAt = now;
19262
19557
  requests = [...providerCooldown.heldRequests, ...requests];
19263
19558
  }
19264
- // A start that carries a turn on and came back before it took its
19559
+ // A start that came back before it took its
19265
19560
  // token stands for the batch it was merged from (MERGED_BATCH),
19266
19561
  // including one a hold released just now. The batch takes its place,
19267
19562
  // each request as it was, judged one by one from here: a wait holds
@@ -19662,10 +19957,10 @@ export class AgentFramework {
19662
19957
  else delete start[TURN_CARRY];
19663
19958
  if (requests.every(isRearmed)) start[REARMED] = true;
19664
19959
  else delete start[REARMED];
19665
- // A start that carries a turn on and took in wakes that don't records
19666
- // the batch it was merged from (MERGED_BATCH): until it takes its
19667
- // token it stands for them all.
19668
- if (continuesTurn(start) && requests.some((r) => !continuesTurn(r))) start[MERGED_BATCH] = [...requests];
19960
+ // A start merged from more than one request, any of them not the
19961
+ // turn's, records the batch it was merged from (MERGED_BATCH): until
19962
+ // it takes its token it stands for them all.
19963
+ if (requests.length > 1 && requests.some((r) => !continuesTurn(r))) start[MERGED_BATCH] = [...requests];
19669
19964
  else delete start[MERGED_BATCH];
19670
19965
  await this.startAgentStream(agent, start);
19671
19966
  }
@@ -20345,8 +20640,6 @@ export class AgentFramework {
20345
20640
  const refuse = (error: string): ToolResult => ({ success: false, error, isError: true });
20346
20641
  // Plain text, not a JSON-quoted string: draft text must read exactly as held.
20347
20642
  const ok = (text: string): ToolResult => ({ success: true, data: [{ type: 'text', text }] });
20348
- // null and omission mean the same thing: the field is not in use.
20349
- const present = (v: unknown): boolean => v !== undefined && v !== null;
20350
20643
  const action = input.action;
20351
20644
  if (action !== 'list' && action !== 'read' && action !== 'resend' && action !== 'dismiss') {
20352
20645
  return refuse('action must be one of "list", "read", "resend" or "dismiss".');
@@ -20355,7 +20648,7 @@ export class AgentFramework {
20355
20648
  refuse(`${field} is not used by action "${action}" — pass null or omit it.`);
20356
20649
 
20357
20650
  let ids: string[] = [];
20358
- if (present(input.draftIds)) {
20651
+ if (fieldInUse(input.draftIds)) {
20359
20652
  if (action === 'list') return unused('draftIds');
20360
20653
  if (!Array.isArray(input.draftIds) || input.draftIds.some((id) => typeof id !== 'string' || !id.trim())) {
20361
20654
  return refuse('draftIds must be a list of draft ids such as "d-k7x3qm2", or null.');
@@ -20364,15 +20657,19 @@ export class AgentFramework {
20364
20657
  if (new Set(ids).size !== ids.length) return refuse('draftIds names a draft more than once.');
20365
20658
  if (ids.length > 20) return refuse('draftIds names more than 20 drafts; act on them in smaller groups.');
20366
20659
  }
20367
- if (present(input.destination) && action !== 'resend') return unused('destination');
20368
- if (present(input.confirmDuplicate) && action !== 'resend') return unused('confirmDuplicate');
20369
- if (present(input.offset) && action !== 'list') return unused('offset');
20660
+ if (fieldInUse(input.destination) && action !== 'resend') return unused('destination');
20661
+ // false is "don't confirm", which every other action already does, so only
20662
+ // true is a confirmDuplicate those actions refuse.
20663
+ if (fieldInUse(input.confirmDuplicate) && input.confirmDuplicate !== false && action !== 'resend') {
20664
+ return unused('confirmDuplicate');
20665
+ }
20666
+ if (fieldInUse(input.offset) && action !== 'list') return unused('offset');
20370
20667
 
20371
20668
  if (action === 'list') {
20372
20669
  let offset = 0;
20373
- if (present(input.offset)) {
20670
+ if (fieldInUse(input.offset)) {
20374
20671
  const raw = input.offset;
20375
- const n = typeof raw === 'number' ? raw : typeof raw === 'string' && /^\d+$/.test(raw.trim()) ? Number(raw) : NaN;
20672
+ const n = integerField(raw);
20376
20673
  if (!Number.isInteger(n) || n < 0) return refuse('offset must be a whole number of drafts to skip (0 or more), or null.');
20377
20674
  offset = n;
20378
20675
  }
@@ -20489,12 +20786,12 @@ export class AgentFramework {
20489
20786
  if (!registry) return refuse('No channels are configured, so there is nowhere to resend to.');
20490
20787
  const home = this.homeOf(agentName);
20491
20788
  let spec = typeof input.destination === 'string' ? input.destination.trim() : '';
20492
- if (present(input.destination) && typeof input.destination !== 'string') {
20789
+ if (fieldInUse(input.destination) && typeof input.destination !== 'string') {
20493
20790
  return refuse('destination must be "#channel", "@person" or a channel id.');
20494
20791
  }
20495
20792
  if (!spec && !home) return refuse('resend needs a destination: "#channel", "@person" or a channel id.');
20496
20793
  let confirmDuplicate = false;
20497
- if (present(input.confirmDuplicate)) {
20794
+ if (fieldInUse(input.confirmDuplicate)) {
20498
20795
  if (typeof input.confirmDuplicate !== 'boolean') return refuse('confirmDuplicate must be true, false or null.');
20499
20796
  confirmDuplicate = input.confirmDuplicate;
20500
20797
  }
@@ -21522,25 +21819,6 @@ export class AgentFramework {
21522
21819
  this.writeChannelNotices(this.channelOpenedNotices(agentName, serverId, channels, cause), `auto-open notice (${cause})`, 'recordChannelAutoOpenNotice');
21523
21820
  }
21524
21821
 
21525
- private tellReasoningLoss(agent: Agent, make: () => string | undefined): void {
21526
- if (this.agents.get(agent.name) !== agent) return;
21527
- let line: string | undefined;
21528
- try {
21529
- line = make();
21530
- } catch (err) {
21531
- console.error(`[reasoning] ${agent.name}: could not tell what reasoning was lost:`, err);
21532
- return;
21533
- }
21534
- if (!line) return;
21535
- try {
21536
- this.addMessage('user', [{ type: 'text', text: line }], { system: true, kind: 'reasoning-loss' } as MessageMetadata, { forAgent: agent.name });
21537
- } catch (err) {
21538
- console.error(`[reasoning] ${agent.name}: the line for lost reasoning could not be stored:`, err);
21539
- return;
21540
- }
21541
- console.error(`[reasoning] ${agent.name}: told of reasoning no longer in its context`);
21542
- }
21543
-
21544
21822
  /**
21545
21823
  * Write a builder's channel notices in order, each on its own: one that
21546
21824
  * can't be written is logged, and the rest are still written. A reader's
@@ -22923,6 +23201,7 @@ export class AgentFramework {
22923
23201
  attempt = 0,
22924
23202
  providerGateAlreadyHeld = false,
22925
23203
  ): Promise<void> {
23204
+ if (attempt === 0) this.deadlineCuts.delete(agent.name);
22926
23205
  const ownsProviderGate = this.registry.ownsProviderScheduling(agent.name);
22927
23206
  // A stopping framework starts no turn, whoever owns provider admission
22928
23207
  // (conversation agents and ephemeral runs do not take the gate).
@@ -22989,6 +23268,15 @@ export class AgentFramework {
22989
23268
  console.error(`[provider-admission] ${agent.name}: host quiesced while parked — the merged start goes back as its ${batch.length} requests`);
22990
23269
  return;
22991
23270
  }
23271
+ if (this.providerAccelerationCooldowns.has(agent.name)) {
23272
+ this.releasePrimaryProviderGate(agent.name);
23273
+ this.eventGate?.onInferenceEnded(agent.name);
23274
+ this.pendingRequests.push(trigger ?? {
23275
+ agentName: agent.name, reason: 'provider-admission:requeue', source: 'scheduler', timestamp: Date.now(),
23276
+ });
23277
+ console.error(`[provider-admission] ${agent.name}: a provider hold stands — parked wake requeued for the scheduler, not started`);
23278
+ return;
23279
+ }
22992
23280
  await this.startAgentStream(agent, trigger, attempt, true);
22993
23281
  }).catch((error) => {
22994
23282
  this.releasePrimaryProviderGate(agent.name);
@@ -23147,7 +23435,7 @@ export class AgentFramework {
23147
23435
  if (hold) {
23148
23436
  const held = hold.heldRequests.length;
23149
23437
  hold.heldRequests = take(hold.heldRequests);
23150
- if (held > 0 && hold.heldRequests.length === 0) hold.emptiedByDrop = true;
23438
+ if (held > 0 && hold.heldRequests.length === 0) hold.emptiedBy = 'drop';
23151
23439
  }
23152
23440
  for (const parked of [...this.parkedContinuations]) {
23153
23441
  if (!isContinuation(parked)) continue;
@@ -23260,9 +23548,8 @@ export class AgentFramework {
23260
23548
  * gets inference:aborted with `reason`, and lastInference records its end
23261
23549
  * as driveStream's would, when the record is still this turn's. Nothing of
23262
23550
  * the kind when another turn of this agent's name holds the turn token (one
23263
- * begun after a stop and start, or by a replacement agent object): the
23264
- * event and the record carry only the name, so this frame's would end that
23265
- * newer turn instead.
23551
+ * begun by a replacement agent object): the event and the record carry only
23552
+ * the name, so this frame's would end that newer turn instead.
23266
23553
  */
23267
23554
  private abortDroppedTurn(agentName: string, turnToken: number, reason: string, startedAt: number): void {
23268
23555
  const owner = this.activeTurnTokens.get(agentName);
@@ -23451,7 +23738,6 @@ export class AgentFramework {
23451
23738
  * operator's abandon. */
23452
23739
  checkWindow: CheckWindow,
23453
23740
  ): Promise<boolean> {
23454
- const lifecycle = this.lifecycleGeneration;
23455
23741
  const resumesTurn = resumesLogicalTurn(trigger?.reason);
23456
23742
  let carry = turnCarryOf(trigger);
23457
23743
  const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting;
@@ -23639,7 +23925,7 @@ export class AgentFramework {
23639
23925
  // everything the turn has decided about where its speech goes.
23640
23926
  // - A resume (a budget restart, a tool-result-guard retry) carries its
23641
23927
  // turn's request on and keeps the turn's object as it is, and the
23642
- // turn stays, or after a stop() becomes again, the agent's open turn.
23928
+ // turn stays, or becomes again, the agent's open turn.
23643
23929
  // - A silent start routes no prose. It gets an object of its own, with
23644
23930
  // no locus, so it neither takes nor disturbs an open turn's. Its
23645
23931
  // object is the agent's live routing while it runs, and the open
@@ -23664,8 +23950,6 @@ export class AgentFramework {
23664
23950
  let opensTurn = false;
23665
23951
  if (resumesTurn) {
23666
23952
  routing = this.routingOf(agent.name);
23667
- // The turn a resume continues is open, unless a stop() ended every
23668
- // open turn since: it opens again, so its later starts continue it.
23669
23953
  if (!trigger?.suppressProse && !this.openLogical.has(agent.name)) this.openLogical.set(agent.name, routing);
23670
23954
  } else if (trigger?.suppressProse) {
23671
23955
  const continued = continuesTurn(trigger) ? this.turnRouting.get(agent.name) : undefined;
@@ -23687,6 +23971,8 @@ export class AgentFramework {
23687
23971
  if (opensTurn) {
23688
23972
  const head = agent.branchHead();
23689
23973
  if (head) routing.start = head;
23974
+ if (!routing.silent) this.pinTurnInputs(agent, routing);
23975
+ else agent.unpinTurnInputs();
23690
23976
  }
23691
23977
  this.turnRouting.set(agent.name, routing);
23692
23978
  if (opensTurn) carry = undefined;
@@ -23821,11 +24107,11 @@ export class AgentFramework {
23821
24107
  });
23822
24108
  // A budget restart continues the same logical inference window. The
23823
24109
  // predecessor keeps EventGate liveness until its successor terminates,
23824
- // and the successor takes it again if it ended in between: at a stop()
23825
- // (a restart left queued, which a start() then takes up), or at a
24110
+ // and the successor takes it again if it ended in between: at a
23826
24111
  // provider hold that kept the successor from starting (a stated wait at
23827
- // its last admission check, or a cooldown after its own call failed),
23828
- // whose end queues it again.
24112
+ // its last admission check, a hold standing when its park behind an
24113
+ // auxiliary call ended, or a cooldown after its own call failed), whose
24114
+ // end queues it again.
23829
24115
  if (!resumesTurn) {
23830
24116
  this.eventGate?.onInferenceStarted(agent.name);
23831
24117
  } else {
@@ -23872,10 +24158,6 @@ export class AgentFramework {
23872
24158
  const name = agent.name;
23873
24159
  agent.onCompiledToSend = (messages) => hub.observeCompile(name, messages);
23874
24160
  }
23875
- if (!agent.onThinkingStrip) {
23876
- agent.onThinkingStrip = (strip, branch) => this.tellReasoningLoss(agent, () => this.reasoningLoss.forStrip(
23877
- agent.name, branch, strip, (this.activeTurnTokens.has(agent.name) && this.turnRouting.get(agent.name)) || {}, agent.getContextManager()));
23878
- }
23879
24161
 
23880
24162
  // Module gatherContext (fail-open, per-module timeout — the module's
23881
24163
  // contextTimeoutMs, else the registry default). Injections are
@@ -23913,13 +24195,11 @@ export class AgentFramework {
23913
24195
  // An ephemeral watchdog may dispose this Agent while hooks/compile await,
23914
24196
  // and stop() may close provider admission. Do not create a provider
23915
24197
  // stream after its generation lost ownership, or once admission closed,
23916
- // or once a stop() and a start() have begun another lifecycle, as after
23917
- // the compile: these come before a stop kept for the turn, which goes
23918
- // with it.
23919
- if (this.agents.get(agent.name) !== agent || this.providerAdmissionClosed || lifecycle !== this.lifecycleGeneration) {
24198
+ // as after the compile: these come before a stop kept for the turn,
24199
+ // which goes with it.
24200
+ if (this.agents.get(agent.name) !== agent || this.providerAdmissionClosed) {
23920
24201
  const why = this.agents.get(agent.name) !== agent ? 'the agent was replaced or removed'
23921
- : this.providerAdmissionClosed ? 'the framework was stopping'
23922
- : 'the framework was stopped and started again';
24202
+ : 'the framework was stopping';
23923
24203
  const reason = `a turn assembled while ${why} is dropped before it sends anything`;
23924
24204
  console.error(`[turn] ${agent.name}: ${reason}`);
23925
24205
  this.endDroppedTurn(agent.name, routing);
@@ -23973,23 +24253,22 @@ export class AgentFramework {
23973
24253
  // This turn's retries are the framework's (errorPolicy below), not
23974
24254
  // membrane's inside each attempt.
23975
24255
  membraneRetries: false,
24256
+ inferenceBounds: this.inferenceBounds,
23976
24257
  });
23977
24258
  // Each later round this stream sends is noted as it goes (noteRoundSent).
23978
24259
  if (noteRequestSent) this.streamRequestNotes.set(stream, { note: noteRequestSent, rounds: [], stopped: false });
23979
24260
  if (carriedBodies) this.streamCarried.set(stream, new Set(carriedBodies));
23980
24261
  // The compile can wait for annotations (each batch has one spill
23981
- // deadline), so check ownership, admission, and the lifecycle again.
23982
- // A stop followed by start must not revive this earlier turn.
24262
+ // deadline), so check ownership and admission again.
23983
24263
  // Membrane's yielding stream starts its request when it is first
23984
24264
  // iterated, so cancelling it here, before driveStream, sends nothing.
23985
24265
  // A turn that was mid-compile when stop() cancelled every stream would
23986
24266
  // otherwise register after that pass and outlive stop(). The caller's
23987
24267
  // finally releases the turn token.
23988
24268
  const stopping = this.providerAdmissionClosed;
23989
- if (this.agents.get(agent.name) !== agent || stopping || lifecycle !== this.lifecycleGeneration) {
24269
+ if (this.agents.get(agent.name) !== agent || stopping) {
23990
24270
  const why = this.agents.get(agent.name) !== agent ? 'the agent was replaced or removed'
23991
- : stopping ? 'the framework was stopping'
23992
- : 'the framework was stopped and started again';
24271
+ : 'the framework was stopping';
23993
24272
  const reason = `a turn compiled while ${why} is dropped before it sends anything`;
23994
24273
  console.error(`[turn] ${agent.name}: ${reason}`);
23995
24274
  this.cancelUnsentStream(agent, stream, `compiled while ${why}`);
@@ -24120,14 +24399,12 @@ export class AgentFramework {
24120
24399
  // catch never holds a compiled request.) As at the checks after the
24121
24400
  // hooks and after the compile, a replaced agent or a stopping framework
24122
24401
  // comes first: the turn is dropped as theirs, and the stop goes with
24123
- // it, and so does a framework stopped and started again, as after the
24124
- // compile. A failed start with no stop goes on below as before.
24402
+ // it. A failed start with no stop goes on below as before.
24125
24403
  const stopAtFailure = this.takeStartStop(agent, checkWindow);
24126
24404
  if (stopAtFailure) {
24127
- if (this.agents.get(agent.name) !== agent || this.providerAdmissionClosed || lifecycle !== this.lifecycleGeneration) {
24405
+ if (this.agents.get(agent.name) !== agent || this.providerAdmissionClosed) {
24128
24406
  const why = this.agents.get(agent.name) !== agent ? 'the agent was replaced or removed'
24129
- : this.providerAdmissionClosed ? 'the framework was stopping'
24130
- : 'the framework was stopped and started again';
24407
+ : 'the framework was stopping';
24131
24408
  const reason = `a turn whose request couldn't be built while ${why} is dropped before it sends anything`;
24132
24409
  console.error(`[turn] ${agent.name}: ${reason}`);
24133
24410
  this.endDroppedTurn(agent.name, routing);
@@ -24140,6 +24417,7 @@ export class AgentFramework {
24140
24417
  }
24141
24418
 
24142
24419
  const retry = trigger && withTurnCarry(trigger, carry);
24420
+ const asTransient = this.takeAsTransient(agent.name, err, trigger);
24143
24421
  if (ownsProviderGate && this.holdProviderAcceleration(agent, err, retry)) {
24144
24422
  // Capacity scheduling is not poisoned history. The cooldown timer owns
24145
24423
  // one later fresh compile; no immediate retry or exhaustion marker.
@@ -24156,24 +24434,25 @@ export class AgentFramework {
24156
24434
  return false;
24157
24435
  }
24158
24436
 
24159
- const action: ErrorAction = statedWait === 'fail' || (isRearmed(trigger) && !gotPartway(routing))
24160
- ? { retry: false } : this.errorPolicy.onInferenceError(err, agent.name, attempt);
24437
+ const cuts = isDeadlineExpiry(err) ? this.deadlineCuts.get(agent.name) ?? 0 : undefined;
24438
+ const action: ErrorAction = statedWait === 'fail' || (isRearmed(trigger) && (!gotPartway(routing) || (isDeadlineExpiry(err) && !asTransient)))
24439
+ ? { retry: false } : this.errorPolicy.onInferenceError(err, agent.name, cuts ?? attempt);
24440
+ if (action.retry && cuts !== undefined) this.deadlineCuts.set(agent.name, cuts + 1);
24441
+ else this.deadlineCuts.delete(agent.name);
24442
+ if (!action.retry && cuts !== undefined && cuts > 0) this.freshCallsCut.add(agent.name);
24161
24443
  if (action.retry) {
24162
24444
  // A policy's delay can be a provider's retry-after: wait all of it.
24163
24445
  await this.waitForRetry(action.delayMs, checkWindow, agent);
24164
- if (this.providerAdmissionClosed || lifecycle !== this.lifecycleGeneration) {
24446
+ if (this.providerAdmissionClosed) {
24165
24447
  // stop() ended the wait: that cancels the retry, it does not hasten
24166
- // it. A stop() and a start() since this start began cancel it too,
24167
- // as after the compile: the retry would revive the earlier
24168
- // lifecycle's turn in the next one, and a stop kept for the turn
24169
- // goes with it. The turn ends as one dropped before it sends
24170
- // anything does: a terminal event saying why, after the
24171
- // inference:failed above, and its record's end. Nothing is written
24448
+ // it, and a stop kept for the turn goes with it. The turn ends as
24449
+ // one dropped before it sends anything does: a terminal event
24450
+ // saying why, after the inference:failed above, and its record's
24451
+ // end. Nothing is written
24172
24452
  // but the [speech-not-posted] notice; this frame still owns its
24173
24453
  // token, since no retry frame replaced it.
24174
24454
  this.nameUnpostedSpeech(agent, routing);
24175
- this.abortDroppedTurn(agent.name, turnToken,
24176
- this.providerAdmissionClosed ? RETRY_CANCELLED_BY_STOP : RETRY_ACROSS_RESTART, turnStartedAt);
24455
+ this.abortDroppedTurn(agent.name, turnToken, RETRY_CANCELLED_BY_STOP, turnStartedAt);
24177
24456
  if (this.activeTurnTokens.get(agent.name) === turnToken) {
24178
24457
  this.activeTurnTokens.delete(agent.name);
24179
24458
  this.activeTurnTriggers.delete(agent.name);
@@ -24217,7 +24496,7 @@ export class AgentFramework {
24217
24496
  speech: '',
24218
24497
  ...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
24219
24498
  }, routing);
24220
- if (isRearmed(trigger) && isTransientFailure(err)) this.exhaustingRearms.add(agent.name);
24499
+ if (isRearmed(trigger) && asTransient) this.exhaustingRearms.add(agent.name);
24221
24500
  this.emitTrace({
24222
24501
  type: 'inference:exhausted',
24223
24502
  agentName: agent.name,
@@ -24237,7 +24516,7 @@ export class AgentFramework {
24237
24516
  if (action.emit) {
24238
24517
  this.pushEvent(action.emit);
24239
24518
  }
24240
- this.rearmExhaustedTurn(agent, err, trigger, ownsProviderGate, routing);
24519
+ this.rearmExhaustedTurn(agent, err, trigger, ownsProviderGate, routing, asTransient);
24241
24520
  }
24242
24521
  // No handoff on either failure branch: the terminal branch deleted the
24243
24522
  // token above; the retry branch's recursive startAgentStream replaced
@@ -24869,7 +25148,7 @@ export class AgentFramework {
24869
25148
  (b) => b.type !== 'tool_result'
24870
25149
  );
24871
25150
  } else {
24872
- // Fallback (older membrane / XML tool mode): preamble + calls.
25151
+ // XML tool mode: this round's text and its calls.
24873
25152
  // XML mode: persist the verbatim generation on each tool_use
24874
25153
  // (membrane#36 round-trip fidelity). context.rawText is the
24875
25154
  // parser's fullMatch — the exact <function_calls> block the
@@ -24881,15 +25160,12 @@ export class AgentFramework {
24881
25160
  const rawXml = rawText && /^<(antml:)?function_calls>/.test(rawText)
24882
25161
  ? rawText
24883
25162
  : undefined;
24884
- // Persist THIS round's text only. `roundPreamble` (membrane
24885
- // ≥0.5.79) is the per-round delta; the legacy `preamble` is
24886
- // CUMULATIVE in XML mode — persisting it per round stored each
24887
- // round's text N times and re-persisted injected
24888
- // <function_results> as the agent's own words (the Evander
24889
- // 2026-08-08 scaffold-leak pyramid: r1 ×3, r2 ×2).
24890
- const roundText =
24891
- (event.context as { roundPreamble?: string }).roundPreamble ??
24892
- event.context.preamble;
25163
+ // Persist THIS round's text only: `roundPreamble` is the
25164
+ // per-round delta. The `preamble` beside it is CUMULATIVE in XML
25165
+ // mode: persisting it per round would store each round's text N
25166
+ // times and re-persist injected <function_results> as the
25167
+ // agent's own words.
25168
+ const roundText = (event.context as { roundPreamble?: string }).roundPreamble;
24893
25169
  assistantBlocks = [];
24894
25170
  if (roundText) {
24895
25171
  assistantBlocks.push({ type: 'text', text: roundText });
@@ -24914,9 +25190,7 @@ export class AgentFramework {
24914
25190
 
24915
25191
  // Whether this stream can carry messages injected with the round's
24916
25192
  // results: true on membrane's native path and false on its XML
24917
- // prefill path, from membrane 0.6.0, and absent on 0.5.x, which
24918
- // doesn't declare it. The tool boundary reads it from the agent's
24919
- // state.
25193
+ // prefill path. The tool boundary reads it from the agent's state.
24920
25194
  agent.enterWaitingForTools(
24921
25195
  calls,
24922
25196
  stream,
@@ -24958,13 +25232,11 @@ export class AgentFramework {
24958
25232
  if (liveRound) liveProseRouting = true;
24959
25233
  // Whether this round's tool-result batch (and anything queued
24960
25234
  // with it) will be presented to the model before it speaks
24961
- // again: the producer says so when it can (membrane from 0.6.0).
24962
- // Otherwise, as on membrane 0.5.x, every round counts as
24963
- // presenting it. That holds for 0.5.x's native rounds, while its
24964
- // XML path drops what's injected, so a 'now' notice there reaches
25235
+ // again: the stream declares it (true on membrane's native path).
25236
+ // On the XML path, which declares false, a 'now' notice reaches
24965
25237
  // the agent at its next compile instead.
24966
25238
  const presentsInjections =
24967
- (event.context as { supportsInjectedMessages?: boolean }).supportsInjectedMessages ?? true;
25239
+ (event.context as { supportsInjectedMessages?: boolean }).supportsInjectedMessages === true;
24968
25240
  // Queued under this turn's routing, whenever it posts, and
24969
25241
  // streamed by this stream once published.
24970
25242
  // This round's request was answered: what it carried is heard.
@@ -25742,14 +26014,14 @@ export class AgentFramework {
25742
26014
  // active (native roundContent), each mid-turn round's prose was
25743
26015
  // already delivered when that round yielded — only the TRAILING
25744
26016
  // prose (blocks after the last tool round) remains here. On the
25745
- // fallback path (XML tool mode / older membrane: no roundContent,
25746
- // so nothing was live-routed) the historical behavior applies:
26017
+ // XML tool path (no roundContent, so nothing was live-routed)
26018
+ // the historical behavior applies:
25747
26019
  // route the whole turn's parsed segments, once, now. The global
25748
26020
  // speech/thoughts split is left untouched (module/TUI rendering
25749
26021
  // unaffected) — this only governs what reaches the channel.
25750
26022
  //
25751
26023
  // Native round routing uses the live sticky flag, which a fresh
25752
- // injected message can reset. The legacy/fallback path has no
26024
+ // injected message can reset. The XML tool path has no
25753
26025
  // reliable round boundaries, so retain its historical turn-wide
25754
26026
  // scan to avoid double-posting.
25755
26027
  const toolCalls = response.content
@@ -25985,6 +26257,7 @@ export class AgentFramework {
25985
26257
 
25986
26258
  this.abandonAgentScripts(agent.name, 'stream error', streamRouting);
25987
26259
  const retry = retryOf(trigger);
26260
+ const asTransient = this.takeAsTransient(agent.name, err, trigger);
25988
26261
 
25989
26262
  // A hold takes the turn's retry, or, once stop() has closed
25990
26263
  // admission, the turn ends here (endFailureAtStop).
@@ -26000,8 +26273,12 @@ export class AgentFramework {
26000
26273
  break;
26001
26274
  }
26002
26275
 
26003
- const action: ErrorAction = statedWait === 'fail' || (isRearmed(trigger) && !gotPartway(streamRouting))
26004
- ? { retry: false } : this.errorPolicy.onInferenceError(err, agent.name, attempt);
26276
+ const cuts = isDeadlineExpiry(err) ? this.deadlineCuts.get(agent.name) ?? 0 : undefined;
26277
+ const action: ErrorAction = statedWait === 'fail' || (isRearmed(trigger) && (!gotPartway(streamRouting) || (isDeadlineExpiry(err) && !asTransient)))
26278
+ ? { retry: false } : this.errorPolicy.onInferenceError(err, agent.name, cuts ?? attempt);
26279
+ if (action.retry && cuts !== undefined) this.deadlineCuts.set(agent.name, cuts + 1);
26280
+ else this.deadlineCuts.delete(agent.name);
26281
+ if (!action.retry && cuts !== undefined && cuts > 0) this.freshCallsCut.add(agent.name);
26005
26282
  if (action.retry) {
26006
26283
  // A stop made during the wait reaches no stream: the check after
26007
26284
  // the wait ends the turn, as the guard's check after its wait
@@ -26058,7 +26335,7 @@ export class AgentFramework {
26058
26335
  speech: '',
26059
26336
  ...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
26060
26337
  }, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting : undefined, streamRouting);
26061
- if (isRearmed(trigger) && isTransientFailure(err)) this.exhaustingRearms.add(agent.name);
26338
+ if (isRearmed(trigger) && asTransient) this.exhaustingRearms.add(agent.name);
26062
26339
  this.emitTrace({
26063
26340
  type: 'inference:exhausted',
26064
26341
  agentName: agent.name,
@@ -26076,7 +26353,7 @@ export class AgentFramework {
26076
26353
  if (action.emit) {
26077
26354
  this.pushEvent(action.emit);
26078
26355
  }
26079
- this.rearmExhaustedTurn(agent, err, trigger, ownsProviderGate, streamRouting);
26356
+ this.rearmExhaustedTurn(agent, err, trigger, ownsProviderGate, streamRouting, asTransient);
26080
26357
  }
26081
26358
  break;
26082
26359
  }
@@ -26151,7 +26428,7 @@ export class AgentFramework {
26151
26428
  agentName: agent.name,
26152
26429
  error: terminal,
26153
26430
  });
26154
- this.rearmExhaustedTurn(agent, new Error(terminal), trigger, ownsProviderGate, streamRouting);
26431
+ this.rearmExhaustedTurn(agent, new Error(terminal), trigger, ownsProviderGate, streamRouting, false);
26155
26432
  // Postmortem 2026-05-28 P2 #7: persist the abort to the
26156
26433
  // inference log so future investigations can attribute the
26157
26434
  // terminal cause without relying on live in-memory reducer
@@ -26169,6 +26446,8 @@ export class AgentFramework {
26169
26446
  }
26170
26447
 
26171
26448
  case 'usage': {
26449
+ this.rearmDeadlineCuts.delete(agent.name);
26450
+ this.deadlineCuts.delete(agent.name);
26172
26451
  // Cumulative across the stream's rounds: replace, never sum.
26173
26452
  latestUsage = event.usage;
26174
26453
  // The stream's accumulated fresh input, which is what the
@@ -26276,10 +26555,6 @@ export class AgentFramework {
26276
26555
  if (thinkingDrops?.recordedAfter !== undefined) {
26277
26556
  this.noteThinkingLoss(agent.name, placedDrops, thinkingDrops.recordedAfter);
26278
26557
  }
26279
- if (thinkingDrops) {
26280
- this.tellReasoningLoss(agent, () => this.reasoningLoss.forDrops(agent.name, thinkingDrops.branch ?? '',
26281
- placedDrops, streamRouting, agent.getContextManager()));
26282
- }
26283
26558
  try {
26284
26559
  if (!thinkingDroppedInStream) {
26285
26560
  strat?.reportRealInputTokens?.(
@@ -26411,6 +26686,7 @@ export class AgentFramework {
26411
26686
  agent.reset();
26412
26687
  this.emitTrace({ type: 'inference:failed', agentName: agent.name, error: err.message, stack: err.stack });
26413
26688
  const retry = retryOf(trigger);
26689
+ const asTransient = this.takeAsTransient(agent.name, err, trigger);
26414
26690
  if (ownsProviderGate && this.holdProviderAcceleration(agent, err, retry)) {
26415
26691
  // Once stop() has closed admission, nothing is held, and the turn
26416
26692
  // ends here (endFailureAtStop).
@@ -26442,14 +26718,14 @@ export class AgentFramework {
26442
26718
  speech: '',
26443
26719
  ...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
26444
26720
  }, streamRouting);
26445
- if (isRearmed(trigger) && isTransientFailure(err)) this.exhaustingRearms.add(agent.name);
26721
+ if (isRearmed(trigger) && asTransient) this.exhaustingRearms.add(agent.name);
26446
26722
  this.emitTrace({
26447
26723
  type: 'inference:exhausted',
26448
26724
  agentName: agent.name,
26449
26725
  error: err.message,
26450
26726
  ...this.classifyInferenceError(err),
26451
26727
  });
26452
- this.rearmExhaustedTurn(agent, err, trigger, ownsProviderGate, streamRouting);
26728
+ this.rearmExhaustedTurn(agent, err, trigger, ownsProviderGate, streamRouting, asTransient);
26453
26729
  // Postmortem 2026-05-28 P2 #7: catch-path failures (stream itself
26454
26730
  // threw) were previously only visible via in-memory trace listeners.
26455
26731
  // Persist to inference log for forensic attribution.
@@ -26660,7 +26936,11 @@ export class AgentFramework {
26660
26936
 
26661
26937
  /**
26662
26938
  * Execute a tool call and return the result.
26663
- * Routes to the appropriate handler (module registry or MCPL).
26939
+ * Routes it where the agent's own call of the name goes: to the
26940
+ * framework's own tools the agent has (agent_settings, the workspace and
26941
+ * gate tools, think, journal, prose_help and the rest), the channel tools,
26942
+ * MCPL or the module registry. skip_reply, which ends the turn it's called
26943
+ * in, is refused, since this call isn't made in one.
26664
26944
  * Its one caller in the framework is the operator's puppet
26665
26945
  * (puppetToolCall), which puts the agent's inline cap on the call as
26666
26946
  * resultMaxChars; a host may call it as well.
@@ -26707,8 +26987,11 @@ export class AgentFramework {
26707
26987
  * wire ordering. For its own duration the puppet HOLDS the turn-alive
26708
26988
  * marker (wakes requeue, cross-turn writers defer), so a wake landing
26709
26989
  * during the tool's execution cannot start a turn underneath the pair.
26710
- * - Refused for tools outside the agent's own surface (canUseTool): the
26711
- * stored pair must be an act the agent could genuinely have taken.
26990
+ * - Refused for tools outside the agent's own surface (the tools it may
26991
+ * call, availableToolsForPresentation), and for skip_reply, by name or
26992
+ * through a toolsets--act that reaches it, which ends the turn it's
26993
+ * called in: the stored pair must be an act the agent could genuinely
26994
+ * have taken.
26712
26995
  * - The call executes FOR REAL through the shared dispatch (MCPL,
26713
26996
  * channel tools, utils, modules) with the agent's provenance — a
26714
26997
  * puppeted walk_to actually moves the avatar.
@@ -26760,14 +27043,21 @@ export class AgentFramework {
26760
27043
  `injecting a turn under an active stream corrupts wire ordering)`,
26761
27044
  );
26762
27045
  }
26763
- const onSurface = this.getToolsForAgent(agentName)
26764
- .some((t) => t.name === toolName && agent.canUseTool(t.name));
27046
+ const onSurface = this.availableToolsForPresentation(agentName).some((t) => t.name === toolName);
26765
27047
  if (!onSurface) {
26766
27048
  throw new Error(
26767
27049
  `puppet refused: tool ${toolName} is not on ${agentName}'s surface — ` +
26768
27050
  `the stored pair must be an act the agent could genuinely have taken`,
26769
27051
  );
26770
27052
  }
27053
+ const route = this.toolRoute({ id: '', name: toolName, input }, agentName);
27054
+ const acting = route.kind === 'host-act' && route.host.kind === 'call' ? route.host.name : toolName;
27055
+ if (acting === 'skip_reply') {
27056
+ throw new Error(
27057
+ "puppet refused: skip_reply ends the turn it's called in, and a puppeted call isn't made in one, " +
27058
+ 'so nothing was run or stored',
27059
+ );
27060
+ }
26771
27061
 
26772
27062
  // Anthropic-shaped id so the stored pair is indistinguishable from a
26773
27063
  // provider-issued call.
@@ -26906,96 +27196,123 @@ export class AgentFramework {
26906
27196
  return { success: false, error: marked, isError: true };
26907
27197
  }
26908
27198
 
26909
- if (isPresentationTool(call.name)) return this.editToolPresentation(call.callerAgentName ?? '__ephemeral__', call);
26910
- // Client-side programmatic tool calling for callers that await a tool's
26911
- // result: the puppet and a host through executeToolCall, and a module
26912
- // through callTool. Keyed by callerAgentName, so each agent's scripts are
26913
- // its own.
26914
- if (call.name === CODE_EXECUTION_TOOL_NAME && this.codeExecution) {
26915
- return this.runCodeExecution(call.callerAgentName ?? '__ephemeral__', call);
26916
- }
26917
-
26918
- // toolsets--act on a host toolset's tool (the built-in one, a server's
26919
- // tab) goes where a programmatic call of that tool goes, under its name.
26920
- if (call.name === META_TOOL_NAMES.act && call.callerAgentName !== undefined) {
26921
- const host = this.hostPresentation?.resolveAct(call.callerAgentName, call.input) ?? null;
26922
- if (host?.kind === 'refused') return failed({ success: false, error: host.error, isError: true });
26923
- if (host?.kind === 'call') return this.executeToolCallFrom({ ...call, name: host.name, input: host.args }, origin);
26924
- }
26925
-
26926
- // RFC-014: toolsets--act, resolved to the dynamic tool it calls, goes
26927
- // where a programmatic call of that tool goes, with its instance.
26928
- if (call.name === META_TOOL_NAMES.act && this.toolsetHub && this.mcplServerRegistry) {
26929
- const target = this.resolveToolsetAct(call);
26930
- if (!target.ok) return failed({ success: false, error: target.error, isError: true }, this.everyServerRedactor()(target.error));
26931
- if (origin.kind === 'agent') {
26932
- const failure = this.agentToolPermissionFailure(origin.agentName, target.call, false);
26933
- if (failure) return failed(failure, this.everyServerRedactor()(failure.error ?? ''));
26934
- }
26935
- return this.executeMcplToolCall(target.call, target.serverId, target.prefix);
27199
+ const subject = origin.kind === 'agent' ? origin.agentName : call.callerAgentName ?? '__ephemeral__';
27200
+ const route = this.toolRoute(call, call.callerAgentName);
27201
+ switch (route.kind) {
27202
+ case 'presentation':
27203
+ return this.editToolPresentation(call.callerAgentName ?? '__ephemeral__', call);
27204
+
27205
+ // toolsets--act on a host toolset's tool (the built-in one, a server's
27206
+ // tab) goes where a programmatic call of that tool goes, under its name.
27207
+ case 'host-act':
27208
+ if (route.host.kind === 'refused') return failed({ success: false, error: route.host.error, isError: true });
27209
+ return this.executeToolCallFrom({ ...call, name: route.host.name, input: route.host.args }, origin);
27210
+
27211
+ // RFC-014: toolsets--act, resolved to the dynamic tool it calls, goes
27212
+ // where a programmatic call of that tool goes, with its instance.
27213
+ case 'toolset-act': {
27214
+ const target = this.resolveToolsetAct(call);
27215
+ if (!target.ok) return failed({ success: false, error: target.error, isError: true }, this.everyServerRedactor()(target.error));
27216
+ if (origin.kind === 'agent') {
27217
+ const failure = this.agentToolPermissionFailure(origin.agentName, target.call, false);
27218
+ if (failure) return failed(failure, this.everyServerRedactor()(failure.error ?? ''));
27219
+ }
27220
+ return this.executeMcplToolCall(target.call, target.serverId, target.prefix);
27221
+ }
27222
+
27223
+ case 'mcpl':
27224
+ return this.executeMcplToolCall(call, route.serverId, route.prefix);
27225
+
27226
+ // Synthesized channel tools. This route was missing: when
27227
+ // subscription-GC moved off the retired MCPL unsubscribe tool onto the
27228
+ // generic channel_close (discord-mcpl b095a9f), module-originated
27229
+ // closes started failing downstream as "Invalid tool name format" —
27230
+ // the janitor had been silently broken since. `origin` is trusted
27231
+ // dispatch context established at the call boundary above, not
27232
+ // inferred from which method was used: only the ModuleRegistry closure
27233
+ // carries module provenance (see handleToolClose).
27234
+ case 'channel': {
27235
+ const turn = call.name === 'channel_publish' && call.callerAgentName ? this.callingTurnOf(call.callerAgentName, call) : undefined;
27236
+ const input = call.name === 'channel_publish' && call.callerAgentName
27237
+ ? this.publishInputWithTurnLocus(call.input, call.callerAgentName, turn)
27238
+ : call.input;
27239
+ return this.channelRegistry!.handleChannelToolCall(call.name, input, origin.kind === 'agent' && turn ? { ...origin, inTurn: true } : origin);
27240
+ }
27241
+
27242
+ case 'agent-settings':
27243
+ return this.awaitedToolAnswer((deliver) => this.dispatchAgentSettingsToolCall(subject, call, deliver));
27244
+ case 'save-image':
27245
+ return this.awaitedToolAnswer((deliver) => this.dispatchSaveImageToolCall(subject, call, deliver));
27246
+ case 'read-image':
27247
+ return this.awaitedToolAnswer((deliver) => this.dispatchReadImageToolCall(subject, call, deliver));
27248
+ case 'fetch-reference':
27249
+ return this.awaitedToolAnswer((deliver) => this.dispatchFetchReferenceToolCall(subject, call, deliver));
27250
+ case 'gate-status':
27251
+ return this.awaitedToolAnswer((deliver) => this.dispatchGateToolCall(subject, call, deliver));
27252
+
27253
+ // The tune-out tool, in process: the same body the dispatcher runs, its
27254
+ // answer awaited here (it can wait for the tune-out's transport).
27255
+ case 'tune-out': {
27256
+ const agentName = call.callerAgentName ?? this.primaryAgentName ?? '__ephemeral__';
27257
+ const input = (call.input ?? {}) as Record<string, unknown>;
27258
+ // The calling turn's own channels, as the channel tools read them here (origin.own).
27259
+ const own = origin.kind === 'agent' ? origin.own ?? [] : [];
27260
+ const turn = this.callingTurnOf(agentName, call);
27261
+ return call.name === 'focus' ? this.handleFocusTool(agentName, input, own, turn) : this.handleTuneOutTool(agentName, input, own, turn);
27262
+ }
27263
+ case 'subconscious': {
27264
+ const routed = this.readerToolCall(subject, call);
27265
+ return 'refused' in routed ? failed({ success: false, error: `${routed.refused}, so the call didn't run.`, isError: true }) : routed.result;
27266
+ }
27267
+ case 'sleep':
27268
+ return this.awaitedToolAnswer((deliver) => this.dispatchSleepToolCall(subject, call, deliver));
27269
+
27270
+ // The heartbeat's tools, for callers that await a tool's result (an
27271
+ // in-process call, a puppet, a module's callTool). A module is trusted;
27272
+ // an agent is held to what dispatch holds it to.
27273
+ case 'heartbeat': {
27274
+ const refusal = origin.kind === 'module' ? null : this.heartbeatRefusal(call.callerAgentName ?? '__ephemeral__', call);
27275
+ return refusal ? { success: false, error: refusal, isError: true } : this.runHeartbeatTool(call);
27276
+ }
27277
+ case 'wake-rule':
27278
+ return this.awaitedToolAnswer((deliver) => this.dispatchWakeRuleToolCall(subject, call, deliver));
27279
+ case 'event-tags':
27280
+ return this.awaitedToolAnswer((deliver) => this.dispatchEventTagsToolCall(subject, call, deliver));
27281
+ case 'agent-verb':
27282
+ if (origin.kind === 'module') return failed(agentsOwnVerbRefusal(call.name));
27283
+ if (call.name === 'skip_reply') {
27284
+ return failed({
27285
+ success: false,
27286
+ error: "skip_reply ends the turn it's called in, and this call wasn't made in a turn, so nothing was done.",
27287
+ isError: true,
27288
+ });
27289
+ }
27290
+ return this.channelRegistry!.handleChannelToolCall(call.name, call.input, origin);
27291
+
27292
+ // Client-side programmatic tool calling for callers that await a tool's
27293
+ // result: the puppet and a host through executeToolCall, and a module
27294
+ // through callTool. Keyed by callerAgentName, so each agent's scripts are
27295
+ // its own.
27296
+ case 'code-execution':
27297
+ return this.runCodeExecution(call.callerAgentName ?? '__ephemeral__', call);
27298
+
27299
+ // drafts: the calling resident's own held drafts. A puppeted call has
27300
+ // agent origin and acts on that resident's drafts, as the model's own
27301
+ // call does through dispatchToolCall, but it isn't made inside the
27302
+ // resident's turn, so it takes none of the turn's channels, engages none,
27303
+ // and isn't noted as the resident's speech (callingTurnOf). A module
27304
+ // holds no drafts.
27305
+ case 'drafts':
27306
+ if (origin.kind === 'module') return failed(agentsOwnVerbRefusal(call.name));
27307
+ return this.draftsToolResult(origin.agentName, call.input);
27308
+ case 'prose-help':
27309
+ return { success: true, data: PROSE_ROUTING_HELP };
27310
+ case 'module':
27311
+ // Module tools. The caller uses the result in process, so it gets the
27312
+ // module's own result, as the module returned it, less a refusal it
27313
+ // claims, which is the dispatcher's to say.
27314
+ return this.moduleRegistry.handleToolCall(call);
26936
27315
  }
26937
-
26938
- // MCPL tools go where the model's own call of the name goes, by the same
26939
- // resolver and with the prefix it matched: to the server whose listing
26940
- // has the name, else the longest matching prefix.
26941
- if (this.mcplServerRegistry) {
26942
- const mcpl = this.resolveMcplTool(call.name);
26943
- if (mcpl) return this.executeMcplToolCall(call, mcpl[0], mcpl[1]);
26944
- }
26945
-
26946
- // Synthesized channel tools. This route was missing: when
26947
- // subscription-GC moved off the retired MCPL unsubscribe tool onto the
26948
- // generic channel_close (discord-mcpl b095a9f), module-originated
26949
- // closes started failing downstream as "Invalid tool name format" —
26950
- // the janitor had been silently broken since. `origin` is trusted
26951
- // dispatch context established at the call boundary above, not
26952
- // inferred from which method was used: only the ModuleRegistry closure
26953
- // carries module provenance (see handleToolClose). The agent-only
26954
- // synthesized verbs (think, skip_reply) are deliberately not routed
26955
- // here.
26956
- if (call.name.startsWith('channel_') && this.channelRegistry) {
26957
- const turn = call.name === 'channel_publish' && call.callerAgentName ? this.callingTurnOf(call.callerAgentName, call) : undefined;
26958
- const input = call.name === 'channel_publish' && call.callerAgentName
26959
- ? this.publishInputWithTurnLocus(call.input, call.callerAgentName, turn)
26960
- : call.input;
26961
- return this.channelRegistry.handleChannelToolCall(call.name, input, origin.kind === 'agent' && turn ? { ...origin, inTurn: true } : origin);
26962
- }
26963
-
26964
- // drafts: the calling resident's own held drafts. A puppeted call has
26965
- // agent origin and acts on that resident's drafts, as the model's own
26966
- // call does through dispatchToolCall, but it isn't made inside the
26967
- // resident's turn, so it takes none of the turn's channels, engages none,
26968
- // and isn't noted as the resident's speech (callingTurnOf). A module
26969
- // holds no drafts.
26970
- if (call.name === 'drafts' && this.channelRegistry && origin.kind === 'agent') {
26971
- return this.draftsToolResult(origin.agentName, call.input);
26972
- }
26973
-
26974
- // The heartbeat's tools, for callers that await a tool's result (an
26975
- // in-process call, a puppet, a module's callTool). A module is trusted;
26976
- // an agent is held to what dispatch holds it to.
26977
- if (this.heartbeat?.ownsTool(call.name)) {
26978
- const refusal = origin.kind === 'module' ? null : this.heartbeatRefusal(call.callerAgentName ?? '__ephemeral__', call);
26979
- return refusal ? { success: false, error: refusal, isError: true } : this.runHeartbeatTool(call);
26980
- }
26981
- // Module tools. The caller uses the result in process, so it gets the
26982
- // module's own result, as the module returned it, less a refusal it
26983
- // claims, which is the dispatcher's to say.
26984
- // The tune-out tool, in process: the same body the dispatcher runs, its
26985
- // answer awaited here (it can wait for the tune-out's transport).
26986
- if ((call.name === 'tune_out' || call.name === 'focus') && this.tuneOutCoordinator) {
26987
- const agentName = call.callerAgentName ?? this.primaryAgentName ?? '__ephemeral__';
26988
- const input = (call.input ?? {}) as Record<string, unknown>;
26989
- // The calling turn's own channels, as the channel tools read them here (origin.own).
26990
- const own = origin.kind === 'agent' ? origin.own ?? [] : [];
26991
- const turn = this.callingTurnOf(agentName, call);
26992
- return call.name === 'focus' ? this.handleFocusTool(agentName, input, own, turn) : this.handleTuneOutTool(agentName, input, own, turn);
26993
- }
26994
- if ((SUBCONSCIOUS_TOOL_NAMES as readonly string[]).includes(call.name)) {
26995
- const routed = this.readerToolCall(origin.kind === 'agent' ? origin.agentName : call.callerAgentName ?? '__ephemeral__', call);
26996
- return 'refused' in routed ? failed({ success: false, error: `${routed.refused}, so the call didn't run.`, isError: true }) : routed.result;
26997
- }
26998
- return this.moduleRegistry.handleToolCall(call);
26999
27316
  }
27000
27317
 
27001
27318
  /**
@@ -27739,7 +28056,7 @@ export class AgentFramework {
27739
28056
  // A batch with no finite deadline (a host whose deadline() threw) gets
27740
28057
  // this write's own, rather than a timer Node would cut to 1 ms.
27741
28058
  if (!Number.isFinite(deadline)) deadline = Date.now() + this.withheldSpillDeadlineMs;
27742
- if (this.guardSpillsClosedFor === this.lifecycleGeneration) return null;
28059
+ if (this.guardSpillsClosed) return null;
27743
28060
  const place = this.toolResultFilePlace(label);
27744
28061
  if (!place) return null;
27745
28062
  if (this.stalledSpills.size > 0) {
@@ -28842,6 +29159,53 @@ export class AgentFramework {
28842
29159
  return { name: call.name, cause: permitted || refused ? silenceCauseOf([call]) : null, refused, permitted };
28843
29160
  }
28844
29161
 
29162
+ private toolRoute(call: ToolCall, actor: string | undefined): ToolRoute {
29163
+ const name = call.name;
29164
+ if (isPresentationTool(name)) return { kind: 'presentation' };
29165
+ if (name === META_TOOL_NAMES.act) {
29166
+ const host = actor === undefined ? null : this.hostPresentation?.resolveAct(actor, call.input) ?? null;
29167
+ if (host) return { kind: 'host-act', host };
29168
+ if (this.toolsetHub && this.mcplServerRegistry) return { kind: 'toolset-act' };
29169
+ }
29170
+ const mcpl = this.mcplServerRegistry ? this.resolveMcplTool(name) : null;
29171
+ if (mcpl) return { kind: 'mcpl', serverId: mcpl[0], prefix: mcpl[1] };
29172
+ if (name.startsWith('channel_') && this.channelRegistry) return { kind: 'channel' };
29173
+ if (name === 'agent_settings') return { kind: 'agent-settings' };
29174
+ if (name === 'save_recent_image') return { kind: 'save-image' };
29175
+ if (name === 'read_image') return { kind: 'read-image' };
29176
+ if (name === 'fetch_reference') return { kind: 'fetch-reference' };
29177
+ if (name === 'gate_status' && this.eventGate) return { kind: 'gate-status' };
29178
+ if ((name === 'tune_out' || name === 'focus') && this.tuneOutCoordinator) return { kind: 'tune-out' };
29179
+ if ((SUBCONSCIOUS_TOOL_NAMES as readonly string[]).includes(name)) return { kind: 'subconscious' };
29180
+ if ((name === 'sleep' || name === 'wake') && this.eventGate) return { kind: 'sleep' };
29181
+ if (this.heartbeat?.ownsTool(name)) return { kind: 'heartbeat' };
29182
+ if ((name === 'wake_add_rule' || name === 'wake_remove_rule') && this.eventGate) return { kind: 'wake-rule' };
29183
+ if (name === 'event_tags' && this.eventGate) return { kind: 'event-tags' };
29184
+ if ((name === 'think' || name === 'journal' || name === 'skip_reply') && this.channelRegistry) return { kind: 'agent-verb' };
29185
+ if (name === CODE_EXECUTION_TOOL_NAME && this.codeExecution) return { kind: 'code-execution' };
29186
+ if (name === 'drafts' && this.channelRegistry) return { kind: 'drafts' };
29187
+ if (name === PROSE_HELP_TOOL.name) return { kind: 'prose-help' };
29188
+ return { kind: 'module' };
29189
+ }
29190
+
29191
+ private pushToolResult(agentName: string, call: ToolCall, moduleName: string): (result: ToolResult) => void {
29192
+ return (result) => this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName, result });
29193
+ }
29194
+
29195
+ private awaitedToolAnswer(run: (deliver: (result: ToolResult) => void) => void): Promise<ToolResult> {
29196
+ return new Promise((resolve) => {
29197
+ try {
29198
+ run((result) => {
29199
+ const answer = { ...result };
29200
+ delete answer.refused;
29201
+ resolve(answer);
29202
+ });
29203
+ } catch (error) {
29204
+ resolve({ success: false, error: thrownText(error), isError: true });
29205
+ }
29206
+ });
29207
+ }
29208
+
28845
29209
  /**
28846
29210
  * Dispatch a call the agent made. `turnId` is the turn it was made in
28847
29211
  * (TurnRouting.turnId), as its caller knows it: a model's call, the turn
@@ -28892,217 +29256,207 @@ export class AgentFramework {
28892
29256
  const enrichedCall: ToolCall = { ...call, callerAgentName: agentName };
28893
29257
  if (turnId !== undefined) enrichedCall.callerTurnId = turnId;
28894
29258
  else delete enrichedCall.callerTurnId;
28895
- if (isPresentationTool(call.name)) {
28896
- const result = this.editToolPresentation(agentName, enrichedCall);
28897
- this.pushEvent({type:'tool-result',callId:call.id,toolName:call.name,agentName,moduleName:'tool-presentation',result});
28898
- return;
28899
- }
29259
+ const route = this.toolRoute(enrichedCall, agentName);
29260
+ switch (route.kind) {
29261
+ case 'presentation': {
29262
+ const result = this.editToolPresentation(agentName, enrichedCall);
29263
+ this.pushEvent({type:'tool-result',callId:call.id,toolName:call.name,agentName,moduleName:'tool-presentation',result});
29264
+ return;
29265
+ }
28900
29266
 
29267
+ // toolsets--act on a host toolset's tool (the built-in one, a server's
29268
+ // tab): the call of that tool, under its own name, down the path a
29269
+ // direct call of it takes, permissions included.
29270
+ case 'host-act':
29271
+ this.dispatchHostAct(agentName, call, route.host, turnId);
29272
+ return;
28901
29273
 
28902
- // toolsets--act on a host toolset's tool (the built-in one, a server's
28903
- // tab): the call of that tool, under its own name, down the path a
28904
- // direct call of it takes, permissions included.
28905
- if (enrichedCall.name === META_TOOL_NAMES.act) {
28906
- const host = this.hostPresentation?.resolveAct(agentName, enrichedCall.input) ?? null;
28907
- if (host) {
28908
- this.dispatchHostAct(agentName, call, host, turnId);
29274
+ // RFC-014: toolsets--act calls a server's dynamic tool, down the same
29275
+ // path as a direct call of it (dispatchToolsetAct).
29276
+ case 'toolset-act': {
29277
+ const scriptWaiter = this.scriptToolWaiters.get(enrichedCall.id);
29278
+ if (scriptWaiter) scriptWaiter.route.toServer = true;
29279
+ this.dispatchToolsetAct(agentName, enrichedCall);
28909
29280
  return;
28910
29281
  }
28911
- }
28912
-
28913
- // RFC-014: toolsets--act calls a server's dynamic tool, down the same
28914
- // path as a direct call of it (dispatchToolsetAct).
28915
- if (enrichedCall.name === META_TOOL_NAMES.act && this.toolsetHub && this.mcplServerRegistry) {
28916
- const scriptWaiter = this.scriptToolWaiters.get(enrichedCall.id);
28917
- if (scriptWaiter) scriptWaiter.route.toServer = true;
28918
- this.dispatchToolsetAct(agentName, enrichedCall);
28919
- return;
28920
- }
28921
29282
 
28922
- // Route MCPL tool calls to the appropriate server via prefix map
28923
- const mcplMatch = this.resolveMcplTool(enrichedCall.name);
28924
- if (mcplMatch && this.mcplServerRegistry) {
28925
- // A script's call: its waiter keeps that it went to a server, for the
28926
- // script lane's exit (scriptToolWaiters).
28927
- const scriptWaiter = this.scriptToolWaiters.get(enrichedCall.id);
28928
- if (scriptWaiter) scriptWaiter.route.toServer = true;
28929
- this.dispatchMcplToolCall(agentName, enrichedCall, mcplMatch[0], mcplMatch[1]);
28930
- return;
28931
- }
29283
+ case 'mcpl': {
29284
+ // A script's call: its waiter keeps that it went to a server, for the
29285
+ // script lane's exit (scriptToolWaiters).
29286
+ const scriptWaiter = this.scriptToolWaiters.get(enrichedCall.id);
29287
+ if (scriptWaiter) scriptWaiter.route.toServer = true;
29288
+ this.dispatchMcplToolCall(agentName, enrichedCall, route.serverId, route.prefix);
29289
+ return;
29290
+ }
28932
29291
 
28933
- // Route synthesized channel tools
28934
- if (enrichedCall.name.startsWith('channel_') && this.channelRegistry) {
28935
- this.dispatchChannelToolCall(agentName, enrichedCall);
28936
- return;
28937
- }
29292
+ case 'channel': {
29293
+ this.dispatchChannelToolCall(agentName, enrichedCall);
29294
+ return;
29295
+ }
28938
29296
 
28939
- if (enrichedCall.name === 'agent_settings') {
28940
- this.dispatchAgentSettingsToolCall(agentName, enrichedCall);
28941
- return;
28942
- }
29297
+ case 'agent-settings': {
29298
+ this.dispatchAgentSettingsToolCall(agentName, enrichedCall);
29299
+ return;
29300
+ }
28943
29301
 
28944
- if (enrichedCall.name === 'save_recent_image') {
28945
- this.dispatchSaveImageToolCall(agentName, enrichedCall);
28946
- return;
28947
- }
29302
+ case 'save-image': {
29303
+ this.dispatchSaveImageToolCall(agentName, enrichedCall);
29304
+ return;
29305
+ }
28948
29306
 
28949
- if (enrichedCall.name === 'read_image') {
28950
- this.dispatchReadImageToolCall(agentName, enrichedCall);
28951
- return;
28952
- }
29307
+ case 'read-image': {
29308
+ this.dispatchReadImageToolCall(agentName, enrichedCall);
29309
+ return;
29310
+ }
28953
29311
 
28954
- if (enrichedCall.name === 'fetch_reference') {
28955
- this.dispatchFetchReferenceToolCall(agentName, enrichedCall);
28956
- return;
28957
- }
29312
+ case 'fetch-reference': {
29313
+ this.dispatchFetchReferenceToolCall(agentName, enrichedCall);
29314
+ return;
29315
+ }
28958
29316
 
28959
- // Route gate_status tool
28960
- if (enrichedCall.name === 'gate_status' && this.eventGate) {
28961
- this.dispatchGateToolCall(agentName, enrichedCall);
28962
- return;
28963
- }
29317
+ case 'gate-status': {
29318
+ this.dispatchGateToolCall(agentName, enrichedCall);
29319
+ return;
29320
+ }
28964
29321
 
28965
- // Route the tune-out tool (residents) and the subconscious's surface
28966
- if ((enrichedCall.name === 'tune_out' || enrichedCall.name === 'focus') && this.tuneOutCoordinator) {
28967
- this.dispatchTuneOutToolCall(agentName, enrichedCall);
28968
- return;
28969
- }
28970
- if ((SUBCONSCIOUS_TOOL_NAMES as readonly string[]).includes(enrichedCall.name)) {
28971
- const routed = this.readerToolCall(agentName, enrichedCall);
28972
- if ('refused' in routed) {
28973
- const error = routed.refused;
28974
- const result = this.refusedResult(agentName, call.id, error);
28975
- this.emitTrace({ type: 'tool:failed', module: 'tune-out', tool: call.name, callId: call.id, error });
28976
- this.pushEvent({
28977
- type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'tune-out', result,
28978
- });
29322
+ case 'tune-out': {
29323
+ this.dispatchTuneOutToolCall(agentName, enrichedCall);
28979
29324
  return;
28980
29325
  }
28981
- void routed.result
28982
- .then((result) => {
28983
- // Through pushEvent, like every other tool's result, so one that
28984
- // arrives after stop() is dropped rather than rejected unhandled.
29326
+ case 'subconscious': {
29327
+ const routed = this.readerToolCall(agentName, enrichedCall);
29328
+ if ('refused' in routed) {
29329
+ const error = routed.refused;
29330
+ const result = this.refusedResult(agentName, call.id, error);
29331
+ this.emitTrace({ type: 'tool:failed', module: 'tune-out', tool: call.name, callId: call.id, error });
28985
29332
  this.pushEvent({
28986
- type: 'tool-result',
28987
- callId: enrichedCall.id,
28988
- toolName: enrichedCall.name,
28989
- agentName,
28990
- moduleName: 'tune-out',
28991
- result,
29333
+ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'tune-out', result,
28992
29334
  });
28993
- });
28994
- return;
28995
- }
29335
+ return;
29336
+ }
29337
+ void routed.result
29338
+ .then((result) => {
29339
+ // Through pushEvent, like every other tool's result, so one that
29340
+ // arrives after stop() is dropped rather than rejected unhandled.
29341
+ this.pushEvent({
29342
+ type: 'tool-result',
29343
+ callId: enrichedCall.id,
29344
+ toolName: enrichedCall.name,
29345
+ agentName,
29346
+ moduleName: 'tune-out',
29347
+ result,
29348
+ });
29349
+ });
29350
+ return;
29351
+ }
28996
29352
 
28997
- // Route sleep / wake tools
28998
- if ((enrichedCall.name === 'sleep' || enrichedCall.name === 'wake') && this.eventGate) {
28999
- this.dispatchSleepToolCall(agentName, enrichedCall);
29000
- return;
29001
- }
29353
+ case 'sleep': {
29354
+ this.dispatchSleepToolCall(agentName, enrichedCall);
29355
+ return;
29356
+ }
29002
29357
 
29003
- // Route the heartbeat's tools (heartbeat--…)
29004
- if (this.heartbeat?.ownsTool(enrichedCall.name)) {
29005
- this.dispatchHeartbeatToolCall(agentName, enrichedCall);
29006
- return;
29007
- }
29358
+ case 'heartbeat': {
29359
+ this.dispatchHeartbeatToolCall(agentName, enrichedCall);
29360
+ return;
29361
+ }
29008
29362
 
29009
- // Route wake-rule tools (runtime gate.json policy add/remove)
29010
- if ((enrichedCall.name === 'wake_add_rule' || enrichedCall.name === 'wake_remove_rule') && this.eventGate) {
29011
- this.dispatchWakeRuleToolCall(agentName, enrichedCall);
29012
- return;
29013
- }
29363
+ case 'wake-rule': {
29364
+ this.dispatchWakeRuleToolCall(agentName, enrichedCall);
29365
+ return;
29366
+ }
29014
29367
 
29015
- // Route event_tags (tag/ontology discovery)
29016
- if (enrichedCall.name === 'event_tags' && this.eventGate) {
29017
- this.dispatchEventTagsToolCall(agentName, enrichedCall);
29018
- return;
29019
- }
29368
+ case 'event-tags': {
29369
+ this.dispatchEventTagsToolCall(agentName, enrichedCall);
29370
+ return;
29371
+ }
29020
29372
 
29021
- // Route synthesized 'think' (private reasoning), 'journal' (private
29022
- // long-form notes) and 'skip_reply' (deliberate stay-silent) tools — handled by the channel registry like the other
29023
- // synthesized channel tools, but they aren't `channel_`-prefixed so they
29024
- // need an explicit route here.
29025
- if ((enrichedCall.name === 'think' || enrichedCall.name === 'journal' || enrichedCall.name === 'skip_reply') && this.channelRegistry) {
29026
- // skip_reply(wake_in_seconds): arm a gate self-wake so "not replying
29027
- // NOW" can also mean "back in a moment" — ends the turn, then wakes
29028
- // the agent after N seconds unless something else wakes it first
29029
- // (any turn start cancels the pending self-wake). Armed here because
29030
- // the framework owns the EventGate; the registry only words the
29031
- // confirmation. A wake the host can't set is refused, so the model
29032
- // learns that its turn remains open and no wake was armed: any
29033
- // wake_in_seconds without a gate, and one that isn't a number of
29034
- // seconds with one.
29035
- if (enrichedCall.name === 'skip_reply') {
29036
- const error = this.skipReplyRefusal(enrichedCall);
29037
- if (error !== undefined) {
29038
- const wakeValue = (enrichedCall.input as { wake_in_seconds?: unknown } | null | undefined)?.wake_in_seconds;
29039
- 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)')}`);
29040
- const result = this.refusedResult(agentName, enrichedCall.id, error);
29041
- this.emitTrace({ type: 'tool:failed', module: 'channels', tool: enrichedCall.name, callId: enrichedCall.id, error });
29042
- this.pushEvent({ type: 'tool-result', callId: enrichedCall.id, toolName: enrichedCall.name, agentName, moduleName: 'channels', result });
29043
- return;
29044
- }
29045
- const skipping = this.callingTurnOf(agentName, enrichedCall);
29046
- if (skipping) {
29047
- skipping.privateEnd = true;
29048
- delete skipping.unpostedSpeech;
29049
- }
29050
- const wake = readSkipReplyWake(enrichedCall.input);
29051
- if (wake.kind === 'wake' && this.eventGate) {
29052
- const armedAt = Date.now();
29053
- const { inMs, armed } = this.eventGate.armSelfWake(agentName, wake.seconds, 'skip_reply');
29054
- // The heartbeat's tick or reminder due before the timer: if it wakes
29055
- // the agent, its turn cancels the timer, so the confirmation names
29056
- // it and the time the self-wake would have left.
29057
- const earlierWake = armed ? this.heartbeatWakeBefore(agentName, armedAt + inMs) : undefined;
29058
- console.error(armed
29059
- ? `[self-wake] agent=${agentName} armed in ${Math.round(inMs / 1000)}s (skip_reply)`
29060
- + (earlierWake ? `; due sooner, in ~${earlierWake.inSeconds}s: ${logDetail(earlierWake.what)}` : '')
29061
- : `[self-wake] agent=${agentName} not armed: the framework has stopped (skip_reply)`);
29062
- this.dispatchChannelToolCall(agentName, enrichedCall, earlierWake);
29063
- return;
29373
+ case 'agent-verb': {
29374
+ // skip_reply(wake_in_seconds): arm a gate self-wake so "not replying
29375
+ // NOW" can also mean "back in a moment" — ends the turn, then wakes
29376
+ // the agent after N seconds unless something else wakes it first
29377
+ // (any turn start cancels the pending self-wake). Armed here because
29378
+ // the framework owns the EventGate; the registry only words the
29379
+ // confirmation. A wake the host can't set is refused, so the model
29380
+ // learns that its turn remains open and no wake was armed: any
29381
+ // wake_in_seconds without a gate, and one that isn't a number of
29382
+ // seconds with one.
29383
+ if (enrichedCall.name === 'skip_reply') {
29384
+ const error = this.skipReplyRefusal(enrichedCall);
29385
+ if (error !== undefined) {
29386
+ const wakeValue = (enrichedCall.input as { wake_in_seconds?: unknown } | null | undefined)?.wake_in_seconds;
29387
+ 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)')}`);
29388
+ const result = this.refusedResult(agentName, enrichedCall.id, error);
29389
+ this.emitTrace({ type: 'tool:failed', module: 'channels', tool: enrichedCall.name, callId: enrichedCall.id, error });
29390
+ this.pushEvent({ type: 'tool-result', callId: enrichedCall.id, toolName: enrichedCall.name, agentName, moduleName: 'channels', result });
29391
+ return;
29392
+ }
29393
+ const skipping = this.callingTurnOf(agentName, enrichedCall);
29394
+ if (skipping) {
29395
+ skipping.privateEnd = true;
29396
+ delete skipping.unpostedSpeech;
29397
+ }
29398
+ const wake = readSkipReplyWake(enrichedCall.input);
29399
+ if (wake.kind === 'wake' && this.eventGate) {
29400
+ const armedAt = Date.now();
29401
+ const { inMs, armed } = this.eventGate.armSelfWake(agentName, wake.seconds, 'skip_reply');
29402
+ // The heartbeat's tick or reminder due before the timer: if it wakes
29403
+ // the agent, its turn cancels the timer, so the confirmation names
29404
+ // it and the time the self-wake would have left.
29405
+ const earlierWake = armed ? this.heartbeatWakeBefore(agentName, armedAt + inMs) : undefined;
29406
+ console.error(armed
29407
+ ? `[self-wake] agent=${agentName} armed in ${Math.round(inMs / 1000)}s (skip_reply)`
29408
+ + (earlierWake ? `; due sooner, in ~${earlierWake.inSeconds}s: ${logDetail(earlierWake.what)}` : '')
29409
+ : `[self-wake] agent=${agentName} not armed: the framework has stopped (skip_reply)`);
29410
+ this.dispatchChannelToolCall(agentName, enrichedCall, earlierWake);
29411
+ return;
29412
+ }
29064
29413
  }
29414
+ this.dispatchChannelToolCall(agentName, enrichedCall);
29415
+ return;
29065
29416
  }
29066
- this.dispatchChannelToolCall(agentName, enrichedCall);
29067
- return;
29068
- }
29069
29417
 
29070
- // Client-side programmatic tool calling: run a script that calls the
29071
- // agent's other tools in-process (script-inner calls come back through
29072
- // this very dispatcher with waiter-tracked call IDs).
29073
- if (enrichedCall.name === CODE_EXECUTION_TOOL_NAME && this.codeExecution) {
29074
- this.dispatchCodeExecutionToolCall(agentName, enrichedCall);
29075
- return;
29076
- }
29418
+ // Client-side programmatic tool calling: run a script that calls the
29419
+ // agent's other tools in-process (script-inner calls come back through
29420
+ // this very dispatcher with waiter-tracked call IDs).
29421
+ case 'code-execution': {
29422
+ this.dispatchCodeExecutionToolCall(agentName, enrichedCall);
29423
+ return;
29424
+ }
29077
29425
 
29078
- // drafts: the resident's own held drafts (list/read/resend/dismiss).
29079
- // Answered by the framework; resend publishes through the registry's
29080
- // publish executor, so the result arrives asynchronously.
29081
- if (enrichedCall.name === 'drafts' && this.channelRegistry) {
29082
- const startedAt = Date.now();
29083
- this.emitTrace({ type: 'tool:started', module: 'framework', tool: 'drafts', callId: enrichedCall.id, input: enrichedCall.input });
29084
- void this.draftsToolResult(agentName, enrichedCall.input, this.callingTurnOf(agentName, enrichedCall), enrichedCall.id)
29085
- .then((result) => {
29086
- this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'drafts', callId: enrichedCall.id, durationMs: Date.now() - startedAt });
29087
- this.pushEvent({ type: 'tool-result', callId: enrichedCall.id, toolName: enrichedCall.name, agentName, moduleName: 'framework', result });
29426
+ // drafts: the resident's own held drafts (list/read/resend/dismiss).
29427
+ // Answered by the framework; resend publishes through the registry's
29428
+ // publish executor, so the result arrives asynchronously.
29429
+ case 'drafts': {
29430
+ const startedAt = Date.now();
29431
+ this.emitTrace({ type: 'tool:started', module: 'framework', tool: 'drafts', callId: enrichedCall.id, input: enrichedCall.input });
29432
+ void this.draftsToolResult(agentName, enrichedCall.input, this.callingTurnOf(agentName, enrichedCall), enrichedCall.id)
29433
+ .then((result) => {
29434
+ this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'drafts', callId: enrichedCall.id, durationMs: Date.now() - startedAt });
29435
+ this.pushEvent({ type: 'tool-result', callId: enrichedCall.id, toolName: enrichedCall.name, agentName, moduleName: 'framework', result });
29436
+ });
29437
+ return;
29438
+ }
29439
+
29440
+ // prose_help: on-demand routing reference for explicit-prose agents.
29441
+ // Answered inline — the grammar is a tool RESULT (model-requested), never
29442
+ // ambient context (see PROSE_ROUTING_HELP / teach-by-bounce).
29443
+ case 'prose-help': {
29444
+ this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'prose_help', callId: enrichedCall.id, durationMs: 0 });
29445
+ this.pushEvent({
29446
+ type: 'tool-result',
29447
+ callId: enrichedCall.id,
29448
+ toolName: enrichedCall.name,
29449
+ agentName,
29450
+ moduleName: 'framework',
29451
+ result: { success: true, data: PROSE_ROUTING_HELP },
29088
29452
  });
29089
- return;
29090
- }
29453
+ return;
29454
+ }
29091
29455
 
29092
- // prose_help: on-demand routing reference for explicit-prose agents.
29093
- // Answered inline — the grammar is a tool RESULT (model-requested), never
29094
- // ambient context (see PROSE_ROUTING_HELP / teach-by-bounce).
29095
- if (enrichedCall.name === 'prose_help') {
29096
- this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'prose_help', callId: enrichedCall.id, durationMs: 0 });
29097
- this.pushEvent({
29098
- type: 'tool-result',
29099
- callId: enrichedCall.id,
29100
- toolName: enrichedCall.name,
29101
- agentName,
29102
- moduleName: 'framework',
29103
- result: { success: true, data: PROSE_ROUTING_HELP },
29104
- });
29105
- return;
29456
+ case 'module':
29457
+ break;
29458
+ default:
29459
+ route satisfies never;
29106
29460
  }
29107
29461
 
29108
29462
  const sepIndex = enrichedCall.name.indexOf('--');
@@ -29502,6 +29856,7 @@ export class AgentFramework {
29502
29856
  private noteSpokenTo(agentName: string, metadata: MessageMetadata | undefined): void {
29503
29857
  const agent = this.agents.get(agentName);
29504
29858
  if ((agent && !this.agentSees(agent, metadata)) || isOwnScheduleWrite(metadata) || !isConversationalInjection(metadata)) return;
29859
+ this.rearmDeadlineCuts?.delete(agentName);
29505
29860
  if (this.turnRearms?.delete(agentName)) console.error(`[inference-rearm] agent=${agentName} re-send count started again: a message was said to it`);
29506
29861
  }
29507
29862
 
@@ -30233,11 +30588,9 @@ export class AgentFramework {
30233
30588
  * at a tool boundary whose stream declares it carries the messages
30234
30589
  * injected there (markContextConsumed). So a notice such a stream carried
30235
30590
  * is under it. One a boundary stored without sending (a turn a tool ended,
30236
- * a budget restart) is above it, and so is one injected into a stream that
30237
- * doesn't declare it carries injections, which may have dropped it:
30238
- * counted as unread, it goes to the primary even if the agent heard it,
30239
- * rather than to no one where the stream dropped it. null when the window
30240
- * can't be read, which is logged.
30591
+ * a budget restart, the direct API's boundary, which has no stream) is
30592
+ * above it until a compile sends it, and counts as unread. null when the
30593
+ * window can't be read, which is logged.
30241
30594
  */
30242
30595
  private releasedWindow(agent: Agent, held: ReadonlySet<string>): ReleasedWindow | null {
30243
30596
  try {
@@ -31395,6 +31748,7 @@ export class AgentFramework {
31395
31748
  const ended = this.exhaustedTurns?.get(agentName);
31396
31749
  this.exhaustedTurns?.delete(agentName);
31397
31750
  const rearmed = this.exhaustingRearms?.delete(agentName) === true;
31751
+ const madeAgain = this.freshCallsCut?.delete(agentName) === true;
31398
31752
  // The reason is the failure's own message, and a provider can echo the
31399
31753
  // whole rejected request in it: one household's [inference-failed]
31400
31754
  // marker carried ~1.7 MB of its own request back into its context. Every
@@ -31451,9 +31805,10 @@ export class AgentFramework {
31451
31805
  // (2) Agent-facing chronicle marker (no inference triggered → no loop).
31452
31806
  const agent = this.agents.get(agentName);
31453
31807
  if (agent && !(rearmed && !ended) && process.env.SUPPRESS_INFERENCE_FAILED_MARKER !== '1') {
31454
- const cause = [errorType, httpStatus !== undefined ? `HTTP ${httpStatus}` : undefined]
31455
- .filter((part): part is string => part !== undefined)
31456
- .join(', ');
31808
+ const timedOut = errorType === 'timeout';
31809
+ const causeParts = [errorType, httpStatus !== undefined ? `HTTP ${httpStatus}` : undefined]
31810
+ .filter((part): part is string => part !== undefined);
31811
+ const cause = timedOut && causeParts.length === 1 ? '' : causeParts.join(', ');
31457
31812
  try {
31458
31813
  agent.getContextManager().addMessage(
31459
31814
  'user',
@@ -31461,17 +31816,17 @@ export class AgentFramework {
31461
31816
  type: 'text',
31462
31817
  text:
31463
31818
  (ended
31464
- ? `[${NOTICE_TAG.inferenceFailed}] Your previous turn did not complete: it ` +
31465
- `failed${cause ? ` (${suppliedValue(cause)})` : ''}.` +
31819
+ ? `[${NOTICE_TAG.inferenceFailed}] Your previous turn did not complete: ` +
31820
+ `${timedOut ? 'its last call timed out' : 'it failed'}${cause ? ` (${suppliedValue(cause)})` : ''}.` +
31821
+ `${madeAgain ? ` ${MADE_AGAIN}` : ''}` +
31466
31822
  `${ended.roundsRan ? ' Its earlier rounds ran, and their tool calls ran as their results show.' : ''}` +
31467
31823
  `${ended.callsCutOff ? ` ${CALLS_CUT_OFF}` : ''}` +
31468
31824
  `${ended.liveSpeech ? ` ${LIVE_SPEECH_UNRECEIPTED}` : ''}` +
31469
31825
  `${ended.speechRouting ? ` ${SPEECH_STILL_ROUTING}` : ''} Reason: `
31470
- : `[${NOTICE_TAG.inferenceFailed}] Your previous turn did not complete: the model ` +
31471
- `call failed${cause ? ` (${suppliedValue(cause)})` : ''} and produced no response, so nothing was sent. Reason: `) +
31472
- `${failureShown(markerReason)}. If this recurs with the same cause, change approach ` +
31473
- `rather than retrying identically (e.g. drop an oversized attachment ` +
31474
- `or an unsupported setting).`,
31826
+ : `[${NOTICE_TAG.inferenceFailed}] Your previous turn did not complete: ` +
31827
+ `the model call ${timedOut ? 'timed out' : 'failed'}${cause ? ` (${suppliedValue(cause)})` : ''} ` +
31828
+ `and produced no response, so nothing was sent.${madeAgain ? ` ${MADE_AGAIN}` : ''} Reason: `) +
31829
+ `${failureShown(markerReason)}. ${timedOut ? TIMEOUT_ADVICE : FAILURE_ADVICE}`,
31475
31830
  }],
31476
31831
  { system: true, kind: 'inference-failed', reason: markerReason, consecutive: streak, ...classification, ...cutAt(markerReason) },
31477
31832
  );
@@ -31732,6 +32087,7 @@ export class AgentFramework {
31732
32087
  this.featureSetManager,
31733
32088
  inferenceRouting ?? null,
31734
32089
  (event) => this.emitTrace(event as { type: TraceEvent['type']; [key: string]: unknown }),
32090
+ () => this.inferenceBounds,
31735
32091
  );
31736
32092
 
31737
32093
  // Checkpoint manager (Step 8), built and loaded at the top
@@ -31771,7 +32127,13 @@ export class AgentFramework {
31771
32127
  if (!coordinator) return undefined;
31772
32128
  const scope = coordinator.getAllExcept();
31773
32129
  if (!scope || scope.params.except.serverId !== serverId || scope.params.except.channelId !== channelId) return undefined;
31774
- coordinator.cancelAllExcept('channel-close', 'the excepted channel was closed');
32130
+ let released: ReturnType<typeof coordinator.cancelAllExcept>;
32131
+ try {
32132
+ released = coordinator.cancelAllExcept('channel-close', 'the excepted channel was closed');
32133
+ } catch (err) {
32134
+ throw new Error(`The close wasn't recorded: ${err instanceof Error ? err.message : String(err)}`);
32135
+ }
32136
+ if (!released.ok) throw new Error(`The close wasn't recorded: ${released.error}`);
31775
32137
  return 'The focus on it ended all the same, and what the other channels held was delivered.';
31776
32138
  },
31777
32139
  focusChannelResolver: () => this.tuneOutCoordinator?.focusChannel() ?? null,
@@ -31948,13 +32310,11 @@ export class AgentFramework {
31948
32310
  attempt: 0,
31949
32311
  willRetry: !!left?.willReconnect,
31950
32312
  });
31951
- // So the server doesn't vanish quietly: alert at once, since no
31952
- // attempt follows, for what needs the operator. That's a
31953
- // protocol-version verdict, as the reconnect path alerts for one, or
31954
- // a launch that couldn't be reaped, whose reap nothing retries on its
31955
- // own.
31956
- if (error instanceof McplProtocolVersionError || left?.holdsUnreapedChild) {
31957
- this.opsAlert('mcpl-down', config.id, `MCPL server unreachable at startup: ${message}`);
32313
+ // So the server doesn't vanish quietly: nothing connects it again on
32314
+ // its own, so alert at once. One that connected before its setup
32315
+ // threw is still connected, and gets no alert.
32316
+ if (!left?.willReconnect && !left?.isConnected) {
32317
+ this.opsAlert('mcpl-down', config.id, `MCPL server not connected at startup, and this host won't retry it on its own: ${message}`);
31958
32318
  }
31959
32319
  }
31960
32320
  }
@@ -33892,9 +34252,10 @@ export class AgentFramework {
33892
34252
 
33893
34253
  /**
33894
34254
  * Restart an MCPL server at runtime: disconnect, then reconnect with the
33895
- * same (or an updated) config. This actually respawns a stdio child —
33896
- * unlike `reconnect: true`, which only retries after transport-level
33897
- * failures. Throws if the server has no stored config and none is given.
34255
+ * same (or an updated) config. This respawns a stdio child at once,
34256
+ * whatever its state; `reconnect: true` spawns a fresh child only after
34257
+ * the connection fails or its child exits. Throws if the server has no
34258
+ * stored config and none is given.
33898
34259
  * What its connect would refuse is refused before the running server is
33899
34260
  * torn down. Until the connect, a connect or a restart started meanwhile
33900
34261
  * is refused the id and the tool prefix the restart moves the server to,
@@ -34376,12 +34737,9 @@ export class AgentFramework {
34376
34737
  // request turned away at stop gets, so it can send it again to
34377
34738
  // the host's next run. Optional chaining: prototype-built
34378
34739
  // harnesses leave the queue unset.
34379
- const stopping = !!this.queue?.isClosed;
34740
+ const answer = this.queue?.isClosed ? hostStoppedWhileHandling() : { code: -32603, message: 'Internal error' };
34380
34741
  try {
34381
- tracked.respondError?.(
34382
- stopping ? -32000 : -32603,
34383
- stopping ? 'the host stopped while handling this request' : 'Internal error',
34384
- );
34742
+ tracked.respondError?.(answer.code, answer.message);
34385
34743
  } catch (sendError) {
34386
34744
  responseError = recorded(describeCause(sendError));
34387
34745
  }
@@ -34538,6 +34896,7 @@ export class AgentFramework {
34538
34896
  respond: (result: unknown) => void;
34539
34897
  respondError: (code: number, message: string) => void;
34540
34898
  sendChunk?: (chunk: InferenceChunkParams) => void;
34899
+ signal?: AbortSignal;
34541
34900
  },
34542
34901
  ) => {
34543
34902
  // RFC-006 §5.3 / SPEC §10.7: no inference from inside a render.
@@ -34563,6 +34922,7 @@ export class AgentFramework {
34563
34922
  // Chunks go out on the transport the request came on, as its
34564
34923
  // answer does.
34565
34924
  ...(responder.sendChunk ? { sendChunk: responder.sendChunk } : {}),
34925
+ ...(responder.signal ? { signal: responder.signal } : {}),
34566
34926
  });
34567
34927
  }
34568
34928
  });
@@ -34878,14 +35238,15 @@ export class AgentFramework {
34878
35238
  // that next one (see refreshMcplTools).
34879
35239
  this.noteMcplServerClosed(connection.id);
34880
35240
  this.dropMcplToolClasses(connection.id);
35241
+ // The transport's own words only: a WebSocket server's close text
35242
+ // stays off the trace.
35243
+ const reason = info?.reason !== undefined && !info.reasonFromPeer ? boundFailureText(info.reason) : null;
34881
35244
  this.emitTrace({
34882
35245
  type: 'mcpl:server-closed',
34883
35246
  serverId: connection.id,
34884
35247
  code: code ?? null,
34885
35248
  signal: signal ?? null,
34886
- // The transport's own words only: a WebSocket server's close text
34887
- // stays off the trace.
34888
- reason: info?.reason !== undefined && !info.reasonFromPeer ? boundFailureText(info.reason) : null,
35249
+ reason,
34889
35250
  willReconnect: connection.willReconnect,
34890
35251
  });
34891
35252
  this.emitTrace({ type: 'module:removed', moduleName: `mcpl:${connection.id}` });
@@ -34895,6 +35256,14 @@ export class AgentFramework {
34895
35256
  // RFC-014: its toolsets go with it; a next connection reads them again.
34896
35257
  this.toolsetHub?.get(connection.id)?.detach(connection);
34897
35258
  this.handleToolsListChanged(connection.id);
35259
+ // Lost on its own with no reconnect coming: nothing in this host
35260
+ // connects it again on its own. A close the host made itself (stop(),
35261
+ // a disconnect, a restart) is byHost, a loss once stop() has begun is
35262
+ // part of the close the host is making, and a child that ended as
35263
+ // asked was stopped: none of them gets an alert.
35264
+ if (!info?.byHost && !this.stopHasBegun() && !endedAsAsked({ code, signal, pipeFailed: info?.pipeFailed })) {
35265
+ this.opsAlert('mcpl-down', connection.id, `MCPL server closed${closeWords(reason, code, signal)}, and this host won't reconnect it on its own`);
35266
+ }
34898
35267
  }
34899
35268
  });
34900
35269
  }
@@ -34920,11 +35289,12 @@ export class AgentFramework {
34920
35289
  this.emitTrace({ type: 'module:added', moduleName: `mcpl:${connection.id}` });
34921
35290
  this.handleToolsListChanged(connection.id);
34922
35291
  });
34923
- const failed = (params: { error: string; attempt: number; permanent?: boolean }) => {
35292
+ const failed = (params: { error: string; stderrTail?: string; attempt: number; permanent?: boolean }) => {
34924
35293
  this.emitTrace({
34925
35294
  type: 'mcpl:server-connect-failed',
34926
35295
  serverId: connection.id,
34927
35296
  error: recorded(params.error),
35297
+ ...(params.stderrTail === undefined ? {} : { stderrTail: recorded(params.stderrTail) }),
34928
35298
  attempt: params.attempt,
34929
35299
  willRetry: connection.willReconnect,
34930
35300
  });
@@ -34942,7 +35312,7 @@ export class AgentFramework {
34942
35312
  connection.on('error', (err: Error) => {
34943
35313
  this.emitTrace({ type: 'mcpl:server-error', serverId: connection.id, error: recorded(err.message) });
34944
35314
  });
34945
- connection.on('close', () => {
35315
+ connection.on('close', (info: { reason: string; code?: number | null; signal?: NodeJS.Signals | null; pipeFailed?: boolean }) => {
34946
35316
  // A closed provider's class hints are stale (RFC-008), as for MCPL,
34947
35317
  // and so is a listing already under way.
34948
35318
  this.noteMcplServerClosed(connection.id);
@@ -34950,8 +35320,9 @@ export class AgentFramework {
34950
35320
  this.emitTrace({
34951
35321
  type: 'mcpl:server-closed',
34952
35322
  serverId: connection.id,
34953
- code: null,
34954
- signal: null,
35323
+ code: info.code ?? null,
35324
+ signal: info.signal ?? null,
35325
+ reason: info.reason,
34955
35326
  willReconnect: connection.willReconnect,
34956
35327
  });
34957
35328
  this.emitTrace({ type: 'module:removed', moduleName: `mcpl:${connection.id}` });
@@ -34971,6 +35342,13 @@ export class AgentFramework {
34971
35342
  // Gone for good: its tools leave the agents' list, as a
34972
35343
  // disconnect's do.
34973
35344
  this.handleToolsListChanged(connection.id);
35345
+ // Lost on its own: nothing in this host connects it again on its
35346
+ // own. A loss once stop() has begun is part of the close the host
35347
+ // is making, and a child that ended as asked was stopped: neither
35348
+ // gets an alert.
35349
+ if (!this.stopHasBegun() && !endedAsAsked(info)) {
35350
+ this.opsAlert('mcpl-down', connection.id, `MCP server closed${closeWords(info.reason, info.code, info.signal)}, and this host won't reconnect it on its own`);
35351
+ }
34974
35352
  });
34975
35353
  }
34976
35354
  });
@@ -36135,7 +36513,7 @@ export class AgentFramework {
36135
36513
  * (same path as gate.json load), so an invalid rule is surfaced as a tool
36136
36514
  * error and nothing is written.
36137
36515
  */
36138
- private dispatchWakeRuleToolCall(agentName: string, call: ToolCall): void {
36516
+ private dispatchWakeRuleToolCall(agentName: string, call: ToolCall, deliver = this.pushToolResult(agentName, call, 'gate')): void {
36139
36517
  this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
36140
36518
  // What wake_add_rule's input held that does nothing, checkToolInput's notes and
36141
36519
  // then the gate's on a rule it saved: a refusal names it too, so the call that
@@ -36150,14 +36528,23 @@ export class AgentFramework {
36150
36528
  module: 'gate', tool: call.name, callId: call.id, durationMs: 0,
36151
36529
  ...(result.isError ? { error: result.error } : {}),
36152
36530
  });
36153
- this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'gate', result });
36531
+ deliver(result);
36154
36532
  };
36155
36533
 
36156
36534
  try {
36157
36535
  if (call.name === 'wake_remove_rule') {
36158
36536
  const input = (call.input ?? {}) as { name?: unknown };
36159
36537
  if (typeof input.name !== 'string' || !input.name) {
36160
- finish({ success: false, error: 'wake_remove_rule: `name` (string) is required', isError: true });
36538
+ const edit = this.eventGate!.ruleNamesForChange();
36539
+ const names = 'names' in edit ? edit.names.map((name) => quotedValue(name)) : [];
36540
+ finish({
36541
+ success: false,
36542
+ error: `wake_remove_rule: name is ${shownValue(input.name)}, where the name of a rule is needed, so nothing was removed. `
36543
+ + ('refused' in edit
36544
+ ? edit.refused
36545
+ : (names.length > 0 ? `The rules a change edits are ${names.join(', ')}.` : 'There is no rule to remove.') + edit.note),
36546
+ isError: true,
36547
+ });
36161
36548
  return;
36162
36549
  }
36163
36550
  const removed = this.removeGatePolicy(input.name);
@@ -36194,9 +36581,8 @@ export class AgentFramework {
36194
36581
  if (behaviorSources.length === 0) {
36195
36582
  finish({
36196
36583
  success: false,
36197
- error: this.judgeWired
36198
- ? 'wake_add_rule: specify exactly one behavior — `behavior` ("always"/"defer"/"skip"), `debounceMs`, `rateLimit`, `passiveSample`, or `judge`.'
36199
- : 'wake_add_rule: specify exactly one behavior — `behavior` ("always"/"defer"/"skip"), `debounceMs`, `rateLimit`, or `passiveSample`.',
36584
+ error: 'wake_add_rule got no behavior, so nothing was saved. Give one of behavior ("always", "defer" or "skip"), '
36585
+ + (this.judgeWired ? 'debounceMs, rateLimit, passiveSample or judge.' : 'debounceMs, rateLimit or passiveSample.'),
36200
36586
  isError: true,
36201
36587
  });
36202
36588
  return;
@@ -36204,7 +36590,7 @@ export class AgentFramework {
36204
36590
  if (behaviorSources.length > 1) {
36205
36591
  finish({
36206
36592
  success: false,
36207
- error: `wake_add_rule: give only one behavior, got [${behaviorSources.join(', ')}].`,
36593
+ error: `wake_add_rule got more than one behavior (${behaviorSources.join(', ')}), so nothing was saved. Give only one of them.`,
36208
36594
  isError: true,
36209
36595
  });
36210
36596
  return;
@@ -36247,7 +36633,7 @@ export class AgentFramework {
36247
36633
  if (placementSources.length > 1) {
36248
36634
  finish({
36249
36635
  success: false,
36250
- error: `wake_add_rule: give at most one placement, got [${placementSources.join(', ')}].`,
36636
+ error: `wake_add_rule got more than one placement (${placementSources.join(', ')}), so nothing was saved. Give only one of them.`,
36251
36637
  isError: true,
36252
36638
  });
36253
36639
  return;
@@ -36267,10 +36653,18 @@ export class AgentFramework {
36267
36653
  for (const field of ['insertBefore', 'insertAfter'] as const) {
36268
36654
  const anchor = input[field];
36269
36655
  if (anchor !== undefined && (typeof anchor !== 'string' || anchor.length === 0)) {
36656
+ const edit = this.eventGate!.ruleNamesForChange();
36657
+ const names = 'names' in edit ? edit.names.filter((name) => name !== input.name).map((name) => quotedValue(name)) : [];
36270
36658
  finish({
36271
36659
  success: false,
36272
- error: `wake_add_rule: ${field} must be the name of a rule, and it's ${shownValue(anchor)}, so nothing `
36273
- + `was saved. The rules are: [${this.getGatePolicyNames().join(', ')}].`,
36660
+ error: `wake_add_rule: ${field} must be the name of a rule, and it's ${shownValue(anchor)}, so nothing was saved. `
36661
+ + ('refused' in edit
36662
+ ? edit.refused
36663
+ : (names.length > 0
36664
+ ? `The other rules a change edits are ${names.join(', ')}. Give ${field} one of them to place the rule beside it, or leave it out`
36665
+ : `There is no other rule to place it beside, so leave ${field} out`)
36666
+ + ' to add the rule at the end, or, where a rule already has its name, to replace that rule where it is.'
36667
+ + edit.note),
36274
36668
  isError: true,
36275
36669
  });
36276
36670
  return;
@@ -36300,9 +36694,12 @@ export class AgentFramework {
36300
36694
  const before = new Map(
36301
36695
  this.eventGate!.probeTable().map((r) => [r.probe, r]),
36302
36696
  );
36303
- const policy = this.addGatePolicy(rawPolicy, {
36697
+ const fieldNames = Object.fromEntries(Object.entries(AgentFramework.WAKE_ADD_RULE_FIELDS)
36698
+ .filter(([field]) => !(field.startsWith('behavior.') && typeof input.behavior === 'object')));
36699
+ const policy = this.eventGate!.addPolicy(rawPolicy, {
36304
36700
  ...(position ? { position } : {}),
36305
36701
  onIgnoredFields: (notes) => { ignored.push(...notes); },
36702
+ fieldNames,
36306
36703
  });
36307
36704
  const after = this.eventGate!.probeTable();
36308
36705
  const probes = after.map((r) => {
@@ -36615,21 +37012,19 @@ export class AgentFramework {
36615
37012
  return decision;
36616
37013
  }
36617
37014
 
36618
- /**
36619
- * Make delivered messages durable now, including those the delivery left
36620
- * in the deferred queue: a tune-out release flips durable epoch state
36621
- * first, so its dump must not be lost to a crash in between (#232 gap 6).
36622
- */
36623
- private commitDeliveries(): void {
37015
+ private commitDeliveries(): boolean {
37016
+ let durable = true;
36624
37017
  if (this.deferredMessages.length > 0) {
36625
37018
  this.deferredWritesPersisted = true;
36626
- this.persistDeferredWrites();
37019
+ durable = this.persistDeferredWrites();
36627
37020
  }
36628
37021
  try {
36629
37022
  this.store.sync();
36630
37023
  } catch (err) {
36631
37024
  console.error('[tune-out] could not sync the store after a release:', err);
37025
+ return false;
36632
37026
  }
37027
+ return durable;
36633
37028
  }
36634
37029
 
36635
37030
  /** The history-reading tool the primary resident actually has, if any (#232 gap 11). */
@@ -36640,7 +37035,7 @@ export class AgentFramework {
36640
37035
  return names.find((n) => /(^|--)(fetch_history|channel_read|read_history|history)$/.test(n)) ?? null;
36641
37036
  }
36642
37037
 
36643
- private dispatchSleepToolCall(agentName: string, call: ToolCall): void {
37038
+ private dispatchSleepToolCall(agentName: string, call: ToolCall, deliver = this.pushToolResult(agentName, call, 'gate')): void {
36644
37039
  // The gate's sleep quiets the whole agent, every conversation of it, so a
36645
37040
  // conversation fork or an ephemeral run neither sets one nor ends the
36646
37041
  // agent's own. Refused before the tool runs, as a fork's channel_open is.
@@ -36659,7 +37054,7 @@ export class AgentFramework {
36659
37054
  ? `Nothing was set: a sleep quiets the whole agent, not one ${one}.${comeBack}`
36660
37055
  : `Nothing was changed: a sleep is the agent's, so this ${one} can't end one.`;
36661
37056
  this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: `${agentName}: ${error}` });
36662
- this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'gate', result: this.refusedResult(agentName, call.id, error) });
37057
+ deliver(this.refusedResult(agentName, call.id, error));
36663
37058
  return;
36664
37059
  }
36665
37060
  this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
@@ -36668,7 +37063,7 @@ export class AgentFramework {
36668
37063
 
36669
37064
  const finish = (result: ToolResult) => {
36670
37065
  this.emitTrace({ type: 'tool:completed', module: 'gate', tool: call.name, callId: call.id, durationMs: 0 });
36671
- this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'gate', result });
37066
+ deliver(result);
36672
37067
  };
36673
37068
 
36674
37069
  if (call.name === 'wake') {
@@ -36788,30 +37183,34 @@ export class AgentFramework {
36788
37183
  */
36789
37184
  /** RFC-005 on-demand dereference: [ref id] → workspace file, via the
36790
37185
  * origin-bound, ceiling-bounded, digest-verified host fetcher. */
36791
- private dispatchFetchReferenceToolCall(agentName: string, call: ToolCall): void {
37186
+ private dispatchFetchReferenceToolCall(agentName: string, call: ToolCall, deliver = this.pushToolResult(agentName, call, 'workspace')): void {
36792
37187
  this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
36793
37188
  const startTime = Date.now();
36794
37189
  void (async () => {
36795
- const args = (call.input ?? {}) as Record<string, unknown>;
36796
- const refId = String(args.ref_id ?? '');
36797
- const record = referenceRegistry.get(refId);
36798
37190
  let result: { success: boolean; data?: string; error?: string; isError?: boolean };
36799
- if (!record) {
36800
- // Vector 20: a stale or unknown id is a defined miss, never a
36801
- // different record.
36802
- result = { success: false, isError: true,
36803
- error: `unknown reference: ${refId || '(missing ref_id)'} — it may have been evicted; ids are never reused` };
36804
- } else {
36805
- const maxBytes = typeof args.max_bytes === 'number'
36806
- ? Math.min(args.max_bytes, DEFAULT_FETCH_MAX_BYTES) : undefined;
36807
- const outcome = await this.getReferenceFetcher().fetch(record, { maxBytes });
36808
- result = outcome.ok
36809
- ? { success: true, data: JSON.stringify({ path: outcome.path, bytes: outcome.bytes,
36810
- mime_type: outcome.mimeType, digest_verified: outcome.digestVerified ?? 'no digest claimed' }) }
36811
- // The fetcher says what happened as a clause about the reference,
36812
- // and what calling again can do as a sentence of its own.
36813
- : { success: false, isError: true,
36814
- error: `fetch_reference couldn't fetch "${refId}": ${outcome.error}. ${outcome.remedy}` };
37191
+ try {
37192
+ const args = (call.input ?? {}) as Record<string, unknown>;
37193
+ const refId = String(args.ref_id ?? '');
37194
+ const record = referenceRegistry.get(refId);
37195
+ if (!record) {
37196
+ // Vector 20: a stale or unknown id is a defined miss, never a
37197
+ // different record.
37198
+ result = { success: false, isError: true,
37199
+ error: `unknown reference: ${refId || '(missing ref_id)'} — it may have been evicted; ids are never reused` };
37200
+ } else {
37201
+ const maxBytes = typeof args.max_bytes === 'number'
37202
+ ? Math.min(args.max_bytes, DEFAULT_FETCH_MAX_BYTES) : undefined;
37203
+ const outcome = await this.getReferenceFetcher().fetch(record, { maxBytes });
37204
+ result = outcome.ok
37205
+ ? { success: true, data: JSON.stringify({ path: outcome.path, bytes: outcome.bytes,
37206
+ mime_type: outcome.mimeType, digest_verified: outcome.digestVerified ?? 'no digest claimed' }) }
37207
+ // The fetcher says what happened as a clause about the reference,
37208
+ // and what calling again can do as a sentence of its own.
37209
+ : { success: false, isError: true,
37210
+ error: `fetch_reference couldn't fetch "${refId}": ${outcome.error}. ${outcome.remedy}` };
37211
+ }
37212
+ } catch (error) {
37213
+ result = { success: false, isError: true, error: `fetch_reference couldn't answer: ${thrownText(error)}` };
36815
37214
  }
36816
37215
  this.emitTrace({
36817
37216
  type: result.isError ? 'tool:failed' : 'tool:completed',
@@ -36819,14 +37218,11 @@ export class AgentFramework {
36819
37218
  durationMs: Date.now() - startTime,
36820
37219
  ...(result.isError ? { error: result.error } : {}),
36821
37220
  });
36822
- this.pushEvent({
36823
- type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'workspace',
36824
- result: { ...result, isError: result.isError ?? false },
36825
- });
37221
+ deliver({ ...result, isError: result.isError ?? false });
36826
37222
  })();
36827
37223
  }
36828
37224
 
36829
- private dispatchReadImageToolCall(agentName: string, call: ToolCall): void {
37225
+ private dispatchReadImageToolCall(agentName: string, call: ToolCall, deliver = this.pushToolResult(agentName, call, 'workspace')): void {
36830
37226
  this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
36831
37227
  const finish = (result: ToolResult): void => {
36832
37228
  this.emitTrace({
@@ -36837,7 +37233,7 @@ export class AgentFramework {
36837
37233
  durationMs: 0,
36838
37234
  ...(result.isError ? { error: result.error } : {}),
36839
37235
  });
36840
- this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'workspace', result });
37236
+ deliver(result);
36841
37237
  };
36842
37238
  void (async (): Promise<void> => {
36843
37239
  try {
@@ -36929,13 +37325,11 @@ export class AgentFramework {
36929
37325
  * every sibling in the round to settle; if one never does, fail closed
36930
37326
  * rather than trust an index the batch may still change. Sibling saves
36931
37327
  * produce no images and are not waited on (two saves in one batch would
36932
- * otherwise deadlock into a double timeout). A script's own calls
36933
- * (`pytc-…`) skip the barrier: the batch's pending siblings include the
36934
- * code_execution call running that script, so waiting on them would wait
36935
- * on itself.
37328
+ * otherwise deadlock into a double timeout). Only the round's own calls
37329
+ * wait: a call that isn't among its pending calls, a script's (`pytc-…`)
37330
+ * or an awaited call's, counts the images the agent has now.
36936
37331
  */
36937
37332
  private async awaitSiblingToolCalls(agent: Agent, call: ToolCall): Promise<void> {
36938
- if (call.id.startsWith('pytc-')) return;
36939
37333
  const BARRIER_MS = 120_000;
36940
37334
  const POLL_MS = 20;
36941
37335
  const startedAt = Date.now();
@@ -36943,6 +37337,7 @@ export class AgentFramework {
36943
37337
  const state = agent.state;
36944
37338
  // Idle (puppet path) or the round was torn down: nothing to wait on.
36945
37339
  if (state.status !== 'waiting_for_tools') return;
37340
+ if (!state.pending.has(call.id)) return;
36946
37341
  const open = [...state.pending.values()].filter((p) => p.id !== call.id && p.name !== 'save_recent_image');
36947
37342
  if (open.length === 0) return;
36948
37343
  if (Date.now() - startedAt > BARRIER_MS) {
@@ -36971,7 +37366,7 @@ export class AgentFramework {
36971
37366
  * nothing. It never slides to an older image — a filename must not
36972
37367
  * authenticate bytes from another surface.
36973
37368
  */
36974
- private dispatchSaveImageToolCall(agentName: string, call: ToolCall): void {
37369
+ private dispatchSaveImageToolCall(agentName: string, call: ToolCall, deliver = this.pushToolResult(agentName, call, 'workspace')): void {
36975
37370
  this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
36976
37371
  const finish = (result: ToolResult): void => {
36977
37372
  this.emitTrace({
@@ -36982,7 +37377,7 @@ export class AgentFramework {
36982
37377
  durationMs: 0,
36983
37378
  ...(result.isError ? { error: result.error } : {}),
36984
37379
  });
36985
- this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'workspace', result });
37380
+ deliver(result);
36986
37381
  };
36987
37382
  type Candidate =
36988
37383
  | { kind: 'attachment'; data: string | null; mediaType: string; messagesBack: number }
@@ -37133,20 +37528,39 @@ export class AgentFramework {
37133
37528
  let count = 1;
37134
37529
  /** Direct-ref mode: the one ref to find in the scannable context. */
37135
37530
  let wantedRef: string | null = null;
37136
- if (input.ref !== undefined) {
37531
+ // A selector is in use only when it carries a value (fieldInUse), so a
37532
+ // caller whose provider presents every property as required can still
37533
+ // choose one, whichever of the two it fills the other selector with.
37534
+ // Any other value is checked, never read as absent: a whitespace or
37535
+ // malformed ref, or an index that isn't an integer, is refused rather
37536
+ // than turned into a different image than the one the caller named.
37537
+ // A malformed ref beside an index or count in use says how to leave a
37538
+ // selector unused, since the caller may have meant the positional one, as
37539
+ // the conflict refusal below does for a well-formed ref. No refusal of one
37540
+ // selector on its own says it: a ref alone is the caller's one selector,
37541
+ // and dropping it would save the newest image; an index and a count are
37542
+ // one selector, and dropping either changes which images are saved.
37543
+ if (fieldInUse(input.ref)) {
37137
37544
  if (typeof input.ref !== 'string' || !TOOL_IMAGE_REF_RE.test(input.ref)) {
37138
- throw new Error('save_recent_image: `ref` must look like "img_k7x3q2_7" (copy it from the image placeholder in your context)');
37545
+ const positionalInUse = fieldInUse(input.index) || fieldInUse(input.count);
37546
+ throw new Error(
37547
+ 'save_recent_image: `ref` must look like "img_k7x3q2_7" (copy it from the image placeholder in your context)' +
37548
+ (positionalInUse ? ' (pass null, or leave it out, for a selector you are not using)' : ''),
37549
+ );
37139
37550
  }
37140
- if (input.index !== undefined || input.count !== undefined) {
37141
- throw new Error('save_recent_image: `ref` is mutually exclusive with `index`/`count`');
37551
+ if (fieldInUse(input.index) || fieldInUse(input.count)) {
37552
+ throw new Error(
37553
+ 'save_recent_image: `ref` is mutually exclusive with `index`/`count` — pass null (or omit) ' +
37554
+ 'for the selector you are not using',
37555
+ );
37142
37556
  }
37143
37557
  wantedRef = input.ref;
37144
37558
  } else {
37145
- index = input.index === undefined ? 0 : Number(input.index);
37559
+ index = fieldInUse(input.index) ? integerField(input.index) : 0;
37146
37560
  if (!Number.isInteger(index) || index < 0) {
37147
37561
  throw new Error('save_recent_image: `index` must be a non-negative integer');
37148
37562
  }
37149
- count = input.count === undefined ? 1 : Number(input.count);
37563
+ count = fieldInUse(input.count) ? integerField(input.count) : 1;
37150
37564
  const MAX_COUNT = 20;
37151
37565
  if (!Number.isInteger(count) || count < 1 || count > MAX_COUNT) {
37152
37566
  throw new Error(`save_recent_image: \`count\` must be an integer in 1..${MAX_COUNT}`);
@@ -37408,7 +37822,7 @@ export class AgentFramework {
37408
37822
  : `Changed before the refusal: ${changed.join(', ')}; no other setting reads differently.`;
37409
37823
  }
37410
37824
 
37411
- private dispatchAgentSettingsToolCall(agentName: string, call: ToolCall): void {
37825
+ private dispatchAgentSettingsToolCall(agentName: string, call: ToolCall, deliver = this.pushToolResult(agentName, call, 'agent')): void {
37412
37826
  this.emitTrace({
37413
37827
  type: 'tool:started',
37414
37828
  module: 'agent',
@@ -37596,14 +38010,7 @@ export class AgentFramework {
37596
38010
  durationMs: 0,
37597
38011
  ...(result.isError ? { error: result.error } : {}),
37598
38012
  });
37599
- this.pushEvent({
37600
- type: 'tool-result',
37601
- callId: call.id,
37602
- toolName: call.name,
37603
- agentName,
37604
- moduleName: 'agent',
37605
- result,
37606
- });
38013
+ deliver(result);
37607
38014
  }
37608
38015
 
37609
38016
  /** Aggregate the event-tag vocabulary: reserved chat:* core + each connected
@@ -37628,7 +38035,7 @@ export class AgentFramework {
37628
38035
  };
37629
38036
  }
37630
38037
 
37631
- private dispatchEventTagsToolCall(agentName: string, call: ToolCall): void {
38038
+ private dispatchEventTagsToolCall(agentName: string, call: ToolCall, deliver = this.pushToolResult(agentName, call, 'gate')): void {
37632
38039
  this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
37633
38040
  let result: import('./types/events.js').ToolResult;
37634
38041
  try {
@@ -37640,10 +38047,10 @@ export class AgentFramework {
37640
38047
  this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
37641
38048
  result = { success: false, error: err.message, isError: true };
37642
38049
  }
37643
- this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'gate', result });
38050
+ deliver(result);
37644
38051
  }
37645
38052
 
37646
- private dispatchGateToolCall(agentName: string, call: ToolCall): void {
38053
+ private dispatchGateToolCall(agentName: string, call: ToolCall, deliver = this.pushToolResult(agentName, call, 'gate')): void {
37647
38054
  this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
37648
38055
  const startTime = Date.now();
37649
38056
 
@@ -37651,27 +38058,13 @@ export class AgentFramework {
37651
38058
  .then((result) => {
37652
38059
  const durationMs = Date.now() - startTime;
37653
38060
  this.emitTrace({ type: 'tool:completed', module: 'gate', tool: call.name, callId: call.id, durationMs });
37654
- this.pushEvent({
37655
- type: 'tool-result',
37656
- callId: call.id,
37657
- toolName: call.name,
37658
- agentName,
37659
- moduleName: 'gate',
37660
- result,
37661
- });
38061
+ deliver(result);
37662
38062
  })
37663
38063
  .catch((error) => {
37664
38064
  const err = error instanceof Error ? error : new Error(String(error));
37665
38065
  this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: err.message });
37666
38066
  this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
37667
- this.pushEvent({
37668
- type: 'tool-result',
37669
- callId: call.id,
37670
- toolName: call.name,
37671
- agentName,
37672
- moduleName: 'gate',
37673
- result: { success: false, error: err.message, isError: true },
37674
- });
38067
+ deliver({ success: false, error: err.message, isError: true });
37675
38068
  });
37676
38069
  }
37677
38070