@animalabs/agent-framework 0.30.0 → 0.31.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 (179) 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 +129 -92
  16. package/dist/src/framework.d.ts.map +1 -1
  17. package/dist/src/framework.js +1115 -704
  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 +2 -1
  132. package/dist/src/types/agent.d.ts.map +1 -1
  133. package/dist/src/types/framework.d.ts +54 -15
  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 +1145 -723
  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 +2 -1
  177. package/src/types/framework.ts +55 -15
  178. package/src/types/module.ts +42 -3
  179. package/src/types/trace.ts +4 -3
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';
@@ -815,6 +816,37 @@ export const BUILTIN_TOOLSET_TOOLS: ReadonlySet<string> = new Set([
815
816
  'workspace--status',
816
817
  ]);
817
818
 
819
+ type ToolRoute =
820
+ | { kind: 'presentation' }
821
+ | { kind: 'host-act'; host: HostActTarget }
822
+ | { kind: 'toolset-act' }
823
+ | { kind: 'mcpl'; serverId: string; prefix: string }
824
+ | { kind: 'channel' }
825
+ | { kind: 'agent-settings' }
826
+ | { kind: 'save-image' }
827
+ | { kind: 'read-image' }
828
+ | { kind: 'fetch-reference' }
829
+ | { kind: 'gate-status' }
830
+ | { kind: 'tune-out' }
831
+ | { kind: 'subconscious' }
832
+ | { kind: 'sleep' }
833
+ | { kind: 'heartbeat' }
834
+ | { kind: 'wake-rule' }
835
+ | { kind: 'event-tags' }
836
+ | { kind: 'agent-verb' }
837
+ | { kind: 'code-execution' }
838
+ | { kind: 'drafts' }
839
+ | { kind: 'prose-help' }
840
+ | { kind: 'module' };
841
+
842
+ function agentsOwnVerbRefusal(toolName: string): ToolResult {
843
+ return {
844
+ success: false,
845
+ error: `${toolName} is the agent's own, which a module's call can't make for it, so nothing was done.`,
846
+ isError: true,
847
+ };
848
+ }
849
+
818
850
  type SettingsReading = {
819
851
  core: Record<string, string>;
820
852
  exts: Map<AgentSettingsExtension, { touched: readonly string[]; fields: Record<string, string> }>;
@@ -867,7 +899,7 @@ import { HEARTBEAT_CHANGING_TOOLS, Heartbeat, isHeartbeatMcplServer, type Heartb
867
899
  import type { GateEntryRef, GateEventInfo, WakeLine } from './gate/types.js';
868
900
  import { EPHEMERAL_USAGE_NAME, UsageTracker, persistedUsageProblem, type PersistedUsageState } from './usage/usage-tracker.js';
869
901
  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';
902
+ import { McplRequestError, McplUnreapedLaunchError, stderrTailOf, toolsetDeclarationsOf, withStderr, type McplConnectionCloseInfo, type McplServerConnection } from './mcpl/server-connection.js';
871
903
  import { ToolsetHub, TOOLSETS_MODULE, META_TOOL_NAMES, type ToolsetController } from './mcpl/toolset-host.js';
872
904
  import { HostPresentation, BUILTIN_TOOLSET, type HostActTarget } from './host-presentation.js';
873
905
  import { isEmptyChange, type Presence } from './mcpl/tool-presence.js';
@@ -875,7 +907,7 @@ import { ToolsetsModule, resolveAct, withLookHint, type TakenChange } from './mc
875
907
  import { ModernMcpConnection } from './mcpl/modern-connection.js';
876
908
  import { MAX_TIMER_MS, WITHHELD_TEXT, asText, credentialRedactor, redactUrl, redactServerText, resolveServerBinding, serverConfigProblems, serverCredentialPatterns, serverTextRedactor, type CredentialPattern } from './mcpl/protocol-family.js';
877
909
  import { MALFORMED_CONTENT_NOTICE, normalizeStandardToolResult } from './mcpl/tool-result-normalize.js';
878
- import { boundedForLog, describeCause } from './mcpl/errors.js';
910
+ import { boundedForLog, describeCause, hostStoppedWhileHandling } from './mcpl/errors.js';
879
911
  import { assertServerConfigBooleans, buildWebSocketUrl, isWebSocketTransport } from './mcpl/transport.js';
880
912
  import type {
881
913
  McplServerConfig,
@@ -1198,8 +1230,8 @@ function resumesLogicalTurn(reason: string | undefined): boolean {
1198
1230
  */
1199
1231
  const TURN_RETRY: unique symbol = Symbol('turn-retry');
1200
1232
  /**
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
1233
+ * The batch a start was merged from, when the scheduler merged more than
1234
+ * one request into it and any of them isn't the turn's: every
1203
1235
  * request it took in, as each was. Until the start takes its turn token it
1204
1236
  * stands for them all. A scheduler pass that takes it back puts the batch in
1205
1237
  * its place, each request with its own identity and flags; a quiesce at the
@@ -1230,14 +1262,9 @@ function waitAlertsOnItsOwn(wait: ProviderWait): boolean {
1230
1262
  return wait.until === null || wait.until - wait.setAt > PROVIDER_WAIT_ALERT_MS;
1231
1263
  }
1232
1264
 
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
1265
  function isTransientFailure(error: Error): boolean {
1239
1266
  if (!isMembraneError(error) || error.retryable !== true) return false;
1240
- if ((error as { [MEMBRANE_DEADLINE_EXPIRY]?: unknown })[MEMBRANE_DEADLINE_EXPIRY] === true) return false;
1267
+ if (isDeadlineExpiry(error)) return false;
1241
1268
  return error.type === 'server' || error.type === 'network' || error.type === 'timeout' || error.type === 'rate_limit';
1242
1269
  }
1243
1270
 
@@ -1492,6 +1519,7 @@ interface TurnRouting {
1492
1519
  */
1493
1520
  addressedElsewhere: Set<string>;
1494
1521
  heard?: Map<MessageId, number>;
1522
+ inputsPin?: { agent: Agent; id: string };
1495
1523
  }
1496
1524
 
1497
1525
  /** A message that would re-pin its turn's locus (TurnRouting.arrivals). */
@@ -1522,6 +1550,29 @@ function freshTurnRouting(): TurnRouting {
1522
1550
  };
1523
1551
  }
1524
1552
 
1553
+ const TURN_INPUTS_SCAN = 64;
1554
+
1555
+ function turnInputs(
1556
+ cm: ContextManager, floor: number, agentName: string, sees: (metadata: unknown) => boolean,
1557
+ ): { first: MessageId; last: MessageId } | undefined {
1558
+ let first: MessageId | undefined;
1559
+ let last: MessageId | undefined;
1560
+ scan: for (let end = cm.getMessageCount(); end > 0; end -= TURN_INPUTS_SCAN) {
1561
+ const { messages } = cm.getMessageWindow(Math.max(0, end - TURN_INPUTS_SCAN), Math.min(TURN_INPUTS_SCAN, end), { resolveBlobs: false });
1562
+ for (let i = messages.length - 1; i >= 0; i--) {
1563
+ const message = messages[i];
1564
+ if (message.sequence <= floor) break scan;
1565
+ if (!sees(message.metadata)) continue;
1566
+ if (message.participant === agentName) break scan;
1567
+ last ??= message.id;
1568
+ const answers = Array.isArray(message.content) && message.content.length > 0
1569
+ && message.content.every((block) => block.type === 'tool_result');
1570
+ if (!answers) first = message.id;
1571
+ }
1572
+ }
1573
+ return first !== undefined && last !== undefined ? { first, last } : undefined;
1574
+ }
1575
+
1525
1576
  /**
1526
1577
  * What a prose delivery tells its stream once a publish succeeds: the
1527
1578
  * destination, its server and channel, and the text it published. The
@@ -1727,11 +1778,6 @@ const LIVE_SPEECH_UNRECEIPTED = `Plain speech its rounds routed live was handed
1727
1778
  * and this says no retry follows it. */
1728
1779
  const RETRY_CANCELLED_BY_STOP = 'a retry waiting while the framework was stopping is dropped before it sends anything';
1729
1780
 
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
1781
  /** The reason on the inference:aborted that ends a turn whose continuation
1736
1782
  * was dropped before it started, other than by an abandon or a stop. */
1737
1783
  const CONTINUATION_DROPPED = "a turn's continuation was dropped before it started";
@@ -1822,6 +1868,26 @@ interface DiscordAwarenessDrainPass {
1822
1868
  drain?: Promise<DiscordAwarenessDrainOutcome>;
1823
1869
  }
1824
1870
 
1871
+ /** Whether a synthesized tool's nullable field is in use: omission, null and
1872
+ * the empty string all mean it isn't, so a caller whose provider presents
1873
+ * every property as required can still leave one unset. Any other value is
1874
+ * in use, and its field checks it. */
1875
+ function fieldInUse(value: unknown): boolean {
1876
+ return value !== undefined && value !== null && value !== '';
1877
+ }
1878
+
1879
+ /** A synthesized tool's integer field: an integer number, or a string of ASCII
1880
+ * digits once trimmed (a model may write an integer as a string), within the
1881
+ * integers a number holds exactly; NaN for anything else. Number() would read
1882
+ * " ", false and [] as 0, and "2.0" or "+2" as 2, and a longer run of digits
1883
+ * as a different, rounded number. */
1884
+ function integerField(value: unknown): number {
1885
+ const n = typeof value === 'number' ? value
1886
+ : typeof value === 'string' && /^[0-9]+$/.test(value.trim()) ? Number(value.trim())
1887
+ : Number.NaN;
1888
+ return Number.isSafeInteger(n) ? n : Number.NaN;
1889
+ }
1890
+
1825
1891
  function normalizeDiscordAwarenessDeadline(value: number | undefined): number {
1826
1892
  if (value === undefined || !Number.isFinite(value) || value <= 0) {
1827
1893
  return DEFAULT_DISCORD_AWARENESS_DEADLINE_MS;
@@ -1835,9 +1901,10 @@ function normalizeDiscordAwarenessDeadline(value: number | undefined): number {
1835
1901
  /**
1836
1902
  * Refuse a config whose `subconscious`, `watchdog` or `processLogging` is
1837
1903
  * 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.
1904
+ * one holds anything but true or false (assertBooleanSetting), or whose
1905
+ * subconscious's `thinkingBinding` assertThinkingBinding refuses, whichever
1906
+ * its reader. A subconscious that isn't on has its `allowChannelSpeech` and
1907
+ * `thinkingBinding` left unchecked, since nothing reads them then.
1841
1908
  */
1842
1909
  function assertFrameworkSwitches(config: FrameworkConfig): void {
1843
1910
  if (holdsSettings('subconscious', config.subconscious, '{ enabled: true }')) {
@@ -1853,6 +1920,7 @@ function assertFrameworkSwitches(config: FrameworkConfig): void {
1853
1920
  config.subconscious.allowChannelSpeech,
1854
1921
  'true lets the subconscious speak in channels with speak_in_channel, and false or leaving it out refuses it',
1855
1922
  );
1923
+ assertThinkingBinding('subconscious.thinkingBinding', config.subconscious.thinkingBinding);
1856
1924
  }
1857
1925
  }
1858
1926
  if (holdsSettings('watchdog', config.watchdog, '{ enabled: true }')) {
@@ -2180,15 +2248,16 @@ export interface HostModeStatus {
2180
2248
  activeTurnAgents: string[];
2181
2249
  /** Wakes that own provider admission while waiting for an in-flight
2182
2250
  * 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`. */
2251
+ * admission continuation rechecks quiesce and provider holds, and requeues
2252
+ * a wake either keeps from starting; `drained` is false until it has. A
2253
+ * parked continuation of a turn is counted here and, as a turn alive, in
2254
+ * `activeTurns` and `activeTurnAgents`. */
2186
2255
  parkedAdmissions: number;
2187
2256
  /** Inference requests waiting to start: queued (parked by the quiesce wake
2188
2257
  * gate while quiesced, or, while serving, until the scheduler's next pass
2189
2258
  * takes them), in a scheduler pass's hands, or held by a provider hold (an
2190
2259
  * 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
2260
+ * merged from several requests counts as those it took in. A start parked
2192
2261
  * on provider admission behind an auxiliary call counts once in
2193
2262
  * `parkedAdmissions` instead, however many wakes are merged into it.
2194
2263
  * Continuations of a turn (budget restart, tool results) and a held turn's
@@ -2356,9 +2425,15 @@ function hostCommandRequester(serverId: string, params: HostCommandParams): Oper
2356
2425
  * schedule, jittered, 10 s doubling to 5 min (by default 13 tries, where
2357
2426
  * membrane's settings have 7: about 30 min).
2358
2427
  * - 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).
2428
+ * - a deadline expiry, which membrane leaves to its caller: a progress
2429
+ * bound's cut (FrameworkConfig.inferenceBounds), the adapter's own
2430
+ * watchdogs, the SDK's request timeout or a runtime's fetch timeout. The
2431
+ * deadline schedule: by default 2 tries, 1 s apart, so one fresh call. For
2432
+ * one, the framework's `attempt` counts that call's consecutive expiries,
2433
+ * not the turn's other failures, so each call a deadline cuts gets its
2434
+ * fresh call. Settings with no deadline schedule give it their base one.
2435
+ * - any other retryable failure: the base schedule (by default 3 tries, 1 s
2436
+ * then 2 s).
2362
2437
  * A provider's stated wait (`retryAfterMs`) is waited when it is longer than
2363
2438
  * the backoff, up to the schedule's ceiling, which by default is at least the
2364
2439
  * framework's own cap (PROVIDER_ACCELERATION_MAX_COOLDOWN_MS, ten minutes);
@@ -2380,6 +2455,10 @@ export class DefaultErrorPolicy implements ErrorPolicy {
2380
2455
  }
2381
2456
 
2382
2457
  onInferenceError(error: Error, _agentName: string, attempt: number): ErrorAction {
2458
+ if (isDeadlineExpiry(error)) {
2459
+ const decision = retryDecision(this.settings.deadline ? 'deadline' : 'retryable', attempt + 1, undefined, this.settings);
2460
+ return decision.retry ? { retry: true, delayMs: decision.delayMs } : { retry: false };
2461
+ }
2383
2462
  if (!isMembraneError(error)) {
2384
2463
  // Not a provider failure membrane classified: retried as any other
2385
2464
  // retryable failure, on the base schedule.
@@ -2388,9 +2467,7 @@ export class DefaultErrorPolicy implements ErrorPolicy {
2388
2467
  }
2389
2468
  if (error.retryable === false) return { retry: false };
2390
2469
  // 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.
2470
+ // copy of membrane, as isMembraneError takes one).
2394
2471
  const facts = error as { retryable?: boolean; type?: string; httpStatus?: number; message?: string };
2395
2472
  // A failure that doesn't say is retried as an unknown one is; only false refuses (handled above).
2396
2473
  const cls = classifyFailure({ retryable: facts.retryable !== false, type: facts.type, httpStatus: facts.httpStatus, message: facts.message });
@@ -2420,6 +2497,19 @@ const MAINTENANCE_TICKS_PER_PASS = 8;
2420
2497
  * together, and then on awareness drains still running, such as its
2421
2498
  * startup's delivery. */
2422
2499
  const FAILED_CREATE_TEARDOWN_TIMEOUT_MS = 30_000;
2500
+ const ABANDON_GRACE_MS = 10_000;
2501
+
2502
+ const testAbandonGraces = new WeakMap<AgentFramework, number>();
2503
+
2504
+ /**
2505
+ * For tests only: `framework`'s abandon takes `ms` as its grace in place of
2506
+ * {@link ABANDON_GRACE_MS}. Set it only where the test holds every turn the
2507
+ * abandon reaches past the grace, so no turn can end within it and a short
2508
+ * grace only brings the abandon's report sooner.
2509
+ */
2510
+ export function setAbandonGraceForTests(framework: AgentFramework, ms: number): void {
2511
+ testAbandonGraces.set(framework, ms);
2512
+ }
2423
2513
 
2424
2514
  /** Local per-residence provider admission (AF #114 bounded first slice). */
2425
2515
  interface LocalProviderGate {
@@ -2443,9 +2533,10 @@ interface ProviderAccelerationCooldown {
2443
2533
  /** Armed by an auxiliary (compression) failure with no primary involved:
2444
2534
  * release reopens auxiliary admission without synthesising an inference. */
2445
2535
  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;
2536
+ /** Its held requests were all taken out, by dropContinuations ('drop') or
2537
+ * by a withdrawal of their coalescing subject ('withdrawal'): release
2538
+ * reopens admission without synthesising an inference in their place. */
2539
+ emptiedBy?: 'drop' | 'withdrawal';
2449
2540
  rearm?: { number: number; of: number };
2450
2541
  /** Set when this hold is a provider wait (a stated retry-after) for the
2451
2542
  * agent's primary model: it holds primary turns only. Auxiliary calls are
@@ -2484,13 +2575,21 @@ const PROVIDER_ACCELERATION_DEFAULT_COOLDOWN_MS = 65_000;
2484
2575
  * longest stated wait an ephemeral run sleeps in its turn: a longer one ends
2485
2576
  * the run instead (nonOwningProviderWait). */
2486
2577
  const PROVIDER_ACCELERATION_MAX_COOLDOWN_MS = 10 * 60_000;
2578
+ const FAILURE_ADVICE = 'If this recurs with the same cause, change approach rather than retrying identically ' +
2579
+ '(e.g. drop an oversized attachment or an unsupported setting).';
2580
+ const TIMEOUT_ADVICE = 'If a reply is still owed from that turn, you can give it now.';
2581
+ const MADE_AGAIN = 'The call was made again, and it timed out again.';
2487
2582
  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;
2583
+ const DEADLINE_TURN_TRIES = 2;
2584
+ const TURN_RETRY_SETTINGS: RetrySettings = {
2585
+ ...Object.fromEntries(
2586
+ Object.entries(MEMBRANE_RETRY_SETTINGS as RetrySettings).map(([cls, s]) => [cls, s && {
2587
+ ...s,
2588
+ ...(cls === 'overloaded' ? { maxAttempts: OVERLOADED_TURN_TRIES } : {}),
2589
+ statedWaitCeilingMs: s.statedWaitCeilingMs === null ? null : Math.max(s.statedWaitCeilingMs, PROVIDER_ACCELERATION_MAX_COOLDOWN_MS),
2590
+ }])),
2591
+ deadline: { maxAttempts: DEADLINE_TURN_TRIES, firstDelayMs: 1_000, multiplier: 1, maxDelayMs: 1_000, statedWaitCeilingMs: null },
2592
+ } as RetrySettings;
2494
2593
  const PROVIDER_ACCELERATION_JITTER_MS = 5_000;
2495
2594
  /** Longest delay one setTimeout can represent (2^31-1 ms, about 24.8 days);
2496
2595
  * a longer timer fires almost at once, so longer waits re-arm in steps. */
@@ -2540,6 +2639,11 @@ async function* turnStreamEvents(stream: YieldingStream, agentName: string): Asy
2540
2639
  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
2640
  return;
2542
2641
  }
2642
+ if (event.type === 'aborted' && event.reason === 'timeout') {
2643
+ decidedBy = 'error';
2644
+ yield { type: 'error', error: deadlineCut(event.message) } as StreamEvent;
2645
+ continue;
2646
+ }
2543
2647
  if (event.type === 'complete' || event.type === 'error' || event.type === 'aborted') decidedBy = event.type;
2544
2648
  yield event;
2545
2649
  }
@@ -2684,6 +2788,7 @@ interface EphemeralRun {
2684
2788
  endReason?: AgentEndReason;
2685
2789
  /** Who ended it, when it was stopped from outside. */
2686
2790
  endedBy?: string;
2791
+ endedFromOutside?: true;
2687
2792
  }
2688
2793
 
2689
2794
  /**
@@ -3026,6 +3131,31 @@ const isLowSurrogate = (text: string, index: number): boolean => {
3026
3131
  const splitsPair = (text: string, index: number): boolean =>
3027
3132
  index > 0 && index < text.length && isHighSurrogate(text, index - 1) && isLowSurrogate(text, index);
3028
3133
 
3134
+ /**
3135
+ * Whether an MCPL server's child ended the way a server ends when it's asked
3136
+ * to: exit 0, death by a signal that asks a process to end (SIGTERM, SIGINT,
3137
+ * SIGHUP), or a shell's report of such a death by its convention, 128 plus the
3138
+ * signal's number (143, 130, 129). A service manager's stop signals the host
3139
+ * and its children together, so a child can go before the host's stop()
3140
+ * closes it: its loss is that stop, not a failure, and raises no mcpl-down.
3141
+ * A child the stdio transport ended itself, after a pipe error or a line on
3142
+ * its stdout longer than the bound, was ended by that, whatever its code or
3143
+ * signal. A WebSocket's close codes are 1000 and up, so they never match.
3144
+ */
3145
+ function endedAsAsked(ended: { code?: number | null; signal?: string | null; pipeFailed?: boolean }): boolean {
3146
+ if (ended.pipeFailed) return false;
3147
+ const { code, signal } = ended;
3148
+ return code === 0 || code === 143 || code === 130 || code === 129 ||
3149
+ signal === 'SIGTERM' || signal === 'SIGINT' || signal === 'SIGHUP';
3150
+ }
3151
+
3152
+ /** A lost connection's words for an alert: the transport's reason, then its
3153
+ * exit or close code and signal, each when there is one. */
3154
+ function closeWords(reason: string | null | undefined, code: number | null | undefined, signal: string | null | undefined): string {
3155
+ const how = [reason ?? null, code != null ? `code ${code}` : null, signal ? `signal ${signal}` : null].filter((part) => part !== null);
3156
+ return how.length > 0 ? ` (${how.join(', ')})` : '';
3157
+ }
3158
+
3029
3159
  /**
3030
3160
  * `text` when it fits within `max` characters; otherwise its head and tail
3031
3161
  * around a marker stating how many of how many characters were omitted, the
@@ -3261,6 +3391,17 @@ export function shedMayHelp(errorType: string | undefined, httpStatus: number |
3261
3391
  return errorType === 'invalid_request' && (httpStatus === undefined || httpStatus === 400 || httpStatus === 422);
3262
3392
  }
3263
3393
 
3394
+ function responseParts(response: EventResponse | undefined): string {
3395
+ return [
3396
+ response?.addMessages?.length ? `${response.addMessages.length} message(s) to add` : '',
3397
+ response?.editMessages?.length ? `${response.editMessages.length} edit(s)` : '',
3398
+ response?.removeMessages?.length ? `${response.removeMessages.length} removal(s)` : '',
3399
+ response?.stateUpdate !== undefined ? 'a state update' : '',
3400
+ response?.requestInference ? 'an inference request' : '',
3401
+ response?.toolsChanged ? 'a tools change' : '',
3402
+ ].filter(Boolean).join(', ');
3403
+ }
3404
+
3264
3405
  /**
3265
3406
  * What a thrown value says, safely and briefly, as boundedErrorMessage gives
3266
3407
  * it, with `recorded` applied before the cut, so no part of a credential
@@ -3423,6 +3564,13 @@ interface StreamRequestNotes {
3423
3564
 
3424
3565
  const SUBCONSCIOUS_MODEL_FIELDS = ['physicalWindowTokens', 'thinkingBinding'] as const satisfies ReadonlyArray<keyof AgentConfig>;
3425
3566
 
3567
+ function readerModelFields(cfg: SubconsciousConfig): Pick<AgentConfig, (typeof SUBCONSCIOUS_MODEL_FIELDS)[number]> {
3568
+ return {
3569
+ ...(cfg.physicalWindowTokens != null ? { physicalWindowTokens: cfg.physicalWindowTokens } : {}),
3570
+ ...(holdsSettings('subconscious.thinkingBinding', cfg.thinkingBinding, '{ binds: true, dropBlock: true }') ? { thinkingBinding: cfg.thinkingBinding } : {}),
3571
+ };
3572
+ }
3573
+
3426
3574
  const JUDGE_PARAMETER: ToolParameter = {
3427
3575
  type: 'object',
3428
3576
  description:
@@ -3491,9 +3639,10 @@ export class AgentFramework {
3491
3639
  private store: JsStore;
3492
3640
  private ownsStore: boolean;
3493
3641
  private stopPromise: Promise<void> | null = null;
3494
- private stopCompleted = false;
3642
+ // Nothing sets it back to 'none': a stop ends its instance (#315).
3643
+ private stopState: 'none' | 'stopping' | 'completed' | 'failed' = 'none';
3644
+ private closedStopFailure: { error: unknown } | undefined;
3495
3645
  private ownedStoreClosed = false;
3496
- private ownedStoreManagersClosed = false;
3497
3646
  /** Whether the latest stop() got through its teardown with nothing failed:
3498
3647
  * every step and every wait up to and including the one on awareness
3499
3648
  * drains, whatever failed after that, in the storage work that finishes
@@ -3509,8 +3658,8 @@ export class AgentFramework {
3509
3658
  * promise: a stream it cancelled or an event loop still in a call; and a
3510
3659
  * stream whose cancel threw, which stop() doesn't wait for. A later
3511
3660
  * 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
3661
+ * since to the entry it was found under (a run's end removes its stream's),
3662
+ * so neither a budget nor a cancel that
3514
3663
  * threw lets the store close under work still running. Each leaves once it
3515
3664
  * settles. */
3516
3665
  private readonly leftRunning = new Map<Promise<unknown>, { label: string; kind: 'stream' | 'loop' }>();
@@ -3576,6 +3725,9 @@ export class AgentFramework {
3576
3725
  private providerAccelerationLastRecovery: Map<string, ProviderAccelerationReceipt> = new Map();
3577
3726
  private providerAccelerationDefaultCooldownMs = PROVIDER_ACCELERATION_DEFAULT_COOLDOWN_MS;
3578
3727
  private providerAccelerationJitterMs = PROVIDER_ACCELERATION_JITTER_MS;
3728
+ private inferenceBounds: InferenceBounds | null = resolveInferenceBounds(undefined);
3729
+ private readonly deadlineCuts = new Map<string, number>();
3730
+ private readonly freshCallsCut = new Set<string>();
3579
3731
  private providerHoldHook: ProviderHoldHook | undefined;
3580
3732
  /** Provider waits (stated retry-after) per (agent, model), kept across restarts. */
3581
3733
  private providerWaits: ProviderWaits | undefined;
@@ -3583,8 +3735,6 @@ export class AgentFramework {
3583
3735
  private retryWaitWakers = new Set<() => void>();
3584
3736
  private waitingTraceEveryMs = WAITING_TRACE_EVERY_MS;
3585
3737
  private providerAdmissionClosed = false;
3586
- /** Invalidates framework turns still assembling when stop begins. */
3587
- private lifecycleGeneration = 0;
3588
3738
  /** Last time we reported stale (busy-requeued) inference requests, per agent. */
3589
3739
  private staleWarnAt = new Map<string, number>();
3590
3740
  /** Per-agent last inference activity (epoch ms), for /healthz + doctor tooling. */
@@ -3736,6 +3886,7 @@ export class AgentFramework {
3736
3886
  private turnRearmMs: readonly number[] = DEFAULT_TURN_REARM_MS;
3737
3887
  private turnRearms: Map<string, number> = new Map();
3738
3888
  private exhaustingRearms: Set<string> = new Set();
3889
+ private rearmDeadlineCuts: Set<string> = new Set();
3739
3890
  private statedWaitRuns: Map<string, StatedWaitRun> = new Map();
3740
3891
  /** N consecutive failed inferences ⇒ the agent is treated as hard-down and
3741
3892
  * escalated loudly to stderr. */
@@ -4314,6 +4465,7 @@ export class AgentFramework {
4314
4465
  getMessage: (id) => this.getMessage(id),
4315
4466
  queryMessages: (filter, opts) => this.queryMessages(filter, opts),
4316
4467
  pushEvent: (event, moduleName) => this.pushModuleEvent(event, moduleName, true),
4468
+ dropRetiredPush: (event, moduleName) => this.dropRetiredModuleEvent(event, moduleName),
4317
4469
  onTrace: (listener) => this.onTrace(listener),
4318
4470
  // The ONLY entry that confers module origin — a private closure the
4319
4471
  // framework hands to its own module registry. The public
@@ -4337,6 +4489,7 @@ export class AgentFramework {
4337
4489
  static async create(config: FrameworkConfig): Promise<AgentFramework> {
4338
4490
  // Before anything opens or starts: a refused config must leave nothing behind.
4339
4491
  if (config.codeExecution != null) validateCodeExecutionConfig(config.codeExecution);
4492
+ resolveInferenceBounds(config.inferenceBounds);
4340
4493
  assertFrameworkSwitches(config);
4341
4494
  for (const agentConfig of config.agents) assertAgentSwitches(agentConfig);
4342
4495
  assertAgentNamesUnique(config.agents);
@@ -4538,6 +4691,7 @@ export class AgentFramework {
4538
4691
  );
4539
4692
  framework.providerHoldHook = config.providerHold;
4540
4693
  framework.turnRearmMs = turnRearmDelays(config.exhaustedTurnRearmMs);
4694
+ framework.inferenceBounds = resolveInferenceBounds(config.inferenceBounds);
4541
4695
 
4542
4696
  // Before any delivery.
4543
4697
  await framework.reconcileDiscordAwarenessAtStartup();
@@ -5062,9 +5216,7 @@ export class AgentFramework {
5062
5216
  * maintenance, event loop and operator operations, so a dump shows what
5063
5217
  * those waits are on, and before its modules and MCPL servers stop, so a
5064
5218
  * 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.
5219
+ * With none in the set, one line says no framework is running.
5068
5220
  *
5069
5221
  * One listener serves the process, installed by the first create() to
5070
5222
  * reach the join and kept: once a process's last SIGUSR2 listener is
@@ -5108,36 +5260,39 @@ export class AgentFramework {
5108
5260
 
5109
5261
  /**
5110
5262
  * 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.
5263
+ * A stop ends its instance (#315): once a stop() is admitted, start() and
5264
+ * addModule() are refused with an error, for good. While that stop() runs,
5265
+ * the error says the framework is stopping. Once it has settled, the error
5266
+ * says what to do: create a new AgentFramework on the store, and after a
5267
+ * failed stop(), call stop() again first, until one resolves. When a
5268
+ * failed stop() left its owned store closed, so that it can't be tried
5269
+ * again, the error carries that failure as its cause.
5270
+ *
5271
+ * A new instance on the store keeps what the store holds. With the
5272
+ * heartbeat on and the same heartbeat state file (heartbeat.statePath, or
5273
+ * its default), it keeps the heartbeat's schedule as it reads it from that
5274
+ * file: a tick or a reminder that fell due meanwhile fires once, shortly
5275
+ * after it starts. What an instance holds only in memory is lost with it,
5276
+ * as on a process restart: an agent's sleep and its wake, a self-wake, the
5277
+ * inference requests still queued (a budget restart, a heartbeat's wake, a
5278
+ * nudge), a re-armed turn's wait and count, and the redo stack. #627 is
5279
+ * about keeping a sleep and a self-wake across a restart. To pause and
5280
+ * resume one framework in place, use quiesce() and resume().
5115
5281
  */
5116
5282
  start(): void {
5117
5283
  // Before the running check: stop() claims its attempt before it clears
5118
5284
  // running, so a start() made in its first steps, such as from a trace
5119
5285
  // listener, is refused too.
5120
- this.resetCompletedStop('start()');
5286
+ this.refuseOnceStopped('start()');
5121
5287
  if (this.running) {
5122
5288
  return;
5123
5289
  }
5124
5290
 
5125
5291
  this.running = true;
5126
- this.providerAdmissionClosed = false;
5127
5292
  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
5293
  for (const config of this.mcplServerConfigs.values()) {
5135
5294
  referenceRegistry.setServerAddress(config.id, serverAddressOf(config), this);
5136
5295
  }
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
5296
  this.heartbeat?.start();
5142
5297
  this.loopPromise = this.runLoop();
5143
5298
 
@@ -5164,11 +5319,14 @@ export class AgentFramework {
5164
5319
  }
5165
5320
 
5166
5321
  /**
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
5322
+ * Stop the event loop, and with it this framework, for good: once a stop()
5323
+ * is admitted, start() and addModule() are refused, as start() says. A
5324
+ * stop() whose budget is refused, which it rejects with a RangeError, is
5325
+ * not admitted and changes nothing. Each step of the teardown runs
5326
+ * whatever an earlier one did: one that throws, such as a host stream's
5327
+ * cancel(), fails the stop() once the rest has run. When a step or a wait
5328
+ * up to and including the wait on awareness drains fails or times out,
5329
+ * storage stays open and
5172
5330
  * stop() can be called again; one that fails after it, in the storage work
5173
5331
  * that finishes the stop, fails the stop() without keeping storage open.
5174
5332
  * A timed-out wait lets teardown go on while what it waited for may still
@@ -5176,7 +5334,10 @@ export class AgentFramework {
5176
5334
  * @param teardownTimeoutMs Optional budget for each of stop()'s waits, in
5177
5335
  * turn: on the streams it cancels, on maintenance in flight (a pass or an
5178
5336
  * 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
5337
+ * already running, on modules and MCPL (its servers and the inference
5338
+ * requests they made) together, where a module whose start() is still
5339
+ * running counts until that start() has settled and its stop() returned,
5340
+ * then on Discord awareness
5180
5341
  * drains, which send nothing once stop() has begun, recording the call the
5181
5342
  * close ended. With a budget, a stop() can take up to six of them, besides
5182
5343
  * the withheld-spill deadline of a guard's write still under way. Omit to
@@ -5199,9 +5360,7 @@ export class AgentFramework {
5199
5360
  * dropped, though a write the stream already had under way can still
5200
5361
  * land in the run's namespace after them.
5201
5362
  * - 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.
5363
+ * that call and the rest of its iteration, and then exits.
5205
5364
  * - a later stop() waits again, within its own budget, for any stream or
5206
5365
  * loop this one left running, so the store never closes under it.
5207
5366
  *
@@ -5224,11 +5383,13 @@ export class AgentFramework {
5224
5383
  let settle!: { resolve: () => void; reject: (error: unknown) => void };
5225
5384
  const attempt = new Promise<void>((resolve, reject) => { settle = { resolve, reject }; });
5226
5385
  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;
5386
+ this.stopState = 'stopping';
5387
+ void attempt.then(() => { this.stopState = 'completed'; }, (error: unknown) => {
5388
+ this.stopState = 'failed';
5389
+ // Failed cleanup can be retried while its original store is open. A
5390
+ // terminal storage failure after successful close must remain a failure.
5391
+ if (this.ownedStoreClosed) this.closedStopFailure = { error };
5392
+ else if (this.stopPromise === attempt) this.stopPromise = null;
5232
5393
  });
5233
5394
  // The body still starts in this call, so its synchronous steps (closing
5234
5395
  // the queue among them) happen before stop() returns.
@@ -5236,22 +5397,29 @@ export class AgentFramework {
5236
5397
  return attempt;
5237
5398
  }
5238
5399
 
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;
5400
+ private refuseOnceStopped(verb: 'start()' | 'addModule()'): void {
5401
+ switch (this.stopState) {
5402
+ case 'none':
5403
+ return;
5404
+ case 'stopping':
5405
+ throw new Error(`${verb} refused: the framework is stopping`);
5406
+ case 'completed':
5407
+ throw new Error(`${verb} refused: this framework has stopped, and a stopped framework doesn't start again. ` +
5408
+ 'Create a new AgentFramework on its store to go on, with its storePath if it opened the store itself.');
5409
+ case 'failed': {
5410
+ const closed = this.closedStopFailure;
5411
+ if (closed) {
5412
+ throw new Error(`${verb} refused: this framework's stop() failed and its owned store is closed, so stop() can't be tried again: ` +
5413
+ `it rejects with that same failure, this error's cause: ${shutdownErrorMessage(closed.error)}. ` +
5414
+ "A stopped framework doesn't start again, and a new AgentFramework on its store doesn't finish what that stop() left unfinished.",
5415
+ { cause: closed.error });
5416
+ }
5417
+ throw new Error(`${verb} refused: this framework's stop() failed, and a stopped framework doesn't start again. ` +
5418
+ 'A failed stop() may have left its cleanup or final persistence unfinished. ' +
5419
+ "Call stop() again: once a stop() of this one resolves, they're done, and only then should a new " +
5420
+ 'AgentFramework use its store, with its storePath if this one opened the store itself, ' +
5421
+ 'or another instance use anything this one shares.');
5422
+ }
5255
5423
  }
5256
5424
  }
5257
5425
 
@@ -5373,6 +5541,13 @@ export class AgentFramework {
5373
5541
  // holding the wait for operator operations below until its handshake's
5374
5542
  // deadline.
5375
5543
  step(() => this.endConnectsInFlight());
5544
+ // Nor does a server lost before or during the stop launch or dial again
5545
+ // on its own: a retry already scheduled starts nothing, and none is
5546
+ // scheduled after.
5547
+ step(() => {
5548
+ for (const connection of this.mcplServerRegistry?.getAllServers() ?? []) connection.stopReconnecting();
5549
+ for (const connection of this.modernServers().values()) connection.stopReconnecting();
5550
+ });
5376
5551
  step(() => this.pushCoalescer?.suspend());
5377
5552
  step(() => this.flushCoalescingSnapshot());
5378
5553
  this.running = false;
@@ -5386,11 +5561,10 @@ export class AgentFramework {
5386
5561
  step(() => this.ackDeferredWrites());
5387
5562
  this.providerAdmissionClosed = true;
5388
5563
  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;
5564
+ this.guardSpillsClosed = false;
5393
5565
  step(() => this.queue.close());
5566
+ let inferences: Array<{ label: string; promise: Promise<void> }> = [];
5567
+ step(() => { inferences = this.inferenceRouter?.stopAll() ?? []; });
5394
5568
  step(() => this.tuneOutCoordinator?.stop());
5395
5569
 
5396
5570
  // Stop running code_execution scripts before cancelling streams: a
@@ -5405,9 +5579,8 @@ export class AgentFramework {
5405
5579
  this.toolImageLedgers.clear();
5406
5580
 
5407
5581
  // 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
5582
+ // a turn's start a stop reaches does: the continuation never starts.
5583
+ // Before the holds and the parks go, so the
5411
5584
  // continuation leaves them with its turn. Each drop is a step of its
5412
5585
  // own, so one that throws leaves the next agent's turn to end all the
5413
5586
  // same, as stop()'s other loops do.
@@ -5418,11 +5591,7 @@ export class AgentFramework {
5418
5591
  });
5419
5592
  // A stopped host must never hang behind its own cooldown.
5420
5593
  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.
5594
+ // An open logical turn doesn't survive a stop.
5426
5595
  this.openLogical.clear();
5427
5596
 
5428
5597
  // Cancel the stream of every agent whose state holds one: streaming,
@@ -5541,8 +5710,9 @@ export class AgentFramework {
5541
5710
  // its idle watchdog. Its end stores its queue, as at any run's end; that
5542
5711
  // stream's late events are dropped once the run has left, and a later
5543
5712
  // stop() waits for the stream itself (leftRunning). (A start() is refused
5544
- // until this stop() settles, so no new lifecycle begins beside it.)
5713
+ // once a stop() is admitted, so no new lifecycle begins beside it.)
5545
5714
  for (const [name, run] of this.ephemeralRuns) run.settle.reject(ephemeralRunStopped(name));
5715
+ for (const agent of this.agents.values()) step(() => { agent.unpinTurnInputs(); });
5546
5716
 
5547
5717
  // Admission closed when stop() began. What admitted operations start now,
5548
5718
  // such as a surgery's config materialization, joins the set, so wait until
@@ -5572,11 +5742,9 @@ export class AgentFramework {
5572
5742
  // generation and nothing reads the old fork's window again. So each live
5573
5743
  // fork ends here as a closing fork's end disposes it, and its release
5574
5744
  // 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).
5745
+ // first, as the idle sweep's does. A fork whose spawn is still waiting
5746
+ // for its creation to return has no binding yet, and its spawn binds
5747
+ // nothing once it has ended (spawnConversationAgent).
5580
5748
  // Each is a step of its own, and each fork ends whatever its stream or
5581
5749
  // turn is doing, as each run does above: its end settles a tool-result
5582
5750
  // batch its stream still held and removes the stream's entries
@@ -5664,17 +5832,17 @@ export class AgentFramework {
5664
5832
  // workspace: its stub names the audit record. A write that was past its
5665
5833
  // deadline isn't waited for here, but the workspace's own stop waits for
5666
5834
  // the mount turn it holds, bounded only by teardownTimeoutMs.
5667
- this.guardSpillsClosedFor = stoppingGeneration;
5835
+ this.guardSpillsClosed = true;
5668
5836
 
5669
5837
  // Stop modules and both engines' MCP servers in parallel
5670
- const shutdownPromises: Promise<void>[] = [this.moduleRegistry.stopAll(teardownTimeoutMs)];
5838
+ const shutdownPromises: Promise<void>[] = [this.moduleRegistry.stopAll(teardownTimeoutMs), waitFor(inferences)];
5671
5839
  if (this.mcplServerRegistry) {
5672
5840
  shutdownPromises.push(this.mcplServerRegistry.closeAll(teardownTimeoutMs));
5673
5841
  }
5674
5842
  shutdownPromises.push(this.closeModernMcpConnections(teardownTimeoutMs));
5675
5843
  // A stopped framework's servers' addresses leave the reference registry,
5676
5844
  // so no other framework's stub reads them and the registry doesn't keep
5677
- // this one reachable. start() records them again.
5845
+ // this one reachable.
5678
5846
  step(() => { for (const id of this.mcplServerConfigs.keys()) referenceRegistry.forgetServerAddress(id, this); });
5679
5847
  // Timed-out or failed teardown retains its context and may still write.
5680
5848
  // Sync available state best-effort, but leave its store open for a retry.
@@ -5734,8 +5902,8 @@ export class AgentFramework {
5734
5902
  // No stream is left, unless its wait above ran out or its cancel threw:
5735
5903
  // from the start of stop() none registers and no agent turn starts
5736
5904
  // (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.)
5905
+ // registered before was cancelled and awaited above. (A start() is
5906
+ // refused once a stop() is admitted, so admission stays closed.)
5739
5907
  // What is still deferred (a message held for a turn's end whose flush
5740
5908
  // found the target busy again, or one deferred behind its target's own
5741
5909
  // state) would wait for a flush that may never come. Land each at its
@@ -5820,7 +5988,7 @@ export class AgentFramework {
5820
5988
  }
5821
5989
 
5822
5990
  if (this.ownsStore && teardownSucceeded) {
5823
- if (this.closeOpenManagers()) this.ownedStoreManagersClosed = true;
5991
+ this.closeOpenManagers();
5824
5992
  try {
5825
5993
  this.store.close();
5826
5994
  this.ownedStoreClosed = true;
@@ -6253,10 +6421,9 @@ export class AgentFramework {
6253
6421
  }
6254
6422
  contextManager.close();
6255
6423
  }
6256
- private closeOpenManagers(): boolean {
6424
+ private closeOpenManagers(): void {
6257
6425
  const alive = [...this.openManagers].flatMap((ref) => ref.deref() ?? []);
6258
6426
  for (const contextManager of alive) this.closeManager(contextManager);
6259
- return alive.length > 0;
6260
6427
  }
6261
6428
  /** The framework's membrane, with each complete() call passed through
6262
6429
  * `admit` along with the model it names. */
@@ -6266,8 +6433,17 @@ export class AgentFramework {
6266
6433
  const value = Reflect.get(obj, prop, receiver);
6267
6434
  if (prop === 'complete' && typeof value === 'function') {
6268
6435
  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>,
6436
+ const config = (args[0] as { config?: { model?: unknown; maxTokens?: unknown } } | undefined)?.config;
6437
+ const model = config?.model;
6438
+ const options = args[1] as { progressTimeoutMs?: unknown } | undefined;
6439
+ const bounds = this.inferenceBounds;
6440
+ const sent = bounds !== null && options?.progressTimeoutMs === undefined
6441
+ ? [args[0], {
6442
+ ...options,
6443
+ progressTimeoutMs: wholeCallMs(bounds, typeof config?.maxTokens === 'number' ? config.maxTokens : undefined),
6444
+ }, ...args.slice(2)]
6445
+ : args;
6446
+ return admit(() => Reflect.apply(value, this.membrane, sent) as Promise<unknown>,
6271
6447
  typeof model === 'string' ? model : undefined);
6272
6448
  };
6273
6449
  }
@@ -6392,7 +6568,7 @@ export class AgentFramework {
6392
6568
  // length: an operator release of it returns the hold to AF's own pacing.
6393
6569
  ...(pacing?.stated ? { statedWait: { model: agent.model, ownUntil: now + pacing.ownMs } } : {}),
6394
6570
  ...(auxiliary && (!existing || existing.auxiliaryOrigin) ? { auxiliaryOrigin: true } : {}),
6395
- ...(existing?.emptiedByDrop && held.length === 0 ? { emptiedByDrop: true } : {}) });
6571
+ ...(existing?.emptiedBy && held.length === 0 ? { emptiedBy: existing.emptiedBy } : {}) });
6396
6572
  if (auxiliary && !existing) {
6397
6573
  console.error(`[provider-cooldown] agent=${agent.name} host hold (${hostHold!.reason}) from an auxiliary call — ` +
6398
6574
  `holding primary/auxiliary for ${delayMs}ms`);
@@ -6407,11 +6583,22 @@ export class AgentFramework {
6407
6583
  if (wait && trigger && (wait.until === null || wait.until > now)) this.noteStatedWaitRun(agent.name, wait, now);
6408
6584
  return true;
6409
6585
  }
6410
- private rearmExhaustedTurn(agent: Agent, error: Error, trigger: InferenceRequest | undefined, ownsProviderGate: boolean, routing: TurnRouting | undefined): void {
6586
+ private takeAsTransient(agentName: string, error: Error, trigger: InferenceRequest | undefined): boolean {
6587
+ if (isTransientFailure(error)) {
6588
+ this.rearmDeadlineCuts.delete(agentName);
6589
+ return true;
6590
+ }
6591
+ if (!isRearmed(trigger) || !isDeadlineExpiry(error)) return false;
6592
+ const first = !this.rearmDeadlineCuts.has(agentName);
6593
+ this.rearmDeadlineCuts.add(agentName);
6594
+ return first;
6595
+ }
6596
+
6597
+ private rearmExhaustedTurn(agent: Agent, error: Error, trigger: InferenceRequest | undefined, ownsProviderGate: boolean, routing: TurnRouting | undefined, asTransient: boolean): void {
6411
6598
  if (!ownsProviderGate || trigger === undefined || this.providerAdmissionClosed || this.turnRearmMs.length === 0) return;
6412
6599
  const done = this.turnRearms.get(agent.name) ?? 0;
6413
6600
  const notSent = (why: string) => console.error(`[inference-rearm] agent=${agent.name} turn not re-armed: ${why}`);
6414
- if (!isTransientFailure(error)) {
6601
+ if (!asTransient) {
6415
6602
  if (done > 0) notSent(`its failure isn't transient (${isMembraneError(error) ? error.type : boundFailureText(error.message, FAILURE_SUMMARY_MAX_CHARS)})`);
6416
6603
  return;
6417
6604
  }
@@ -6551,12 +6738,15 @@ export class AgentFramework {
6551
6738
  ? `no longer holds primary turns: the primary model is now ${model} (the wait stays recorded)`
6552
6739
  : `primary hold ended after ${Date.now() - cooldown.startedAt}ms`));
6553
6740
  }
6554
- if ((cooldown.auxiliaryOrigin || cooldown.emptiedByDrop) && cooldown.heldRequests.length === 0) {
6741
+ if ((cooldown.auxiliaryOrigin || cooldown.emptiedBy) && cooldown.heldRequests.length === 0) {
6555
6742
  // 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);
6743
+ // continuation since dropped with its turn, or wakes since withdrawn:
6744
+ // just reopen the gate. A primary the hold left pending won't run, so
6745
+ // its recovery ends.
6746
+ const which = cooldown.emptiedBy === 'drop' ? 'hold, its held continuation dropped,'
6747
+ : cooldown.emptiedBy === 'withdrawal' ? 'hold, its held wakes withdrawn,' : 'auxiliary hold';
6748
+ console.error(`[provider-cooldown] agent=${agentName} ${which} released after ${Date.now() - cooldown.startedAt}ms`);
6749
+ if (cooldown.emptiedBy) this.endPendingPrimary(agentName);
6560
6750
  this.flushAuxiliaryAdmission(agentName);
6561
6751
  return;
6562
6752
  }
@@ -7212,12 +7402,7 @@ export class AgentFramework {
7212
7402
  */
7213
7403
  pushEvent(event: ProcessEvent): void {
7214
7404
  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}`);
7405
+ console.error(droppedToolResultLine(event, 'after stop'));
7221
7406
  return;
7222
7407
  }
7223
7408
  this.queue.push(event);
@@ -7247,6 +7432,14 @@ export class AgentFramework {
7247
7432
  else this.queue.push(event);
7248
7433
  }
7249
7434
 
7435
+ private dropRetiredModuleEvent(event: ProcessEvent, moduleName: string): void {
7436
+ if (this.queue.isClosed) {
7437
+ this.pushModuleEvent(event, moduleName, true);
7438
+ return;
7439
+ }
7440
+ console.error(retiredPushLine(event, moduleName));
7441
+ }
7442
+
7250
7443
  /**
7251
7444
  * Add a trace event listener for observability.
7252
7445
  */
@@ -7298,12 +7491,17 @@ export class AgentFramework {
7298
7491
  }
7299
7492
 
7300
7493
  /**
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.
7494
+ * Add a module at runtime. Refused, with a rejection, once a stop() has
7495
+ * been admitted, before anything else is checked: while that stop() runs,
7496
+ * a module added then would either be stopped by it or, once it has taken
7497
+ * its list of modules to stop, stay started past it; once it has settled,
7498
+ * the framework has stopped for good, as start() says. A stop() admitted
7499
+ * while the module's start() runs stops the module once that start() has
7500
+ * settled; if that start() fulfils, this rejects the same way then, with
7501
+ * no `module:added`, and if it rejects, this rejects with its error.
7305
7502
  */
7306
7503
  async addModule(module: Module): Promise<void> {
7504
+ this.refuseOnceStopped('addModule()');
7307
7505
  // A server's tool prefix and a module's name share one namespace, as
7308
7506
  // create() checks at startup: a module isn't added under a prefix a
7309
7507
  // server holds, or one a restart is moving its server to.
@@ -7321,8 +7519,8 @@ export class AgentFramework {
7321
7519
  throw new Error(`Module "${module.name}" collides with MCPL server "${id}"'s toolPrefix "${module.name}" (a closed tab)`);
7322
7520
  }
7323
7521
  }
7324
- this.resetCompletedStop('addModule()');
7325
7522
  await this.moduleRegistry.addModule(module);
7523
+ this.refuseOnceStopped('addModule()');
7326
7524
  // The workspace judges whether an agent's history holds a message as the
7327
7525
  // framework judges its own notices, for its head-move notice
7328
7526
  // (noticeHeadMoves) and the writes it weighs against it. A module taken
@@ -7337,7 +7535,9 @@ export class AgentFramework {
7337
7535
  }
7338
7536
 
7339
7537
  /**
7340
- * Remove a module at runtime.
7538
+ * Remove a module at runtime. A module whose start() is still running is
7539
+ * stopped once that start() has settled; this waits for that and for its
7540
+ * stop(), with no time limit.
7341
7541
  */
7342
7542
  async removeModule(name: string): Promise<void> {
7343
7543
  await this.moduleRegistry.removeModule(name);
@@ -7686,7 +7886,7 @@ export class AgentFramework {
7686
7886
  ];
7687
7887
  }
7688
7888
 
7689
- /** The registered workspace module, if any (used by save_image). */
7889
+ /** The registered workspace module, if any (used by save_recent_image). */
7690
7890
  private getWorkspaceModule(): WorkspaceModule | undefined {
7691
7891
  const module = this.moduleRegistry.getAllModules().find((m) => m.name === 'workspace');
7692
7892
  return module && typeof (module as WorkspaceModule).writeBinary === 'function'
@@ -8165,15 +8365,11 @@ export class AgentFramework {
8165
8365
  private stallSkips = 0;
8166
8366
  /** See WITHHELD_SPILL_DEADLINE_MS; an instance field so tests can shorten it. */
8167
8367
  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;
8368
+ /** Set once a stop() has let the running annotations finish, and cleared
8369
+ * as each stop() begins: while set, a withheld original is no longer
8370
+ * written to a workspace that is stopping, and its stub names the audit
8371
+ * record. */
8372
+ private guardSpillsClosed = false;
8177
8373
 
8178
8374
  /** A new agent's guard: its persisted setting, the tool-results spill its
8179
8375
  * withheld originals are written to, and the set stop() waits on
@@ -8904,7 +9100,7 @@ export class AgentFramework {
8904
9100
  // Bounded grace for cancelled streams. Stop cancels streams too; a turn
8905
9101
  // with no stream settles on its own. Report the abandon that already ran,
8906
9102
  // even if stop cuts its grace short.
8907
- const grace = Date.now() + 10_000;
9103
+ const grace = Date.now() + (testAbandonGraces.get(this) ?? ABANDON_GRACE_MS);
8908
9104
  while (this.quiesced && !this.queue?.isClosed && this.activeTurnTokens.size > 0 && Date.now() < grace) {
8909
9105
  await new Promise((resolve) => setTimeout(resolve, 250));
8910
9106
  }
@@ -9334,8 +9530,9 @@ export class AgentFramework {
9334
9530
  * memory-only: they land within the turn, at a tool boundary or at its
9335
9531
  * end, and a crash before then loses them. That window runs to the turn's
9336
9532
  * 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.
9533
+ * stream that doesn't declare it carries injected messages (membrane's XML
9534
+ * prefill path declares it can't), for every message deferred during the
9535
+ * turn.
9339
9536
  *
9340
9537
  * A write that fails, or a queue over the cap, which writes nothing,
9341
9538
  * leaves the record as it last held it, except that store only, a write
@@ -9353,20 +9550,21 @@ export class AgentFramework {
9353
9550
  * itself before it changes the host's mode, and refuses instead, unless
9354
9551
  * the call has `abandon` (writeDeferredWritesForQuiesce).
9355
9552
  */
9356
- private persistDeferredWrites(): void {
9553
+ private persistDeferredWrites(): boolean {
9357
9554
  let outcome: DeferredWritesOutcome;
9358
9555
  try {
9359
9556
  outcome = this.writeDeferredWrites(this.quiesced);
9360
9557
  } catch (err) {
9361
9558
  console.error('[host-mode] failed to persist deferred context writes:', err);
9362
9559
  this.deferredWritesFellBehind({ error: err });
9363
- return;
9560
+ return false;
9364
9561
  }
9365
9562
  if (outcome === 'over-cap') {
9366
9563
  this.deferredWritesFellBehind('over-cap');
9367
- } else if (outcome !== 'nothing') {
9368
- this.deferredWritesCaughtUp();
9564
+ return false;
9369
9565
  }
9566
+ if (outcome !== 'nothing') this.deferredWritesCaughtUp();
9567
+ return true;
9370
9568
  }
9371
9569
 
9372
9570
  /**
@@ -10757,8 +10955,9 @@ export class AgentFramework {
10757
10955
  * **Inheritance.** `mode: 'shared'` (the default) gives the child the
10758
10956
  * parent's messages and fold state through shared storage: nothing is
10759
10957
  * 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'`
10958
+ * alter what the other reads. It needs a store that supports derivation
10959
+ * (`ContextManager.supportsDerivation`), and is refused, not silently
10960
+ * downgraded, without one. `mode: 'copy'`
10762
10961
  * is the historical mechanism — the parent's compiled context re-added as
10763
10962
  * the child's own messages — and works everywhere.
10764
10963
  *
@@ -11347,6 +11546,7 @@ export class AgentFramework {
11347
11546
  if (run) {
11348
11547
  run.endReason ??= 'stopped';
11349
11548
  run.endedBy ??= options.by;
11549
+ run.endedFromOutside = true;
11350
11550
  // The awaiting caller is told who stopped the job and why, whether or
11351
11551
  // not a stream was in flight: settled first, so the aborted stream's own
11352
11552
  // settle ('Stream cancelled') finds the run already settled. The caller's
@@ -11725,6 +11925,7 @@ export class AgentFramework {
11725
11925
  if (run) {
11726
11926
  run.endReason = reason;
11727
11927
  run.endedBy = record.ended?.by;
11928
+ run.endedFromOutside = true;
11728
11929
  run.settle.reject(new AgentStoppedError(name, reason, record.ended?.by, detail));
11729
11930
  return;
11730
11931
  }
@@ -11733,6 +11934,15 @@ export class AgentFramework {
11733
11934
  this.releaseAgentRuntime(agent, this.channelOfHome(record.homeChannel), undefined, record.lifetime.kind === 'persistent');
11734
11935
  }
11735
11936
 
11937
+ private forgetFailureAccounting(name: string): void {
11938
+ this.consecutiveInferenceFailures.delete(name);
11939
+ this.exhaustionRewinds.delete(name);
11940
+ this.refusalStreak.delete(name);
11941
+ this.refusalRewinds.delete(name);
11942
+ this.rewindEpisode.delete(name);
11943
+ if (this.forcedRewind.delete(name)) console.error(`[unstick] agent=${name} its /unstick session ended with its agent`);
11944
+ }
11945
+
11736
11946
  /**
11737
11947
  * Everything the process holds for a live, non-ephemeral agent that has
11738
11948
  * ended, released once, on every path an agent ends by — stopAgent, the
@@ -11776,6 +11986,9 @@ export class AgentFramework {
11776
11986
  this.eventGate?.agentGone(name);
11777
11987
  this.agents.delete(name);
11778
11988
  this.agentConfigs.delete(name);
11989
+ this.deadlineCuts.delete(name);
11990
+ this.freshCallsCut.delete(name);
11991
+ this.forgetFailureAccounting(name);
11779
11992
  this.thinkingLossIncidents.forget(name);
11780
11993
  this.reasoningLoss.forget(name);
11781
11994
  // main's manager release: the context manager an ephemeral run or a fork
@@ -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,6 +12786,9 @@ 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
12793
  this.reasoningLoss.forget(agent.name);
12548
12794
  // Its pending self-wake ends with it, as a fork's does.
@@ -12717,6 +12963,15 @@ export class AgentFramework {
12717
12963
  },
12718
12964
  ];
12719
12965
 
12966
+ private static readonly WAKE_ADD_RULE_FIELDS: Readonly<Record<string, string>> = {
12967
+ 'behavior.debounce': 'debounceMs',
12968
+ 'behavior.rate_limit': 'rateLimit',
12969
+ 'behavior.passive_sample': 'passiveSample',
12970
+ 'behavior.judge': 'judge',
12971
+ 'position.before': 'insertBefore',
12972
+ 'position.after': 'insertAfter',
12973
+ };
12974
+
12720
12975
  /** Synthesized wake-rule tools: add/remove a gate.json policy at runtime.
12721
12976
  * Present when a gate is wired. They write validated policies into the
12722
12977
  * hot-reloaded gate.json (same validation as load), so a rule takes effect
@@ -12909,10 +13164,10 @@ export class AgentFramework {
12909
13164
  /** Synthesized drafts tool — present whenever MCPL channels are
12910
13165
  * configured. A resident's held drafts (src/prose-drafts.ts): plain speech
12911
13166
  * 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 = {
13167
+ * kept as that module's retention says. Its optional fields are nullable,
13168
+ * as save_recent_image's selectors are: a caller whose provider presents
13169
+ * every property as required can still say "not in use" (fieldInUse). */
13170
+ private static readonly DRAFTS_TOOL: import('./types/index.js').ToolDefinition = {
12916
13171
  name: 'drafts',
12917
13172
  description:
12918
13173
  'Your held drafts: plain speech of yours that was NOT sent — held back because an explicit send in its turn ' +
@@ -12951,12 +13206,12 @@ export class AgentFramework {
12951
13206
  },
12952
13207
  required: ['action'],
12953
13208
  },
12954
- } as unknown as import('./types/index.js').ToolDefinition;
13209
+ };
12955
13210
 
12956
- /** Synthesized save_image tool — present when a workspace module is
13211
+ /** Synthesized save_recent_image tool — present when a workspace module is
12957
13212
  * registered. Lets the agent persist an image it has already seen in its
12958
13213
  * own context (Discord attachments arrive inlined as base64 and are
12959
- * otherwise unreachable as files). */
13214
+ * otherwise unreachable as files). Its three selectors are nullable. */
12960
13215
  private static readonly SAVE_IMAGE_TOOL: import('./types/index.js').ToolDefinition = {
12961
13216
  name: 'save_recent_image',
12962
13217
  description:
@@ -12972,7 +13227,8 @@ export class AgentFramework {
12972
13227
  'restart) the call fails and writes nothing — it never substitutes an older ' +
12973
13228
  'image. When other tool calls in the same batch are still running, the save ' +
12974
13229
  '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.',
13230
+ 'files are visible via workspace tools. Select by `ref` or by ' +
13231
+ '`index`/`count`; a selector you are not using may be omitted or passed as null.',
12976
13232
  inputSchema: {
12977
13233
  type: 'object',
12978
13234
  properties: {
@@ -12980,19 +13236,27 @@ export class AgentFramework {
12980
13236
  type: 'string',
12981
13237
  description: 'Mount-prefixed destination path, e.g. "project/photos/name.png".',
12982
13238
  },
13239
+ // Nullable selectors: a caller whose provider presents every property
13240
+ // as required must still be able to say "not this selector". Omission,
13241
+ // null and "" all mean it isn't used (fieldInUse); any other value is
13242
+ // checked. The descriptions name null, the form to ask for.
12983
13243
  index: {
12984
- type: 'number',
12985
- description: 'Which image, counting back from the most recent (0 = most recent). Default 0.',
13244
+ type: ['integer', 'null'],
13245
+ description:
13246
+ 'Which image, counting back from the most recent (0 = most recent). Default 0. ' +
13247
+ 'null, like omitting it, means the default.',
12986
13248
  },
12987
13249
  count: {
12988
- type: 'number',
12989
- description: 'How many images to save, starting at `index` and going further back. Default 1.',
13250
+ type: ['integer', 'null'],
13251
+ description:
13252
+ 'How many images to save, starting at `index` and going further back. Default 1. ' +
13253
+ 'null, like omitting it, means the default.',
12990
13254
  },
12991
13255
  ref: {
12992
- type: 'string',
13256
+ type: ['string', 'null'],
12993
13257
  description:
12994
13258
  '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`.',
13259
+ 'Mutually exclusive with `index`/`count`: pass null for the selector you are not using. null, like omitting it, means no ref.',
12996
13260
  },
12997
13261
  },
12998
13262
  required: ['path'],
@@ -15808,8 +16072,9 @@ export class AgentFramework {
15808
16072
  // idiom): reasoning effort (thinking), provider params, token ceilings,
15809
16073
  // caching, refusal handling, timezone — a same-model side-process must
15810
16074
  // not silently run under different inference conditions than the
15811
- // resident it serves. Only identity- and surface-defining fields are
15812
- // overridden below.
16075
+ // resident it serves. Below, identity- and surface-defining fields are
16076
+ // overridden, and the window and thinking binding where the
16077
+ // subconscious's config sets its own.
15813
16078
  const model = cfg.model ?? primaryConfig.model;
15814
16079
  const inherited: AgentConfig = { ...primaryConfig };
15815
16080
  if (model !== primaryConfig.model) {
@@ -15817,6 +16082,7 @@ export class AgentFramework {
15817
16082
  }
15818
16083
  const agentConfig: AgentConfig = {
15819
16084
  ...inherited,
16085
+ ...readerModelFields(cfg),
15820
16086
  name,
15821
16087
  model,
15822
16088
  systemPrompt: cfg.systemPrompt,
@@ -15924,6 +16190,7 @@ export class AgentFramework {
15924
16190
  config: {
15925
16191
  model: cfg.model,
15926
16192
  proseRouting: 'disabled',
16193
+ ...readerModelFields(cfg),
15927
16194
  },
15928
16195
  inheritRefusals: true,
15929
16196
  onParentEnd: 'end',
@@ -16037,13 +16304,7 @@ export class AgentFramework {
16037
16304
  }
16038
16305
 
16039
16306
  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) {
16307
+ while (this.running) {
16047
16308
  try {
16048
16309
  await this.processNextEvent();
16049
16310
  } catch (error) {
@@ -16331,18 +16592,21 @@ export class AgentFramework {
16331
16592
  // compiled prefix and the live request still match without it
16332
16593
  // (#232 gap 5).
16333
16594
  //
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.
16595
+ // Only a stream that declares it carries injected messages
16596
+ // (supportsInjectedMessages true: membrane's native path) gets
16597
+ // them. Membrane's XML prefill path declares false, and a stream
16598
+ // that doesn't say is taken as one that doesn't, so nothing is
16599
+ // drained for either: everything queued for the agent waits for
16600
+ // the turn-end flush, as a held message does, rather than sitting
16601
+ // in the window between rounds the stream never showed it. A
16602
+ // boundary with no stream, the direct API's, drains: its
16603
+ // re-inference compiles from the store.
16340
16604
  const midTurnInjections: Array<{ participant: string; content: ContentBlock[]; metadata?: MessageMetadata; id?: MessageId }> = [];
16341
16605
  // What this boundary stored for the agent to hear, in store order,
16342
16606
  // for a boundary that leaves it to the turn's next start as
16343
16607
  // arrivals: that start's compile sends it.
16344
16608
  const heard: Array<{ msg: DeferredWrite; id: MessageId; content: ContentBlock[] }> = [];
16345
- if (currentState.supportsInjectedMessages !== false) {
16609
+ if (currentState.supportsInjectedMessages === true || !currentState.stream) {
16346
16610
  // A clock row due (storeDueClock) goes right after the round's
16347
16611
  // results, ahead of what is drained below, and joins the stream
16348
16612
  // the same way. A boundary that passed during the round can't
@@ -16443,12 +16707,10 @@ export class AgentFramework {
16443
16707
 
16444
16708
  // A live stream handed these messages after the results follows
16445
16709
  // 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
16710
+ // [Continue]: that round's rows carry no prompt. A boundary that
16711
+ // ends the turn, restarts the stream or falls back to a new start
16712
+ // below hands them to no live stream, and a new start reads its own
16713
+ // prompt from
16452
16714
  // what it sends. (The routing notice below rides only an injection
16453
16715
  // already here.)
16454
16716
  if (midTurnInjections.length > 0) {
@@ -16683,11 +16945,10 @@ export class AgentFramework {
16683
16945
  }
16684
16946
  // Streaming path: convert results and resume the stream.
16685
16947
  // 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
16948
+ // messages. Membrane's native path appends them after the
16949
+ // tool_result envelope, so the next round of THIS turn hears
16950
+ // them. Its XML prefill path declares it can't carry them, so
16951
+ // none are collected for it. Each goes as its participant and content
16691
16952
  // alone: a stored row's metadata is the host's record, not input
16692
16953
  // for membrane. Each goes with its name and text as the store
16693
16954
  // keeps them, whether it's a heard message, rendered or not, or a
@@ -16718,15 +16979,11 @@ export class AgentFramework {
16718
16979
  // the watermark of the turn's compile and read as unread, so a
16719
16980
  // coalesced replacement could remove a message the agent heard
16720
16981
  // 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.
16982
+ // Only a stream that declares it carries them gets them, and so
16983
+ // is marked read (supportsInjectedMessages true: membrane's native
16984
+ // path).
16985
+ // The shown mark moves at every boundary that injects into its
16986
+ // stream.
16730
16987
  // The mark is the store's head, a scalar, as a compile's is: it
16731
16988
  // also covers what was stored since the compile without being
16732
16989
  // carried, such as another slot's messages, and a write that
@@ -16738,8 +16995,7 @@ export class AgentFramework {
16738
16995
  // replies after it go without their thinking, whatever the mark
16739
16996
  // says; deferring those writers is the fix.
16740
16997
  if (midTurnInjections.length > 0) {
16741
- // Possibly shown, whatever the stream declares: the first-view
16742
- // mark (CoalescerHost.isUnshown) counts a doubt as shown.
16998
+ // Shown: the stream carries them (CoalescerHost.isUnshown).
16743
16999
  agent.markContextShown();
16744
17000
  if (currentState.supportsInjectedMessages === true) agent.markContextConsumed();
16745
17001
  }
@@ -16799,14 +17055,22 @@ export class AgentFramework {
16799
17055
  private async dispatchProcessEventToModules(event: ProcessEvent, startTime = Date.now()): Promise<void> {
16800
17056
  // Dispatch to all modules, tracking responses with module names
16801
17057
  const responses: ModuleProcessResponse[] = [];
16802
- for (const module of this.moduleRegistry.getAllModules()) {
17058
+ const registrations = new Map<ModuleProcessResponse, object | undefined>();
17059
+ const stillRegistered = (entry: ModuleProcessResponse): boolean =>
17060
+ this.moduleRegistry.registrationOf(entry.moduleName) === registrations.get(entry);
17061
+ const dispatched = this.moduleRegistry.getAllModules()
17062
+ .map((module) => ({ module, registration: this.moduleRegistry.registrationOf(module.name) }));
17063
+ for (const { module, registration } of dispatched) {
16803
17064
  // The framework's own toolsets module answers no event; every agent has
16804
17065
  // it now, so it isn't listed beside the modules that do.
16805
17066
  if (module instanceof ToolsetsModule) continue;
17067
+ if (this.moduleRegistry.registrationOf(module.name) !== registration) continue;
16806
17068
  try {
16807
17069
  const processState = this.moduleRegistry.createProcessState(module.name);
16808
17070
  const response = await module.onProcess(event, processState);
16809
- responses.push({ moduleName: module.name, response });
17071
+ const entry = { moduleName: module.name, response };
17072
+ responses.push(entry);
17073
+ registrations.set(entry, registration);
16810
17074
  } catch (error) {
16811
17075
  console.error(`Module ${module.name} error handling process event:`, error);
16812
17076
  }
@@ -16832,7 +17096,17 @@ export class AgentFramework {
16832
17096
  // responses, a tool call's dispatch and the trace go on, since a module
16833
17097
  // can't catch a throw on this path, and one module's mustn't cost the
16834
17098
  // event.
16835
- const applying = responses.filter(({ moduleName, response }) => {
17099
+ const dropUnregistered = (entry: ModuleProcessResponse): boolean => {
17100
+ if (stillRegistered(entry)) return false;
17101
+ const parts = responseParts(entry.response);
17102
+ if (parts) {
17103
+ console.error(
17104
+ `[process] module ${entry.moduleName} stopped before its response to ${event.type} was applied, so none of it was: ${parts}`,
17105
+ );
17106
+ }
17107
+ return true;
17108
+ };
17109
+ const applying = responses.filter((entry) => !dropUnregistered(entry)).filter(({ moduleName, response }) => {
16836
17110
  const refusal = malformedResponseContent(response);
16837
17111
  if (refusal === null) return true;
16838
17112
  const { where, places } = refusal;
@@ -16855,7 +17129,7 @@ export class AgentFramework {
16855
17129
  const { moduleName, response } = entry;
16856
17130
  const step = { at: 'its start' };
16857
17131
  try {
16858
- await this.applyProcessResponse(response, event, moduleName, surface, step);
17132
+ this.applyProcessResponse(response, event, moduleName, surface, step);
16859
17133
  } catch (error) {
16860
17134
  entry.threw = { at: step.at, error: error instanceof Error ? error.message : String(error) };
16861
17135
  console.error(
@@ -16883,7 +17157,7 @@ export class AgentFramework {
16883
17157
  }
16884
17158
  }
16885
17159
 
16886
- private async applyProcessResponse(
17160
+ private applyProcessResponse(
16887
17161
  response: EventResponse,
16888
17162
  event: ProcessEvent,
16889
17163
  moduleName: string,
@@ -16891,7 +17165,7 @@ export class AgentFramework {
16891
17165
  surface: InboundSource | undefined,
16892
17166
  /** Names the operation under way, for the line when one throws. */
16893
17167
  step: { at: string },
16894
- ): Promise<void> {
17168
+ ): void {
16895
17169
  // Add messages. Only the framework writes the inbound envelope: one a
16896
17170
  // module's metadata carries (echoed from a server's event, say) is
16897
17171
  // dropped, and a surface event's messages carry the framework's own.
@@ -17695,6 +17969,9 @@ export class AgentFramework {
17695
17969
  } else {
17696
17970
  // Nothing live under the name: only what the process keeps by name.
17697
17971
  this.closingConversationAgents.delete(agentName);
17972
+ this.deadlineCuts.delete(agentName);
17973
+ this.freshCallsCut.delete(agentName);
17974
+ this.forgetFailureAccounting(agentName);
17698
17975
  this.thinkingLossIncidents.forget(agentName);
17699
17976
  this.reasoningLoss.forget(agentName);
17700
17977
  // A continuation of its turn still on its way goes with it.
@@ -18728,25 +19005,45 @@ export class AgentFramework {
18728
19005
  }
18729
19006
  }
18730
19007
 
18731
- /** Withdraw unstarted wakes whose sole cause is this subject (§4.2). */
19008
+ /** Withdraw unstarted wakes whose sole cause is this subject (§4.2), and
19009
+ * a merged start whose every request is withdrawn. */
18732
19010
  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
19011
+ // A wake of the subject's merged into a start
18738
19012
  // (MERGED_BATCH) is withdrawn from the start's batch, so no stop puts
18739
19013
  // it back and no pass unpacks it.
19014
+ const emptied = new Set<InferenceRequest>();
18740
19015
  const unrecord = (r: InferenceRequest): void => {
18741
19016
  const batch = mergedBatch(r);
18742
19017
  if (batch?.some((w) => w.coalescingSubject === subject)) {
18743
- (r as MarkedRequest)[MERGED_BATCH] = batch.filter((w) => w.coalescingSubject !== subject);
19018
+ const left = batch.filter((w) => w.coalescingSubject !== subject);
19019
+ (r as MarkedRequest)[MERGED_BATCH] = left;
19020
+ if (left.length === 0) emptied.add(r);
18744
19021
  }
18745
19022
  };
18746
19023
  for (const r of this.pendingRequests) unrecord(r);
18747
19024
  for (const cooldown of this.providerAccelerationCooldowns.values()) cooldown.heldRequests.forEach(unrecord);
18748
19025
  for (const r of this.parkedContinuations ?? []) unrecord(r);
18749
19026
  for (const pass of this.passRequests ?? []) for (const requests of pass.values()) requests.forEach(unrecord);
19027
+ const withdrawn = (r: InferenceRequest): boolean => r.coalescingSubject === subject || emptied.has(r);
19028
+ this.pendingRequests = this.pendingRequests.filter((r) => !withdrawn(r));
19029
+ for (const cooldown of this.providerAccelerationCooldowns.values()) {
19030
+ const held = cooldown.heldRequests.length;
19031
+ cooldown.heldRequests = cooldown.heldRequests.filter((r) => !withdrawn(r));
19032
+ if (held > 0 && cooldown.heldRequests.length === 0) cooldown.emptiedBy = 'withdrawal';
19033
+ }
19034
+ for (const pass of this.passRequests ?? []) {
19035
+ for (const requests of pass.values()) {
19036
+ if (!requests.some((r) => emptied.has(r))) continue;
19037
+ const left = requests.filter((r) => !emptied.has(r));
19038
+ requests.length = 0;
19039
+ requests.push(...left);
19040
+ }
19041
+ }
19042
+ for (const start of emptied) {
19043
+ if (!start.coalescingBatch || start.coalescingSubject !== undefined) continue;
19044
+ console.error(`[coalescing] ${start.agentName}: its deferred batches were withdrawn while its wake waited; turn not started (no wake cause left)`);
19045
+ this.emitTrace({ type: 'mcpl:coalescing', kind: 'turn-withdrawn', agentName: start.agentName, subjects: start.coalescingBatchSubjects ?? [] });
19046
+ }
18750
19047
  }
18751
19048
 
18752
19049
  /**
@@ -19261,7 +19558,7 @@ export class AgentFramework {
19261
19558
  if (recovery) recovery.releasedAt = now;
19262
19559
  requests = [...providerCooldown.heldRequests, ...requests];
19263
19560
  }
19264
- // A start that carries a turn on and came back before it took its
19561
+ // A start that came back before it took its
19265
19562
  // token stands for the batch it was merged from (MERGED_BATCH),
19266
19563
  // including one a hold released just now. The batch takes its place,
19267
19564
  // each request as it was, judged one by one from here: a wait holds
@@ -19662,10 +19959,10 @@ export class AgentFramework {
19662
19959
  else delete start[TURN_CARRY];
19663
19960
  if (requests.every(isRearmed)) start[REARMED] = true;
19664
19961
  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];
19962
+ // A start merged from more than one request, any of them not the
19963
+ // turn's, records the batch it was merged from (MERGED_BATCH): until
19964
+ // it takes its token it stands for them all.
19965
+ if (requests.length > 1 && requests.some((r) => !continuesTurn(r))) start[MERGED_BATCH] = [...requests];
19669
19966
  else delete start[MERGED_BATCH];
19670
19967
  await this.startAgentStream(agent, start);
19671
19968
  }
@@ -20345,8 +20642,6 @@ export class AgentFramework {
20345
20642
  const refuse = (error: string): ToolResult => ({ success: false, error, isError: true });
20346
20643
  // Plain text, not a JSON-quoted string: draft text must read exactly as held.
20347
20644
  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
20645
  const action = input.action;
20351
20646
  if (action !== 'list' && action !== 'read' && action !== 'resend' && action !== 'dismiss') {
20352
20647
  return refuse('action must be one of "list", "read", "resend" or "dismiss".');
@@ -20355,7 +20650,7 @@ export class AgentFramework {
20355
20650
  refuse(`${field} is not used by action "${action}" — pass null or omit it.`);
20356
20651
 
20357
20652
  let ids: string[] = [];
20358
- if (present(input.draftIds)) {
20653
+ if (fieldInUse(input.draftIds)) {
20359
20654
  if (action === 'list') return unused('draftIds');
20360
20655
  if (!Array.isArray(input.draftIds) || input.draftIds.some((id) => typeof id !== 'string' || !id.trim())) {
20361
20656
  return refuse('draftIds must be a list of draft ids such as "d-k7x3qm2", or null.');
@@ -20364,15 +20659,19 @@ export class AgentFramework {
20364
20659
  if (new Set(ids).size !== ids.length) return refuse('draftIds names a draft more than once.');
20365
20660
  if (ids.length > 20) return refuse('draftIds names more than 20 drafts; act on them in smaller groups.');
20366
20661
  }
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');
20662
+ if (fieldInUse(input.destination) && action !== 'resend') return unused('destination');
20663
+ // false is "don't confirm", which every other action already does, so only
20664
+ // true is a confirmDuplicate those actions refuse.
20665
+ if (fieldInUse(input.confirmDuplicate) && input.confirmDuplicate !== false && action !== 'resend') {
20666
+ return unused('confirmDuplicate');
20667
+ }
20668
+ if (fieldInUse(input.offset) && action !== 'list') return unused('offset');
20370
20669
 
20371
20670
  if (action === 'list') {
20372
20671
  let offset = 0;
20373
- if (present(input.offset)) {
20672
+ if (fieldInUse(input.offset)) {
20374
20673
  const raw = input.offset;
20375
- const n = typeof raw === 'number' ? raw : typeof raw === 'string' && /^\d+$/.test(raw.trim()) ? Number(raw) : NaN;
20674
+ const n = integerField(raw);
20376
20675
  if (!Number.isInteger(n) || n < 0) return refuse('offset must be a whole number of drafts to skip (0 or more), or null.');
20377
20676
  offset = n;
20378
20677
  }
@@ -20489,12 +20788,12 @@ export class AgentFramework {
20489
20788
  if (!registry) return refuse('No channels are configured, so there is nowhere to resend to.');
20490
20789
  const home = this.homeOf(agentName);
20491
20790
  let spec = typeof input.destination === 'string' ? input.destination.trim() : '';
20492
- if (present(input.destination) && typeof input.destination !== 'string') {
20791
+ if (fieldInUse(input.destination) && typeof input.destination !== 'string') {
20493
20792
  return refuse('destination must be "#channel", "@person" or a channel id.');
20494
20793
  }
20495
20794
  if (!spec && !home) return refuse('resend needs a destination: "#channel", "@person" or a channel id.');
20496
20795
  let confirmDuplicate = false;
20497
- if (present(input.confirmDuplicate)) {
20796
+ if (fieldInUse(input.confirmDuplicate)) {
20498
20797
  if (typeof input.confirmDuplicate !== 'boolean') return refuse('confirmDuplicate must be true, false or null.');
20499
20798
  confirmDuplicate = input.confirmDuplicate;
20500
20799
  }
@@ -22923,6 +23222,7 @@ export class AgentFramework {
22923
23222
  attempt = 0,
22924
23223
  providerGateAlreadyHeld = false,
22925
23224
  ): Promise<void> {
23225
+ if (attempt === 0) this.deadlineCuts.delete(agent.name);
22926
23226
  const ownsProviderGate = this.registry.ownsProviderScheduling(agent.name);
22927
23227
  // A stopping framework starts no turn, whoever owns provider admission
22928
23228
  // (conversation agents and ephemeral runs do not take the gate).
@@ -22989,6 +23289,15 @@ export class AgentFramework {
22989
23289
  console.error(`[provider-admission] ${agent.name}: host quiesced while parked — the merged start goes back as its ${batch.length} requests`);
22990
23290
  return;
22991
23291
  }
23292
+ if (this.providerAccelerationCooldowns.has(agent.name)) {
23293
+ this.releasePrimaryProviderGate(agent.name);
23294
+ this.eventGate?.onInferenceEnded(agent.name);
23295
+ this.pendingRequests.push(trigger ?? {
23296
+ agentName: agent.name, reason: 'provider-admission:requeue', source: 'scheduler', timestamp: Date.now(),
23297
+ });
23298
+ console.error(`[provider-admission] ${agent.name}: a provider hold stands — parked wake requeued for the scheduler, not started`);
23299
+ return;
23300
+ }
22992
23301
  await this.startAgentStream(agent, trigger, attempt, true);
22993
23302
  }).catch((error) => {
22994
23303
  this.releasePrimaryProviderGate(agent.name);
@@ -23147,7 +23456,7 @@ export class AgentFramework {
23147
23456
  if (hold) {
23148
23457
  const held = hold.heldRequests.length;
23149
23458
  hold.heldRequests = take(hold.heldRequests);
23150
- if (held > 0 && hold.heldRequests.length === 0) hold.emptiedByDrop = true;
23459
+ if (held > 0 && hold.heldRequests.length === 0) hold.emptiedBy = 'drop';
23151
23460
  }
23152
23461
  for (const parked of [...this.parkedContinuations]) {
23153
23462
  if (!isContinuation(parked)) continue;
@@ -23260,9 +23569,8 @@ export class AgentFramework {
23260
23569
  * gets inference:aborted with `reason`, and lastInference records its end
23261
23570
  * as driveStream's would, when the record is still this turn's. Nothing of
23262
23571
  * 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.
23572
+ * begun by a replacement agent object): the event and the record carry only
23573
+ * the name, so this frame's would end that newer turn instead.
23266
23574
  */
23267
23575
  private abortDroppedTurn(agentName: string, turnToken: number, reason: string, startedAt: number): void {
23268
23576
  const owner = this.activeTurnTokens.get(agentName);
@@ -23451,7 +23759,6 @@ export class AgentFramework {
23451
23759
  * operator's abandon. */
23452
23760
  checkWindow: CheckWindow,
23453
23761
  ): Promise<boolean> {
23454
- const lifecycle = this.lifecycleGeneration;
23455
23762
  const resumesTurn = resumesLogicalTurn(trigger?.reason);
23456
23763
  let carry = turnCarryOf(trigger);
23457
23764
  const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting;
@@ -23639,7 +23946,7 @@ export class AgentFramework {
23639
23946
  // everything the turn has decided about where its speech goes.
23640
23947
  // - A resume (a budget restart, a tool-result-guard retry) carries its
23641
23948
  // 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.
23949
+ // turn stays, or becomes again, the agent's open turn.
23643
23950
  // - A silent start routes no prose. It gets an object of its own, with
23644
23951
  // no locus, so it neither takes nor disturbs an open turn's. Its
23645
23952
  // object is the agent's live routing while it runs, and the open
@@ -23664,8 +23971,6 @@ export class AgentFramework {
23664
23971
  let opensTurn = false;
23665
23972
  if (resumesTurn) {
23666
23973
  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
23974
  if (!trigger?.suppressProse && !this.openLogical.has(agent.name)) this.openLogical.set(agent.name, routing);
23670
23975
  } else if (trigger?.suppressProse) {
23671
23976
  const continued = continuesTurn(trigger) ? this.turnRouting.get(agent.name) : undefined;
@@ -23687,6 +23992,8 @@ export class AgentFramework {
23687
23992
  if (opensTurn) {
23688
23993
  const head = agent.branchHead();
23689
23994
  if (head) routing.start = head;
23995
+ if (!routing.silent) this.pinTurnInputs(agent, routing);
23996
+ else agent.unpinTurnInputs();
23690
23997
  }
23691
23998
  this.turnRouting.set(agent.name, routing);
23692
23999
  if (opensTurn) carry = undefined;
@@ -23821,11 +24128,11 @@ export class AgentFramework {
23821
24128
  });
23822
24129
  // A budget restart continues the same logical inference window. The
23823
24130
  // 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
24131
+ // and the successor takes it again if it ended in between: at a
23826
24132
  // 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.
24133
+ // its last admission check, a hold standing when its park behind an
24134
+ // auxiliary call ended, or a cooldown after its own call failed), whose
24135
+ // end queues it again.
23829
24136
  if (!resumesTurn) {
23830
24137
  this.eventGate?.onInferenceStarted(agent.name);
23831
24138
  } else {
@@ -23913,13 +24220,11 @@ export class AgentFramework {
23913
24220
  // An ephemeral watchdog may dispose this Agent while hooks/compile await,
23914
24221
  // and stop() may close provider admission. Do not create a provider
23915
24222
  // 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) {
24223
+ // as after the compile: these come before a stop kept for the turn,
24224
+ // which goes with it.
24225
+ if (this.agents.get(agent.name) !== agent || this.providerAdmissionClosed) {
23920
24226
  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';
24227
+ : 'the framework was stopping';
23923
24228
  const reason = `a turn assembled while ${why} is dropped before it sends anything`;
23924
24229
  console.error(`[turn] ${agent.name}: ${reason}`);
23925
24230
  this.endDroppedTurn(agent.name, routing);
@@ -23973,23 +24278,22 @@ export class AgentFramework {
23973
24278
  // This turn's retries are the framework's (errorPolicy below), not
23974
24279
  // membrane's inside each attempt.
23975
24280
  membraneRetries: false,
24281
+ inferenceBounds: this.inferenceBounds,
23976
24282
  });
23977
24283
  // Each later round this stream sends is noted as it goes (noteRoundSent).
23978
24284
  if (noteRequestSent) this.streamRequestNotes.set(stream, { note: noteRequestSent, rounds: [], stopped: false });
23979
24285
  if (carriedBodies) this.streamCarried.set(stream, new Set(carriedBodies));
23980
24286
  // 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.
24287
+ // deadline), so check ownership and admission again.
23983
24288
  // Membrane's yielding stream starts its request when it is first
23984
24289
  // iterated, so cancelling it here, before driveStream, sends nothing.
23985
24290
  // A turn that was mid-compile when stop() cancelled every stream would
23986
24291
  // otherwise register after that pass and outlive stop(). The caller's
23987
24292
  // finally releases the turn token.
23988
24293
  const stopping = this.providerAdmissionClosed;
23989
- if (this.agents.get(agent.name) !== agent || stopping || lifecycle !== this.lifecycleGeneration) {
24294
+ if (this.agents.get(agent.name) !== agent || stopping) {
23990
24295
  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';
24296
+ : 'the framework was stopping';
23993
24297
  const reason = `a turn compiled while ${why} is dropped before it sends anything`;
23994
24298
  console.error(`[turn] ${agent.name}: ${reason}`);
23995
24299
  this.cancelUnsentStream(agent, stream, `compiled while ${why}`);
@@ -24120,14 +24424,12 @@ export class AgentFramework {
24120
24424
  // catch never holds a compiled request.) As at the checks after the
24121
24425
  // hooks and after the compile, a replaced agent or a stopping framework
24122
24426
  // 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.
24427
+ // it. A failed start with no stop goes on below as before.
24125
24428
  const stopAtFailure = this.takeStartStop(agent, checkWindow);
24126
24429
  if (stopAtFailure) {
24127
- if (this.agents.get(agent.name) !== agent || this.providerAdmissionClosed || lifecycle !== this.lifecycleGeneration) {
24430
+ if (this.agents.get(agent.name) !== agent || this.providerAdmissionClosed) {
24128
24431
  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';
24432
+ : 'the framework was stopping';
24131
24433
  const reason = `a turn whose request couldn't be built while ${why} is dropped before it sends anything`;
24132
24434
  console.error(`[turn] ${agent.name}: ${reason}`);
24133
24435
  this.endDroppedTurn(agent.name, routing);
@@ -24140,6 +24442,7 @@ export class AgentFramework {
24140
24442
  }
24141
24443
 
24142
24444
  const retry = trigger && withTurnCarry(trigger, carry);
24445
+ const asTransient = this.takeAsTransient(agent.name, err, trigger);
24143
24446
  if (ownsProviderGate && this.holdProviderAcceleration(agent, err, retry)) {
24144
24447
  // Capacity scheduling is not poisoned history. The cooldown timer owns
24145
24448
  // one later fresh compile; no immediate retry or exhaustion marker.
@@ -24156,24 +24459,25 @@ export class AgentFramework {
24156
24459
  return false;
24157
24460
  }
24158
24461
 
24159
- const action: ErrorAction = statedWait === 'fail' || (isRearmed(trigger) && !gotPartway(routing))
24160
- ? { retry: false } : this.errorPolicy.onInferenceError(err, agent.name, attempt);
24462
+ const cuts = isDeadlineExpiry(err) ? this.deadlineCuts.get(agent.name) ?? 0 : undefined;
24463
+ const action: ErrorAction = statedWait === 'fail' || (isRearmed(trigger) && (!gotPartway(routing) || (isDeadlineExpiry(err) && !asTransient)))
24464
+ ? { retry: false } : this.errorPolicy.onInferenceError(err, agent.name, cuts ?? attempt);
24465
+ if (action.retry && cuts !== undefined) this.deadlineCuts.set(agent.name, cuts + 1);
24466
+ else this.deadlineCuts.delete(agent.name);
24467
+ if (!action.retry && cuts !== undefined && cuts > 0) this.freshCallsCut.add(agent.name);
24161
24468
  if (action.retry) {
24162
24469
  // A policy's delay can be a provider's retry-after: wait all of it.
24163
24470
  await this.waitForRetry(action.delayMs, checkWindow, agent);
24164
- if (this.providerAdmissionClosed || lifecycle !== this.lifecycleGeneration) {
24471
+ if (this.providerAdmissionClosed) {
24165
24472
  // 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
24473
+ // it, and a stop kept for the turn goes with it. The turn ends as
24474
+ // one dropped before it sends anything does: a terminal event
24475
+ // saying why, after the inference:failed above, and its record's
24476
+ // end. Nothing is written
24172
24477
  // but the [speech-not-posted] notice; this frame still owns its
24173
24478
  // token, since no retry frame replaced it.
24174
24479
  this.nameUnpostedSpeech(agent, routing);
24175
- this.abortDroppedTurn(agent.name, turnToken,
24176
- this.providerAdmissionClosed ? RETRY_CANCELLED_BY_STOP : RETRY_ACROSS_RESTART, turnStartedAt);
24480
+ this.abortDroppedTurn(agent.name, turnToken, RETRY_CANCELLED_BY_STOP, turnStartedAt);
24177
24481
  if (this.activeTurnTokens.get(agent.name) === turnToken) {
24178
24482
  this.activeTurnTokens.delete(agent.name);
24179
24483
  this.activeTurnTriggers.delete(agent.name);
@@ -24217,7 +24521,7 @@ export class AgentFramework {
24217
24521
  speech: '',
24218
24522
  ...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
24219
24523
  }, routing);
24220
- if (isRearmed(trigger) && isTransientFailure(err)) this.exhaustingRearms.add(agent.name);
24524
+ if (isRearmed(trigger) && asTransient) this.exhaustingRearms.add(agent.name);
24221
24525
  this.emitTrace({
24222
24526
  type: 'inference:exhausted',
24223
24527
  agentName: agent.name,
@@ -24237,7 +24541,7 @@ export class AgentFramework {
24237
24541
  if (action.emit) {
24238
24542
  this.pushEvent(action.emit);
24239
24543
  }
24240
- this.rearmExhaustedTurn(agent, err, trigger, ownsProviderGate, routing);
24544
+ this.rearmExhaustedTurn(agent, err, trigger, ownsProviderGate, routing, asTransient);
24241
24545
  }
24242
24546
  // No handoff on either failure branch: the terminal branch deleted the
24243
24547
  // token above; the retry branch's recursive startAgentStream replaced
@@ -24869,7 +25173,7 @@ export class AgentFramework {
24869
25173
  (b) => b.type !== 'tool_result'
24870
25174
  );
24871
25175
  } else {
24872
- // Fallback (older membrane / XML tool mode): preamble + calls.
25176
+ // XML tool mode: this round's text and its calls.
24873
25177
  // XML mode: persist the verbatim generation on each tool_use
24874
25178
  // (membrane#36 round-trip fidelity). context.rawText is the
24875
25179
  // parser's fullMatch — the exact <function_calls> block the
@@ -24881,15 +25185,12 @@ export class AgentFramework {
24881
25185
  const rawXml = rawText && /^<(antml:)?function_calls>/.test(rawText)
24882
25186
  ? rawText
24883
25187
  : 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;
25188
+ // Persist THIS round's text only: `roundPreamble` is the
25189
+ // per-round delta. The `preamble` beside it is CUMULATIVE in XML
25190
+ // mode: persisting it per round would store each round's text N
25191
+ // times and re-persist injected <function_results> as the
25192
+ // agent's own words.
25193
+ const roundText = (event.context as { roundPreamble?: string }).roundPreamble;
24893
25194
  assistantBlocks = [];
24894
25195
  if (roundText) {
24895
25196
  assistantBlocks.push({ type: 'text', text: roundText });
@@ -24914,9 +25215,7 @@ export class AgentFramework {
24914
25215
 
24915
25216
  // Whether this stream can carry messages injected with the round's
24916
25217
  // 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.
25218
+ // prefill path. The tool boundary reads it from the agent's state.
24920
25219
  agent.enterWaitingForTools(
24921
25220
  calls,
24922
25221
  stream,
@@ -24958,13 +25257,11 @@ export class AgentFramework {
24958
25257
  if (liveRound) liveProseRouting = true;
24959
25258
  // Whether this round's tool-result batch (and anything queued
24960
25259
  // 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
25260
+ // again: the stream declares it (true on membrane's native path).
25261
+ // On the XML path, which declares false, a 'now' notice reaches
24965
25262
  // the agent at its next compile instead.
24966
25263
  const presentsInjections =
24967
- (event.context as { supportsInjectedMessages?: boolean }).supportsInjectedMessages ?? true;
25264
+ (event.context as { supportsInjectedMessages?: boolean }).supportsInjectedMessages === true;
24968
25265
  // Queued under this turn's routing, whenever it posts, and
24969
25266
  // streamed by this stream once published.
24970
25267
  // This round's request was answered: what it carried is heard.
@@ -25742,14 +26039,14 @@ export class AgentFramework {
25742
26039
  // active (native roundContent), each mid-turn round's prose was
25743
26040
  // already delivered when that round yielded — only the TRAILING
25744
26041
  // 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:
26042
+ // XML tool path (no roundContent, so nothing was live-routed)
26043
+ // the historical behavior applies:
25747
26044
  // route the whole turn's parsed segments, once, now. The global
25748
26045
  // speech/thoughts split is left untouched (module/TUI rendering
25749
26046
  // unaffected) — this only governs what reaches the channel.
25750
26047
  //
25751
26048
  // Native round routing uses the live sticky flag, which a fresh
25752
- // injected message can reset. The legacy/fallback path has no
26049
+ // injected message can reset. The XML tool path has no
25753
26050
  // reliable round boundaries, so retain its historical turn-wide
25754
26051
  // scan to avoid double-posting.
25755
26052
  const toolCalls = response.content
@@ -25985,6 +26282,7 @@ export class AgentFramework {
25985
26282
 
25986
26283
  this.abandonAgentScripts(agent.name, 'stream error', streamRouting);
25987
26284
  const retry = retryOf(trigger);
26285
+ const asTransient = this.takeAsTransient(agent.name, err, trigger);
25988
26286
 
25989
26287
  // A hold takes the turn's retry, or, once stop() has closed
25990
26288
  // admission, the turn ends here (endFailureAtStop).
@@ -26000,8 +26298,12 @@ export class AgentFramework {
26000
26298
  break;
26001
26299
  }
26002
26300
 
26003
- const action: ErrorAction = statedWait === 'fail' || (isRearmed(trigger) && !gotPartway(streamRouting))
26004
- ? { retry: false } : this.errorPolicy.onInferenceError(err, agent.name, attempt);
26301
+ const cuts = isDeadlineExpiry(err) ? this.deadlineCuts.get(agent.name) ?? 0 : undefined;
26302
+ const action: ErrorAction = statedWait === 'fail' || (isRearmed(trigger) && (!gotPartway(streamRouting) || (isDeadlineExpiry(err) && !asTransient)))
26303
+ ? { retry: false } : this.errorPolicy.onInferenceError(err, agent.name, cuts ?? attempt);
26304
+ if (action.retry && cuts !== undefined) this.deadlineCuts.set(agent.name, cuts + 1);
26305
+ else this.deadlineCuts.delete(agent.name);
26306
+ if (!action.retry && cuts !== undefined && cuts > 0) this.freshCallsCut.add(agent.name);
26005
26307
  if (action.retry) {
26006
26308
  // A stop made during the wait reaches no stream: the check after
26007
26309
  // the wait ends the turn, as the guard's check after its wait
@@ -26058,7 +26360,7 @@ export class AgentFramework {
26058
26360
  speech: '',
26059
26361
  ...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
26060
26362
  }, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting : undefined, streamRouting);
26061
- if (isRearmed(trigger) && isTransientFailure(err)) this.exhaustingRearms.add(agent.name);
26363
+ if (isRearmed(trigger) && asTransient) this.exhaustingRearms.add(agent.name);
26062
26364
  this.emitTrace({
26063
26365
  type: 'inference:exhausted',
26064
26366
  agentName: agent.name,
@@ -26076,7 +26378,7 @@ export class AgentFramework {
26076
26378
  if (action.emit) {
26077
26379
  this.pushEvent(action.emit);
26078
26380
  }
26079
- this.rearmExhaustedTurn(agent, err, trigger, ownsProviderGate, streamRouting);
26381
+ this.rearmExhaustedTurn(agent, err, trigger, ownsProviderGate, streamRouting, asTransient);
26080
26382
  }
26081
26383
  break;
26082
26384
  }
@@ -26151,7 +26453,7 @@ export class AgentFramework {
26151
26453
  agentName: agent.name,
26152
26454
  error: terminal,
26153
26455
  });
26154
- this.rearmExhaustedTurn(agent, new Error(terminal), trigger, ownsProviderGate, streamRouting);
26456
+ this.rearmExhaustedTurn(agent, new Error(terminal), trigger, ownsProviderGate, streamRouting, false);
26155
26457
  // Postmortem 2026-05-28 P2 #7: persist the abort to the
26156
26458
  // inference log so future investigations can attribute the
26157
26459
  // terminal cause without relying on live in-memory reducer
@@ -26169,6 +26471,8 @@ export class AgentFramework {
26169
26471
  }
26170
26472
 
26171
26473
  case 'usage': {
26474
+ this.rearmDeadlineCuts.delete(agent.name);
26475
+ this.deadlineCuts.delete(agent.name);
26172
26476
  // Cumulative across the stream's rounds: replace, never sum.
26173
26477
  latestUsage = event.usage;
26174
26478
  // The stream's accumulated fresh input, which is what the
@@ -26411,6 +26715,7 @@ export class AgentFramework {
26411
26715
  agent.reset();
26412
26716
  this.emitTrace({ type: 'inference:failed', agentName: agent.name, error: err.message, stack: err.stack });
26413
26717
  const retry = retryOf(trigger);
26718
+ const asTransient = this.takeAsTransient(agent.name, err, trigger);
26414
26719
  if (ownsProviderGate && this.holdProviderAcceleration(agent, err, retry)) {
26415
26720
  // Once stop() has closed admission, nothing is held, and the turn
26416
26721
  // ends here (endFailureAtStop).
@@ -26442,14 +26747,14 @@ export class AgentFramework {
26442
26747
  speech: '',
26443
26748
  ...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
26444
26749
  }, streamRouting);
26445
- if (isRearmed(trigger) && isTransientFailure(err)) this.exhaustingRearms.add(agent.name);
26750
+ if (isRearmed(trigger) && asTransient) this.exhaustingRearms.add(agent.name);
26446
26751
  this.emitTrace({
26447
26752
  type: 'inference:exhausted',
26448
26753
  agentName: agent.name,
26449
26754
  error: err.message,
26450
26755
  ...this.classifyInferenceError(err),
26451
26756
  });
26452
- this.rearmExhaustedTurn(agent, err, trigger, ownsProviderGate, streamRouting);
26757
+ this.rearmExhaustedTurn(agent, err, trigger, ownsProviderGate, streamRouting, asTransient);
26453
26758
  // Postmortem 2026-05-28 P2 #7: catch-path failures (stream itself
26454
26759
  // threw) were previously only visible via in-memory trace listeners.
26455
26760
  // Persist to inference log for forensic attribution.
@@ -26660,7 +26965,11 @@ export class AgentFramework {
26660
26965
 
26661
26966
  /**
26662
26967
  * Execute a tool call and return the result.
26663
- * Routes to the appropriate handler (module registry or MCPL).
26968
+ * Routes it where the agent's own call of the name goes: to the
26969
+ * framework's own tools the agent has (agent_settings, the workspace and
26970
+ * gate tools, think, journal, prose_help and the rest), the channel tools,
26971
+ * MCPL or the module registry. skip_reply, which ends the turn it's called
26972
+ * in, is refused, since this call isn't made in one.
26664
26973
  * Its one caller in the framework is the operator's puppet
26665
26974
  * (puppetToolCall), which puts the agent's inline cap on the call as
26666
26975
  * resultMaxChars; a host may call it as well.
@@ -26707,8 +27016,11 @@ export class AgentFramework {
26707
27016
  * wire ordering. For its own duration the puppet HOLDS the turn-alive
26708
27017
  * marker (wakes requeue, cross-turn writers defer), so a wake landing
26709
27018
  * 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.
27019
+ * - Refused for tools outside the agent's own surface (the tools it may
27020
+ * call, availableToolsForPresentation), and for skip_reply, by name or
27021
+ * through a toolsets--act that reaches it, which ends the turn it's
27022
+ * called in: the stored pair must be an act the agent could genuinely
27023
+ * have taken.
26712
27024
  * - The call executes FOR REAL through the shared dispatch (MCPL,
26713
27025
  * channel tools, utils, modules) with the agent's provenance — a
26714
27026
  * puppeted walk_to actually moves the avatar.
@@ -26760,14 +27072,21 @@ export class AgentFramework {
26760
27072
  `injecting a turn under an active stream corrupts wire ordering)`,
26761
27073
  );
26762
27074
  }
26763
- const onSurface = this.getToolsForAgent(agentName)
26764
- .some((t) => t.name === toolName && agent.canUseTool(t.name));
27075
+ const onSurface = this.availableToolsForPresentation(agentName).some((t) => t.name === toolName);
26765
27076
  if (!onSurface) {
26766
27077
  throw new Error(
26767
27078
  `puppet refused: tool ${toolName} is not on ${agentName}'s surface — ` +
26768
27079
  `the stored pair must be an act the agent could genuinely have taken`,
26769
27080
  );
26770
27081
  }
27082
+ const route = this.toolRoute({ id: '', name: toolName, input }, agentName);
27083
+ const acting = route.kind === 'host-act' && route.host.kind === 'call' ? route.host.name : toolName;
27084
+ if (acting === 'skip_reply') {
27085
+ throw new Error(
27086
+ "puppet refused: skip_reply ends the turn it's called in, and a puppeted call isn't made in one, " +
27087
+ 'so nothing was run or stored',
27088
+ );
27089
+ }
26771
27090
 
26772
27091
  // Anthropic-shaped id so the stored pair is indistinguishable from a
26773
27092
  // provider-issued call.
@@ -26906,96 +27225,123 @@ export class AgentFramework {
26906
27225
  return { success: false, error: marked, isError: true };
26907
27226
  }
26908
27227
 
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);
27228
+ const subject = origin.kind === 'agent' ? origin.agentName : call.callerAgentName ?? '__ephemeral__';
27229
+ const route = this.toolRoute(call, call.callerAgentName);
27230
+ switch (route.kind) {
27231
+ case 'presentation':
27232
+ return this.editToolPresentation(call.callerAgentName ?? '__ephemeral__', call);
27233
+
27234
+ // toolsets--act on a host toolset's tool (the built-in one, a server's
27235
+ // tab) goes where a programmatic call of that tool goes, under its name.
27236
+ case 'host-act':
27237
+ if (route.host.kind === 'refused') return failed({ success: false, error: route.host.error, isError: true });
27238
+ return this.executeToolCallFrom({ ...call, name: route.host.name, input: route.host.args }, origin);
27239
+
27240
+ // RFC-014: toolsets--act, resolved to the dynamic tool it calls, goes
27241
+ // where a programmatic call of that tool goes, with its instance.
27242
+ case 'toolset-act': {
27243
+ const target = this.resolveToolsetAct(call);
27244
+ if (!target.ok) return failed({ success: false, error: target.error, isError: true }, this.everyServerRedactor()(target.error));
27245
+ if (origin.kind === 'agent') {
27246
+ const failure = this.agentToolPermissionFailure(origin.agentName, target.call, false);
27247
+ if (failure) return failed(failure, this.everyServerRedactor()(failure.error ?? ''));
27248
+ }
27249
+ return this.executeMcplToolCall(target.call, target.serverId, target.prefix);
27250
+ }
27251
+
27252
+ case 'mcpl':
27253
+ return this.executeMcplToolCall(call, route.serverId, route.prefix);
27254
+
27255
+ // Synthesized channel tools. This route was missing: when
27256
+ // subscription-GC moved off the retired MCPL unsubscribe tool onto the
27257
+ // generic channel_close (discord-mcpl b095a9f), module-originated
27258
+ // closes started failing downstream as "Invalid tool name format" —
27259
+ // the janitor had been silently broken since. `origin` is trusted
27260
+ // dispatch context established at the call boundary above, not
27261
+ // inferred from which method was used: only the ModuleRegistry closure
27262
+ // carries module provenance (see handleToolClose).
27263
+ case 'channel': {
27264
+ const turn = call.name === 'channel_publish' && call.callerAgentName ? this.callingTurnOf(call.callerAgentName, call) : undefined;
27265
+ const input = call.name === 'channel_publish' && call.callerAgentName
27266
+ ? this.publishInputWithTurnLocus(call.input, call.callerAgentName, turn)
27267
+ : call.input;
27268
+ return this.channelRegistry!.handleChannelToolCall(call.name, input, origin.kind === 'agent' && turn ? { ...origin, inTurn: true } : origin);
27269
+ }
27270
+
27271
+ case 'agent-settings':
27272
+ return this.awaitedToolAnswer((deliver) => this.dispatchAgentSettingsToolCall(subject, call, deliver));
27273
+ case 'save-image':
27274
+ return this.awaitedToolAnswer((deliver) => this.dispatchSaveImageToolCall(subject, call, deliver));
27275
+ case 'read-image':
27276
+ return this.awaitedToolAnswer((deliver) => this.dispatchReadImageToolCall(subject, call, deliver));
27277
+ case 'fetch-reference':
27278
+ return this.awaitedToolAnswer((deliver) => this.dispatchFetchReferenceToolCall(subject, call, deliver));
27279
+ case 'gate-status':
27280
+ return this.awaitedToolAnswer((deliver) => this.dispatchGateToolCall(subject, call, deliver));
27281
+
27282
+ // The tune-out tool, in process: the same body the dispatcher runs, its
27283
+ // answer awaited here (it can wait for the tune-out's transport).
27284
+ case 'tune-out': {
27285
+ const agentName = call.callerAgentName ?? this.primaryAgentName ?? '__ephemeral__';
27286
+ const input = (call.input ?? {}) as Record<string, unknown>;
27287
+ // The calling turn's own channels, as the channel tools read them here (origin.own).
27288
+ const own = origin.kind === 'agent' ? origin.own ?? [] : [];
27289
+ const turn = this.callingTurnOf(agentName, call);
27290
+ return call.name === 'focus' ? this.handleFocusTool(agentName, input, own, turn) : this.handleTuneOutTool(agentName, input, own, turn);
27291
+ }
27292
+ case 'subconscious': {
27293
+ const routed = this.readerToolCall(subject, call);
27294
+ return 'refused' in routed ? failed({ success: false, error: `${routed.refused}, so the call didn't run.`, isError: true }) : routed.result;
27295
+ }
27296
+ case 'sleep':
27297
+ return this.awaitedToolAnswer((deliver) => this.dispatchSleepToolCall(subject, call, deliver));
27298
+
27299
+ // The heartbeat's tools, for callers that await a tool's result (an
27300
+ // in-process call, a puppet, a module's callTool). A module is trusted;
27301
+ // an agent is held to what dispatch holds it to.
27302
+ case 'heartbeat': {
27303
+ const refusal = origin.kind === 'module' ? null : this.heartbeatRefusal(call.callerAgentName ?? '__ephemeral__', call);
27304
+ return refusal ? { success: false, error: refusal, isError: true } : this.runHeartbeatTool(call);
27305
+ }
27306
+ case 'wake-rule':
27307
+ return this.awaitedToolAnswer((deliver) => this.dispatchWakeRuleToolCall(subject, call, deliver));
27308
+ case 'event-tags':
27309
+ return this.awaitedToolAnswer((deliver) => this.dispatchEventTagsToolCall(subject, call, deliver));
27310
+ case 'agent-verb':
27311
+ if (origin.kind === 'module') return failed(agentsOwnVerbRefusal(call.name));
27312
+ if (call.name === 'skip_reply') {
27313
+ return failed({
27314
+ success: false,
27315
+ error: "skip_reply ends the turn it's called in, and this call wasn't made in a turn, so nothing was done.",
27316
+ isError: true,
27317
+ });
27318
+ }
27319
+ return this.channelRegistry!.handleChannelToolCall(call.name, call.input, origin);
27320
+
27321
+ // Client-side programmatic tool calling for callers that await a tool's
27322
+ // result: the puppet and a host through executeToolCall, and a module
27323
+ // through callTool. Keyed by callerAgentName, so each agent's scripts are
27324
+ // its own.
27325
+ case 'code-execution':
27326
+ return this.runCodeExecution(call.callerAgentName ?? '__ephemeral__', call);
27327
+
27328
+ // drafts: the calling resident's own held drafts. A puppeted call has
27329
+ // agent origin and acts on that resident's drafts, as the model's own
27330
+ // call does through dispatchToolCall, but it isn't made inside the
27331
+ // resident's turn, so it takes none of the turn's channels, engages none,
27332
+ // and isn't noted as the resident's speech (callingTurnOf). A module
27333
+ // holds no drafts.
27334
+ case 'drafts':
27335
+ if (origin.kind === 'module') return failed(agentsOwnVerbRefusal(call.name));
27336
+ return this.draftsToolResult(origin.agentName, call.input);
27337
+ case 'prose-help':
27338
+ return { success: true, data: PROSE_ROUTING_HELP };
27339
+ case 'module':
27340
+ // Module tools. The caller uses the result in process, so it gets the
27341
+ // module's own result, as the module returned it, less a refusal it
27342
+ // claims, which is the dispatcher's to say.
27343
+ return this.moduleRegistry.handleToolCall(call);
26936
27344
  }
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
27345
  }
27000
27346
 
27001
27347
  /**
@@ -27739,7 +28085,7 @@ export class AgentFramework {
27739
28085
  // A batch with no finite deadline (a host whose deadline() threw) gets
27740
28086
  // this write's own, rather than a timer Node would cut to 1 ms.
27741
28087
  if (!Number.isFinite(deadline)) deadline = Date.now() + this.withheldSpillDeadlineMs;
27742
- if (this.guardSpillsClosedFor === this.lifecycleGeneration) return null;
28088
+ if (this.guardSpillsClosed) return null;
27743
28089
  const place = this.toolResultFilePlace(label);
27744
28090
  if (!place) return null;
27745
28091
  if (this.stalledSpills.size > 0) {
@@ -28842,6 +29188,53 @@ export class AgentFramework {
28842
29188
  return { name: call.name, cause: permitted || refused ? silenceCauseOf([call]) : null, refused, permitted };
28843
29189
  }
28844
29190
 
29191
+ private toolRoute(call: ToolCall, actor: string | undefined): ToolRoute {
29192
+ const name = call.name;
29193
+ if (isPresentationTool(name)) return { kind: 'presentation' };
29194
+ if (name === META_TOOL_NAMES.act) {
29195
+ const host = actor === undefined ? null : this.hostPresentation?.resolveAct(actor, call.input) ?? null;
29196
+ if (host) return { kind: 'host-act', host };
29197
+ if (this.toolsetHub && this.mcplServerRegistry) return { kind: 'toolset-act' };
29198
+ }
29199
+ const mcpl = this.mcplServerRegistry ? this.resolveMcplTool(name) : null;
29200
+ if (mcpl) return { kind: 'mcpl', serverId: mcpl[0], prefix: mcpl[1] };
29201
+ if (name.startsWith('channel_') && this.channelRegistry) return { kind: 'channel' };
29202
+ if (name === 'agent_settings') return { kind: 'agent-settings' };
29203
+ if (name === 'save_recent_image') return { kind: 'save-image' };
29204
+ if (name === 'read_image') return { kind: 'read-image' };
29205
+ if (name === 'fetch_reference') return { kind: 'fetch-reference' };
29206
+ if (name === 'gate_status' && this.eventGate) return { kind: 'gate-status' };
29207
+ if ((name === 'tune_out' || name === 'focus') && this.tuneOutCoordinator) return { kind: 'tune-out' };
29208
+ if ((SUBCONSCIOUS_TOOL_NAMES as readonly string[]).includes(name)) return { kind: 'subconscious' };
29209
+ if ((name === 'sleep' || name === 'wake') && this.eventGate) return { kind: 'sleep' };
29210
+ if (this.heartbeat?.ownsTool(name)) return { kind: 'heartbeat' };
29211
+ if ((name === 'wake_add_rule' || name === 'wake_remove_rule') && this.eventGate) return { kind: 'wake-rule' };
29212
+ if (name === 'event_tags' && this.eventGate) return { kind: 'event-tags' };
29213
+ if ((name === 'think' || name === 'journal' || name === 'skip_reply') && this.channelRegistry) return { kind: 'agent-verb' };
29214
+ if (name === CODE_EXECUTION_TOOL_NAME && this.codeExecution) return { kind: 'code-execution' };
29215
+ if (name === 'drafts' && this.channelRegistry) return { kind: 'drafts' };
29216
+ if (name === PROSE_HELP_TOOL.name) return { kind: 'prose-help' };
29217
+ return { kind: 'module' };
29218
+ }
29219
+
29220
+ private pushToolResult(agentName: string, call: ToolCall, moduleName: string): (result: ToolResult) => void {
29221
+ return (result) => this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName, result });
29222
+ }
29223
+
29224
+ private awaitedToolAnswer(run: (deliver: (result: ToolResult) => void) => void): Promise<ToolResult> {
29225
+ return new Promise((resolve) => {
29226
+ try {
29227
+ run((result) => {
29228
+ const answer = { ...result };
29229
+ delete answer.refused;
29230
+ resolve(answer);
29231
+ });
29232
+ } catch (error) {
29233
+ resolve({ success: false, error: thrownText(error), isError: true });
29234
+ }
29235
+ });
29236
+ }
29237
+
28845
29238
  /**
28846
29239
  * Dispatch a call the agent made. `turnId` is the turn it was made in
28847
29240
  * (TurnRouting.turnId), as its caller knows it: a model's call, the turn
@@ -28892,217 +29285,207 @@ export class AgentFramework {
28892
29285
  const enrichedCall: ToolCall = { ...call, callerAgentName: agentName };
28893
29286
  if (turnId !== undefined) enrichedCall.callerTurnId = turnId;
28894
29287
  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
- }
29288
+ const route = this.toolRoute(enrichedCall, agentName);
29289
+ switch (route.kind) {
29290
+ case 'presentation': {
29291
+ const result = this.editToolPresentation(agentName, enrichedCall);
29292
+ this.pushEvent({type:'tool-result',callId:call.id,toolName:call.name,agentName,moduleName:'tool-presentation',result});
29293
+ return;
29294
+ }
28900
29295
 
29296
+ // toolsets--act on a host toolset's tool (the built-in one, a server's
29297
+ // tab): the call of that tool, under its own name, down the path a
29298
+ // direct call of it takes, permissions included.
29299
+ case 'host-act':
29300
+ this.dispatchHostAct(agentName, call, route.host, turnId);
29301
+ return;
28901
29302
 
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);
29303
+ // RFC-014: toolsets--act calls a server's dynamic tool, down the same
29304
+ // path as a direct call of it (dispatchToolsetAct).
29305
+ case 'toolset-act': {
29306
+ const scriptWaiter = this.scriptToolWaiters.get(enrichedCall.id);
29307
+ if (scriptWaiter) scriptWaiter.route.toServer = true;
29308
+ this.dispatchToolsetAct(agentName, enrichedCall);
28909
29309
  return;
28910
29310
  }
28911
- }
28912
29311
 
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
-
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
- }
29312
+ case 'mcpl': {
29313
+ // A script's call: its waiter keeps that it went to a server, for the
29314
+ // script lane's exit (scriptToolWaiters).
29315
+ const scriptWaiter = this.scriptToolWaiters.get(enrichedCall.id);
29316
+ if (scriptWaiter) scriptWaiter.route.toServer = true;
29317
+ this.dispatchMcplToolCall(agentName, enrichedCall, route.serverId, route.prefix);
29318
+ return;
29319
+ }
28932
29320
 
28933
- // Route synthesized channel tools
28934
- if (enrichedCall.name.startsWith('channel_') && this.channelRegistry) {
28935
- this.dispatchChannelToolCall(agentName, enrichedCall);
28936
- return;
28937
- }
29321
+ case 'channel': {
29322
+ this.dispatchChannelToolCall(agentName, enrichedCall);
29323
+ return;
29324
+ }
28938
29325
 
28939
- if (enrichedCall.name === 'agent_settings') {
28940
- this.dispatchAgentSettingsToolCall(agentName, enrichedCall);
28941
- return;
28942
- }
29326
+ case 'agent-settings': {
29327
+ this.dispatchAgentSettingsToolCall(agentName, enrichedCall);
29328
+ return;
29329
+ }
28943
29330
 
28944
- if (enrichedCall.name === 'save_recent_image') {
28945
- this.dispatchSaveImageToolCall(agentName, enrichedCall);
28946
- return;
28947
- }
29331
+ case 'save-image': {
29332
+ this.dispatchSaveImageToolCall(agentName, enrichedCall);
29333
+ return;
29334
+ }
28948
29335
 
28949
- if (enrichedCall.name === 'read_image') {
28950
- this.dispatchReadImageToolCall(agentName, enrichedCall);
28951
- return;
28952
- }
29336
+ case 'read-image': {
29337
+ this.dispatchReadImageToolCall(agentName, enrichedCall);
29338
+ return;
29339
+ }
28953
29340
 
28954
- if (enrichedCall.name === 'fetch_reference') {
28955
- this.dispatchFetchReferenceToolCall(agentName, enrichedCall);
28956
- return;
28957
- }
29341
+ case 'fetch-reference': {
29342
+ this.dispatchFetchReferenceToolCall(agentName, enrichedCall);
29343
+ return;
29344
+ }
28958
29345
 
28959
- // Route gate_status tool
28960
- if (enrichedCall.name === 'gate_status' && this.eventGate) {
28961
- this.dispatchGateToolCall(agentName, enrichedCall);
28962
- return;
28963
- }
29346
+ case 'gate-status': {
29347
+ this.dispatchGateToolCall(agentName, enrichedCall);
29348
+ return;
29349
+ }
28964
29350
 
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
- });
29351
+ case 'tune-out': {
29352
+ this.dispatchTuneOutToolCall(agentName, enrichedCall);
28979
29353
  return;
28980
29354
  }
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.
29355
+ case 'subconscious': {
29356
+ const routed = this.readerToolCall(agentName, enrichedCall);
29357
+ if ('refused' in routed) {
29358
+ const error = routed.refused;
29359
+ const result = this.refusedResult(agentName, call.id, error);
29360
+ this.emitTrace({ type: 'tool:failed', module: 'tune-out', tool: call.name, callId: call.id, error });
28985
29361
  this.pushEvent({
28986
- type: 'tool-result',
28987
- callId: enrichedCall.id,
28988
- toolName: enrichedCall.name,
28989
- agentName,
28990
- moduleName: 'tune-out',
28991
- result,
29362
+ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'tune-out', result,
28992
29363
  });
28993
- });
28994
- return;
28995
- }
29364
+ return;
29365
+ }
29366
+ void routed.result
29367
+ .then((result) => {
29368
+ // Through pushEvent, like every other tool's result, so one that
29369
+ // arrives after stop() is dropped rather than rejected unhandled.
29370
+ this.pushEvent({
29371
+ type: 'tool-result',
29372
+ callId: enrichedCall.id,
29373
+ toolName: enrichedCall.name,
29374
+ agentName,
29375
+ moduleName: 'tune-out',
29376
+ result,
29377
+ });
29378
+ });
29379
+ return;
29380
+ }
28996
29381
 
28997
- // Route sleep / wake tools
28998
- if ((enrichedCall.name === 'sleep' || enrichedCall.name === 'wake') && this.eventGate) {
28999
- this.dispatchSleepToolCall(agentName, enrichedCall);
29000
- return;
29001
- }
29382
+ case 'sleep': {
29383
+ this.dispatchSleepToolCall(agentName, enrichedCall);
29384
+ return;
29385
+ }
29002
29386
 
29003
- // Route the heartbeat's tools (heartbeat--…)
29004
- if (this.heartbeat?.ownsTool(enrichedCall.name)) {
29005
- this.dispatchHeartbeatToolCall(agentName, enrichedCall);
29006
- return;
29007
- }
29387
+ case 'heartbeat': {
29388
+ this.dispatchHeartbeatToolCall(agentName, enrichedCall);
29389
+ return;
29390
+ }
29008
29391
 
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
- }
29392
+ case 'wake-rule': {
29393
+ this.dispatchWakeRuleToolCall(agentName, enrichedCall);
29394
+ return;
29395
+ }
29014
29396
 
29015
- // Route event_tags (tag/ontology discovery)
29016
- if (enrichedCall.name === 'event_tags' && this.eventGate) {
29017
- this.dispatchEventTagsToolCall(agentName, enrichedCall);
29018
- return;
29019
- }
29397
+ case 'event-tags': {
29398
+ this.dispatchEventTagsToolCall(agentName, enrichedCall);
29399
+ return;
29400
+ }
29020
29401
 
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;
29402
+ case 'agent-verb': {
29403
+ // skip_reply(wake_in_seconds): arm a gate self-wake so "not replying
29404
+ // NOW" can also mean "back in a moment" — ends the turn, then wakes
29405
+ // the agent after N seconds unless something else wakes it first
29406
+ // (any turn start cancels the pending self-wake). Armed here because
29407
+ // the framework owns the EventGate; the registry only words the
29408
+ // confirmation. A wake the host can't set is refused, so the model
29409
+ // learns that its turn remains open and no wake was armed: any
29410
+ // wake_in_seconds without a gate, and one that isn't a number of
29411
+ // seconds with one.
29412
+ if (enrichedCall.name === 'skip_reply') {
29413
+ const error = this.skipReplyRefusal(enrichedCall);
29414
+ if (error !== undefined) {
29415
+ const wakeValue = (enrichedCall.input as { wake_in_seconds?: unknown } | null | undefined)?.wake_in_seconds;
29416
+ 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)')}`);
29417
+ const result = this.refusedResult(agentName, enrichedCall.id, error);
29418
+ this.emitTrace({ type: 'tool:failed', module: 'channels', tool: enrichedCall.name, callId: enrichedCall.id, error });
29419
+ this.pushEvent({ type: 'tool-result', callId: enrichedCall.id, toolName: enrichedCall.name, agentName, moduleName: 'channels', result });
29420
+ return;
29421
+ }
29422
+ const skipping = this.callingTurnOf(agentName, enrichedCall);
29423
+ if (skipping) {
29424
+ skipping.privateEnd = true;
29425
+ delete skipping.unpostedSpeech;
29426
+ }
29427
+ const wake = readSkipReplyWake(enrichedCall.input);
29428
+ if (wake.kind === 'wake' && this.eventGate) {
29429
+ const armedAt = Date.now();
29430
+ const { inMs, armed } = this.eventGate.armSelfWake(agentName, wake.seconds, 'skip_reply');
29431
+ // The heartbeat's tick or reminder due before the timer: if it wakes
29432
+ // the agent, its turn cancels the timer, so the confirmation names
29433
+ // it and the time the self-wake would have left.
29434
+ const earlierWake = armed ? this.heartbeatWakeBefore(agentName, armedAt + inMs) : undefined;
29435
+ console.error(armed
29436
+ ? `[self-wake] agent=${agentName} armed in ${Math.round(inMs / 1000)}s (skip_reply)`
29437
+ + (earlierWake ? `; due sooner, in ~${earlierWake.inSeconds}s: ${logDetail(earlierWake.what)}` : '')
29438
+ : `[self-wake] agent=${agentName} not armed: the framework has stopped (skip_reply)`);
29439
+ this.dispatchChannelToolCall(agentName, enrichedCall, earlierWake);
29440
+ return;
29441
+ }
29064
29442
  }
29443
+ this.dispatchChannelToolCall(agentName, enrichedCall);
29444
+ return;
29065
29445
  }
29066
- this.dispatchChannelToolCall(agentName, enrichedCall);
29067
- return;
29068
- }
29069
29446
 
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
- }
29447
+ // Client-side programmatic tool calling: run a script that calls the
29448
+ // agent's other tools in-process (script-inner calls come back through
29449
+ // this very dispatcher with waiter-tracked call IDs).
29450
+ case 'code-execution': {
29451
+ this.dispatchCodeExecutionToolCall(agentName, enrichedCall);
29452
+ return;
29453
+ }
29077
29454
 
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 });
29455
+ // drafts: the resident's own held drafts (list/read/resend/dismiss).
29456
+ // Answered by the framework; resend publishes through the registry's
29457
+ // publish executor, so the result arrives asynchronously.
29458
+ case 'drafts': {
29459
+ const startedAt = Date.now();
29460
+ this.emitTrace({ type: 'tool:started', module: 'framework', tool: 'drafts', callId: enrichedCall.id, input: enrichedCall.input });
29461
+ void this.draftsToolResult(agentName, enrichedCall.input, this.callingTurnOf(agentName, enrichedCall), enrichedCall.id)
29462
+ .then((result) => {
29463
+ this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'drafts', callId: enrichedCall.id, durationMs: Date.now() - startedAt });
29464
+ this.pushEvent({ type: 'tool-result', callId: enrichedCall.id, toolName: enrichedCall.name, agentName, moduleName: 'framework', result });
29465
+ });
29466
+ return;
29467
+ }
29468
+
29469
+ // prose_help: on-demand routing reference for explicit-prose agents.
29470
+ // Answered inline — the grammar is a tool RESULT (model-requested), never
29471
+ // ambient context (see PROSE_ROUTING_HELP / teach-by-bounce).
29472
+ case 'prose-help': {
29473
+ this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'prose_help', callId: enrichedCall.id, durationMs: 0 });
29474
+ this.pushEvent({
29475
+ type: 'tool-result',
29476
+ callId: enrichedCall.id,
29477
+ toolName: enrichedCall.name,
29478
+ agentName,
29479
+ moduleName: 'framework',
29480
+ result: { success: true, data: PROSE_ROUTING_HELP },
29088
29481
  });
29089
- return;
29090
- }
29482
+ return;
29483
+ }
29091
29484
 
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;
29485
+ case 'module':
29486
+ break;
29487
+ default:
29488
+ route satisfies never;
29106
29489
  }
29107
29490
 
29108
29491
  const sepIndex = enrichedCall.name.indexOf('--');
@@ -29502,6 +29885,7 @@ export class AgentFramework {
29502
29885
  private noteSpokenTo(agentName: string, metadata: MessageMetadata | undefined): void {
29503
29886
  const agent = this.agents.get(agentName);
29504
29887
  if ((agent && !this.agentSees(agent, metadata)) || isOwnScheduleWrite(metadata) || !isConversationalInjection(metadata)) return;
29888
+ this.rearmDeadlineCuts?.delete(agentName);
29505
29889
  if (this.turnRearms?.delete(agentName)) console.error(`[inference-rearm] agent=${agentName} re-send count started again: a message was said to it`);
29506
29890
  }
29507
29891
 
@@ -30233,11 +30617,9 @@ export class AgentFramework {
30233
30617
  * at a tool boundary whose stream declares it carries the messages
30234
30618
  * injected there (markContextConsumed). So a notice such a stream carried
30235
30619
  * 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.
30620
+ * a budget restart, the direct API's boundary, which has no stream) is
30621
+ * above it until a compile sends it, and counts as unread. null when the
30622
+ * window can't be read, which is logged.
30241
30623
  */
30242
30624
  private releasedWindow(agent: Agent, held: ReadonlySet<string>): ReleasedWindow | null {
30243
30625
  try {
@@ -31395,6 +31777,7 @@ export class AgentFramework {
31395
31777
  const ended = this.exhaustedTurns?.get(agentName);
31396
31778
  this.exhaustedTurns?.delete(agentName);
31397
31779
  const rearmed = this.exhaustingRearms?.delete(agentName) === true;
31780
+ const madeAgain = this.freshCallsCut?.delete(agentName) === true;
31398
31781
  // The reason is the failure's own message, and a provider can echo the
31399
31782
  // whole rejected request in it: one household's [inference-failed]
31400
31783
  // marker carried ~1.7 MB of its own request back into its context. Every
@@ -31451,9 +31834,10 @@ export class AgentFramework {
31451
31834
  // (2) Agent-facing chronicle marker (no inference triggered → no loop).
31452
31835
  const agent = this.agents.get(agentName);
31453
31836
  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(', ');
31837
+ const timedOut = errorType === 'timeout';
31838
+ const causeParts = [errorType, httpStatus !== undefined ? `HTTP ${httpStatus}` : undefined]
31839
+ .filter((part): part is string => part !== undefined);
31840
+ const cause = timedOut && causeParts.length === 1 ? '' : causeParts.join(', ');
31457
31841
  try {
31458
31842
  agent.getContextManager().addMessage(
31459
31843
  'user',
@@ -31461,17 +31845,17 @@ export class AgentFramework {
31461
31845
  type: 'text',
31462
31846
  text:
31463
31847
  (ended
31464
- ? `[${NOTICE_TAG.inferenceFailed}] Your previous turn did not complete: it ` +
31465
- `failed${cause ? ` (${suppliedValue(cause)})` : ''}.` +
31848
+ ? `[${NOTICE_TAG.inferenceFailed}] Your previous turn did not complete: ` +
31849
+ `${timedOut ? 'its last call timed out' : 'it failed'}${cause ? ` (${suppliedValue(cause)})` : ''}.` +
31850
+ `${madeAgain ? ` ${MADE_AGAIN}` : ''}` +
31466
31851
  `${ended.roundsRan ? ' Its earlier rounds ran, and their tool calls ran as their results show.' : ''}` +
31467
31852
  `${ended.callsCutOff ? ` ${CALLS_CUT_OFF}` : ''}` +
31468
31853
  `${ended.liveSpeech ? ` ${LIVE_SPEECH_UNRECEIPTED}` : ''}` +
31469
31854
  `${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).`,
31855
+ : `[${NOTICE_TAG.inferenceFailed}] Your previous turn did not complete: ` +
31856
+ `the model call ${timedOut ? 'timed out' : 'failed'}${cause ? ` (${suppliedValue(cause)})` : ''} ` +
31857
+ `and produced no response, so nothing was sent.${madeAgain ? ` ${MADE_AGAIN}` : ''} Reason: `) +
31858
+ `${failureShown(markerReason)}. ${timedOut ? TIMEOUT_ADVICE : FAILURE_ADVICE}`,
31475
31859
  }],
31476
31860
  { system: true, kind: 'inference-failed', reason: markerReason, consecutive: streak, ...classification, ...cutAt(markerReason) },
31477
31861
  );
@@ -31732,6 +32116,7 @@ export class AgentFramework {
31732
32116
  this.featureSetManager,
31733
32117
  inferenceRouting ?? null,
31734
32118
  (event) => this.emitTrace(event as { type: TraceEvent['type']; [key: string]: unknown }),
32119
+ () => this.inferenceBounds,
31735
32120
  );
31736
32121
 
31737
32122
  // Checkpoint manager (Step 8), built and loaded at the top
@@ -31771,7 +32156,13 @@ export class AgentFramework {
31771
32156
  if (!coordinator) return undefined;
31772
32157
  const scope = coordinator.getAllExcept();
31773
32158
  if (!scope || scope.params.except.serverId !== serverId || scope.params.except.channelId !== channelId) return undefined;
31774
- coordinator.cancelAllExcept('channel-close', 'the excepted channel was closed');
32159
+ let released: ReturnType<typeof coordinator.cancelAllExcept>;
32160
+ try {
32161
+ released = coordinator.cancelAllExcept('channel-close', 'the excepted channel was closed');
32162
+ } catch (err) {
32163
+ throw new Error(`The close wasn't recorded: ${err instanceof Error ? err.message : String(err)}`);
32164
+ }
32165
+ if (!released.ok) throw new Error(`The close wasn't recorded: ${released.error}`);
31775
32166
  return 'The focus on it ended all the same, and what the other channels held was delivered.';
31776
32167
  },
31777
32168
  focusChannelResolver: () => this.tuneOutCoordinator?.focusChannel() ?? null,
@@ -31948,13 +32339,11 @@ export class AgentFramework {
31948
32339
  attempt: 0,
31949
32340
  willRetry: !!left?.willReconnect,
31950
32341
  });
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}`);
32342
+ // So the server doesn't vanish quietly: nothing connects it again on
32343
+ // its own, so alert at once. One that connected before its setup
32344
+ // threw is still connected, and gets no alert.
32345
+ if (!left?.willReconnect && !left?.isConnected) {
32346
+ this.opsAlert('mcpl-down', config.id, `MCPL server not connected at startup, and this host won't retry it on its own: ${message}`);
31958
32347
  }
31959
32348
  }
31960
32349
  }
@@ -33892,9 +34281,10 @@ export class AgentFramework {
33892
34281
 
33893
34282
  /**
33894
34283
  * 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.
34284
+ * same (or an updated) config. This respawns a stdio child at once,
34285
+ * whatever its state; `reconnect: true` spawns a fresh child only after
34286
+ * the connection fails or its child exits. Throws if the server has no
34287
+ * stored config and none is given.
33898
34288
  * What its connect would refuse is refused before the running server is
33899
34289
  * torn down. Until the connect, a connect or a restart started meanwhile
33900
34290
  * is refused the id and the tool prefix the restart moves the server to,
@@ -34376,12 +34766,9 @@ export class AgentFramework {
34376
34766
  // request turned away at stop gets, so it can send it again to
34377
34767
  // the host's next run. Optional chaining: prototype-built
34378
34768
  // harnesses leave the queue unset.
34379
- const stopping = !!this.queue?.isClosed;
34769
+ const answer = this.queue?.isClosed ? hostStoppedWhileHandling() : { code: -32603, message: 'Internal error' };
34380
34770
  try {
34381
- tracked.respondError?.(
34382
- stopping ? -32000 : -32603,
34383
- stopping ? 'the host stopped while handling this request' : 'Internal error',
34384
- );
34771
+ tracked.respondError?.(answer.code, answer.message);
34385
34772
  } catch (sendError) {
34386
34773
  responseError = recorded(describeCause(sendError));
34387
34774
  }
@@ -34538,6 +34925,7 @@ export class AgentFramework {
34538
34925
  respond: (result: unknown) => void;
34539
34926
  respondError: (code: number, message: string) => void;
34540
34927
  sendChunk?: (chunk: InferenceChunkParams) => void;
34928
+ signal?: AbortSignal;
34541
34929
  },
34542
34930
  ) => {
34543
34931
  // RFC-006 §5.3 / SPEC §10.7: no inference from inside a render.
@@ -34563,6 +34951,7 @@ export class AgentFramework {
34563
34951
  // Chunks go out on the transport the request came on, as its
34564
34952
  // answer does.
34565
34953
  ...(responder.sendChunk ? { sendChunk: responder.sendChunk } : {}),
34954
+ ...(responder.signal ? { signal: responder.signal } : {}),
34566
34955
  });
34567
34956
  }
34568
34957
  });
@@ -34878,14 +35267,15 @@ export class AgentFramework {
34878
35267
  // that next one (see refreshMcplTools).
34879
35268
  this.noteMcplServerClosed(connection.id);
34880
35269
  this.dropMcplToolClasses(connection.id);
35270
+ // The transport's own words only: a WebSocket server's close text
35271
+ // stays off the trace.
35272
+ const reason = info?.reason !== undefined && !info.reasonFromPeer ? boundFailureText(info.reason) : null;
34881
35273
  this.emitTrace({
34882
35274
  type: 'mcpl:server-closed',
34883
35275
  serverId: connection.id,
34884
35276
  code: code ?? null,
34885
35277
  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,
35278
+ reason,
34889
35279
  willReconnect: connection.willReconnect,
34890
35280
  });
34891
35281
  this.emitTrace({ type: 'module:removed', moduleName: `mcpl:${connection.id}` });
@@ -34895,6 +35285,14 @@ export class AgentFramework {
34895
35285
  // RFC-014: its toolsets go with it; a next connection reads them again.
34896
35286
  this.toolsetHub?.get(connection.id)?.detach(connection);
34897
35287
  this.handleToolsListChanged(connection.id);
35288
+ // Lost on its own with no reconnect coming: nothing in this host
35289
+ // connects it again on its own. A close the host made itself (stop(),
35290
+ // a disconnect, a restart) is byHost, a loss once stop() has begun is
35291
+ // part of the close the host is making, and a child that ended as
35292
+ // asked was stopped: none of them gets an alert.
35293
+ if (!info?.byHost && !this.stopHasBegun() && !endedAsAsked({ code, signal, pipeFailed: info?.pipeFailed })) {
35294
+ this.opsAlert('mcpl-down', connection.id, `MCPL server closed${closeWords(reason, code, signal)}, and this host won't reconnect it on its own`);
35295
+ }
34898
35296
  }
34899
35297
  });
34900
35298
  }
@@ -34920,11 +35318,12 @@ export class AgentFramework {
34920
35318
  this.emitTrace({ type: 'module:added', moduleName: `mcpl:${connection.id}` });
34921
35319
  this.handleToolsListChanged(connection.id);
34922
35320
  });
34923
- const failed = (params: { error: string; attempt: number; permanent?: boolean }) => {
35321
+ const failed = (params: { error: string; stderrTail?: string; attempt: number; permanent?: boolean }) => {
34924
35322
  this.emitTrace({
34925
35323
  type: 'mcpl:server-connect-failed',
34926
35324
  serverId: connection.id,
34927
35325
  error: recorded(params.error),
35326
+ ...(params.stderrTail === undefined ? {} : { stderrTail: recorded(params.stderrTail) }),
34928
35327
  attempt: params.attempt,
34929
35328
  willRetry: connection.willReconnect,
34930
35329
  });
@@ -34942,7 +35341,7 @@ export class AgentFramework {
34942
35341
  connection.on('error', (err: Error) => {
34943
35342
  this.emitTrace({ type: 'mcpl:server-error', serverId: connection.id, error: recorded(err.message) });
34944
35343
  });
34945
- connection.on('close', () => {
35344
+ connection.on('close', (info: { reason: string; code?: number | null; signal?: NodeJS.Signals | null; pipeFailed?: boolean }) => {
34946
35345
  // A closed provider's class hints are stale (RFC-008), as for MCPL,
34947
35346
  // and so is a listing already under way.
34948
35347
  this.noteMcplServerClosed(connection.id);
@@ -34950,8 +35349,9 @@ export class AgentFramework {
34950
35349
  this.emitTrace({
34951
35350
  type: 'mcpl:server-closed',
34952
35351
  serverId: connection.id,
34953
- code: null,
34954
- signal: null,
35352
+ code: info.code ?? null,
35353
+ signal: info.signal ?? null,
35354
+ reason: info.reason,
34955
35355
  willReconnect: connection.willReconnect,
34956
35356
  });
34957
35357
  this.emitTrace({ type: 'module:removed', moduleName: `mcpl:${connection.id}` });
@@ -34971,6 +35371,13 @@ export class AgentFramework {
34971
35371
  // Gone for good: its tools leave the agents' list, as a
34972
35372
  // disconnect's do.
34973
35373
  this.handleToolsListChanged(connection.id);
35374
+ // Lost on its own: nothing in this host connects it again on its
35375
+ // own. A loss once stop() has begun is part of the close the host
35376
+ // is making, and a child that ended as asked was stopped: neither
35377
+ // gets an alert.
35378
+ if (!this.stopHasBegun() && !endedAsAsked(info)) {
35379
+ 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`);
35380
+ }
34974
35381
  });
34975
35382
  }
34976
35383
  });
@@ -36135,7 +36542,7 @@ export class AgentFramework {
36135
36542
  * (same path as gate.json load), so an invalid rule is surfaced as a tool
36136
36543
  * error and nothing is written.
36137
36544
  */
36138
- private dispatchWakeRuleToolCall(agentName: string, call: ToolCall): void {
36545
+ private dispatchWakeRuleToolCall(agentName: string, call: ToolCall, deliver = this.pushToolResult(agentName, call, 'gate')): void {
36139
36546
  this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
36140
36547
  // What wake_add_rule's input held that does nothing, checkToolInput's notes and
36141
36548
  // then the gate's on a rule it saved: a refusal names it too, so the call that
@@ -36150,14 +36557,23 @@ export class AgentFramework {
36150
36557
  module: 'gate', tool: call.name, callId: call.id, durationMs: 0,
36151
36558
  ...(result.isError ? { error: result.error } : {}),
36152
36559
  });
36153
- this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'gate', result });
36560
+ deliver(result);
36154
36561
  };
36155
36562
 
36156
36563
  try {
36157
36564
  if (call.name === 'wake_remove_rule') {
36158
36565
  const input = (call.input ?? {}) as { name?: unknown };
36159
36566
  if (typeof input.name !== 'string' || !input.name) {
36160
- finish({ success: false, error: 'wake_remove_rule: `name` (string) is required', isError: true });
36567
+ const edit = this.eventGate!.ruleNamesForChange();
36568
+ const names = 'names' in edit ? edit.names.map((name) => quotedValue(name)) : [];
36569
+ finish({
36570
+ success: false,
36571
+ error: `wake_remove_rule: name is ${shownValue(input.name)}, where the name of a rule is needed, so nothing was removed. `
36572
+ + ('refused' in edit
36573
+ ? edit.refused
36574
+ : (names.length > 0 ? `The rules a change edits are ${names.join(', ')}.` : 'There is no rule to remove.') + edit.note),
36575
+ isError: true,
36576
+ });
36161
36577
  return;
36162
36578
  }
36163
36579
  const removed = this.removeGatePolicy(input.name);
@@ -36194,9 +36610,8 @@ export class AgentFramework {
36194
36610
  if (behaviorSources.length === 0) {
36195
36611
  finish({
36196
36612
  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`.',
36613
+ error: 'wake_add_rule got no behavior, so nothing was saved. Give one of behavior ("always", "defer" or "skip"), '
36614
+ + (this.judgeWired ? 'debounceMs, rateLimit, passiveSample or judge.' : 'debounceMs, rateLimit or passiveSample.'),
36200
36615
  isError: true,
36201
36616
  });
36202
36617
  return;
@@ -36204,7 +36619,7 @@ export class AgentFramework {
36204
36619
  if (behaviorSources.length > 1) {
36205
36620
  finish({
36206
36621
  success: false,
36207
- error: `wake_add_rule: give only one behavior, got [${behaviorSources.join(', ')}].`,
36622
+ error: `wake_add_rule got more than one behavior (${behaviorSources.join(', ')}), so nothing was saved. Give only one of them.`,
36208
36623
  isError: true,
36209
36624
  });
36210
36625
  return;
@@ -36247,7 +36662,7 @@ export class AgentFramework {
36247
36662
  if (placementSources.length > 1) {
36248
36663
  finish({
36249
36664
  success: false,
36250
- error: `wake_add_rule: give at most one placement, got [${placementSources.join(', ')}].`,
36665
+ error: `wake_add_rule got more than one placement (${placementSources.join(', ')}), so nothing was saved. Give only one of them.`,
36251
36666
  isError: true,
36252
36667
  });
36253
36668
  return;
@@ -36267,10 +36682,18 @@ export class AgentFramework {
36267
36682
  for (const field of ['insertBefore', 'insertAfter'] as const) {
36268
36683
  const anchor = input[field];
36269
36684
  if (anchor !== undefined && (typeof anchor !== 'string' || anchor.length === 0)) {
36685
+ const edit = this.eventGate!.ruleNamesForChange();
36686
+ const names = 'names' in edit ? edit.names.filter((name) => name !== input.name).map((name) => quotedValue(name)) : [];
36270
36687
  finish({
36271
36688
  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(', ')}].`,
36689
+ error: `wake_add_rule: ${field} must be the name of a rule, and it's ${shownValue(anchor)}, so nothing was saved. `
36690
+ + ('refused' in edit
36691
+ ? edit.refused
36692
+ : (names.length > 0
36693
+ ? `The other rules a change edits are ${names.join(', ')}. Give ${field} one of them to place the rule beside it, or leave it out`
36694
+ : `There is no other rule to place it beside, so leave ${field} out`)
36695
+ + ' to add the rule at the end, or, where a rule already has its name, to replace that rule where it is.'
36696
+ + edit.note),
36274
36697
  isError: true,
36275
36698
  });
36276
36699
  return;
@@ -36300,9 +36723,12 @@ export class AgentFramework {
36300
36723
  const before = new Map(
36301
36724
  this.eventGate!.probeTable().map((r) => [r.probe, r]),
36302
36725
  );
36303
- const policy = this.addGatePolicy(rawPolicy, {
36726
+ const fieldNames = Object.fromEntries(Object.entries(AgentFramework.WAKE_ADD_RULE_FIELDS)
36727
+ .filter(([field]) => !(field.startsWith('behavior.') && typeof input.behavior === 'object')));
36728
+ const policy = this.eventGate!.addPolicy(rawPolicy, {
36304
36729
  ...(position ? { position } : {}),
36305
36730
  onIgnoredFields: (notes) => { ignored.push(...notes); },
36731
+ fieldNames,
36306
36732
  });
36307
36733
  const after = this.eventGate!.probeTable();
36308
36734
  const probes = after.map((r) => {
@@ -36615,21 +37041,19 @@ export class AgentFramework {
36615
37041
  return decision;
36616
37042
  }
36617
37043
 
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 {
37044
+ private commitDeliveries(): boolean {
37045
+ let durable = true;
36624
37046
  if (this.deferredMessages.length > 0) {
36625
37047
  this.deferredWritesPersisted = true;
36626
- this.persistDeferredWrites();
37048
+ durable = this.persistDeferredWrites();
36627
37049
  }
36628
37050
  try {
36629
37051
  this.store.sync();
36630
37052
  } catch (err) {
36631
37053
  console.error('[tune-out] could not sync the store after a release:', err);
37054
+ return false;
36632
37055
  }
37056
+ return durable;
36633
37057
  }
36634
37058
 
36635
37059
  /** The history-reading tool the primary resident actually has, if any (#232 gap 11). */
@@ -36640,7 +37064,7 @@ export class AgentFramework {
36640
37064
  return names.find((n) => /(^|--)(fetch_history|channel_read|read_history|history)$/.test(n)) ?? null;
36641
37065
  }
36642
37066
 
36643
- private dispatchSleepToolCall(agentName: string, call: ToolCall): void {
37067
+ private dispatchSleepToolCall(agentName: string, call: ToolCall, deliver = this.pushToolResult(agentName, call, 'gate')): void {
36644
37068
  // The gate's sleep quiets the whole agent, every conversation of it, so a
36645
37069
  // conversation fork or an ephemeral run neither sets one nor ends the
36646
37070
  // agent's own. Refused before the tool runs, as a fork's channel_open is.
@@ -36659,7 +37083,7 @@ export class AgentFramework {
36659
37083
  ? `Nothing was set: a sleep quiets the whole agent, not one ${one}.${comeBack}`
36660
37084
  : `Nothing was changed: a sleep is the agent's, so this ${one} can't end one.`;
36661
37085
  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) });
37086
+ deliver(this.refusedResult(agentName, call.id, error));
36663
37087
  return;
36664
37088
  }
36665
37089
  this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
@@ -36668,7 +37092,7 @@ export class AgentFramework {
36668
37092
 
36669
37093
  const finish = (result: ToolResult) => {
36670
37094
  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 });
37095
+ deliver(result);
36672
37096
  };
36673
37097
 
36674
37098
  if (call.name === 'wake') {
@@ -36788,30 +37212,34 @@ export class AgentFramework {
36788
37212
  */
36789
37213
  /** RFC-005 on-demand dereference: [ref id] → workspace file, via the
36790
37214
  * origin-bound, ceiling-bounded, digest-verified host fetcher. */
36791
- private dispatchFetchReferenceToolCall(agentName: string, call: ToolCall): void {
37215
+ private dispatchFetchReferenceToolCall(agentName: string, call: ToolCall, deliver = this.pushToolResult(agentName, call, 'workspace')): void {
36792
37216
  this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
36793
37217
  const startTime = Date.now();
36794
37218
  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
37219
  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}` };
37220
+ try {
37221
+ const args = (call.input ?? {}) as Record<string, unknown>;
37222
+ const refId = String(args.ref_id ?? '');
37223
+ const record = referenceRegistry.get(refId);
37224
+ if (!record) {
37225
+ // Vector 20: a stale or unknown id is a defined miss, never a
37226
+ // different record.
37227
+ result = { success: false, isError: true,
37228
+ error: `unknown reference: ${refId || '(missing ref_id)'} — it may have been evicted; ids are never reused` };
37229
+ } else {
37230
+ const maxBytes = typeof args.max_bytes === 'number'
37231
+ ? Math.min(args.max_bytes, DEFAULT_FETCH_MAX_BYTES) : undefined;
37232
+ const outcome = await this.getReferenceFetcher().fetch(record, { maxBytes });
37233
+ result = outcome.ok
37234
+ ? { success: true, data: JSON.stringify({ path: outcome.path, bytes: outcome.bytes,
37235
+ mime_type: outcome.mimeType, digest_verified: outcome.digestVerified ?? 'no digest claimed' }) }
37236
+ // The fetcher says what happened as a clause about the reference,
37237
+ // and what calling again can do as a sentence of its own.
37238
+ : { success: false, isError: true,
37239
+ error: `fetch_reference couldn't fetch "${refId}": ${outcome.error}. ${outcome.remedy}` };
37240
+ }
37241
+ } catch (error) {
37242
+ result = { success: false, isError: true, error: `fetch_reference couldn't answer: ${thrownText(error)}` };
36815
37243
  }
36816
37244
  this.emitTrace({
36817
37245
  type: result.isError ? 'tool:failed' : 'tool:completed',
@@ -36819,14 +37247,11 @@ export class AgentFramework {
36819
37247
  durationMs: Date.now() - startTime,
36820
37248
  ...(result.isError ? { error: result.error } : {}),
36821
37249
  });
36822
- this.pushEvent({
36823
- type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'workspace',
36824
- result: { ...result, isError: result.isError ?? false },
36825
- });
37250
+ deliver({ ...result, isError: result.isError ?? false });
36826
37251
  })();
36827
37252
  }
36828
37253
 
36829
- private dispatchReadImageToolCall(agentName: string, call: ToolCall): void {
37254
+ private dispatchReadImageToolCall(agentName: string, call: ToolCall, deliver = this.pushToolResult(agentName, call, 'workspace')): void {
36830
37255
  this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
36831
37256
  const finish = (result: ToolResult): void => {
36832
37257
  this.emitTrace({
@@ -36837,7 +37262,7 @@ export class AgentFramework {
36837
37262
  durationMs: 0,
36838
37263
  ...(result.isError ? { error: result.error } : {}),
36839
37264
  });
36840
- this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'workspace', result });
37265
+ deliver(result);
36841
37266
  };
36842
37267
  void (async (): Promise<void> => {
36843
37268
  try {
@@ -36929,13 +37354,11 @@ export class AgentFramework {
36929
37354
  * every sibling in the round to settle; if one never does, fail closed
36930
37355
  * rather than trust an index the batch may still change. Sibling saves
36931
37356
  * 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.
37357
+ * otherwise deadlock into a double timeout). Only the round's own calls
37358
+ * wait: a call that isn't among its pending calls, a script's (`pytc-…`)
37359
+ * or an awaited call's, counts the images the agent has now.
36936
37360
  */
36937
37361
  private async awaitSiblingToolCalls(agent: Agent, call: ToolCall): Promise<void> {
36938
- if (call.id.startsWith('pytc-')) return;
36939
37362
  const BARRIER_MS = 120_000;
36940
37363
  const POLL_MS = 20;
36941
37364
  const startedAt = Date.now();
@@ -36943,6 +37366,7 @@ export class AgentFramework {
36943
37366
  const state = agent.state;
36944
37367
  // Idle (puppet path) or the round was torn down: nothing to wait on.
36945
37368
  if (state.status !== 'waiting_for_tools') return;
37369
+ if (!state.pending.has(call.id)) return;
36946
37370
  const open = [...state.pending.values()].filter((p) => p.id !== call.id && p.name !== 'save_recent_image');
36947
37371
  if (open.length === 0) return;
36948
37372
  if (Date.now() - startedAt > BARRIER_MS) {
@@ -36971,7 +37395,7 @@ export class AgentFramework {
36971
37395
  * nothing. It never slides to an older image — a filename must not
36972
37396
  * authenticate bytes from another surface.
36973
37397
  */
36974
- private dispatchSaveImageToolCall(agentName: string, call: ToolCall): void {
37398
+ private dispatchSaveImageToolCall(agentName: string, call: ToolCall, deliver = this.pushToolResult(agentName, call, 'workspace')): void {
36975
37399
  this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
36976
37400
  const finish = (result: ToolResult): void => {
36977
37401
  this.emitTrace({
@@ -36982,7 +37406,7 @@ export class AgentFramework {
36982
37406
  durationMs: 0,
36983
37407
  ...(result.isError ? { error: result.error } : {}),
36984
37408
  });
36985
- this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'workspace', result });
37409
+ deliver(result);
36986
37410
  };
36987
37411
  type Candidate =
36988
37412
  | { kind: 'attachment'; data: string | null; mediaType: string; messagesBack: number }
@@ -37133,20 +37557,39 @@ export class AgentFramework {
37133
37557
  let count = 1;
37134
37558
  /** Direct-ref mode: the one ref to find in the scannable context. */
37135
37559
  let wantedRef: string | null = null;
37136
- if (input.ref !== undefined) {
37560
+ // A selector is in use only when it carries a value (fieldInUse), so a
37561
+ // caller whose provider presents every property as required can still
37562
+ // choose one, whichever of the two it fills the other selector with.
37563
+ // Any other value is checked, never read as absent: a whitespace or
37564
+ // malformed ref, or an index that isn't an integer, is refused rather
37565
+ // than turned into a different image than the one the caller named.
37566
+ // A malformed ref beside an index or count in use says how to leave a
37567
+ // selector unused, since the caller may have meant the positional one, as
37568
+ // the conflict refusal below does for a well-formed ref. No refusal of one
37569
+ // selector on its own says it: a ref alone is the caller's one selector,
37570
+ // and dropping it would save the newest image; an index and a count are
37571
+ // one selector, and dropping either changes which images are saved.
37572
+ if (fieldInUse(input.ref)) {
37137
37573
  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)');
37574
+ const positionalInUse = fieldInUse(input.index) || fieldInUse(input.count);
37575
+ throw new Error(
37576
+ 'save_recent_image: `ref` must look like "img_k7x3q2_7" (copy it from the image placeholder in your context)' +
37577
+ (positionalInUse ? ' (pass null, or leave it out, for a selector you are not using)' : ''),
37578
+ );
37139
37579
  }
37140
- if (input.index !== undefined || input.count !== undefined) {
37141
- throw new Error('save_recent_image: `ref` is mutually exclusive with `index`/`count`');
37580
+ if (fieldInUse(input.index) || fieldInUse(input.count)) {
37581
+ throw new Error(
37582
+ 'save_recent_image: `ref` is mutually exclusive with `index`/`count` — pass null (or omit) ' +
37583
+ 'for the selector you are not using',
37584
+ );
37142
37585
  }
37143
37586
  wantedRef = input.ref;
37144
37587
  } else {
37145
- index = input.index === undefined ? 0 : Number(input.index);
37588
+ index = fieldInUse(input.index) ? integerField(input.index) : 0;
37146
37589
  if (!Number.isInteger(index) || index < 0) {
37147
37590
  throw new Error('save_recent_image: `index` must be a non-negative integer');
37148
37591
  }
37149
- count = input.count === undefined ? 1 : Number(input.count);
37592
+ count = fieldInUse(input.count) ? integerField(input.count) : 1;
37150
37593
  const MAX_COUNT = 20;
37151
37594
  if (!Number.isInteger(count) || count < 1 || count > MAX_COUNT) {
37152
37595
  throw new Error(`save_recent_image: \`count\` must be an integer in 1..${MAX_COUNT}`);
@@ -37408,7 +37851,7 @@ export class AgentFramework {
37408
37851
  : `Changed before the refusal: ${changed.join(', ')}; no other setting reads differently.`;
37409
37852
  }
37410
37853
 
37411
- private dispatchAgentSettingsToolCall(agentName: string, call: ToolCall): void {
37854
+ private dispatchAgentSettingsToolCall(agentName: string, call: ToolCall, deliver = this.pushToolResult(agentName, call, 'agent')): void {
37412
37855
  this.emitTrace({
37413
37856
  type: 'tool:started',
37414
37857
  module: 'agent',
@@ -37596,14 +38039,7 @@ export class AgentFramework {
37596
38039
  durationMs: 0,
37597
38040
  ...(result.isError ? { error: result.error } : {}),
37598
38041
  });
37599
- this.pushEvent({
37600
- type: 'tool-result',
37601
- callId: call.id,
37602
- toolName: call.name,
37603
- agentName,
37604
- moduleName: 'agent',
37605
- result,
37606
- });
38042
+ deliver(result);
37607
38043
  }
37608
38044
 
37609
38045
  /** Aggregate the event-tag vocabulary: reserved chat:* core + each connected
@@ -37628,7 +38064,7 @@ export class AgentFramework {
37628
38064
  };
37629
38065
  }
37630
38066
 
37631
- private dispatchEventTagsToolCall(agentName: string, call: ToolCall): void {
38067
+ private dispatchEventTagsToolCall(agentName: string, call: ToolCall, deliver = this.pushToolResult(agentName, call, 'gate')): void {
37632
38068
  this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
37633
38069
  let result: import('./types/events.js').ToolResult;
37634
38070
  try {
@@ -37640,10 +38076,10 @@ export class AgentFramework {
37640
38076
  this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
37641
38077
  result = { success: false, error: err.message, isError: true };
37642
38078
  }
37643
- this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'gate', result });
38079
+ deliver(result);
37644
38080
  }
37645
38081
 
37646
- private dispatchGateToolCall(agentName: string, call: ToolCall): void {
38082
+ private dispatchGateToolCall(agentName: string, call: ToolCall, deliver = this.pushToolResult(agentName, call, 'gate')): void {
37647
38083
  this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
37648
38084
  const startTime = Date.now();
37649
38085
 
@@ -37651,27 +38087,13 @@ export class AgentFramework {
37651
38087
  .then((result) => {
37652
38088
  const durationMs = Date.now() - startTime;
37653
38089
  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
- });
38090
+ deliver(result);
37662
38091
  })
37663
38092
  .catch((error) => {
37664
38093
  const err = error instanceof Error ? error : new Error(String(error));
37665
38094
  this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: err.message });
37666
38095
  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
- });
38096
+ deliver({ success: false, error: err.message, isError: true });
37675
38097
  });
37676
38098
  }
37677
38099