@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
@@ -8,6 +8,7 @@ import { appendFileSync, closeSync, fsyncSync, mkdirSync, openSync, readFileSync
8
8
  import { JsStore } from '@animalabs/chronicle';
9
9
  import { MembraneError } from '@animalabs/membrane';
10
10
  import { classifyFailure, retryDecision, MEMBRANE_RETRY_SETTINGS } from '@animalabs/membrane/retry';
11
+ import { deadlineCut, isDeadlineExpiry, resolveInferenceBounds, wholeCallMs } from './inference-bounds.js';
11
12
  import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy, OverBudgetError, UncoveredDropError, stripUnpairedToolBlocks, continuesBody, MalformedContentError, malformedContentPlaces, CompressionWithdrawn } from '@animalabs/context-manager';
12
13
  import { SILENT_HEARTBEAT_SEPARATOR } from './silent-heartbeat.js';
13
14
  import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES, defaultReaderFraming, } from './tune-out/tools.js';
@@ -18,9 +19,9 @@ import { asModuleRow, heldHandle, heldWriteId, matchesMessageQuery } from './hel
18
19
  import { ProcessQueueImpl } from './queue.js';
19
20
  import { DEFAULT_SHUTDOWN_TIMEOUT_MS, validateShutdownTimeout, shutdownErrorMessage, waitForShutdown, ShutdownTimeoutError } from './shutdown.js';
20
21
  import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK, REFUSAL_REACTION_BASELINE } from './refusal-reactions.js';
21
- import { Agent, assertAgentSwitches, dryCompileStatsOf } from './agent.js';
22
+ import { Agent, assertAgentSwitches, assertThinkingBinding, dryCompileStatsOf } from './agent.js';
22
23
  import { assertBooleanSetting, holdsSettings, kindOf } from './config-checks.js';
23
- import { ModuleRegistry, checkedToolResult, ensureStateRegistered, storableToolResult, thrownResult } from './module-registry.js';
24
+ import { ModuleRegistry, checkedToolResult, droppedToolResultLine, ensureStateRegistered, retiredPushLine, storableToolResult, thrownResult } from './module-registry.js';
24
25
  import { McplServerRegistry } from './mcpl/server-registry.js';
25
26
  import { prefixesMeet, prefixesMeetError } from './mcpl/prefix-rule.js';
26
27
  import { logDetail, logValue } from './mcpl/log-value.js';
@@ -627,6 +628,13 @@ export const BUILTIN_TOOLSET_TOOLS = new Set([
627
628
  'heartbeat--heartbeat_trigger',
628
629
  'workspace--status',
629
630
  ]);
631
+ function agentsOwnVerbRefusal(toolName) {
632
+ return {
633
+ success: false,
634
+ error: `${toolName} is the agent's own, which a module's call can't make for it, so nothing was done.`,
635
+ isError: true,
636
+ };
637
+ }
630
638
  const readExtensionSettings = (ext, agentName) => {
631
639
  const values = ext.get(agentName);
632
640
  return Object.fromEntries(Object.entries(values).map(([key, value]) => [key, value === undefined ? 'undefined' : JSON.stringify(value)]));
@@ -669,7 +677,7 @@ import { EventGate, MCPL_GATE_EVENT_TYPES, checkToolInput, formatShadowWarning,
669
677
  import { ReplyJudge } from './gate/reply-judge.js';
670
678
  import { HEARTBEAT_CHANGING_TOOLS, Heartbeat, isHeartbeatMcplServer } from './heartbeat/heartbeat.js';
671
679
  import { EPHEMERAL_USAGE_NAME, UsageTracker, persistedUsageProblem } from './usage/usage-tracker.js';
672
- import { McplProtocolVersionError, McplRequestError, McplUnreapedLaunchError, stderrTailOf, toolsetDeclarationsOf, withStderr } from './mcpl/server-connection.js';
680
+ import { McplRequestError, McplUnreapedLaunchError, stderrTailOf, toolsetDeclarationsOf, withStderr } from './mcpl/server-connection.js';
673
681
  import { ToolsetHub, TOOLSETS_MODULE, META_TOOL_NAMES } from './mcpl/toolset-host.js';
674
682
  import { HostPresentation, BUILTIN_TOOLSET } from './host-presentation.js';
675
683
  import { isEmptyChange } from './mcpl/tool-presence.js';
@@ -677,7 +685,7 @@ import { ToolsetsModule, resolveAct, withLookHint } from './mcpl/toolsets-module
677
685
  import { ModernMcpConnection } from './mcpl/modern-connection.js';
678
686
  import { MAX_TIMER_MS, WITHHELD_TEXT, asText, credentialRedactor, redactUrl, redactServerText, resolveServerBinding, serverConfigProblems, serverCredentialPatterns, serverTextRedactor } from './mcpl/protocol-family.js';
679
687
  import { MALFORMED_CONTENT_NOTICE, normalizeStandardToolResult } from './mcpl/tool-result-normalize.js';
680
- import { boundedForLog, describeCause } from './mcpl/errors.js';
688
+ import { boundedForLog, describeCause, hostStoppedWhileHandling } from './mcpl/errors.js';
681
689
  import { assertServerConfigBooleans, buildWebSocketUrl, isWebSocketTransport } from './mcpl/transport.js';
682
690
  import { formatZonedDateTime, resolveTimeZone } from './timezone.js';
683
691
  import { CLOCK_ROW_KIND, clockStampOf, planClock, takesClockBefore } from './clock-rows.js';
@@ -826,8 +834,8 @@ function resumesLogicalTurn(reason) {
826
834
  */
827
835
  const TURN_RETRY = Symbol('turn-retry');
828
836
  /**
829
- * The batch a start that carries a turn on (continuesTurn) was merged from,
830
- * when the scheduler merged wakes that aren't the turn's into it: every
837
+ * The batch a start was merged from, when the scheduler merged more than
838
+ * one request into it and any of them isn't the turn's: every
831
839
  * request it took in, as each was. Until the start takes its turn token it
832
840
  * stands for them all. A scheduler pass that takes it back puts the batch in
833
841
  * its place, each request with its own identity and flags; a quiesce at the
@@ -847,14 +855,10 @@ function isRearmed(request) {
847
855
  function waitAlertsOnItsOwn(wait) {
848
856
  return wait.until === null || wait.until - wait.setAt > PROVIDER_WAIT_ALERT_MS;
849
857
  }
850
- // membrane 0.8.0 marks a deadline it reports as run out with this registered
851
- // symbol (on a sent request, or the Anthropic SDK's timeout, which covers its
852
- // connect timeout too), and doesn't export its reader.
853
- const MEMBRANE_DEADLINE_EXPIRY = Symbol.for('membrane.deadlineExpiry');
854
858
  function isTransientFailure(error) {
855
859
  if (!isMembraneError(error) || error.retryable !== true)
856
860
  return false;
857
- if (error[MEMBRANE_DEADLINE_EXPIRY] === true)
861
+ if (isDeadlineExpiry(error))
858
862
  return false;
859
863
  return error.type === 'server' || error.type === 'network' || error.type === 'timeout' || error.type === 'rate_limit';
860
864
  }
@@ -949,6 +953,29 @@ function freshTurnRouting() {
949
953
  addressedElsewhere: new Set(),
950
954
  };
951
955
  }
956
+ const TURN_INPUTS_SCAN = 64;
957
+ function turnInputs(cm, floor, agentName, sees) {
958
+ let first;
959
+ let last;
960
+ scan: for (let end = cm.getMessageCount(); end > 0; end -= TURN_INPUTS_SCAN) {
961
+ const { messages } = cm.getMessageWindow(Math.max(0, end - TURN_INPUTS_SCAN), Math.min(TURN_INPUTS_SCAN, end), { resolveBlobs: false });
962
+ for (let i = messages.length - 1; i >= 0; i--) {
963
+ const message = messages[i];
964
+ if (message.sequence <= floor)
965
+ break scan;
966
+ if (!sees(message.metadata))
967
+ continue;
968
+ if (message.participant === agentName)
969
+ break scan;
970
+ last ??= message.id;
971
+ const answers = Array.isArray(message.content) && message.content.length > 0
972
+ && message.content.every((block) => block.type === 'tool_result');
973
+ if (!answers)
974
+ first = message.id;
975
+ }
976
+ }
977
+ return first !== undefined && last !== undefined ? { first, last } : undefined;
978
+ }
952
979
  const sameReach = (a, b) => {
953
980
  if (!a || !b)
954
981
  return a === b;
@@ -1026,10 +1053,6 @@ const LIVE_SPEECH_UNRECEIPTED = `Plain speech its rounds routed live was handed
1026
1053
  * had closed admission (endFailureAtStop). Its inference:failed came first,
1027
1054
  * and this says no retry follows it. */
1028
1055
  const RETRY_CANCELLED_BY_STOP = 'a retry waiting while the framework was stopping is dropped before it sends anything';
1029
- /** The reason on the inference:aborted that ends a turn whose start waited to
1030
- * retry a failed compile across a stop() and a start(): the retry would
1031
- * revive the earlier lifecycle's turn in the next one. */
1032
- const RETRY_ACROSS_RESTART = 'a retry waiting while the framework was stopped and started again is dropped before it sends anything';
1033
1056
  /** The reason on the inference:aborted that ends a turn whose continuation
1034
1057
  * was dropped before it started, other than by an abandon or a stop. */
1035
1058
  const CONTINUATION_DROPPED = "a turn's continuation was dropped before it started";
@@ -1075,6 +1098,24 @@ const MAX_TURN_CHECKPOINTS = 20;
1075
1098
  const DEFAULT_DISCORD_AWARENESS_DEADLINE_MS = 10_000;
1076
1099
  const MIN_DISCORD_AWARENESS_DEADLINE_MS = 50;
1077
1100
  const MAX_DISCORD_AWARENESS_DEADLINE_MS = 60_000;
1101
+ /** Whether a synthesized tool's nullable field is in use: omission, null and
1102
+ * the empty string all mean it isn't, so a caller whose provider presents
1103
+ * every property as required can still leave one unset. Any other value is
1104
+ * in use, and its field checks it. */
1105
+ function fieldInUse(value) {
1106
+ return value !== undefined && value !== null && value !== '';
1107
+ }
1108
+ /** A synthesized tool's integer field: an integer number, or a string of ASCII
1109
+ * digits once trimmed (a model may write an integer as a string), within the
1110
+ * integers a number holds exactly; NaN for anything else. Number() would read
1111
+ * " ", false and [] as 0, and "2.0" or "+2" as 2, and a longer run of digits
1112
+ * as a different, rounded number. */
1113
+ function integerField(value) {
1114
+ const n = typeof value === 'number' ? value
1115
+ : typeof value === 'string' && /^[0-9]+$/.test(value.trim()) ? Number(value.trim())
1116
+ : Number.NaN;
1117
+ return Number.isSafeInteger(n) ? n : Number.NaN;
1118
+ }
1078
1119
  function normalizeDiscordAwarenessDeadline(value) {
1079
1120
  if (value === undefined || !Number.isFinite(value) || value <= 0) {
1080
1121
  return DEFAULT_DISCORD_AWARENESS_DEADLINE_MS;
@@ -1084,9 +1125,10 @@ function normalizeDiscordAwarenessDeadline(value) {
1084
1125
  /**
1085
1126
  * Refuse a config whose `subconscious`, `watchdog` or `processLogging` is
1086
1127
  * anything but an object, null or false (holdsSettings), or whose switch in
1087
- * one holds anything but true or false (assertBooleanSetting). A subconscious
1088
- * that isn't on has its `allowChannelSpeech` left unchecked, since nothing
1089
- * reads it then.
1128
+ * one holds anything but true or false (assertBooleanSetting), or whose
1129
+ * subconscious's `thinkingBinding` assertThinkingBinding refuses, whichever
1130
+ * its reader. A subconscious that isn't on has its `allowChannelSpeech` and
1131
+ * `thinkingBinding` left unchecked, since nothing reads them then.
1090
1132
  */
1091
1133
  function assertFrameworkSwitches(config) {
1092
1134
  if (holdsSettings('subconscious', config.subconscious, '{ enabled: true }')) {
@@ -1094,6 +1136,7 @@ function assertFrameworkSwitches(config) {
1094
1136
  'and false or leaving it out doesn\'t');
1095
1137
  if (config.subconscious.enabled === true) {
1096
1138
  assertBooleanSetting('subconscious.allowChannelSpeech', config.subconscious.allowChannelSpeech, 'true lets the subconscious speak in channels with speak_in_channel, and false or leaving it out refuses it');
1139
+ assertThinkingBinding('subconscious.thinkingBinding', config.subconscious.thinkingBinding);
1097
1140
  }
1098
1141
  }
1099
1142
  if (holdsSettings('watchdog', config.watchdog, '{ enabled: true }')) {
@@ -1454,9 +1497,15 @@ function hostCommandRequester(serverId, params) {
1454
1497
  * schedule, jittered, 10 s doubling to 5 min (by default 13 tries, where
1455
1498
  * membrane's settings have 7: about 30 min).
1456
1499
  * - rate limited (429): the base schedule with at least 5 tries.
1457
- * - any other retryable failure, a stream deadline on a sent request
1458
- * included (membrane leaves those to its caller): the base schedule (by
1459
- * default 3 tries, 1 s then 2 s).
1500
+ * - a deadline expiry, which membrane leaves to its caller: a progress
1501
+ * bound's cut (FrameworkConfig.inferenceBounds), the adapter's own
1502
+ * watchdogs, the SDK's request timeout or a runtime's fetch timeout. The
1503
+ * deadline schedule: by default 2 tries, 1 s apart, so one fresh call. For
1504
+ * one, the framework's `attempt` counts that call's consecutive expiries,
1505
+ * not the turn's other failures, so each call a deadline cuts gets its
1506
+ * fresh call. Settings with no deadline schedule give it their base one.
1507
+ * - any other retryable failure: the base schedule (by default 3 tries, 1 s
1508
+ * then 2 s).
1460
1509
  * A provider's stated wait (`retryAfterMs`) is waited when it is longer than
1461
1510
  * the backoff, up to the schedule's ceiling, which by default is at least the
1462
1511
  * framework's own cap (PROVIDER_ACCELERATION_MAX_COOLDOWN_MS, ten minutes);
@@ -1476,6 +1525,10 @@ export class DefaultErrorPolicy {
1476
1525
  this.maxRetries = Math.max(0, ...Object.values(settings).map((s) => (s?.maxAttempts ?? 1) - 1));
1477
1526
  }
1478
1527
  onInferenceError(error, _agentName, attempt) {
1528
+ if (isDeadlineExpiry(error)) {
1529
+ const decision = retryDecision(this.settings.deadline ? 'deadline' : 'retryable', attempt + 1, undefined, this.settings);
1530
+ return decision.retry ? { retry: true, delayMs: decision.delayMs } : { retry: false };
1531
+ }
1479
1532
  if (!isMembraneError(error)) {
1480
1533
  // Not a provider failure membrane classified: retried as any other
1481
1534
  // retryable failure, on the base schedule.
@@ -1485,9 +1538,7 @@ export class DefaultErrorPolicy {
1485
1538
  if (error.retryable === false)
1486
1539
  return { retry: false };
1487
1540
  // The facts membrane classifies by, read off the error itself (from either
1488
- // copy of membrane, as isMembraneError takes one). No `deadline` fact: a
1489
- // stream deadline is a retryable timeout here, as it was before membrane
1490
- // 0.7, where membrane itself gives it no retry.
1541
+ // copy of membrane, as isMembraneError takes one).
1491
1542
  const facts = error;
1492
1543
  // A failure that doesn't say is retried as an unknown one is; only false refuses (handled above).
1493
1544
  const cls = classifyFailure({ retryable: facts.retryable !== false, type: facts.type, httpStatus: facts.httpStatus, message: facts.message });
@@ -1516,18 +1567,37 @@ const MAINTENANCE_TICKS_PER_PASS = 8;
1516
1567
  * together, and then on awareness drains still running, such as its
1517
1568
  * startup's delivery. */
1518
1569
  const FAILED_CREATE_TEARDOWN_TIMEOUT_MS = 30_000;
1570
+ const ABANDON_GRACE_MS = 10_000;
1571
+ const testAbandonGraces = new WeakMap();
1572
+ /**
1573
+ * For tests only: `framework`'s abandon takes `ms` as its grace in place of
1574
+ * {@link ABANDON_GRACE_MS}. Set it only where the test holds every turn the
1575
+ * abandon reaches past the grace, so no turn can end within it and a short
1576
+ * grace only brings the abandon's report sooner.
1577
+ */
1578
+ export function setAbandonGraceForTests(framework, ms) {
1579
+ testAbandonGraces.set(framework, ms);
1580
+ }
1519
1581
  const PROVIDER_ACCELERATION_DEFAULT_COOLDOWN_MS = 65_000;
1520
1582
  /** Cap on waits the framework chooses itself. A provider's stated wait is
1521
1583
  * never capped: it is a lower bound (see ProviderWaits). It is also the
1522
1584
  * longest stated wait an ephemeral run sleeps in its turn: a longer one ends
1523
1585
  * the run instead (nonOwningProviderWait). */
1524
1586
  const PROVIDER_ACCELERATION_MAX_COOLDOWN_MS = 10 * 60_000;
1587
+ const FAILURE_ADVICE = 'If this recurs with the same cause, change approach rather than retrying identically ' +
1588
+ '(e.g. drop an oversized attachment or an unsupported setting).';
1589
+ const TIMEOUT_ADVICE = 'If a reply is still owed from that turn, you can give it now.';
1590
+ const MADE_AGAIN = 'The call was made again, and it timed out again.';
1525
1591
  const OVERLOADED_TURN_TRIES = 13;
1526
- const TURN_RETRY_SETTINGS = Object.fromEntries(Object.entries(MEMBRANE_RETRY_SETTINGS).map(([cls, s]) => [cls, s && {
1527
- ...s,
1528
- ...(cls === 'overloaded' ? { maxAttempts: OVERLOADED_TURN_TRIES } : {}),
1529
- statedWaitCeilingMs: s.statedWaitCeilingMs === null ? null : Math.max(s.statedWaitCeilingMs, PROVIDER_ACCELERATION_MAX_COOLDOWN_MS),
1530
- }]));
1592
+ const DEADLINE_TURN_TRIES = 2;
1593
+ const TURN_RETRY_SETTINGS = {
1594
+ ...Object.fromEntries(Object.entries(MEMBRANE_RETRY_SETTINGS).map(([cls, s]) => [cls, s && {
1595
+ ...s,
1596
+ ...(cls === 'overloaded' ? { maxAttempts: OVERLOADED_TURN_TRIES } : {}),
1597
+ statedWaitCeilingMs: s.statedWaitCeilingMs === null ? null : Math.max(s.statedWaitCeilingMs, PROVIDER_ACCELERATION_MAX_COOLDOWN_MS),
1598
+ }])),
1599
+ deadline: { maxAttempts: DEADLINE_TURN_TRIES, firstDelayMs: 1_000, multiplier: 1, maxDelayMs: 1_000, statedWaitCeilingMs: null },
1600
+ };
1531
1601
  const PROVIDER_ACCELERATION_JITTER_MS = 5_000;
1532
1602
  /** Longest delay one setTimeout can represent (2^31-1 ms, about 24.8 days);
1533
1603
  * a longer timer fires almost at once, so longer waits re-arm in steps. */
@@ -1577,6 +1647,11 @@ async function* turnStreamEvents(stream, agentName) {
1577
1647
  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`);
1578
1648
  return;
1579
1649
  }
1650
+ if (event.type === 'aborted' && event.reason === 'timeout') {
1651
+ decidedBy = 'error';
1652
+ yield { type: 'error', error: deadlineCut(event.message) };
1653
+ continue;
1654
+ }
1580
1655
  if (event.type === 'complete' || event.type === 'error' || event.type === 'aborted')
1581
1656
  decidedBy = event.type;
1582
1657
  yield event;
@@ -1822,6 +1897,30 @@ const isLowSurrogate = (text, index) => {
1822
1897
  };
1823
1898
  /** Whether a cut before `index` would separate the halves of a surrogate pair. */
1824
1899
  const splitsPair = (text, index) => index > 0 && index < text.length && isHighSurrogate(text, index - 1) && isLowSurrogate(text, index);
1900
+ /**
1901
+ * Whether an MCPL server's child ended the way a server ends when it's asked
1902
+ * to: exit 0, death by a signal that asks a process to end (SIGTERM, SIGINT,
1903
+ * SIGHUP), or a shell's report of such a death by its convention, 128 plus the
1904
+ * signal's number (143, 130, 129). A service manager's stop signals the host
1905
+ * and its children together, so a child can go before the host's stop()
1906
+ * closes it: its loss is that stop, not a failure, and raises no mcpl-down.
1907
+ * A child the stdio transport ended itself, after a pipe error or a line on
1908
+ * its stdout longer than the bound, was ended by that, whatever its code or
1909
+ * signal. A WebSocket's close codes are 1000 and up, so they never match.
1910
+ */
1911
+ function endedAsAsked(ended) {
1912
+ if (ended.pipeFailed)
1913
+ return false;
1914
+ const { code, signal } = ended;
1915
+ return code === 0 || code === 143 || code === 130 || code === 129 ||
1916
+ signal === 'SIGTERM' || signal === 'SIGINT' || signal === 'SIGHUP';
1917
+ }
1918
+ /** A lost connection's words for an alert: the transport's reason, then its
1919
+ * exit or close code and signal, each when there is one. */
1920
+ function closeWords(reason, code, signal) {
1921
+ const how = [reason ?? null, code != null ? `code ${code}` : null, signal ? `signal ${signal}` : null].filter((part) => part !== null);
1922
+ return how.length > 0 ? ` (${how.join(', ')})` : '';
1923
+ }
1825
1924
  /**
1826
1925
  * `text` when it fits within `max` characters; otherwise its head and tail
1827
1926
  * around a marker stating how many of how many characters were omitted, the
@@ -2067,6 +2166,16 @@ export function shedMayHelp(errorType, httpStatus) {
2067
2166
  return errorType === 'invalid_request' || errorType === 'context_length';
2068
2167
  return errorType === 'invalid_request' && (httpStatus === undefined || httpStatus === 400 || httpStatus === 422);
2069
2168
  }
2169
+ function responseParts(response) {
2170
+ return [
2171
+ response?.addMessages?.length ? `${response.addMessages.length} message(s) to add` : '',
2172
+ response?.editMessages?.length ? `${response.editMessages.length} edit(s)` : '',
2173
+ response?.removeMessages?.length ? `${response.removeMessages.length} removal(s)` : '',
2174
+ response?.stateUpdate !== undefined ? 'a state update' : '',
2175
+ response?.requestInference ? 'an inference request' : '',
2176
+ response?.toolsChanged ? 'a tools change' : '',
2177
+ ].filter(Boolean).join(', ');
2178
+ }
2070
2179
  /**
2071
2180
  * What a thrown value says, safely and briefly, as boundedErrorMessage gives
2072
2181
  * it, with `recorded` applied before the cut, so no part of a credential
@@ -2168,6 +2277,12 @@ function wakeLineKey(line) {
2168
2277
  return JSON.stringify([eventType, serverId ?? null, channelId ?? null, messageId ?? null, eventId ?? null]);
2169
2278
  }
2170
2279
  const SUBCONSCIOUS_MODEL_FIELDS = ['physicalWindowTokens', 'thinkingBinding'];
2280
+ function readerModelFields(cfg) {
2281
+ return {
2282
+ ...(cfg.physicalWindowTokens != null ? { physicalWindowTokens: cfg.physicalWindowTokens } : {}),
2283
+ ...(holdsSettings('subconscious.thinkingBinding', cfg.thinkingBinding, '{ binds: true, dropBlock: true }') ? { thinkingBinding: cfg.thinkingBinding } : {}),
2284
+ };
2285
+ }
2171
2286
  const JUDGE_PARAMETER = {
2172
2287
  type: 'object',
2173
2288
  description: 'Ask the reply judge first: batch matching events like debounceMs, then a small, cheap model reads your ' +
@@ -2233,9 +2348,10 @@ export class AgentFramework {
2233
2348
  store;
2234
2349
  ownsStore;
2235
2350
  stopPromise = null;
2236
- stopCompleted = false;
2351
+ // Nothing sets it back to 'none': a stop ends its instance (#315).
2352
+ stopState = 'none';
2353
+ closedStopFailure;
2237
2354
  ownedStoreClosed = false;
2238
- ownedStoreManagersClosed = false;
2239
2355
  /** Whether the latest stop() got through its teardown with nothing failed:
2240
2356
  * every step and every wait up to and including the one on awareness
2241
2357
  * drains, whatever failed after that, in the storage work that finishes
@@ -2251,8 +2367,8 @@ export class AgentFramework {
2251
2367
  * promise: a stream it cancelled or an event loop still in a call; and a
2252
2368
  * stream whose cancel threw, which stop() doesn't wait for. A later
2253
2369
  * stop() waits for each again, beside what it finds, whatever has happened
2254
- * since to the entry it was found under (a run's end removes its stream's,
2255
- * a start() replaces the loop), so neither a budget nor a cancel that
2370
+ * since to the entry it was found under (a run's end removes its stream's),
2371
+ * so neither a budget nor a cancel that
2256
2372
  * threw lets the store close under work still running. Each leaves once it
2257
2373
  * settles. */
2258
2374
  leftRunning = new Map();
@@ -2318,6 +2434,9 @@ export class AgentFramework {
2318
2434
  providerAccelerationLastRecovery = new Map();
2319
2435
  providerAccelerationDefaultCooldownMs = PROVIDER_ACCELERATION_DEFAULT_COOLDOWN_MS;
2320
2436
  providerAccelerationJitterMs = PROVIDER_ACCELERATION_JITTER_MS;
2437
+ inferenceBounds = resolveInferenceBounds(undefined);
2438
+ deadlineCuts = new Map();
2439
+ freshCallsCut = new Set();
2321
2440
  providerHoldHook;
2322
2441
  /** Provider waits (stated retry-after) per (agent, model), kept across restarts. */
2323
2442
  providerWaits;
@@ -2325,8 +2444,6 @@ export class AgentFramework {
2325
2444
  retryWaitWakers = new Set();
2326
2445
  waitingTraceEveryMs = WAITING_TRACE_EVERY_MS;
2327
2446
  providerAdmissionClosed = false;
2328
- /** Invalidates framework turns still assembling when stop begins. */
2329
- lifecycleGeneration = 0;
2330
2447
  /** Last time we reported stale (busy-requeued) inference requests, per agent. */
2331
2448
  staleWarnAt = new Map();
2332
2449
  /** Per-agent last inference activity (epoch ms), for /healthz + doctor tooling. */
@@ -2467,6 +2584,7 @@ export class AgentFramework {
2467
2584
  turnRearmMs = DEFAULT_TURN_REARM_MS;
2468
2585
  turnRearms = new Map();
2469
2586
  exhaustingRearms = new Set();
2587
+ rearmDeadlineCuts = new Set();
2470
2588
  statedWaitRuns = new Map();
2471
2589
  /** N consecutive failed inferences ⇒ the agent is treated as hard-down and
2472
2590
  * escalated loudly to stderr. */
@@ -2999,6 +3117,7 @@ export class AgentFramework {
2999
3117
  getMessage: (id) => this.getMessage(id),
3000
3118
  queryMessages: (filter, opts) => this.queryMessages(filter, opts),
3001
3119
  pushEvent: (event, moduleName) => this.pushModuleEvent(event, moduleName, true),
3120
+ dropRetiredPush: (event, moduleName) => this.dropRetiredModuleEvent(event, moduleName),
3002
3121
  onTrace: (listener) => this.onTrace(listener),
3003
3122
  // The ONLY entry that confers module origin — a private closure the
3004
3123
  // framework hands to its own module registry. The public
@@ -3022,6 +3141,7 @@ export class AgentFramework {
3022
3141
  // Before anything opens or starts: a refused config must leave nothing behind.
3023
3142
  if (config.codeExecution != null)
3024
3143
  validateCodeExecutionConfig(config.codeExecution);
3144
+ resolveInferenceBounds(config.inferenceBounds);
3025
3145
  assertFrameworkSwitches(config);
3026
3146
  for (const agentConfig of config.agents)
3027
3147
  assertAgentSwitches(agentConfig);
@@ -3190,6 +3310,7 @@ export class AgentFramework {
3190
3310
  const framework = building.framework = new AgentFramework(store, ownsStore, config.membrane, config.inferencePolicy ?? new DefaultInferencePolicy(), config.errorPolicy ?? new DefaultErrorPolicy(), config.syncIntervalMs ?? DEFAULT_SYNC_INTERVAL_MS, config.maintenanceIntervalMs ?? DEFAULT_MAINTENANCE_INTERVAL_MS, processLoggingPersist, resolveTimeZone(config.timeZone), discordAwarenessOutbox, config.discordAwarenessEmoji ?? DEFAULT_DISCORD_AWARENESS_EMOJI, normalizeDiscordAwarenessDeadline(config.discordAwarenessDeadlineMs), new OperatorLog(operatorLogPath));
3191
3311
  framework.providerHoldHook = config.providerHold;
3192
3312
  framework.turnRearmMs = turnRearmDelays(config.exhaustedTurnRearmMs);
3313
+ framework.inferenceBounds = resolveInferenceBounds(config.inferenceBounds);
3193
3314
  // Before any delivery.
3194
3315
  await framework.reconcileDiscordAwarenessAtStartup();
3195
3316
  // Restore persisted usage data (if any) from prior session. The names it
@@ -3671,9 +3792,7 @@ export class AgentFramework {
3671
3792
  * maintenance, event loop and operator operations, so a dump shows what
3672
3793
  * those waits are on, and before its modules and MCPL servers stop, so a
3673
3794
  * stop() whose teardown hangs, fails or times out doesn't keep it listed.
3674
- * With none in the set, one line says no framework is running. A framework
3675
- * started again after a completed stop() doesn't rejoin; whether that
3676
- * lifecycle is supported is #315.
3795
+ * With none in the set, one line says no framework is running.
3677
3796
  *
3678
3797
  * One listener serves the process, installed by the first create() to
3679
3798
  * reach the join and kept: once a process's last SIGUSR2 listener is
@@ -3717,35 +3836,38 @@ export class AgentFramework {
3717
3836
  }
3718
3837
  /**
3719
3838
  * Start the event loop. A start() on a running framework does nothing.
3720
- * Refused, with an error, from a stop() call until that stop() settles,
3721
- * since that stop would wait on the lifecycle a start() begins, or tear it
3722
- * down under it. What a start() after a settled stop() should do is #315's
3723
- * question.
3839
+ * A stop ends its instance (#315): once a stop() is admitted, start() and
3840
+ * addModule() are refused with an error, for good. While that stop() runs,
3841
+ * the error says the framework is stopping. Once it has settled, the error
3842
+ * says what to do: create a new AgentFramework on the store, and after a
3843
+ * failed stop(), call stop() again first, until one resolves. When a
3844
+ * failed stop() left its owned store closed, so that it can't be tried
3845
+ * again, the error carries that failure as its cause.
3846
+ *
3847
+ * A new instance on the store keeps what the store holds. With the
3848
+ * heartbeat on and the same heartbeat state file (heartbeat.statePath, or
3849
+ * its default), it keeps the heartbeat's schedule as it reads it from that
3850
+ * file: a tick or a reminder that fell due meanwhile fires once, shortly
3851
+ * after it starts. What an instance holds only in memory is lost with it,
3852
+ * as on a process restart: an agent's sleep and its wake, a self-wake, the
3853
+ * inference requests still queued (a budget restart, a heartbeat's wake, a
3854
+ * nudge), a re-armed turn's wait and count, and the redo stack. #627 is
3855
+ * about keeping a sleep and a self-wake across a restart. To pause and
3856
+ * resume one framework in place, use quiesce() and resume().
3724
3857
  */
3725
3858
  start() {
3726
3859
  // Before the running check: stop() claims its attempt before it clears
3727
3860
  // running, so a start() made in its first steps, such as from a trace
3728
3861
  // listener, is refused too.
3729
- this.resetCompletedStop('start()');
3862
+ this.refuseOnceStopped('start()');
3730
3863
  if (this.running) {
3731
3864
  return;
3732
3865
  }
3733
3866
  this.running = true;
3734
- this.providerAdmissionClosed = false;
3735
3867
  this.earlyTraces = null;
3736
- // A new lifecycle: a turn compiled before an earlier stop() never drives,
3737
- // and withheld spills, closed for the generation a stop ended, are open
3738
- // for this one.
3739
- this.lifecycleGeneration++;
3740
- // Its servers' addresses, which an earlier stop() took out of the
3741
- // reference registry, for its stubs.
3742
3868
  for (const config of this.mcplServerConfigs.values()) {
3743
3869
  referenceRegistry.setServerAddress(config.id, serverAddressOf(config), this);
3744
3870
  }
3745
- // A sleep kept through a stop() wakes this lifecycle at its end, or now
3746
- // if it ended while stopped. Before the loop's first pass, so that a wake
3747
- // owed now joins the requests already pending in one turn.
3748
- this.eventGate?.resumeSleep();
3749
3871
  this.heartbeat?.start();
3750
3872
  this.loopPromise = this.runLoop();
3751
3873
  // Start periodic sync timer (if enabled)
@@ -3770,11 +3892,14 @@ export class AgentFramework {
3770
3892
  }
3771
3893
  }
3772
3894
  /**
3773
- * Stop the event loop. Until it settles, start() and addModule() are
3774
- * refused. Each step of the teardown runs whatever an earlier one did: one
3775
- * that throws, such as a host stream's cancel(), fails the stop() once the
3776
- * rest has run. When a step or a wait up to and including
3777
- * the wait on awareness drains fails or times out, storage stays open and
3895
+ * Stop the event loop, and with it this framework, for good: once a stop()
3896
+ * is admitted, start() and addModule() are refused, as start() says. A
3897
+ * stop() whose budget is refused, which it rejects with a RangeError, is
3898
+ * not admitted and changes nothing. Each step of the teardown runs
3899
+ * whatever an earlier one did: one that throws, such as a host stream's
3900
+ * cancel(), fails the stop() once the rest has run. When a step or a wait
3901
+ * up to and including the wait on awareness drains fails or times out,
3902
+ * storage stays open and
3778
3903
  * stop() can be called again; one that fails after it, in the storage work
3779
3904
  * that finishes the stop, fails the stop() without keeping storage open.
3780
3905
  * A timed-out wait lets teardown go on while what it waited for may still
@@ -3782,7 +3907,10 @@ export class AgentFramework {
3782
3907
  * @param teardownTimeoutMs Optional budget for each of stop()'s waits, in
3783
3908
  * turn: on the streams it cancels, on maintenance in flight (a pass or an
3784
3909
  * over-budget drain kick), on the event loop, on an operator's operations
3785
- * already running, on modules and MCPL together, then on Discord awareness
3910
+ * already running, on modules and MCPL (its servers and the inference
3911
+ * requests they made) together, where a module whose start() is still
3912
+ * running counts until that start() has settled and its stop() returned,
3913
+ * then on Discord awareness
3786
3914
  * drains, which send nothing once stop() has begun, recording the call the
3787
3915
  * close ended. With a budget, a stop() can take up to six of them, besides
3788
3916
  * the withheld-spill deadline of a guard's write still under way. Omit to
@@ -3805,9 +3933,7 @@ export class AgentFramework {
3805
3933
  * dropped, though a write the stream already had under way can still
3806
3934
  * land in the run's namespace after them.
3807
3935
  * - an event loop still in a call, such as a module's onProcess, finishes
3808
- * that call and the rest of its iteration, and then exits. A start()
3809
- * made once that stop() has failed runs its own loop beside that last
3810
- * iteration.
3936
+ * that call and the rest of its iteration, and then exits.
3811
3937
  * - a later stop() waits again, within its own budget, for any stream or
3812
3938
  * loop this one left running, so the store never closes under it.
3813
3939
  *
@@ -3835,11 +3961,14 @@ export class AgentFramework {
3835
3961
  let settle;
3836
3962
  const attempt = new Promise((resolve, reject) => { settle = { resolve, reject }; });
3837
3963
  this.stopPromise = attempt;
3838
- void attempt.then(() => { this.stopCompleted = true; }, () => { });
3839
- // Failed cleanup can be retried while its original store is open. A
3840
- // terminal storage failure after successful close must remain a failure.
3841
- void attempt.catch(() => {
3842
- if (!this.ownedStoreClosed && this.stopPromise === attempt)
3964
+ this.stopState = 'stopping';
3965
+ void attempt.then(() => { this.stopState = 'completed'; }, (error) => {
3966
+ this.stopState = 'failed';
3967
+ // Failed cleanup can be retried while its original store is open. A
3968
+ // terminal storage failure after successful close must remain a failure.
3969
+ if (this.ownedStoreClosed)
3970
+ this.closedStopFailure = { error };
3971
+ else if (this.stopPromise === attempt)
3843
3972
  this.stopPromise = null;
3844
3973
  });
3845
3974
  // The body still starts in this call, so its synchronous steps (closing
@@ -3847,24 +3976,28 @@ export class AgentFramework {
3847
3976
  this.stopOwned(teardownTimeoutMs).then(settle.resolve, settle.reject);
3848
3977
  return attempt;
3849
3978
  }
3850
- /** What start() and addModule() pass before either begins work of a
3851
- * lifecycle. A stop() still running refuses both, from the moment it
3852
- * claims its attempt until that attempt settles. A failed stop() has
3853
- * cleared its attempt, unless it closed an owned store, or the context
3854
- * managers it closes before that store, which are refused first. A
3855
- * completed stop() is cleared, and the call goes on as before;
3856
- * whether a lifecycle should begin after a stop() is #315's question. */
3857
- resetCompletedStop(verb) {
3858
- if (this.ownedStoreClosed)
3859
- throw new Error('Cannot start a new lifecycle: owned store is closed');
3860
- if (this.ownedStoreManagersClosed) {
3861
- throw new Error('Cannot start a new lifecycle: stop() closed its context managers, and then failed to close its owned store');
3862
- }
3863
- if (this.stopPromise && !this.stopCompleted)
3864
- throw new Error(`${verb} refused: the framework is stopping`);
3865
- if (this.stopCompleted) {
3866
- this.stopPromise = null;
3867
- this.stopCompleted = false;
3979
+ refuseOnceStopped(verb) {
3980
+ switch (this.stopState) {
3981
+ case 'none':
3982
+ return;
3983
+ case 'stopping':
3984
+ throw new Error(`${verb} refused: the framework is stopping`);
3985
+ case 'completed':
3986
+ throw new Error(`${verb} refused: this framework has stopped, and a stopped framework doesn't start again. ` +
3987
+ 'Create a new AgentFramework on its store to go on, with its storePath if it opened the store itself.');
3988
+ case 'failed': {
3989
+ const closed = this.closedStopFailure;
3990
+ if (closed) {
3991
+ throw new Error(`${verb} refused: this framework's stop() failed and its owned store is closed, so stop() can't be tried again: ` +
3992
+ `it rejects with that same failure, this error's cause: ${shutdownErrorMessage(closed.error)}. ` +
3993
+ "A stopped framework doesn't start again, and a new AgentFramework on its store doesn't finish what that stop() left unfinished.", { cause: closed.error });
3994
+ }
3995
+ throw new Error(`${verb} refused: this framework's stop() failed, and a stopped framework doesn't start again. ` +
3996
+ 'A failed stop() may have left its cleanup or final persistence unfinished. ' +
3997
+ "Call stop() again: once a stop() of this one resolves, they're done, and only then should a new " +
3998
+ 'AgentFramework use its store, with its storePath if this one opened the store itself, ' +
3999
+ 'or another instance use anything this one shares.');
4000
+ }
3868
4001
  }
3869
4002
  }
3870
4003
  /** Register before invoking the body, so the set holds the operation for
@@ -3995,6 +4128,15 @@ export class AgentFramework {
3995
4128
  // holding the wait for operator operations below until its handshake's
3996
4129
  // deadline.
3997
4130
  step(() => this.endConnectsInFlight());
4131
+ // Nor does a server lost before or during the stop launch or dial again
4132
+ // on its own: a retry already scheduled starts nothing, and none is
4133
+ // scheduled after.
4134
+ step(() => {
4135
+ for (const connection of this.mcplServerRegistry?.getAllServers() ?? [])
4136
+ connection.stopReconnecting();
4137
+ for (const connection of this.modernServers().values())
4138
+ connection.stopReconnecting();
4139
+ });
3998
4140
  step(() => this.pushCoalescer?.suspend());
3999
4141
  step(() => this.flushCoalescingSnapshot());
4000
4142
  this.running = false;
@@ -4010,11 +4152,10 @@ export class AgentFramework {
4010
4152
  this.providerAdmissionClosed = true;
4011
4153
  for (const wake of [...this.retryWaitWakers])
4012
4154
  step(wake);
4013
- this.lifecycleGeneration++;
4014
- // The generation this stop ends; withheld spills close for it alone, so a
4015
- // start() after this stop has settled reopens them for its own.
4016
- const stoppingGeneration = this.lifecycleGeneration;
4155
+ this.guardSpillsClosed = false;
4017
4156
  step(() => this.queue.close());
4157
+ let inferences = [];
4158
+ step(() => { inferences = this.inferenceRouter?.stopAll() ?? []; });
4018
4159
  step(() => this.tuneOutCoordinator?.stop());
4019
4160
  // Stop running code_execution scripts before cancelling streams: a
4020
4161
  // zombie script must not keep firing side-effectful tool calls into a
@@ -4027,9 +4168,8 @@ export class AgentFramework {
4027
4168
  // framework must not keep up to the whole ledger budget referenced.
4028
4169
  this.toolImageLedgers.clear();
4029
4170
  // A turn whose continuation is still on its way ends with the stop, as
4030
- // a turn's start a stop reaches does: the continuation would start only
4031
- // after a start(), in a lifecycle where its logical turn is gone
4032
- // (openLogical, below). Before the holds and the parks go, so the
4171
+ // a turn's start a stop reaches does: the continuation never starts.
4172
+ // Before the holds and the parks go, so the
4033
4173
  // continuation leaves them with its turn. Each drop is a step of its
4034
4174
  // own, so one that throws leaves the next agent's turn to end all the
4035
4175
  // same, as stop()'s other loops do.
@@ -4040,11 +4180,7 @@ export class AgentFramework {
4040
4180
  });
4041
4181
  // A stopped host must never hang behind its own cooldown.
4042
4182
  step(() => this.cancelProviderAdmission());
4043
- // An open logical turn doesn't survive a stop: a start after a restart
4044
- // on this instance opens a new one rather than continuing the old turn's
4045
- // routing (openLogical). A resume still continues its turn, with its
4046
- // routing state (turnRouting), since it carries that turn's own request
4047
- // on: beginAgentTurn opens the turn again for it.
4183
+ // An open logical turn doesn't survive a stop.
4048
4184
  this.openLogical.clear();
4049
4185
  // Cancel the stream of every agent whose state holds one: streaming,
4050
4186
  // waiting on its tools, or ready at a tool boundary. Membrane reports
@@ -4152,9 +4288,11 @@ export class AgentFramework {
4152
4288
  // its idle watchdog. Its end stores its queue, as at any run's end; that
4153
4289
  // stream's late events are dropped once the run has left, and a later
4154
4290
  // stop() waits for the stream itself (leftRunning). (A start() is refused
4155
- // until this stop() settles, so no new lifecycle begins beside it.)
4291
+ // once a stop() is admitted, so no new lifecycle begins beside it.)
4156
4292
  for (const [name, run] of this.ephemeralRuns)
4157
4293
  run.settle.reject(ephemeralRunStopped(name));
4294
+ for (const agent of this.agents.values())
4295
+ step(() => { agent.unpinTurnInputs(); });
4158
4296
  // Admission closed when stop() began. What admitted operations start now,
4159
4297
  // such as a surgery's config materialization, joins the set, so wait until
4160
4298
  // the set is empty. This comes after the streams and the event loop, which
@@ -4183,11 +4321,9 @@ export class AgentFramework {
4183
4321
  // generation and nothing reads the old fork's window again. So each live
4184
4322
  // fork ends here as a closing fork's end disposes it, and its release
4185
4323
  // passes on what it left unread, as any release does. Its channel unbinds
4186
- // first, as the idle sweep's does: a start() after this stop() would
4187
- // otherwise route a message still queued for it to a name that's gone. A
4188
- // fork whose spawn is still waiting for its creation to return has no
4189
- // binding yet, and its spawn binds nothing once it has ended
4190
- // (spawnConversationAgent).
4324
+ // first, as the idle sweep's does. A fork whose spawn is still waiting
4325
+ // for its creation to return has no binding yet, and its spawn binds
4326
+ // nothing once it has ended (spawnConversationAgent).
4191
4327
  // Each is a step of its own, and each fork ends whatever its stream or
4192
4328
  // turn is doing, as each run does above: its end settles a tool-result
4193
4329
  // batch its stream still held and removes the stream's entries
@@ -4278,16 +4414,16 @@ export class AgentFramework {
4278
4414
  // workspace: its stub names the audit record. A write that was past its
4279
4415
  // deadline isn't waited for here, but the workspace's own stop waits for
4280
4416
  // the mount turn it holds, bounded only by teardownTimeoutMs.
4281
- this.guardSpillsClosedFor = stoppingGeneration;
4417
+ this.guardSpillsClosed = true;
4282
4418
  // Stop modules and both engines' MCP servers in parallel
4283
- const shutdownPromises = [this.moduleRegistry.stopAll(teardownTimeoutMs)];
4419
+ const shutdownPromises = [this.moduleRegistry.stopAll(teardownTimeoutMs), waitFor(inferences)];
4284
4420
  if (this.mcplServerRegistry) {
4285
4421
  shutdownPromises.push(this.mcplServerRegistry.closeAll(teardownTimeoutMs));
4286
4422
  }
4287
4423
  shutdownPromises.push(this.closeModernMcpConnections(teardownTimeoutMs));
4288
4424
  // A stopped framework's servers' addresses leave the reference registry,
4289
4425
  // so no other framework's stub reads them and the registry doesn't keep
4290
- // this one reachable. start() records them again.
4426
+ // this one reachable.
4291
4427
  step(() => { for (const id of this.mcplServerConfigs.keys())
4292
4428
  referenceRegistry.forgetServerAddress(id, this); });
4293
4429
  // Timed-out or failed teardown retains its context and may still write.
@@ -4346,8 +4482,8 @@ export class AgentFramework {
4346
4482
  // No stream is left, unless its wait above ran out or its cancel threw:
4347
4483
  // from the start of stop() none registers and no agent turn starts
4348
4484
  // (providerAdmissionClosed, checked for every agent), and every stream
4349
- // registered before was cancelled and awaited above. (A start(), which
4350
- // would reopen admission, is refused until this stop() settles.)
4485
+ // registered before was cancelled and awaited above. (A start() is
4486
+ // refused once a stop() is admitted, so admission stays closed.)
4351
4487
  // What is still deferred (a message held for a turn's end whose flush
4352
4488
  // found the target busy again, or one deferred behind its target's own
4353
4489
  // state) would wait for a flush that may never come. Land each at its
@@ -4427,8 +4563,7 @@ export class AgentFramework {
4427
4563
  shutdownErrors.push(error);
4428
4564
  }
4429
4565
  if (this.ownsStore && teardownSucceeded) {
4430
- if (this.closeOpenManagers())
4431
- this.ownedStoreManagersClosed = true;
4566
+ this.closeOpenManagers();
4432
4567
  try {
4433
4568
  this.store.close();
4434
4569
  this.ownedStoreClosed = true;
@@ -4889,7 +5024,6 @@ export class AgentFramework {
4889
5024
  const alive = [...this.openManagers].flatMap((ref) => ref.deref() ?? []);
4890
5025
  for (const contextManager of alive)
4891
5026
  this.closeManager(contextManager);
4892
- return alive.length > 0;
4893
5027
  }
4894
5028
  /** The framework's membrane, with each complete() call passed through
4895
5029
  * `admit` along with the model it names. */
@@ -4899,8 +5033,17 @@ export class AgentFramework {
4899
5033
  const value = Reflect.get(obj, prop, receiver);
4900
5034
  if (prop === 'complete' && typeof value === 'function') {
4901
5035
  return (...args) => {
4902
- const model = args[0]?.config?.model;
4903
- return admit(() => Reflect.apply(value, this.membrane, args), typeof model === 'string' ? model : undefined);
5036
+ const config = args[0]?.config;
5037
+ const model = config?.model;
5038
+ const options = args[1];
5039
+ const bounds = this.inferenceBounds;
5040
+ const sent = bounds !== null && options?.progressTimeoutMs === undefined
5041
+ ? [args[0], {
5042
+ ...options,
5043
+ progressTimeoutMs: wholeCallMs(bounds, typeof config?.maxTokens === 'number' ? config.maxTokens : undefined),
5044
+ }, ...args.slice(2)]
5045
+ : args;
5046
+ return admit(() => Reflect.apply(value, this.membrane, sent), typeof model === 'string' ? model : undefined);
4904
5047
  };
4905
5048
  }
4906
5049
  return typeof value === 'function' ? value.bind(this.membrane) : value;
@@ -5038,7 +5181,7 @@ export class AgentFramework {
5038
5181
  // length: an operator release of it returns the hold to AF's own pacing.
5039
5182
  ...(pacing?.stated ? { statedWait: { model: agent.model, ownUntil: now + pacing.ownMs } } : {}),
5040
5183
  ...(auxiliary && (!existing || existing.auxiliaryOrigin) ? { auxiliaryOrigin: true } : {}),
5041
- ...(existing?.emptiedByDrop && held.length === 0 ? { emptiedByDrop: true } : {}) });
5184
+ ...(existing?.emptiedBy && held.length === 0 ? { emptiedBy: existing.emptiedBy } : {}) });
5042
5185
  if (auxiliary && !existing) {
5043
5186
  console.error(`[provider-cooldown] agent=${agent.name} host hold (${hostHold.reason}) from an auxiliary call — ` +
5044
5187
  `holding primary/auxiliary for ${delayMs}ms`);
@@ -5055,12 +5198,23 @@ export class AgentFramework {
5055
5198
  this.noteStatedWaitRun(agent.name, wait, now);
5056
5199
  return true;
5057
5200
  }
5058
- rearmExhaustedTurn(agent, error, trigger, ownsProviderGate, routing) {
5201
+ takeAsTransient(agentName, error, trigger) {
5202
+ if (isTransientFailure(error)) {
5203
+ this.rearmDeadlineCuts.delete(agentName);
5204
+ return true;
5205
+ }
5206
+ if (!isRearmed(trigger) || !isDeadlineExpiry(error))
5207
+ return false;
5208
+ const first = !this.rearmDeadlineCuts.has(agentName);
5209
+ this.rearmDeadlineCuts.add(agentName);
5210
+ return first;
5211
+ }
5212
+ rearmExhaustedTurn(agent, error, trigger, ownsProviderGate, routing, asTransient) {
5059
5213
  if (!ownsProviderGate || trigger === undefined || this.providerAdmissionClosed || this.turnRearmMs.length === 0)
5060
5214
  return;
5061
5215
  const done = this.turnRearms.get(agent.name) ?? 0;
5062
5216
  const notSent = (why) => console.error(`[inference-rearm] agent=${agent.name} turn not re-armed: ${why}`);
5063
- if (!isTransientFailure(error)) {
5217
+ if (!asTransient) {
5064
5218
  if (done > 0)
5065
5219
  notSent(`its failure isn't transient (${isMembraneError(error) ? error.type : boundFailureText(error.message, FAILURE_SUMMARY_MAX_CHARS)})`);
5066
5220
  return;
@@ -5207,12 +5361,15 @@ export class AgentFramework {
5207
5361
  ? `no longer holds primary turns: the primary model is now ${model} (the wait stays recorded)`
5208
5362
  : `primary hold ended after ${Date.now() - cooldown.startedAt}ms`));
5209
5363
  }
5210
- if ((cooldown.auxiliaryOrigin || cooldown.emptiedByDrop) && cooldown.heldRequests.length === 0) {
5364
+ if ((cooldown.auxiliaryOrigin || cooldown.emptiedBy) && cooldown.heldRequests.length === 0) {
5211
5365
  // Nobody asked for an inference while parked, or what was held was a
5212
- // continuation since dropped with its turn: just reopen the gate. A
5213
- // primary the hold left pending won't run, so its recovery ends.
5214
- console.error(`[provider-cooldown] agent=${agentName} ${cooldown.auxiliaryOrigin ? 'auxiliary hold' : 'hold, its held continuation dropped,'} released after ${Date.now() - cooldown.startedAt}ms`);
5215
- if (cooldown.emptiedByDrop)
5366
+ // continuation since dropped with its turn, or wakes since withdrawn:
5367
+ // just reopen the gate. A primary the hold left pending won't run, so
5368
+ // its recovery ends.
5369
+ const which = cooldown.emptiedBy === 'drop' ? 'hold, its held continuation dropped,'
5370
+ : cooldown.emptiedBy === 'withdrawal' ? 'hold, its held wakes withdrawn,' : 'auxiliary hold';
5371
+ console.error(`[provider-cooldown] agent=${agentName} ${which} released after ${Date.now() - cooldown.startedAt}ms`);
5372
+ if (cooldown.emptiedBy)
5216
5373
  this.endPendingPrimary(agentName);
5217
5374
  this.flushAuxiliaryAdmission(agentName);
5218
5375
  return;
@@ -5862,12 +6019,7 @@ export class AgentFramework {
5862
6019
  */
5863
6020
  pushEvent(event) {
5864
6021
  if (event.type === 'tool-result' && this.queue?.isClosed) {
5865
- // What an operator reconciling a dropped result needs (the resident
5866
- // may run the tool again): which tool, and whether it succeeded.
5867
- const outcome = event.result.success && !event.result.isError
5868
- ? 'succeeded'
5869
- : `failed${event.result.error ? `: ${thrownText(event.result.error)}` : ''}`;
5870
- console.error(`[framework] ${event.agentName}: result of tool call ${event.callId} (${event.toolName ? `${event.toolName}, ` : ''}${event.moduleName}) arrived after stop — dropped; the tool ${outcome}`);
6022
+ console.error(droppedToolResultLine(event, 'after stop'));
5871
6023
  return;
5872
6024
  }
5873
6025
  this.queue.push(event);
@@ -5897,6 +6049,13 @@ export class AgentFramework {
5897
6049
  else
5898
6050
  this.queue.push(event);
5899
6051
  }
6052
+ dropRetiredModuleEvent(event, moduleName) {
6053
+ if (this.queue.isClosed) {
6054
+ this.pushModuleEvent(event, moduleName, true);
6055
+ return;
6056
+ }
6057
+ console.error(retiredPushLine(event, moduleName));
6058
+ }
5900
6059
  /**
5901
6060
  * Add a trace event listener for observability.
5902
6061
  */
@@ -5951,12 +6110,17 @@ export class AgentFramework {
5951
6110
  this.traceListeners.splice(index, 1);
5952
6111
  }
5953
6112
  /**
5954
- * Add a module at runtime. Refused, with a rejection, while a stop() is
5955
- * still running: a module added then would either be stopped by that stop
5956
- * or, once the stop has taken its list of modules to stop, stay started
5957
- * past it.
6113
+ * Add a module at runtime. Refused, with a rejection, once a stop() has
6114
+ * been admitted, before anything else is checked: while that stop() runs,
6115
+ * a module added then would either be stopped by it or, once it has taken
6116
+ * its list of modules to stop, stay started past it; once it has settled,
6117
+ * the framework has stopped for good, as start() says. A stop() admitted
6118
+ * while the module's start() runs stops the module once that start() has
6119
+ * settled; if that start() fulfils, this rejects the same way then, with
6120
+ * no `module:added`, and if it rejects, this rejects with its error.
5958
6121
  */
5959
6122
  async addModule(module) {
6123
+ this.refuseOnceStopped('addModule()');
5960
6124
  // A server's tool prefix and a module's name share one namespace, as
5961
6125
  // create() checks at startup: a module isn't added under a prefix a
5962
6126
  // server holds, or one a restart is moving its server to.
@@ -5974,8 +6138,8 @@ export class AgentFramework {
5974
6138
  throw new Error(`Module "${module.name}" collides with MCPL server "${id}"'s toolPrefix "${module.name}" (a closed tab)`);
5975
6139
  }
5976
6140
  }
5977
- this.resetCompletedStop('addModule()');
5978
6141
  await this.moduleRegistry.addModule(module);
6142
+ this.refuseOnceStopped('addModule()');
5979
6143
  // The workspace judges whether an agent's history holds a message as the
5980
6144
  // framework judges its own notices, for its head-move notice
5981
6145
  // (noticeHeadMoves) and the writes it weighs against it. A module taken
@@ -5988,7 +6152,9 @@ export class AgentFramework {
5988
6152
  this.emitTrace({ type: 'module:added', moduleName: module.name });
5989
6153
  }
5990
6154
  /**
5991
- * Remove a module at runtime.
6155
+ * Remove a module at runtime. A module whose start() is still running is
6156
+ * stopped once that start() has settled; this waits for that and for its
6157
+ * stop(), with no time limit.
5992
6158
  */
5993
6159
  async removeModule(name) {
5994
6160
  await this.moduleRegistry.removeModule(name);
@@ -6297,7 +6463,7 @@ export class AgentFramework {
6297
6463
  : []),
6298
6464
  ];
6299
6465
  }
6300
- /** The registered workspace module, if any (used by save_image). */
6466
+ /** The registered workspace module, if any (used by save_recent_image). */
6301
6467
  getWorkspaceModule() {
6302
6468
  const module = this.moduleRegistry.getAllModules().find((m) => m.name === 'workspace');
6303
6469
  return module && typeof module.writeBinary === 'function'
@@ -6770,15 +6936,11 @@ export class AgentFramework {
6770
6936
  stallSkips = 0;
6771
6937
  /** See WITHHELD_SPILL_DEADLINE_MS; an instance field so tests can shorten it. */
6772
6938
  withheldSpillDeadlineMs = WITHHELD_SPILL_DEADLINE_MS;
6773
- /** The lifecycle generation whose stop() has let the running annotations
6774
- * finish: a withheld original is then no longer written to a workspace
6775
- * that is stopping, and its stub names the audit record. A start() after
6776
- * that stop() has finished has a generation of its own, so the spill gate
6777
- * is open again for an instance kept with a store it doesn't own and that
6778
- * is still open (start() doesn't restore stopped modules or MCPL
6779
- * connections, and refuses an owned store that stop() closed). A start()
6780
- * made while a stop() is still running is refused. */
6781
- guardSpillsClosedFor = null;
6939
+ /** Set once a stop() has let the running annotations finish, and cleared
6940
+ * as each stop() begins: while set, a withheld original is no longer
6941
+ * written to a workspace that is stopping, and its stub names the audit
6942
+ * record. */
6943
+ guardSpillsClosed = false;
6782
6944
  /** A new agent's guard: its persisted setting, the tool-results spill its
6783
6945
  * withheld originals are written to, and the set stop() waits on
6784
6946
  * (agent-framework #277). The setting is read from framework/state's
@@ -7453,7 +7615,7 @@ export class AgentFramework {
7453
7615
  // Bounded grace for cancelled streams. Stop cancels streams too; a turn
7454
7616
  // with no stream settles on its own. Report the abandon that already ran,
7455
7617
  // even if stop cuts its grace short.
7456
- const grace = Date.now() + 10_000;
7618
+ const grace = Date.now() + (testAbandonGraces.get(this) ?? ABANDON_GRACE_MS);
7457
7619
  while (this.quiesced && !this.queue?.isClosed && this.activeTurnTokens.size > 0 && Date.now() < grace) {
7458
7620
  await new Promise((resolve) => setTimeout(resolve, 250));
7459
7621
  }
@@ -7876,8 +8038,9 @@ export class AgentFramework {
7876
8038
  * memory-only: they land within the turn, at a tool boundary or at its
7877
8039
  * end, and a crash before then loses them. That window runs to the turn's
7878
8040
  * end for a message held there and what is queued behind it, and, on a
7879
- * stream that declares it can't carry injected messages (membrane's XML
7880
- * prefill path, from 0.6.0), for every message deferred during the turn.
8041
+ * stream that doesn't declare it carries injected messages (membrane's XML
8042
+ * prefill path declares it can't), for every message deferred during the
8043
+ * turn.
7881
8044
  *
7882
8045
  * A write that fails, or a queue over the cap, which writes nothing,
7883
8046
  * leaves the record as it last held it, except that store only, a write
@@ -7903,14 +8066,15 @@ export class AgentFramework {
7903
8066
  catch (err) {
7904
8067
  console.error('[host-mode] failed to persist deferred context writes:', err);
7905
8068
  this.deferredWritesFellBehind({ error: err });
7906
- return;
8069
+ return false;
7907
8070
  }
7908
8071
  if (outcome === 'over-cap') {
7909
8072
  this.deferredWritesFellBehind('over-cap');
8073
+ return false;
7910
8074
  }
7911
- else if (outcome !== 'nothing') {
8075
+ if (outcome !== 'nothing')
7912
8076
  this.deferredWritesCaughtUp();
7913
- }
8077
+ return true;
7914
8078
  }
7915
8079
  /**
7916
8080
  * Write the queue to its record as it stands, when the host is quiesced
@@ -9204,8 +9368,9 @@ export class AgentFramework {
9204
9368
  * **Inheritance.** `mode: 'shared'` (the default) gives the child the
9205
9369
  * parent's messages and fold state through shared storage: nothing is
9206
9370
  * copied, the child folds with the parent's memory tree, and neither can
9207
- * alter what the other reads. It needs branch-bound store handles and is
9208
- * refused, not silently downgraded, where they are missing. `mode: 'copy'`
9371
+ * alter what the other reads. It needs a store that supports derivation
9372
+ * (`ContextManager.supportsDerivation`), and is refused, not silently
9373
+ * downgraded, without one. `mode: 'copy'`
9209
9374
  * is the historical mechanism — the parent's compiled context re-added as
9210
9375
  * the child's own messages — and works everywhere.
9211
9376
  *
@@ -9756,6 +9921,7 @@ export class AgentFramework {
9756
9921
  if (run) {
9757
9922
  run.endReason ??= 'stopped';
9758
9923
  run.endedBy ??= options.by;
9924
+ run.endedFromOutside = true;
9759
9925
  // The awaiting caller is told who stopped the job and why, whether or
9760
9926
  // not a stream was in flight: settled first, so the aborted stream's own
9761
9927
  // settle ('Stream cancelled') finds the run already settled. The caller's
@@ -10106,6 +10272,7 @@ export class AgentFramework {
10106
10272
  if (run) {
10107
10273
  run.endReason = reason;
10108
10274
  run.endedBy = record.ended?.by;
10275
+ run.endedFromOutside = true;
10109
10276
  run.settle.reject(new AgentStoppedError(name, reason, record.ended?.by, detail));
10110
10277
  return;
10111
10278
  }
@@ -10114,6 +10281,15 @@ export class AgentFramework {
10114
10281
  return;
10115
10282
  this.releaseAgentRuntime(agent, this.channelOfHome(record.homeChannel), undefined, record.lifetime.kind === 'persistent');
10116
10283
  }
10284
+ forgetFailureAccounting(name) {
10285
+ this.consecutiveInferenceFailures.delete(name);
10286
+ this.exhaustionRewinds.delete(name);
10287
+ this.refusalStreak.delete(name);
10288
+ this.refusalRewinds.delete(name);
10289
+ this.rewindEpisode.delete(name);
10290
+ if (this.forcedRewind.delete(name))
10291
+ console.error(`[unstick] agent=${name} its /unstick session ended with its agent`);
10292
+ }
10117
10293
  /**
10118
10294
  * Everything the process holds for a live, non-ephemeral agent that has
10119
10295
  * ended, released once, on every path an agent ends by — stopAgent, the
@@ -10159,6 +10335,9 @@ export class AgentFramework {
10159
10335
  this.eventGate?.agentGone(name);
10160
10336
  this.agents.delete(name);
10161
10337
  this.agentConfigs.delete(name);
10338
+ this.deadlineCuts.delete(name);
10339
+ this.freshCallsCut.delete(name);
10340
+ this.forgetFailureAccounting(name);
10162
10341
  this.thinkingLossIncidents.forget(name);
10163
10342
  this.reasoningLoss.forget(name);
10164
10343
  // main's manager release: the context manager an ephemeral run or a fork
@@ -10201,6 +10380,7 @@ export class AgentFramework {
10201
10380
  // waits ends here as at a turn's end, alerted if it ran long
10202
10381
  // (endStatedWaitRun).
10203
10382
  this.turnRearms.delete(name);
10383
+ this.rearmDeadlineCuts.delete(name);
10204
10384
  this.exhaustingRearms.delete(name);
10205
10385
  this.exhaustedTurns.delete(name);
10206
10386
  this.endStatedWaitRun(name);
@@ -10593,13 +10773,12 @@ export class AgentFramework {
10593
10773
  }
10594
10774
  /**
10595
10775
  * A start dropped before its stream sends anything (its agent was replaced,
10596
- * or stop() closed admission or ended the lifecycle the start began in)
10597
- * ends the logical turn it was running. stop() ends every open turn
10598
- * (openLogical), but a fresh start that awaited its batches' assembly
10599
- * across a stop() reaches its routing after it and opens its turn. That
10600
- * turn would then stay open, and the agent's first start after a restart
10601
- * would continue the dropped turn's routing. The identity check leaves any
10602
- * other turn open.
10776
+ * or stop() closed admission) ends the logical turn it was running. stop()
10777
+ * ends every open turn (openLogical), but a fresh start that awaited its
10778
+ * batches' assembly across a stop() reaches its routing after it and opens
10779
+ * its turn. That turn would then stay open, and the next start of the
10780
+ * agent's name would continue the dropped turn's routing. The identity
10781
+ * check leaves any other turn open.
10603
10782
  */
10604
10783
  endDroppedTurn(agentName, routing) {
10605
10784
  if (this.openLogical.get(agentName) === routing)
@@ -10610,8 +10789,36 @@ export class AgentFramework {
10610
10789
  // Its scripts take it as a settle's end of the turn: the start sent
10611
10790
  // nothing, so none of its own calls is waiting on a script.
10612
10791
  this.settledTurns.add(routing);
10792
+ this.releaseTurnInputs(routing);
10613
10793
  this.codeExecution?.scripts.turnEnded(agentName, { id: routing.turnId, live: this.liveTurns(agentName, routing) }, false);
10614
10794
  }
10795
+ pinTurnInputs(agent, routing) {
10796
+ let range;
10797
+ try {
10798
+ const cm = agent.getContextManager();
10799
+ const watermark = agent.getConsumedWatermark();
10800
+ if (watermark && watermark.branch === cm.currentBranch().name) {
10801
+ range = turnInputs(cm, watermark.sequence, agent.name, (metadata) => this.agentSees(agent, metadata));
10802
+ }
10803
+ }
10804
+ catch (error) {
10805
+ console.error(`[turn-inputs] ${agent.name}: could not read the turn's inputs, so none is pinned:`, error);
10806
+ }
10807
+ if (!range) {
10808
+ agent.unpinTurnInputs();
10809
+ return;
10810
+ }
10811
+ const id = agent.pinTurnInputs(range.first, range.last);
10812
+ if (id !== undefined)
10813
+ routing.inputsPin = { agent, id };
10814
+ }
10815
+ releaseTurnInputs(routing) {
10816
+ const pin = routing.inputsPin;
10817
+ if (!pin)
10818
+ return;
10819
+ routing.inputsPin = undefined;
10820
+ pin.agent.unpinTurnInputs(pin.id);
10821
+ }
10615
10822
  /** A logical turn's end, for every agent: it closes the agent's open
10616
10823
  * logical turn when it is the settling stream's own (openLogical), and
10617
10824
  * settles an active ephemeral run. The run's tool-call count comes from
@@ -10641,8 +10848,10 @@ export class AgentFramework {
10641
10848
  // or of the stream that now holds the agent, are left alone.
10642
10849
  if (stream) {
10643
10850
  // Ended, unless it's the logical turn this settle left open.
10644
- if (this.openLogical.get(agentName) !== stream)
10851
+ if (this.openLogical.get(agentName) !== stream) {
10645
10852
  this.settledTurns.add(stream);
10853
+ this.releaseTurnInputs(stream);
10854
+ }
10646
10855
  this.codeExecution?.scripts.turnEnded(agentName, { id: stream.turnId, live: this.liveTurns(agentName, stream) }, outcome.stopReason === 'exhausted', outcome.error);
10647
10856
  }
10648
10857
  const run = this.ephemeralRuns.get(agentName);
@@ -10897,6 +11106,10 @@ export class AgentFramework {
10897
11106
  // deregistered, so disposal must release EventGate liveness itself.
10898
11107
  this.eventGate?.onInferenceEnded(agent.name);
10899
11108
  this.agents.delete(agent.name);
11109
+ this.deadlineCuts.delete(agent.name);
11110
+ this.freshCallsCut.delete(agent.name);
11111
+ if (run.endedFromOutside)
11112
+ this.forgetFailureAccounting(agent.name);
10900
11113
  this.thinkingLossIncidents.forget(agent.name);
10901
11114
  this.reasoningLoss.forget(agent.name);
10902
11115
  // Its pending self-wake ends with it, as a fork's does.
@@ -11063,6 +11276,14 @@ export class AgentFramework {
11063
11276
  inputSchema: { type: 'object' },
11064
11277
  },
11065
11278
  ];
11279
+ static WAKE_ADD_RULE_FIELDS = {
11280
+ 'behavior.debounce': 'debounceMs',
11281
+ 'behavior.rate_limit': 'rateLimit',
11282
+ 'behavior.passive_sample': 'passiveSample',
11283
+ 'behavior.judge': 'judge',
11284
+ 'position.before': 'insertBefore',
11285
+ 'position.after': 'insertAfter',
11286
+ };
11066
11287
  /** Synthesized wake-rule tools: add/remove a gate.json policy at runtime.
11067
11288
  * Present when a gate is wired. They write validated policies into the
11068
11289
  * hot-reloaded gate.json (same validation as load), so a rule takes effect
@@ -11244,9 +11465,9 @@ export class AgentFramework {
11244
11465
  /** Synthesized drafts tool — present whenever MCPL channels are
11245
11466
  * configured. A resident's held drafts (src/prose-drafts.ts): plain speech
11246
11467
  * that was not sent, never published except by its resident's resend, and
11247
- * kept as that module's retention says. Nullable
11248
- * fields, cast like save_recent_image's: a caller whose provider presents
11249
- * every property as required can still say "not in use". */
11468
+ * kept as that module's retention says. Its optional fields are nullable,
11469
+ * as save_recent_image's selectors are: a caller whose provider presents
11470
+ * every property as required can still say "not in use" (fieldInUse). */
11250
11471
  static DRAFTS_TOOL = {
11251
11472
  name: 'drafts',
11252
11473
  description: 'Your held drafts: plain speech of yours that was NOT sent — held back because an explicit send in its turn ' +
@@ -11285,10 +11506,10 @@ export class AgentFramework {
11285
11506
  required: ['action'],
11286
11507
  },
11287
11508
  };
11288
- /** Synthesized save_image tool — present when a workspace module is
11509
+ /** Synthesized save_recent_image tool — present when a workspace module is
11289
11510
  * registered. Lets the agent persist an image it has already seen in its
11290
11511
  * own context (Discord attachments arrive inlined as base64 and are
11291
- * otherwise unreachable as files). */
11512
+ * otherwise unreachable as files). Its three selectors are nullable. */
11292
11513
  static SAVE_IMAGE_TOOL = {
11293
11514
  name: 'save_recent_image',
11294
11515
  description: 'Save one or more recent images from your own context to workspace files. ' +
@@ -11303,7 +11524,8 @@ export class AgentFramework {
11303
11524
  'restart) the call fails and writes nothing — it never substitutes an older ' +
11304
11525
  'image. When other tool calls in the same batch are still running, the save ' +
11305
11526
  'waits for them so their images are counted. Receipts carry source tool call, MIME, byte size and SHA-256. Saved ' +
11306
- 'files are visible via workspace tools.',
11527
+ 'files are visible via workspace tools. Select by `ref` or by ' +
11528
+ '`index`/`count`; a selector you are not using may be omitted or passed as null.',
11307
11529
  inputSchema: {
11308
11530
  type: 'object',
11309
11531
  properties: {
@@ -11311,18 +11533,24 @@ export class AgentFramework {
11311
11533
  type: 'string',
11312
11534
  description: 'Mount-prefixed destination path, e.g. "project/photos/name.png".',
11313
11535
  },
11536
+ // Nullable selectors: a caller whose provider presents every property
11537
+ // as required must still be able to say "not this selector". Omission,
11538
+ // null and "" all mean it isn't used (fieldInUse); any other value is
11539
+ // checked. The descriptions name null, the form to ask for.
11314
11540
  index: {
11315
- type: 'number',
11316
- description: 'Which image, counting back from the most recent (0 = most recent). Default 0.',
11541
+ type: ['integer', 'null'],
11542
+ description: 'Which image, counting back from the most recent (0 = most recent). Default 0. ' +
11543
+ 'null, like omitting it, means the default.',
11317
11544
  },
11318
11545
  count: {
11319
- type: 'number',
11320
- description: 'How many images to save, starting at `index` and going further back. Default 1.',
11546
+ type: ['integer', 'null'],
11547
+ description: 'How many images to save, starting at `index` and going further back. Default 1. ' +
11548
+ 'null, like omitting it, means the default.',
11321
11549
  },
11322
11550
  ref: {
11323
- type: 'string',
11551
+ type: ['string', 'null'],
11324
11552
  description: '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. ' +
11325
- 'Mutually exclusive with `index`/`count`.',
11553
+ 'Mutually exclusive with `index`/`count`: pass null for the selector you are not using. null, like omitting it, means no ref.',
11326
11554
  },
11327
11555
  },
11328
11556
  required: ['path'],
@@ -13797,8 +14025,9 @@ export class AgentFramework {
13797
14025
  // idiom): reasoning effort (thinking), provider params, token ceilings,
13798
14026
  // caching, refusal handling, timezone — a same-model side-process must
13799
14027
  // not silently run under different inference conditions than the
13800
- // resident it serves. Only identity- and surface-defining fields are
13801
- // overridden below.
14028
+ // resident it serves. Below, identity- and surface-defining fields are
14029
+ // overridden, and the window and thinking binding where the
14030
+ // subconscious's config sets its own.
13802
14031
  const model = cfg.model ?? primaryConfig.model;
13803
14032
  const inherited = { ...primaryConfig };
13804
14033
  if (model !== primaryConfig.model) {
@@ -13807,6 +14036,7 @@ export class AgentFramework {
13807
14036
  }
13808
14037
  const agentConfig = {
13809
14038
  ...inherited,
14039
+ ...readerModelFields(cfg),
13810
14040
  name,
13811
14041
  model,
13812
14042
  systemPrompt: cfg.systemPrompt,
@@ -13906,6 +14136,7 @@ export class AgentFramework {
13906
14136
  config: {
13907
14137
  model: cfg.model,
13908
14138
  proseRouting: 'disabled',
14139
+ ...readerModelFields(cfg),
13909
14140
  },
13910
14141
  inheritRefusals: true,
13911
14142
  onParentEnd: 'end',
@@ -14019,13 +14250,7 @@ export class AgentFramework {
14019
14250
  this.addMessage(reader, content, metadata);
14020
14251
  }
14021
14252
  async runLoop() {
14022
- // A loop ends with its lifecycle. A stop() whose budget ran out while
14023
- // this loop was in a call (a module's onProcess) returns before the call
14024
- // does, and a start() after it begins a lifecycle with a loop of its own;
14025
- // this one finishes its iteration and exits, rather than running beside
14026
- // that loop for good.
14027
- const lifecycle = this.lifecycleGeneration;
14028
- while (this.running && this.lifecycleGeneration === lifecycle) {
14253
+ while (this.running) {
14029
14254
  try {
14030
14255
  await this.processNextEvent();
14031
14256
  }
@@ -14287,18 +14512,21 @@ export class AgentFramework {
14287
14512
  // compiled prefix and the live request still match without it
14288
14513
  // (#232 gap 5).
14289
14514
  //
14290
- // A stream that declares it can't carry injected messages
14291
- // (supportsInjectedMessages false: membrane's XML prefill path,
14292
- // from membrane 0.6.0) gets none, so nothing is drained here.
14293
- // Everything queued for the agent waits for the turn-end flush, as
14294
- // a held message does, rather than sitting in the window between
14295
- // rounds the stream never showed it.
14515
+ // Only a stream that declares it carries injected messages
14516
+ // (supportsInjectedMessages true: membrane's native path) gets
14517
+ // them. Membrane's XML prefill path declares false, and a stream
14518
+ // that doesn't say is taken as one that doesn't, so nothing is
14519
+ // drained for either: everything queued for the agent waits for
14520
+ // the turn-end flush, as a held message does, rather than sitting
14521
+ // in the window between rounds the stream never showed it. A
14522
+ // boundary with no stream, the direct API's, drains: its
14523
+ // re-inference compiles from the store.
14296
14524
  const midTurnInjections = [];
14297
14525
  // What this boundary stored for the agent to hear, in store order,
14298
14526
  // for a boundary that leaves it to the turn's next start as
14299
14527
  // arrivals: that start's compile sends it.
14300
14528
  const heard = [];
14301
- if (currentState.supportsInjectedMessages !== false) {
14529
+ if (currentState.supportsInjectedMessages === true || !currentState.stream) {
14302
14530
  // A clock row due (storeDueClock) goes right after the round's
14303
14531
  // results, ahead of what is drained below, and joins the stream
14304
14532
  // the same way. A boundary that passed during the round can't
@@ -14397,12 +14625,10 @@ export class AgentFramework {
14397
14625
  }
14398
14626
  // A live stream handed these messages after the results follows
14399
14627
  // them in its next round, not the tool loop of a reply to
14400
- // [Continue]: that round's rows carry no prompt. One that doesn't
14401
- // say whether it carries them (membrane 0.5.x, whose native path
14402
- // carries them and whose XML path drops them) counts as carrying
14403
- // them here, as at the drain above. A boundary that ends the turn,
14404
- // restarts the stream or falls back to a new start below hands
14405
- // them to no live stream, and a new start reads its own prompt from
14628
+ // [Continue]: that round's rows carry no prompt. A boundary that
14629
+ // ends the turn, restarts the stream or falls back to a new start
14630
+ // below hands them to no live stream, and a new start reads its own
14631
+ // prompt from
14406
14632
  // what it sends. (The routing notice below rides only an injection
14407
14633
  // already here.)
14408
14634
  if (midTurnInjections.length > 0) {
@@ -14645,11 +14871,10 @@ export class AgentFramework {
14645
14871
  }
14646
14872
  // Streaming path: convert results and resume the stream.
14647
14873
  // Mid-turn messages collected above ride along as injected user
14648
- // messages. Membrane's native path (from 0.5.72) appends them
14649
- // after the tool_result envelope, so the next round of THIS turn
14650
- // hears them. Its XML prefill path drops them with a warning on
14651
- // 0.5.x, and from 0.6.0 declares it can't carry them, so none are
14652
- // collected for it. Each goes as its participant and content
14874
+ // messages. Membrane's native path appends them after the
14875
+ // tool_result envelope, so the next round of THIS turn hears
14876
+ // them. Its XML prefill path declares it can't carry them, so
14877
+ // none are collected for it. Each goes as its participant and content
14653
14878
  // alone: a stored row's metadata is the host's record, not input
14654
14879
  // for membrane. Each goes with its name and text as the store
14655
14880
  // keeps them, whether it's a heard message, rendered or not, or a
@@ -14679,15 +14904,11 @@ export class AgentFramework {
14679
14904
  // the watermark of the turn's compile and read as unread, so a
14680
14905
  // coalesced replacement could remove a message the agent heard
14681
14906
  // and answered (and that its later replies were minted after).
14682
- // Only a stream that declares it carries them is marked read
14683
- // (supportsInjectedMessages true: membrane's native path, from
14684
- // membrane 0.6.0). One that doesn't say may be membrane 0.5.87's
14685
- // XML path, which drops them with a warning, and a message marked
14686
- // read there could no longer be replaced though the agent never
14687
- // saw it. So without the declaration they stay unread, as before
14688
- // this mark.
14689
- // The shown mark moves at every boundary that injects, declared
14690
- // or not, since there a doubt counts as shown.
14907
+ // Only a stream that declares it carries them gets them, and so
14908
+ // is marked read (supportsInjectedMessages true: membrane's native
14909
+ // path).
14910
+ // The shown mark moves at every boundary that injects into its
14911
+ // stream.
14691
14912
  // The mark is the store's head, a scalar, as a compile's is: it
14692
14913
  // also covers what was stored since the compile without being
14693
14914
  // carried, such as another slot's messages, and a write that
@@ -14699,8 +14920,7 @@ export class AgentFramework {
14699
14920
  // replies after it go without their thinking, whatever the mark
14700
14921
  // says; deferring those writers is the fix.
14701
14922
  if (midTurnInjections.length > 0) {
14702
- // Possibly shown, whatever the stream declares: the first-view
14703
- // mark (CoalescerHost.isUnshown) counts a doubt as shown.
14923
+ // Shown: the stream carries them (CoalescerHost.isUnshown).
14704
14924
  agent.markContextShown();
14705
14925
  if (currentState.supportsInjectedMessages === true)
14706
14926
  agent.markContextConsumed();
@@ -14749,15 +14969,23 @@ export class AgentFramework {
14749
14969
  async dispatchProcessEventToModules(event, startTime = Date.now()) {
14750
14970
  // Dispatch to all modules, tracking responses with module names
14751
14971
  const responses = [];
14752
- for (const module of this.moduleRegistry.getAllModules()) {
14972
+ const registrations = new Map();
14973
+ const stillRegistered = (entry) => this.moduleRegistry.registrationOf(entry.moduleName) === registrations.get(entry);
14974
+ const dispatched = this.moduleRegistry.getAllModules()
14975
+ .map((module) => ({ module, registration: this.moduleRegistry.registrationOf(module.name) }));
14976
+ for (const { module, registration } of dispatched) {
14753
14977
  // The framework's own toolsets module answers no event; every agent has
14754
14978
  // it now, so it isn't listed beside the modules that do.
14755
14979
  if (module instanceof ToolsetsModule)
14756
14980
  continue;
14981
+ if (this.moduleRegistry.registrationOf(module.name) !== registration)
14982
+ continue;
14757
14983
  try {
14758
14984
  const processState = this.moduleRegistry.createProcessState(module.name);
14759
14985
  const response = await module.onProcess(event, processState);
14760
- responses.push({ moduleName: module.name, response });
14986
+ const entry = { moduleName: module.name, response };
14987
+ responses.push(entry);
14988
+ registrations.set(entry, registration);
14761
14989
  }
14762
14990
  catch (error) {
14763
14991
  console.error(`Module ${module.name} error handling process event:`, error);
@@ -14783,7 +15011,16 @@ export class AgentFramework {
14783
15011
  // responses, a tool call's dispatch and the trace go on, since a module
14784
15012
  // can't catch a throw on this path, and one module's mustn't cost the
14785
15013
  // event.
14786
- const applying = responses.filter(({ moduleName, response }) => {
15014
+ const dropUnregistered = (entry) => {
15015
+ if (stillRegistered(entry))
15016
+ return false;
15017
+ const parts = responseParts(entry.response);
15018
+ if (parts) {
15019
+ console.error(`[process] module ${entry.moduleName} stopped before its response to ${event.type} was applied, so none of it was: ${parts}`);
15020
+ }
15021
+ return true;
15022
+ };
15023
+ const applying = responses.filter((entry) => !dropUnregistered(entry)).filter(({ moduleName, response }) => {
14787
15024
  const refusal = malformedResponseContent(response);
14788
15025
  if (refusal === null)
14789
15026
  return true;
@@ -14804,7 +15041,7 @@ export class AgentFramework {
14804
15041
  const { moduleName, response } = entry;
14805
15042
  const step = { at: 'its start' };
14806
15043
  try {
14807
- await this.applyProcessResponse(response, event, moduleName, surface, step);
15044
+ this.applyProcessResponse(response, event, moduleName, surface, step);
14808
15045
  }
14809
15046
  catch (error) {
14810
15047
  entry.threw = { at: step.at, error: error instanceof Error ? error.message : String(error) };
@@ -14825,7 +15062,7 @@ export class AgentFramework {
14825
15062
  this.logProcessEvent(event, applying);
14826
15063
  }
14827
15064
  }
14828
- async applyProcessResponse(response, event, moduleName,
15065
+ applyProcessResponse(response, event, moduleName,
14829
15066
  /** The event's surface envelope, when it is console/API input. */
14830
15067
  surface,
14831
15068
  /** Names the operation under way, for the line when one throws. */
@@ -15576,6 +15813,9 @@ export class AgentFramework {
15576
15813
  else {
15577
15814
  // Nothing live under the name: only what the process keeps by name.
15578
15815
  this.closingConversationAgents.delete(agentName);
15816
+ this.deadlineCuts.delete(agentName);
15817
+ this.freshCallsCut.delete(agentName);
15818
+ this.forgetFailureAccounting(agentName);
15579
15819
  this.thinkingLossIncidents.forget(agentName);
15580
15820
  this.reasoningLoss.forget(agentName);
15581
15821
  // A continuation of its turn still on its way goes with it.
@@ -16566,19 +16806,20 @@ export class AgentFramework {
16566
16806
  });
16567
16807
  }
16568
16808
  }
16569
- /** Withdraw unstarted wakes whose sole cause is this subject (§4.2). */
16809
+ /** Withdraw unstarted wakes whose sole cause is this subject (§4.2), and
16810
+ * a merged start whose every request is withdrawn. */
16570
16811
  cancelCoalescedWakes(subject) {
16571
- this.pendingRequests = this.pendingRequests.filter((r) => r.coalescingSubject !== subject);
16572
- for (const cooldown of this.providerAccelerationCooldowns.values()) {
16573
- cooldown.heldRequests = cooldown.heldRequests.filter((r) => r.coalescingSubject !== subject);
16574
- }
16575
- // A wake of the subject's merged into a start that carries a turn on
16812
+ // A wake of the subject's merged into a start
16576
16813
  // (MERGED_BATCH) is withdrawn from the start's batch, so no stop puts
16577
16814
  // it back and no pass unpacks it.
16815
+ const emptied = new Set();
16578
16816
  const unrecord = (r) => {
16579
16817
  const batch = mergedBatch(r);
16580
16818
  if (batch?.some((w) => w.coalescingSubject === subject)) {
16581
- r[MERGED_BATCH] = batch.filter((w) => w.coalescingSubject !== subject);
16819
+ const left = batch.filter((w) => w.coalescingSubject !== subject);
16820
+ r[MERGED_BATCH] = left;
16821
+ if (left.length === 0)
16822
+ emptied.add(r);
16582
16823
  }
16583
16824
  };
16584
16825
  for (const r of this.pendingRequests)
@@ -16590,6 +16831,29 @@ export class AgentFramework {
16590
16831
  for (const pass of this.passRequests ?? [])
16591
16832
  for (const requests of pass.values())
16592
16833
  requests.forEach(unrecord);
16834
+ const withdrawn = (r) => r.coalescingSubject === subject || emptied.has(r);
16835
+ this.pendingRequests = this.pendingRequests.filter((r) => !withdrawn(r));
16836
+ for (const cooldown of this.providerAccelerationCooldowns.values()) {
16837
+ const held = cooldown.heldRequests.length;
16838
+ cooldown.heldRequests = cooldown.heldRequests.filter((r) => !withdrawn(r));
16839
+ if (held > 0 && cooldown.heldRequests.length === 0)
16840
+ cooldown.emptiedBy = 'withdrawal';
16841
+ }
16842
+ for (const pass of this.passRequests ?? []) {
16843
+ for (const requests of pass.values()) {
16844
+ if (!requests.some((r) => emptied.has(r)))
16845
+ continue;
16846
+ const left = requests.filter((r) => !emptied.has(r));
16847
+ requests.length = 0;
16848
+ requests.push(...left);
16849
+ }
16850
+ }
16851
+ for (const start of emptied) {
16852
+ if (!start.coalescingBatch || start.coalescingSubject !== undefined)
16853
+ continue;
16854
+ console.error(`[coalescing] ${start.agentName}: its deferred batches were withdrawn while its wake waited; turn not started (no wake cause left)`);
16855
+ this.emitTrace({ type: 'mcpl:coalescing', kind: 'turn-withdrawn', agentName: start.agentName, subjects: start.coalescingBatchSubjects ?? [] });
16856
+ }
16593
16857
  }
16594
16858
  /**
16595
16859
  * A placement in the deferred queue is upgraded in place once the flush has
@@ -17099,7 +17363,7 @@ export class AgentFramework {
17099
17363
  recovery.releasedAt = now;
17100
17364
  requests = [...providerCooldown.heldRequests, ...requests];
17101
17365
  }
17102
- // A start that carries a turn on and came back before it took its
17366
+ // A start that came back before it took its
17103
17367
  // token stands for the batch it was merged from (MERGED_BATCH),
17104
17368
  // including one a hold released just now. The batch takes its place,
17105
17369
  // each request as it was, judged one by one from here: a wait holds
@@ -17501,10 +17765,10 @@ export class AgentFramework {
17501
17765
  start[REARMED] = true;
17502
17766
  else
17503
17767
  delete start[REARMED];
17504
- // A start that carries a turn on and took in wakes that don't records
17505
- // the batch it was merged from (MERGED_BATCH): until it takes its
17506
- // token it stands for them all.
17507
- if (continuesTurn(start) && requests.some((r) => !continuesTurn(r)))
17768
+ // A start merged from more than one request, any of them not the
17769
+ // turn's, records the batch it was merged from (MERGED_BATCH): until
17770
+ // it takes its token it stands for them all.
17771
+ if (requests.length > 1 && requests.some((r) => !continuesTurn(r)))
17508
17772
  start[MERGED_BATCH] = [...requests];
17509
17773
  else
17510
17774
  delete start[MERGED_BATCH];
@@ -18154,15 +18418,13 @@ export class AgentFramework {
18154
18418
  const refuse = (error) => ({ success: false, error, isError: true });
18155
18419
  // Plain text, not a JSON-quoted string: draft text must read exactly as held.
18156
18420
  const ok = (text) => ({ success: true, data: [{ type: 'text', text }] });
18157
- // null and omission mean the same thing: the field is not in use.
18158
- const present = (v) => v !== undefined && v !== null;
18159
18421
  const action = input.action;
18160
18422
  if (action !== 'list' && action !== 'read' && action !== 'resend' && action !== 'dismiss') {
18161
18423
  return refuse('action must be one of "list", "read", "resend" or "dismiss".');
18162
18424
  }
18163
18425
  const unused = (field) => refuse(`${field} is not used by action "${action}" — pass null or omit it.`);
18164
18426
  let ids = [];
18165
- if (present(input.draftIds)) {
18427
+ if (fieldInUse(input.draftIds)) {
18166
18428
  if (action === 'list')
18167
18429
  return unused('draftIds');
18168
18430
  if (!Array.isArray(input.draftIds) || input.draftIds.some((id) => typeof id !== 'string' || !id.trim())) {
@@ -18174,17 +18436,20 @@ export class AgentFramework {
18174
18436
  if (ids.length > 20)
18175
18437
  return refuse('draftIds names more than 20 drafts; act on them in smaller groups.');
18176
18438
  }
18177
- if (present(input.destination) && action !== 'resend')
18439
+ if (fieldInUse(input.destination) && action !== 'resend')
18178
18440
  return unused('destination');
18179
- if (present(input.confirmDuplicate) && action !== 'resend')
18441
+ // false is "don't confirm", which every other action already does, so only
18442
+ // true is a confirmDuplicate those actions refuse.
18443
+ if (fieldInUse(input.confirmDuplicate) && input.confirmDuplicate !== false && action !== 'resend') {
18180
18444
  return unused('confirmDuplicate');
18181
- if (present(input.offset) && action !== 'list')
18445
+ }
18446
+ if (fieldInUse(input.offset) && action !== 'list')
18182
18447
  return unused('offset');
18183
18448
  if (action === 'list') {
18184
18449
  let offset = 0;
18185
- if (present(input.offset)) {
18450
+ if (fieldInUse(input.offset)) {
18186
18451
  const raw = input.offset;
18187
- const n = typeof raw === 'number' ? raw : typeof raw === 'string' && /^\d+$/.test(raw.trim()) ? Number(raw) : NaN;
18452
+ const n = integerField(raw);
18188
18453
  if (!Number.isInteger(n) || n < 0)
18189
18454
  return refuse('offset must be a whole number of drafts to skip (0 or more), or null.');
18190
18455
  offset = n;
@@ -18299,13 +18564,13 @@ export class AgentFramework {
18299
18564
  return refuse('No channels are configured, so there is nowhere to resend to.');
18300
18565
  const home = this.homeOf(agentName);
18301
18566
  let spec = typeof input.destination === 'string' ? input.destination.trim() : '';
18302
- if (present(input.destination) && typeof input.destination !== 'string') {
18567
+ if (fieldInUse(input.destination) && typeof input.destination !== 'string') {
18303
18568
  return refuse('destination must be "#channel", "@person" or a channel id.');
18304
18569
  }
18305
18570
  if (!spec && !home)
18306
18571
  return refuse('resend needs a destination: "#channel", "@person" or a channel id.');
18307
18572
  let confirmDuplicate = false;
18308
- if (present(input.confirmDuplicate)) {
18573
+ if (fieldInUse(input.confirmDuplicate)) {
18309
18574
  if (typeof input.confirmDuplicate !== 'boolean')
18310
18575
  return refuse('confirmDuplicate must be true, false or null.');
18311
18576
  confirmDuplicate = input.confirmDuplicate;
@@ -20615,6 +20880,8 @@ export class AgentFramework {
20615
20880
  }
20616
20881
  }
20617
20882
  async startAgentStream(agent, trigger, attempt = 0, providerGateAlreadyHeld = false) {
20883
+ if (attempt === 0)
20884
+ this.deadlineCuts.delete(agent.name);
20618
20885
  const ownsProviderGate = this.registry.ownsProviderScheduling(agent.name);
20619
20886
  // A stopping framework starts no turn, whoever owns provider admission
20620
20887
  // (conversation agents and ephemeral runs do not take the gate).
@@ -20684,6 +20951,15 @@ export class AgentFramework {
20684
20951
  console.error(`[provider-admission] ${agent.name}: host quiesced while parked — the merged start goes back as its ${batch.length} requests`);
20685
20952
  return;
20686
20953
  }
20954
+ if (this.providerAccelerationCooldowns.has(agent.name)) {
20955
+ this.releasePrimaryProviderGate(agent.name);
20956
+ this.eventGate?.onInferenceEnded(agent.name);
20957
+ this.pendingRequests.push(trigger ?? {
20958
+ agentName: agent.name, reason: 'provider-admission:requeue', source: 'scheduler', timestamp: Date.now(),
20959
+ });
20960
+ console.error(`[provider-admission] ${agent.name}: a provider hold stands — parked wake requeued for the scheduler, not started`);
20961
+ return;
20962
+ }
20687
20963
  await this.startAgentStream(agent, trigger, attempt, true);
20688
20964
  }).catch((error) => {
20689
20965
  this.releasePrimaryProviderGate(agent.name);
@@ -20850,7 +21126,7 @@ export class AgentFramework {
20850
21126
  const held = hold.heldRequests.length;
20851
21127
  hold.heldRequests = take(hold.heldRequests);
20852
21128
  if (held > 0 && hold.heldRequests.length === 0)
20853
- hold.emptiedByDrop = true;
21129
+ hold.emptiedBy = 'drop';
20854
21130
  }
20855
21131
  for (const parked of [...this.parkedContinuations]) {
20856
21132
  if (!isContinuation(parked))
@@ -20967,9 +21243,8 @@ export class AgentFramework {
20967
21243
  * gets inference:aborted with `reason`, and lastInference records its end
20968
21244
  * as driveStream's would, when the record is still this turn's. Nothing of
20969
21245
  * the kind when another turn of this agent's name holds the turn token (one
20970
- * begun after a stop and start, or by a replacement agent object): the
20971
- * event and the record carry only the name, so this frame's would end that
20972
- * newer turn instead.
21246
+ * begun by a replacement agent object): the event and the record carry only
21247
+ * the name, so this frame's would end that newer turn instead.
20973
21248
  */
20974
21249
  abortDroppedTurn(agentName, turnToken, reason, startedAt) {
20975
21250
  const owner = this.activeTurnTokens.get(agentName);
@@ -21144,7 +21419,6 @@ export class AgentFramework {
21144
21419
  /** This start's window (checkAhead), which its checks read for an
21145
21420
  * operator's abandon. */
21146
21421
  checkWindow) {
21147
- const lifecycle = this.lifecycleGeneration;
21148
21422
  const resumesTurn = resumesLogicalTurn(trigger?.reason);
21149
21423
  let carry = turnCarryOf(trigger);
21150
21424
  const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting;
@@ -21328,7 +21602,7 @@ export class AgentFramework {
21328
21602
  // everything the turn has decided about where its speech goes.
21329
21603
  // - A resume (a budget restart, a tool-result-guard retry) carries its
21330
21604
  // turn's request on and keeps the turn's object as it is, and the
21331
- // turn stays, or after a stop() becomes again, the agent's open turn.
21605
+ // turn stays, or becomes again, the agent's open turn.
21332
21606
  // - A silent start routes no prose. It gets an object of its own, with
21333
21607
  // no locus, so it neither takes nor disturbs an open turn's. Its
21334
21608
  // object is the agent's live routing while it runs, and the open
@@ -21353,8 +21627,6 @@ export class AgentFramework {
21353
21627
  let opensTurn = false;
21354
21628
  if (resumesTurn) {
21355
21629
  routing = this.routingOf(agent.name);
21356
- // The turn a resume continues is open, unless a stop() ended every
21357
- // open turn since: it opens again, so its later starts continue it.
21358
21630
  if (!trigger?.suppressProse && !this.openLogical.has(agent.name))
21359
21631
  this.openLogical.set(agent.name, routing);
21360
21632
  }
@@ -21383,6 +21655,10 @@ export class AgentFramework {
21383
21655
  const head = agent.branchHead();
21384
21656
  if (head)
21385
21657
  routing.start = head;
21658
+ if (!routing.silent)
21659
+ this.pinTurnInputs(agent, routing);
21660
+ else
21661
+ agent.unpinTurnInputs();
21386
21662
  }
21387
21663
  this.turnRouting.set(agent.name, routing);
21388
21664
  if (opensTurn)
@@ -21527,11 +21803,11 @@ export class AgentFramework {
21527
21803
  });
21528
21804
  // A budget restart continues the same logical inference window. The
21529
21805
  // predecessor keeps EventGate liveness until its successor terminates,
21530
- // and the successor takes it again if it ended in between: at a stop()
21531
- // (a restart left queued, which a start() then takes up), or at a
21806
+ // and the successor takes it again if it ended in between: at a
21532
21807
  // provider hold that kept the successor from starting (a stated wait at
21533
- // its last admission check, or a cooldown after its own call failed),
21534
- // whose end queues it again.
21808
+ // its last admission check, a hold standing when its park behind an
21809
+ // auxiliary call ended, or a cooldown after its own call failed), whose
21810
+ // end queues it again.
21535
21811
  if (!resumesTurn) {
21536
21812
  this.eventGate?.onInferenceStarted(agent.name);
21537
21813
  }
@@ -21608,13 +21884,11 @@ export class AgentFramework {
21608
21884
  // An ephemeral watchdog may dispose this Agent while hooks/compile await,
21609
21885
  // and stop() may close provider admission. Do not create a provider
21610
21886
  // stream after its generation lost ownership, or once admission closed,
21611
- // or once a stop() and a start() have begun another lifecycle, as after
21612
- // the compile: these come before a stop kept for the turn, which goes
21613
- // with it.
21614
- if (this.agents.get(agent.name) !== agent || this.providerAdmissionClosed || lifecycle !== this.lifecycleGeneration) {
21887
+ // as after the compile: these come before a stop kept for the turn,
21888
+ // which goes with it.
21889
+ if (this.agents.get(agent.name) !== agent || this.providerAdmissionClosed) {
21615
21890
  const why = this.agents.get(agent.name) !== agent ? 'the agent was replaced or removed'
21616
- : this.providerAdmissionClosed ? 'the framework was stopping'
21617
- : 'the framework was stopped and started again';
21891
+ : 'the framework was stopping';
21618
21892
  const reason = `a turn assembled while ${why} is dropped before it sends anything`;
21619
21893
  console.error(`[turn] ${agent.name}: ${reason}`);
21620
21894
  this.endDroppedTurn(agent.name, routing);
@@ -21655,6 +21929,7 @@ export class AgentFramework {
21655
21929
  // This turn's retries are the framework's (errorPolicy below), not
21656
21930
  // membrane's inside each attempt.
21657
21931
  membraneRetries: false,
21932
+ inferenceBounds: this.inferenceBounds,
21658
21933
  });
21659
21934
  // Each later round this stream sends is noted as it goes (noteRoundSent).
21660
21935
  if (noteRequestSent)
@@ -21662,18 +21937,16 @@ export class AgentFramework {
21662
21937
  if (carriedBodies)
21663
21938
  this.streamCarried.set(stream, new Set(carriedBodies));
21664
21939
  // The compile can wait for annotations (each batch has one spill
21665
- // deadline), so check ownership, admission, and the lifecycle again.
21666
- // A stop followed by start must not revive this earlier turn.
21940
+ // deadline), so check ownership and admission again.
21667
21941
  // Membrane's yielding stream starts its request when it is first
21668
21942
  // iterated, so cancelling it here, before driveStream, sends nothing.
21669
21943
  // A turn that was mid-compile when stop() cancelled every stream would
21670
21944
  // otherwise register after that pass and outlive stop(). The caller's
21671
21945
  // finally releases the turn token.
21672
21946
  const stopping = this.providerAdmissionClosed;
21673
- if (this.agents.get(agent.name) !== agent || stopping || lifecycle !== this.lifecycleGeneration) {
21947
+ if (this.agents.get(agent.name) !== agent || stopping) {
21674
21948
  const why = this.agents.get(agent.name) !== agent ? 'the agent was replaced or removed'
21675
- : stopping ? 'the framework was stopping'
21676
- : 'the framework was stopped and started again';
21949
+ : 'the framework was stopping';
21677
21950
  const reason = `a turn compiled while ${why} is dropped before it sends anything`;
21678
21951
  console.error(`[turn] ${agent.name}: ${reason}`);
21679
21952
  this.cancelUnsentStream(agent, stream, `compiled while ${why}`);
@@ -21794,14 +22067,12 @@ export class AgentFramework {
21794
22067
  // catch never holds a compiled request.) As at the checks after the
21795
22068
  // hooks and after the compile, a replaced agent or a stopping framework
21796
22069
  // comes first: the turn is dropped as theirs, and the stop goes with
21797
- // it, and so does a framework stopped and started again, as after the
21798
- // compile. A failed start with no stop goes on below as before.
22070
+ // it. A failed start with no stop goes on below as before.
21799
22071
  const stopAtFailure = this.takeStartStop(agent, checkWindow);
21800
22072
  if (stopAtFailure) {
21801
- if (this.agents.get(agent.name) !== agent || this.providerAdmissionClosed || lifecycle !== this.lifecycleGeneration) {
22073
+ if (this.agents.get(agent.name) !== agent || this.providerAdmissionClosed) {
21802
22074
  const why = this.agents.get(agent.name) !== agent ? 'the agent was replaced or removed'
21803
- : this.providerAdmissionClosed ? 'the framework was stopping'
21804
- : 'the framework was stopped and started again';
22075
+ : 'the framework was stopping';
21805
22076
  const reason = `a turn whose request couldn't be built while ${why} is dropped before it sends anything`;
21806
22077
  console.error(`[turn] ${agent.name}: ${reason}`);
21807
22078
  this.endDroppedTurn(agent.name, routing);
@@ -21813,6 +22084,7 @@ export class AgentFramework {
21813
22084
  return false;
21814
22085
  }
21815
22086
  const retry = trigger && withTurnCarry(trigger, carry);
22087
+ const asTransient = this.takeAsTransient(agent.name, err, trigger);
21816
22088
  if (ownsProviderGate && this.holdProviderAcceleration(agent, err, retry)) {
21817
22089
  // Capacity scheduling is not poisoned history. The cooldown timer owns
21818
22090
  // one later fresh compile; no immediate retry or exhaustion marker.
@@ -21828,23 +22100,28 @@ export class AgentFramework {
21828
22100
  this.endHeldStart(agent, checkWindow, turnToken, turnStartedAt, routing);
21829
22101
  return false;
21830
22102
  }
21831
- const action = statedWait === 'fail' || (isRearmed(trigger) && !gotPartway(routing))
21832
- ? { retry: false } : this.errorPolicy.onInferenceError(err, agent.name, attempt);
22103
+ const cuts = isDeadlineExpiry(err) ? this.deadlineCuts.get(agent.name) ?? 0 : undefined;
22104
+ const action = statedWait === 'fail' || (isRearmed(trigger) && (!gotPartway(routing) || (isDeadlineExpiry(err) && !asTransient)))
22105
+ ? { retry: false } : this.errorPolicy.onInferenceError(err, agent.name, cuts ?? attempt);
22106
+ if (action.retry && cuts !== undefined)
22107
+ this.deadlineCuts.set(agent.name, cuts + 1);
22108
+ else
22109
+ this.deadlineCuts.delete(agent.name);
22110
+ if (!action.retry && cuts !== undefined && cuts > 0)
22111
+ this.freshCallsCut.add(agent.name);
21833
22112
  if (action.retry) {
21834
22113
  // A policy's delay can be a provider's retry-after: wait all of it.
21835
22114
  await this.waitForRetry(action.delayMs, checkWindow, agent);
21836
- if (this.providerAdmissionClosed || lifecycle !== this.lifecycleGeneration) {
22115
+ if (this.providerAdmissionClosed) {
21837
22116
  // stop() ended the wait: that cancels the retry, it does not hasten
21838
- // it. A stop() and a start() since this start began cancel it too,
21839
- // as after the compile: the retry would revive the earlier
21840
- // lifecycle's turn in the next one, and a stop kept for the turn
21841
- // goes with it. The turn ends as one dropped before it sends
21842
- // anything does: a terminal event saying why, after the
21843
- // inference:failed above, and its record's end. Nothing is written
22117
+ // it, and a stop kept for the turn goes with it. The turn ends as
22118
+ // one dropped before it sends anything does: a terminal event
22119
+ // saying why, after the inference:failed above, and its record's
22120
+ // end. Nothing is written
21844
22121
  // but the [speech-not-posted] notice; this frame still owns its
21845
22122
  // token, since no retry frame replaced it.
21846
22123
  this.nameUnpostedSpeech(agent, routing);
21847
- this.abortDroppedTurn(agent.name, turnToken, this.providerAdmissionClosed ? RETRY_CANCELLED_BY_STOP : RETRY_ACROSS_RESTART, turnStartedAt);
22124
+ this.abortDroppedTurn(agent.name, turnToken, RETRY_CANCELLED_BY_STOP, turnStartedAt);
21848
22125
  if (this.activeTurnTokens.get(agent.name) === turnToken) {
21849
22126
  this.activeTurnTokens.delete(agent.name);
21850
22127
  this.activeTurnTriggers.delete(agent.name);
@@ -21890,7 +22167,7 @@ export class AgentFramework {
21890
22167
  speech: '',
21891
22168
  ...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
21892
22169
  }, routing);
21893
- if (isRearmed(trigger) && isTransientFailure(err))
22170
+ if (isRearmed(trigger) && asTransient)
21894
22171
  this.exhaustingRearms.add(agent.name);
21895
22172
  this.emitTrace({
21896
22173
  type: 'inference:exhausted',
@@ -21911,7 +22188,7 @@ export class AgentFramework {
21911
22188
  if (action.emit) {
21912
22189
  this.pushEvent(action.emit);
21913
22190
  }
21914
- this.rearmExhaustedTurn(agent, err, trigger, ownsProviderGate, routing);
22191
+ this.rearmExhaustedTurn(agent, err, trigger, ownsProviderGate, routing, asTransient);
21915
22192
  }
21916
22193
  // No handoff on either failure branch: the terminal branch deleted the
21917
22194
  // token above; the retry branch's recursive startAgentStream replaced
@@ -22527,7 +22804,7 @@ export class AgentFramework {
22527
22804
  assistantBlocks = roundContent.filter((b) => b.type !== 'tool_result');
22528
22805
  }
22529
22806
  else {
22530
- // Fallback (older membrane / XML tool mode): preamble + calls.
22807
+ // XML tool mode: this round's text and its calls.
22531
22808
  // XML mode: persist the verbatim generation on each tool_use
22532
22809
  // (membrane#36 round-trip fidelity). context.rawText is the
22533
22810
  // parser's fullMatch — the exact <function_calls> block the
@@ -22539,14 +22816,12 @@ export class AgentFramework {
22539
22816
  const rawXml = rawText && /^<(antml:)?function_calls>/.test(rawText)
22540
22817
  ? rawText
22541
22818
  : undefined;
22542
- // Persist THIS round's text only. `roundPreamble` (membrane
22543
- // ≥0.5.79) is the per-round delta; the legacy `preamble` is
22544
- // CUMULATIVE in XML mode — persisting it per round stored each
22545
- // round's text N times and re-persisted injected
22546
- // <function_results> as the agent's own words (the Evander
22547
- // 2026-08-08 scaffold-leak pyramid: r1 ×3, r2 ×2).
22548
- const roundText = event.context.roundPreamble ??
22549
- event.context.preamble;
22819
+ // Persist THIS round's text only: `roundPreamble` is the
22820
+ // per-round delta. The `preamble` beside it is CUMULATIVE in XML
22821
+ // mode: persisting it per round would store each round's text N
22822
+ // times and re-persist injected <function_results> as the
22823
+ // agent's own words.
22824
+ const roundText = event.context.roundPreamble;
22550
22825
  assistantBlocks = [];
22551
22826
  if (roundText) {
22552
22827
  assistantBlocks.push({ type: 'text', text: roundText });
@@ -22570,9 +22845,7 @@ export class AgentFramework {
22570
22845
  this.roundTurns?.set(assistantBlocks, streamRouting);
22571
22846
  // Whether this stream can carry messages injected with the round's
22572
22847
  // results: true on membrane's native path and false on its XML
22573
- // prefill path, from membrane 0.6.0, and absent on 0.5.x, which
22574
- // doesn't declare it. The tool boundary reads it from the agent's
22575
- // state.
22848
+ // prefill path. The tool boundary reads it from the agent's state.
22576
22849
  agent.enterWaitingForTools(calls, stream, event.context.supportsInjectedMessages);
22577
22850
  // Speak-while-acting: route THIS round's prose to the locus NOW
22578
22851
  // instead of batching every segment to the end of the turn. Long
@@ -22610,12 +22883,10 @@ export class AgentFramework {
22610
22883
  liveProseRouting = true;
22611
22884
  // Whether this round's tool-result batch (and anything queued
22612
22885
  // with it) will be presented to the model before it speaks
22613
- // again: the producer says so when it can (membrane from 0.6.0).
22614
- // Otherwise, as on membrane 0.5.x, every round counts as
22615
- // presenting it. That holds for 0.5.x's native rounds, while its
22616
- // XML path drops what's injected, so a 'now' notice there reaches
22886
+ // again: the stream declares it (true on membrane's native path).
22887
+ // On the XML path, which declares false, a 'now' notice reaches
22617
22888
  // the agent at its next compile instead.
22618
- const presentsInjections = event.context.supportsInjectedMessages ?? true;
22889
+ const presentsInjections = event.context.supportsInjectedMessages === true;
22619
22890
  // Queued under this turn's routing, whenever it posts, and
22620
22891
  // streamed by this stream once published.
22621
22892
  // This round's request was answered: what it carried is heard.
@@ -23348,14 +23619,14 @@ export class AgentFramework {
23348
23619
  // active (native roundContent), each mid-turn round's prose was
23349
23620
  // already delivered when that round yielded — only the TRAILING
23350
23621
  // prose (blocks after the last tool round) remains here. On the
23351
- // fallback path (XML tool mode / older membrane: no roundContent,
23352
- // so nothing was live-routed) the historical behavior applies:
23622
+ // XML tool path (no roundContent, so nothing was live-routed)
23623
+ // the historical behavior applies:
23353
23624
  // route the whole turn's parsed segments, once, now. The global
23354
23625
  // speech/thoughts split is left untouched (module/TUI rendering
23355
23626
  // unaffected) — this only governs what reaches the channel.
23356
23627
  //
23357
23628
  // Native round routing uses the live sticky flag, which a fresh
23358
- // injected message can reset. The legacy/fallback path has no
23629
+ // injected message can reset. The XML tool path has no
23359
23630
  // reliable round boundaries, so retain its historical turn-wide
23360
23631
  // scan to avoid double-posting.
23361
23632
  const toolCalls = response.content
@@ -23582,6 +23853,7 @@ export class AgentFramework {
23582
23853
  break;
23583
23854
  this.abandonAgentScripts(agent.name, 'stream error', streamRouting);
23584
23855
  const retry = retryOf(trigger);
23856
+ const asTransient = this.takeAsTransient(agent.name, err, trigger);
23585
23857
  // A hold takes the turn's retry, or, once stop() has closed
23586
23858
  // admission, the turn ends here (endFailureAtStop).
23587
23859
  if (ownsProviderGate && this.holdProviderAcceleration(agent, err, retry)) {
@@ -23597,8 +23869,15 @@ export class AgentFramework {
23597
23869
  this.eventGate?.onInferenceEnded(agent.name);
23598
23870
  break;
23599
23871
  }
23600
- const action = statedWait === 'fail' || (isRearmed(trigger) && !gotPartway(streamRouting))
23601
- ? { retry: false } : this.errorPolicy.onInferenceError(err, agent.name, attempt);
23872
+ const cuts = isDeadlineExpiry(err) ? this.deadlineCuts.get(agent.name) ?? 0 : undefined;
23873
+ const action = statedWait === 'fail' || (isRearmed(trigger) && (!gotPartway(streamRouting) || (isDeadlineExpiry(err) && !asTransient)))
23874
+ ? { retry: false } : this.errorPolicy.onInferenceError(err, agent.name, cuts ?? attempt);
23875
+ if (action.retry && cuts !== undefined)
23876
+ this.deadlineCuts.set(agent.name, cuts + 1);
23877
+ else
23878
+ this.deadlineCuts.delete(agent.name);
23879
+ if (!action.retry && cuts !== undefined && cuts > 0)
23880
+ this.freshCallsCut.add(agent.name);
23602
23881
  if (action.retry) {
23603
23882
  // A stop made during the wait reaches no stream: the check after
23604
23883
  // the wait ends the turn, as the guard's check after its wait
@@ -23660,7 +23939,7 @@ export class AgentFramework {
23660
23939
  speech: '',
23661
23940
  ...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
23662
23941
  }, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting : undefined, streamRouting);
23663
- if (isRearmed(trigger) && isTransientFailure(err))
23942
+ if (isRearmed(trigger) && asTransient)
23664
23943
  this.exhaustingRearms.add(agent.name);
23665
23944
  this.emitTrace({
23666
23945
  type: 'inference:exhausted',
@@ -23680,7 +23959,7 @@ export class AgentFramework {
23680
23959
  if (action.emit) {
23681
23960
  this.pushEvent(action.emit);
23682
23961
  }
23683
- this.rearmExhaustedTurn(agent, err, trigger, ownsProviderGate, streamRouting);
23962
+ this.rearmExhaustedTurn(agent, err, trigger, ownsProviderGate, streamRouting, asTransient);
23684
23963
  }
23685
23964
  break;
23686
23965
  }
@@ -23755,7 +24034,7 @@ export class AgentFramework {
23755
24034
  agentName: agent.name,
23756
24035
  error: terminal,
23757
24036
  });
23758
- this.rearmExhaustedTurn(agent, new Error(terminal), trigger, ownsProviderGate, streamRouting);
24037
+ this.rearmExhaustedTurn(agent, new Error(terminal), trigger, ownsProviderGate, streamRouting, false);
23759
24038
  // Postmortem 2026-05-28 P2 #7: persist the abort to the
23760
24039
  // inference log so future investigations can attribute the
23761
24040
  // terminal cause without relying on live in-memory reducer
@@ -23773,6 +24052,8 @@ export class AgentFramework {
23773
24052
  break;
23774
24053
  }
23775
24054
  case 'usage': {
24055
+ this.rearmDeadlineCuts.delete(agent.name);
24056
+ this.deadlineCuts.delete(agent.name);
23776
24057
  // Cumulative across the stream's rounds: replace, never sum.
23777
24058
  latestUsage = event.usage;
23778
24059
  // The stream's accumulated fresh input, which is what the
@@ -23995,6 +24276,7 @@ export class AgentFramework {
23995
24276
  agent.reset();
23996
24277
  this.emitTrace({ type: 'inference:failed', agentName: agent.name, error: err.message, stack: err.stack });
23997
24278
  const retry = retryOf(trigger);
24279
+ const asTransient = this.takeAsTransient(agent.name, err, trigger);
23998
24280
  if (ownsProviderGate && this.holdProviderAcceleration(agent, err, retry)) {
23999
24281
  // Once stop() has closed admission, nothing is held, and the turn
24000
24282
  // ends here (endFailureAtStop).
@@ -24032,7 +24314,7 @@ export class AgentFramework {
24032
24314
  speech: '',
24033
24315
  ...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
24034
24316
  }, streamRouting);
24035
- if (isRearmed(trigger) && isTransientFailure(err))
24317
+ if (isRearmed(trigger) && asTransient)
24036
24318
  this.exhaustingRearms.add(agent.name);
24037
24319
  this.emitTrace({
24038
24320
  type: 'inference:exhausted',
@@ -24040,7 +24322,7 @@ export class AgentFramework {
24040
24322
  error: err.message,
24041
24323
  ...this.classifyInferenceError(err),
24042
24324
  });
24043
- this.rearmExhaustedTurn(agent, err, trigger, ownsProviderGate, streamRouting);
24325
+ this.rearmExhaustedTurn(agent, err, trigger, ownsProviderGate, streamRouting, asTransient);
24044
24326
  // Postmortem 2026-05-28 P2 #7: catch-path failures (stream itself
24045
24327
  // threw) were previously only visible via in-memory trace listeners.
24046
24328
  // Persist to inference log for forensic attribution.
@@ -24246,7 +24528,11 @@ export class AgentFramework {
24246
24528
  }
24247
24529
  /**
24248
24530
  * Execute a tool call and return the result.
24249
- * Routes to the appropriate handler (module registry or MCPL).
24531
+ * Routes it where the agent's own call of the name goes: to the
24532
+ * framework's own tools the agent has (agent_settings, the workspace and
24533
+ * gate tools, think, journal, prose_help and the rest), the channel tools,
24534
+ * MCPL or the module registry. skip_reply, which ends the turn it's called
24535
+ * in, is refused, since this call isn't made in one.
24250
24536
  * Its one caller in the framework is the operator's puppet
24251
24537
  * (puppetToolCall), which puts the agent's inline cap on the call as
24252
24538
  * resultMaxChars; a host may call it as well.
@@ -24292,8 +24578,11 @@ export class AgentFramework {
24292
24578
  * wire ordering. For its own duration the puppet HOLDS the turn-alive
24293
24579
  * marker (wakes requeue, cross-turn writers defer), so a wake landing
24294
24580
  * during the tool's execution cannot start a turn underneath the pair.
24295
- * - Refused for tools outside the agent's own surface (canUseTool): the
24296
- * stored pair must be an act the agent could genuinely have taken.
24581
+ * - Refused for tools outside the agent's own surface (the tools it may
24582
+ * call, availableToolsForPresentation), and for skip_reply, by name or
24583
+ * through a toolsets--act that reaches it, which ends the turn it's
24584
+ * called in: the stored pair must be an act the agent could genuinely
24585
+ * have taken.
24297
24586
  * - The call executes FOR REAL through the shared dispatch (MCPL,
24298
24587
  * channel tools, utils, modules) with the agent's provenance — a
24299
24588
  * puppeted walk_to actually moves the avatar.
@@ -24334,12 +24623,17 @@ export class AgentFramework {
24334
24623
  : `puppet refused: agent ${agentName} is ${shown} (requires idle — ` +
24335
24624
  `injecting a turn under an active stream corrupts wire ordering)`);
24336
24625
  }
24337
- const onSurface = this.getToolsForAgent(agentName)
24338
- .some((t) => t.name === toolName && agent.canUseTool(t.name));
24626
+ const onSurface = this.availableToolsForPresentation(agentName).some((t) => t.name === toolName);
24339
24627
  if (!onSurface) {
24340
24628
  throw new Error(`puppet refused: tool ${toolName} is not on ${agentName}'s surface — ` +
24341
24629
  `the stored pair must be an act the agent could genuinely have taken`);
24342
24630
  }
24631
+ const route = this.toolRoute({ id: '', name: toolName, input }, agentName);
24632
+ const acting = route.kind === 'host-act' && route.host.kind === 'call' ? route.host.name : toolName;
24633
+ if (acting === 'skip_reply') {
24634
+ throw new Error("puppet refused: skip_reply ends the turn it's called in, and a puppeted call isn't made in one, " +
24635
+ 'so nothing was run or stored');
24636
+ }
24343
24637
  // Anthropic-shaped id so the stored pair is indistinguishable from a
24344
24638
  // provider-issued call.
24345
24639
  const alphabet = 'ABCDEFGHJKMNPQRSTVWXYZabcdefghjkmnpqrstvwxyz0123456789';
@@ -24471,96 +24765,119 @@ export class AgentFramework {
24471
24765
  this.emitTrace({ type: 'tool:failed', module: 'framework', tool: call.name, callId: call.id, error: marked });
24472
24766
  return { success: false, error: marked, isError: true };
24473
24767
  }
24474
- if (isPresentationTool(call.name))
24475
- return this.editToolPresentation(call.callerAgentName ?? '__ephemeral__', call);
24476
- // Client-side programmatic tool calling for callers that await a tool's
24477
- // result: the puppet and a host through executeToolCall, and a module
24478
- // through callTool. Keyed by callerAgentName, so each agent's scripts are
24479
- // its own.
24480
- if (call.name === CODE_EXECUTION_TOOL_NAME && this.codeExecution) {
24481
- return this.runCodeExecution(call.callerAgentName ?? '__ephemeral__', call);
24482
- }
24483
- // toolsets--act on a host toolset's tool (the built-in one, a server's
24484
- // tab) goes where a programmatic call of that tool goes, under its name.
24485
- if (call.name === META_TOOL_NAMES.act && call.callerAgentName !== undefined) {
24486
- const host = this.hostPresentation?.resolveAct(call.callerAgentName, call.input) ?? null;
24487
- if (host?.kind === 'refused')
24488
- return failed({ success: false, error: host.error, isError: true });
24489
- if (host?.kind === 'call')
24490
- return this.executeToolCallFrom({ ...call, name: host.name, input: host.args }, origin);
24491
- }
24492
- // RFC-014: toolsets--act, resolved to the dynamic tool it calls, goes
24493
- // where a programmatic call of that tool goes, with its instance.
24494
- if (call.name === META_TOOL_NAMES.act && this.toolsetHub && this.mcplServerRegistry) {
24495
- const target = this.resolveToolsetAct(call);
24496
- if (!target.ok)
24497
- return failed({ success: false, error: target.error, isError: true }, this.everyServerRedactor()(target.error));
24498
- if (origin.kind === 'agent') {
24499
- const failure = this.agentToolPermissionFailure(origin.agentName, target.call, false);
24500
- if (failure)
24501
- return failed(failure, this.everyServerRedactor()(failure.error ?? ''));
24502
- }
24503
- return this.executeMcplToolCall(target.call, target.serverId, target.prefix);
24504
- }
24505
- // MCPL tools go where the model's own call of the name goes, by the same
24506
- // resolver and with the prefix it matched: to the server whose listing
24507
- // has the name, else the longest matching prefix.
24508
- if (this.mcplServerRegistry) {
24509
- const mcpl = this.resolveMcplTool(call.name);
24510
- if (mcpl)
24511
- return this.executeMcplToolCall(call, mcpl[0], mcpl[1]);
24512
- }
24513
- // Synthesized channel tools. This route was missing: when
24514
- // subscription-GC moved off the retired MCPL unsubscribe tool onto the
24515
- // generic channel_close (discord-mcpl b095a9f), module-originated
24516
- // closes started failing downstream as "Invalid tool name format" —
24517
- // the janitor had been silently broken since. `origin` is trusted
24518
- // dispatch context established at the call boundary above, not
24519
- // inferred from which method was used: only the ModuleRegistry closure
24520
- // carries module provenance (see handleToolClose). The agent-only
24521
- // synthesized verbs (think, skip_reply) are deliberately not routed
24522
- // here.
24523
- if (call.name.startsWith('channel_') && this.channelRegistry) {
24524
- const turn = call.name === 'channel_publish' && call.callerAgentName ? this.callingTurnOf(call.callerAgentName, call) : undefined;
24525
- const input = call.name === 'channel_publish' && call.callerAgentName
24526
- ? this.publishInputWithTurnLocus(call.input, call.callerAgentName, turn)
24527
- : call.input;
24528
- return this.channelRegistry.handleChannelToolCall(call.name, input, origin.kind === 'agent' && turn ? { ...origin, inTurn: true } : origin);
24529
- }
24530
- // drafts: the calling resident's own held drafts. A puppeted call has
24531
- // agent origin and acts on that resident's drafts, as the model's own
24532
- // call does through dispatchToolCall, but it isn't made inside the
24533
- // resident's turn, so it takes none of the turn's channels, engages none,
24534
- // and isn't noted as the resident's speech (callingTurnOf). A module
24535
- // holds no drafts.
24536
- if (call.name === 'drafts' && this.channelRegistry && origin.kind === 'agent') {
24537
- return this.draftsToolResult(origin.agentName, call.input);
24538
- }
24539
- // The heartbeat's tools, for callers that await a tool's result (an
24540
- // in-process call, a puppet, a module's callTool). A module is trusted;
24541
- // an agent is held to what dispatch holds it to.
24542
- if (this.heartbeat?.ownsTool(call.name)) {
24543
- const refusal = origin.kind === 'module' ? null : this.heartbeatRefusal(call.callerAgentName ?? '__ephemeral__', call);
24544
- return refusal ? { success: false, error: refusal, isError: true } : this.runHeartbeatTool(call);
24545
- }
24546
- // Module tools. The caller uses the result in process, so it gets the
24547
- // module's own result, as the module returned it, less a refusal it
24548
- // claims, which is the dispatcher's to say.
24549
- // The tune-out tool, in process: the same body the dispatcher runs, its
24550
- // answer awaited here (it can wait for the tune-out's transport).
24551
- if ((call.name === 'tune_out' || call.name === 'focus') && this.tuneOutCoordinator) {
24552
- const agentName = call.callerAgentName ?? this.primaryAgentName ?? '__ephemeral__';
24553
- const input = (call.input ?? {});
24554
- // The calling turn's own channels, as the channel tools read them here (origin.own).
24555
- const own = origin.kind === 'agent' ? origin.own ?? [] : [];
24556
- const turn = this.callingTurnOf(agentName, call);
24557
- return call.name === 'focus' ? this.handleFocusTool(agentName, input, own, turn) : this.handleTuneOutTool(agentName, input, own, turn);
24558
- }
24559
- if (SUBCONSCIOUS_TOOL_NAMES.includes(call.name)) {
24560
- const routed = this.readerToolCall(origin.kind === 'agent' ? origin.agentName : call.callerAgentName ?? '__ephemeral__', call);
24561
- return 'refused' in routed ? failed({ success: false, error: `${routed.refused}, so the call didn't run.`, isError: true }) : routed.result;
24768
+ const subject = origin.kind === 'agent' ? origin.agentName : call.callerAgentName ?? '__ephemeral__';
24769
+ const route = this.toolRoute(call, call.callerAgentName);
24770
+ switch (route.kind) {
24771
+ case 'presentation':
24772
+ return this.editToolPresentation(call.callerAgentName ?? '__ephemeral__', call);
24773
+ // toolsets--act on a host toolset's tool (the built-in one, a server's
24774
+ // tab) goes where a programmatic call of that tool goes, under its name.
24775
+ case 'host-act':
24776
+ if (route.host.kind === 'refused')
24777
+ return failed({ success: false, error: route.host.error, isError: true });
24778
+ return this.executeToolCallFrom({ ...call, name: route.host.name, input: route.host.args }, origin);
24779
+ // RFC-014: toolsets--act, resolved to the dynamic tool it calls, goes
24780
+ // where a programmatic call of that tool goes, with its instance.
24781
+ case 'toolset-act': {
24782
+ const target = this.resolveToolsetAct(call);
24783
+ if (!target.ok)
24784
+ return failed({ success: false, error: target.error, isError: true }, this.everyServerRedactor()(target.error));
24785
+ if (origin.kind === 'agent') {
24786
+ const failure = this.agentToolPermissionFailure(origin.agentName, target.call, false);
24787
+ if (failure)
24788
+ return failed(failure, this.everyServerRedactor()(failure.error ?? ''));
24789
+ }
24790
+ return this.executeMcplToolCall(target.call, target.serverId, target.prefix);
24791
+ }
24792
+ case 'mcpl':
24793
+ return this.executeMcplToolCall(call, route.serverId, route.prefix);
24794
+ // Synthesized channel tools. This route was missing: when
24795
+ // subscription-GC moved off the retired MCPL unsubscribe tool onto the
24796
+ // generic channel_close (discord-mcpl b095a9f), module-originated
24797
+ // closes started failing downstream as "Invalid tool name format" —
24798
+ // the janitor had been silently broken since. `origin` is trusted
24799
+ // dispatch context established at the call boundary above, not
24800
+ // inferred from which method was used: only the ModuleRegistry closure
24801
+ // carries module provenance (see handleToolClose).
24802
+ case 'channel': {
24803
+ const turn = call.name === 'channel_publish' && call.callerAgentName ? this.callingTurnOf(call.callerAgentName, call) : undefined;
24804
+ const input = call.name === 'channel_publish' && call.callerAgentName
24805
+ ? this.publishInputWithTurnLocus(call.input, call.callerAgentName, turn)
24806
+ : call.input;
24807
+ return this.channelRegistry.handleChannelToolCall(call.name, input, origin.kind === 'agent' && turn ? { ...origin, inTurn: true } : origin);
24808
+ }
24809
+ case 'agent-settings':
24810
+ return this.awaitedToolAnswer((deliver) => this.dispatchAgentSettingsToolCall(subject, call, deliver));
24811
+ case 'save-image':
24812
+ return this.awaitedToolAnswer((deliver) => this.dispatchSaveImageToolCall(subject, call, deliver));
24813
+ case 'read-image':
24814
+ return this.awaitedToolAnswer((deliver) => this.dispatchReadImageToolCall(subject, call, deliver));
24815
+ case 'fetch-reference':
24816
+ return this.awaitedToolAnswer((deliver) => this.dispatchFetchReferenceToolCall(subject, call, deliver));
24817
+ case 'gate-status':
24818
+ return this.awaitedToolAnswer((deliver) => this.dispatchGateToolCall(subject, call, deliver));
24819
+ // The tune-out tool, in process: the same body the dispatcher runs, its
24820
+ // answer awaited here (it can wait for the tune-out's transport).
24821
+ case 'tune-out': {
24822
+ const agentName = call.callerAgentName ?? this.primaryAgentName ?? '__ephemeral__';
24823
+ const input = (call.input ?? {});
24824
+ // The calling turn's own channels, as the channel tools read them here (origin.own).
24825
+ const own = origin.kind === 'agent' ? origin.own ?? [] : [];
24826
+ const turn = this.callingTurnOf(agentName, call);
24827
+ return call.name === 'focus' ? this.handleFocusTool(agentName, input, own, turn) : this.handleTuneOutTool(agentName, input, own, turn);
24828
+ }
24829
+ case 'subconscious': {
24830
+ const routed = this.readerToolCall(subject, call);
24831
+ return 'refused' in routed ? failed({ success: false, error: `${routed.refused}, so the call didn't run.`, isError: true }) : routed.result;
24832
+ }
24833
+ case 'sleep':
24834
+ return this.awaitedToolAnswer((deliver) => this.dispatchSleepToolCall(subject, call, deliver));
24835
+ // The heartbeat's tools, for callers that await a tool's result (an
24836
+ // in-process call, a puppet, a module's callTool). A module is trusted;
24837
+ // an agent is held to what dispatch holds it to.
24838
+ case 'heartbeat': {
24839
+ const refusal = origin.kind === 'module' ? null : this.heartbeatRefusal(call.callerAgentName ?? '__ephemeral__', call);
24840
+ return refusal ? { success: false, error: refusal, isError: true } : this.runHeartbeatTool(call);
24841
+ }
24842
+ case 'wake-rule':
24843
+ return this.awaitedToolAnswer((deliver) => this.dispatchWakeRuleToolCall(subject, call, deliver));
24844
+ case 'event-tags':
24845
+ return this.awaitedToolAnswer((deliver) => this.dispatchEventTagsToolCall(subject, call, deliver));
24846
+ case 'agent-verb':
24847
+ if (origin.kind === 'module')
24848
+ return failed(agentsOwnVerbRefusal(call.name));
24849
+ if (call.name === 'skip_reply') {
24850
+ return failed({
24851
+ success: false,
24852
+ error: "skip_reply ends the turn it's called in, and this call wasn't made in a turn, so nothing was done.",
24853
+ isError: true,
24854
+ });
24855
+ }
24856
+ return this.channelRegistry.handleChannelToolCall(call.name, call.input, origin);
24857
+ // Client-side programmatic tool calling for callers that await a tool's
24858
+ // result: the puppet and a host through executeToolCall, and a module
24859
+ // through callTool. Keyed by callerAgentName, so each agent's scripts are
24860
+ // its own.
24861
+ case 'code-execution':
24862
+ return this.runCodeExecution(call.callerAgentName ?? '__ephemeral__', call);
24863
+ // drafts: the calling resident's own held drafts. A puppeted call has
24864
+ // agent origin and acts on that resident's drafts, as the model's own
24865
+ // call does through dispatchToolCall, but it isn't made inside the
24866
+ // resident's turn, so it takes none of the turn's channels, engages none,
24867
+ // and isn't noted as the resident's speech (callingTurnOf). A module
24868
+ // holds no drafts.
24869
+ case 'drafts':
24870
+ if (origin.kind === 'module')
24871
+ return failed(agentsOwnVerbRefusal(call.name));
24872
+ return this.draftsToolResult(origin.agentName, call.input);
24873
+ case 'prose-help':
24874
+ return { success: true, data: PROSE_ROUTING_HELP };
24875
+ case 'module':
24876
+ // Module tools. The caller uses the result in process, so it gets the
24877
+ // module's own result, as the module returned it, less a refusal it
24878
+ // claims, which is the dispatcher's to say.
24879
+ return this.moduleRegistry.handleToolCall(call);
24562
24880
  }
24563
- return this.moduleRegistry.handleToolCall(call);
24564
24881
  }
24565
24882
  /**
24566
24883
  * RFC-014: the calls toolsets--act resolved to a server's dynamic tool,
@@ -25288,7 +25605,7 @@ export class AgentFramework {
25288
25605
  // this write's own, rather than a timer Node would cut to 1 ms.
25289
25606
  if (!Number.isFinite(deadline))
25290
25607
  deadline = Date.now() + this.withheldSpillDeadlineMs;
25291
- if (this.guardSpillsClosedFor === this.lifecycleGeneration)
25608
+ if (this.guardSpillsClosed)
25292
25609
  return null;
25293
25610
  const place = this.toolResultFilePlace(label);
25294
25611
  if (!place)
@@ -26341,6 +26658,71 @@ export class AgentFramework {
26341
26658
  const refused = bareToolName(call.name) === 'skip_reply' && (!permitted || this.refusesSelfWake(call));
26342
26659
  return { name: call.name, cause: permitted || refused ? silenceCauseOf([call]) : null, refused, permitted };
26343
26660
  }
26661
+ toolRoute(call, actor) {
26662
+ const name = call.name;
26663
+ if (isPresentationTool(name))
26664
+ return { kind: 'presentation' };
26665
+ if (name === META_TOOL_NAMES.act) {
26666
+ const host = actor === undefined ? null : this.hostPresentation?.resolveAct(actor, call.input) ?? null;
26667
+ if (host)
26668
+ return { kind: 'host-act', host };
26669
+ if (this.toolsetHub && this.mcplServerRegistry)
26670
+ return { kind: 'toolset-act' };
26671
+ }
26672
+ const mcpl = this.mcplServerRegistry ? this.resolveMcplTool(name) : null;
26673
+ if (mcpl)
26674
+ return { kind: 'mcpl', serverId: mcpl[0], prefix: mcpl[1] };
26675
+ if (name.startsWith('channel_') && this.channelRegistry)
26676
+ return { kind: 'channel' };
26677
+ if (name === 'agent_settings')
26678
+ return { kind: 'agent-settings' };
26679
+ if (name === 'save_recent_image')
26680
+ return { kind: 'save-image' };
26681
+ if (name === 'read_image')
26682
+ return { kind: 'read-image' };
26683
+ if (name === 'fetch_reference')
26684
+ return { kind: 'fetch-reference' };
26685
+ if (name === 'gate_status' && this.eventGate)
26686
+ return { kind: 'gate-status' };
26687
+ if ((name === 'tune_out' || name === 'focus') && this.tuneOutCoordinator)
26688
+ return { kind: 'tune-out' };
26689
+ if (SUBCONSCIOUS_TOOL_NAMES.includes(name))
26690
+ return { kind: 'subconscious' };
26691
+ if ((name === 'sleep' || name === 'wake') && this.eventGate)
26692
+ return { kind: 'sleep' };
26693
+ if (this.heartbeat?.ownsTool(name))
26694
+ return { kind: 'heartbeat' };
26695
+ if ((name === 'wake_add_rule' || name === 'wake_remove_rule') && this.eventGate)
26696
+ return { kind: 'wake-rule' };
26697
+ if (name === 'event_tags' && this.eventGate)
26698
+ return { kind: 'event-tags' };
26699
+ if ((name === 'think' || name === 'journal' || name === 'skip_reply') && this.channelRegistry)
26700
+ return { kind: 'agent-verb' };
26701
+ if (name === CODE_EXECUTION_TOOL_NAME && this.codeExecution)
26702
+ return { kind: 'code-execution' };
26703
+ if (name === 'drafts' && this.channelRegistry)
26704
+ return { kind: 'drafts' };
26705
+ if (name === PROSE_HELP_TOOL.name)
26706
+ return { kind: 'prose-help' };
26707
+ return { kind: 'module' };
26708
+ }
26709
+ pushToolResult(agentName, call, moduleName) {
26710
+ return (result) => this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName, result });
26711
+ }
26712
+ awaitedToolAnswer(run) {
26713
+ return new Promise((resolve) => {
26714
+ try {
26715
+ run((result) => {
26716
+ const answer = { ...result };
26717
+ delete answer.refused;
26718
+ resolve(answer);
26719
+ });
26720
+ }
26721
+ catch (error) {
26722
+ resolve({ success: false, error: thrownText(error), isError: true });
26723
+ }
26724
+ });
26725
+ }
26344
26726
  /**
26345
26727
  * Dispatch a call the agent made. `turnId` is the turn it was made in
26346
26728
  * (TurnRouting.turnId), as its caller knows it: a model's call, the turn
@@ -26392,200 +26774,190 @@ export class AgentFramework {
26392
26774
  enrichedCall.callerTurnId = turnId;
26393
26775
  else
26394
26776
  delete enrichedCall.callerTurnId;
26395
- if (isPresentationTool(call.name)) {
26396
- const result = this.editToolPresentation(agentName, enrichedCall);
26397
- this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'tool-presentation', result });
26398
- return;
26399
- }
26400
- // toolsets--act on a host toolset's tool (the built-in one, a server's
26401
- // tab): the call of that tool, under its own name, down the path a
26402
- // direct call of it takes, permissions included.
26403
- if (enrichedCall.name === META_TOOL_NAMES.act) {
26404
- const host = this.hostPresentation?.resolveAct(agentName, enrichedCall.input) ?? null;
26405
- if (host) {
26406
- this.dispatchHostAct(agentName, call, host, turnId);
26777
+ const route = this.toolRoute(enrichedCall, agentName);
26778
+ switch (route.kind) {
26779
+ case 'presentation': {
26780
+ const result = this.editToolPresentation(agentName, enrichedCall);
26781
+ this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'tool-presentation', result });
26407
26782
  return;
26408
26783
  }
26409
- }
26410
- // RFC-014: toolsets--act calls a server's dynamic tool, down the same
26411
- // path as a direct call of it (dispatchToolsetAct).
26412
- if (enrichedCall.name === META_TOOL_NAMES.act && this.toolsetHub && this.mcplServerRegistry) {
26413
- const scriptWaiter = this.scriptToolWaiters.get(enrichedCall.id);
26414
- if (scriptWaiter)
26415
- scriptWaiter.route.toServer = true;
26416
- this.dispatchToolsetAct(agentName, enrichedCall);
26417
- return;
26418
- }
26419
- // Route MCPL tool calls to the appropriate server via prefix map
26420
- const mcplMatch = this.resolveMcplTool(enrichedCall.name);
26421
- if (mcplMatch && this.mcplServerRegistry) {
26422
- // A script's call: its waiter keeps that it went to a server, for the
26423
- // script lane's exit (scriptToolWaiters).
26424
- const scriptWaiter = this.scriptToolWaiters.get(enrichedCall.id);
26425
- if (scriptWaiter)
26426
- scriptWaiter.route.toServer = true;
26427
- this.dispatchMcplToolCall(agentName, enrichedCall, mcplMatch[0], mcplMatch[1]);
26428
- return;
26429
- }
26430
- // Route synthesized channel tools
26431
- if (enrichedCall.name.startsWith('channel_') && this.channelRegistry) {
26432
- this.dispatchChannelToolCall(agentName, enrichedCall);
26433
- return;
26434
- }
26435
- if (enrichedCall.name === 'agent_settings') {
26436
- this.dispatchAgentSettingsToolCall(agentName, enrichedCall);
26437
- return;
26438
- }
26439
- if (enrichedCall.name === 'save_recent_image') {
26440
- this.dispatchSaveImageToolCall(agentName, enrichedCall);
26441
- return;
26442
- }
26443
- if (enrichedCall.name === 'read_image') {
26444
- this.dispatchReadImageToolCall(agentName, enrichedCall);
26445
- return;
26446
- }
26447
- if (enrichedCall.name === 'fetch_reference') {
26448
- this.dispatchFetchReferenceToolCall(agentName, enrichedCall);
26449
- return;
26450
- }
26451
- // Route gate_status tool
26452
- if (enrichedCall.name === 'gate_status' && this.eventGate) {
26453
- this.dispatchGateToolCall(agentName, enrichedCall);
26454
- return;
26455
- }
26456
- // Route the tune-out tool (residents) and the subconscious's surface
26457
- if ((enrichedCall.name === 'tune_out' || enrichedCall.name === 'focus') && this.tuneOutCoordinator) {
26458
- this.dispatchTuneOutToolCall(agentName, enrichedCall);
26459
- return;
26460
- }
26461
- if (SUBCONSCIOUS_TOOL_NAMES.includes(enrichedCall.name)) {
26462
- const routed = this.readerToolCall(agentName, enrichedCall);
26463
- if ('refused' in routed) {
26464
- const error = routed.refused;
26465
- const result = this.refusedResult(agentName, call.id, error);
26466
- this.emitTrace({ type: 'tool:failed', module: 'tune-out', tool: call.name, callId: call.id, error });
26467
- this.pushEvent({
26468
- type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'tune-out', result,
26784
+ // toolsets--act on a host toolset's tool (the built-in one, a server's
26785
+ // tab): the call of that tool, under its own name, down the path a
26786
+ // direct call of it takes, permissions included.
26787
+ case 'host-act':
26788
+ this.dispatchHostAct(agentName, call, route.host, turnId);
26789
+ return;
26790
+ // RFC-014: toolsets--act calls a server's dynamic tool, down the same
26791
+ // path as a direct call of it (dispatchToolsetAct).
26792
+ case 'toolset-act': {
26793
+ const scriptWaiter = this.scriptToolWaiters.get(enrichedCall.id);
26794
+ if (scriptWaiter)
26795
+ scriptWaiter.route.toServer = true;
26796
+ this.dispatchToolsetAct(agentName, enrichedCall);
26797
+ return;
26798
+ }
26799
+ case 'mcpl': {
26800
+ // A script's call: its waiter keeps that it went to a server, for the
26801
+ // script lane's exit (scriptToolWaiters).
26802
+ const scriptWaiter = this.scriptToolWaiters.get(enrichedCall.id);
26803
+ if (scriptWaiter)
26804
+ scriptWaiter.route.toServer = true;
26805
+ this.dispatchMcplToolCall(agentName, enrichedCall, route.serverId, route.prefix);
26806
+ return;
26807
+ }
26808
+ case 'channel': {
26809
+ this.dispatchChannelToolCall(agentName, enrichedCall);
26810
+ return;
26811
+ }
26812
+ case 'agent-settings': {
26813
+ this.dispatchAgentSettingsToolCall(agentName, enrichedCall);
26814
+ return;
26815
+ }
26816
+ case 'save-image': {
26817
+ this.dispatchSaveImageToolCall(agentName, enrichedCall);
26818
+ return;
26819
+ }
26820
+ case 'read-image': {
26821
+ this.dispatchReadImageToolCall(agentName, enrichedCall);
26822
+ return;
26823
+ }
26824
+ case 'fetch-reference': {
26825
+ this.dispatchFetchReferenceToolCall(agentName, enrichedCall);
26826
+ return;
26827
+ }
26828
+ case 'gate-status': {
26829
+ this.dispatchGateToolCall(agentName, enrichedCall);
26830
+ return;
26831
+ }
26832
+ case 'tune-out': {
26833
+ this.dispatchTuneOutToolCall(agentName, enrichedCall);
26834
+ return;
26835
+ }
26836
+ case 'subconscious': {
26837
+ const routed = this.readerToolCall(agentName, enrichedCall);
26838
+ if ('refused' in routed) {
26839
+ const error = routed.refused;
26840
+ const result = this.refusedResult(agentName, call.id, error);
26841
+ this.emitTrace({ type: 'tool:failed', module: 'tune-out', tool: call.name, callId: call.id, error });
26842
+ this.pushEvent({
26843
+ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'tune-out', result,
26844
+ });
26845
+ return;
26846
+ }
26847
+ void routed.result
26848
+ .then((result) => {
26849
+ // Through pushEvent, like every other tool's result, so one that
26850
+ // arrives after stop() is dropped rather than rejected unhandled.
26851
+ this.pushEvent({
26852
+ type: 'tool-result',
26853
+ callId: enrichedCall.id,
26854
+ toolName: enrichedCall.name,
26855
+ agentName,
26856
+ moduleName: 'tune-out',
26857
+ result,
26858
+ });
26469
26859
  });
26470
26860
  return;
26471
26861
  }
26472
- void routed.result
26473
- .then((result) => {
26474
- // Through pushEvent, like every other tool's result, so one that
26475
- // arrives after stop() is dropped rather than rejected unhandled.
26862
+ case 'sleep': {
26863
+ this.dispatchSleepToolCall(agentName, enrichedCall);
26864
+ return;
26865
+ }
26866
+ case 'heartbeat': {
26867
+ this.dispatchHeartbeatToolCall(agentName, enrichedCall);
26868
+ return;
26869
+ }
26870
+ case 'wake-rule': {
26871
+ this.dispatchWakeRuleToolCall(agentName, enrichedCall);
26872
+ return;
26873
+ }
26874
+ case 'event-tags': {
26875
+ this.dispatchEventTagsToolCall(agentName, enrichedCall);
26876
+ return;
26877
+ }
26878
+ case 'agent-verb': {
26879
+ // skip_reply(wake_in_seconds): arm a gate self-wake so "not replying
26880
+ // NOW" can also mean "back in a moment" — ends the turn, then wakes
26881
+ // the agent after N seconds unless something else wakes it first
26882
+ // (any turn start cancels the pending self-wake). Armed here because
26883
+ // the framework owns the EventGate; the registry only words the
26884
+ // confirmation. A wake the host can't set is refused, so the model
26885
+ // learns that its turn remains open and no wake was armed: any
26886
+ // wake_in_seconds without a gate, and one that isn't a number of
26887
+ // seconds with one.
26888
+ if (enrichedCall.name === 'skip_reply') {
26889
+ const error = this.skipReplyRefusal(enrichedCall);
26890
+ if (error !== undefined) {
26891
+ const wakeValue = enrichedCall.input?.wake_in_seconds;
26892
+ 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)')}`);
26893
+ const result = this.refusedResult(agentName, enrichedCall.id, error);
26894
+ this.emitTrace({ type: 'tool:failed', module: 'channels', tool: enrichedCall.name, callId: enrichedCall.id, error });
26895
+ this.pushEvent({ type: 'tool-result', callId: enrichedCall.id, toolName: enrichedCall.name, agentName, moduleName: 'channels', result });
26896
+ return;
26897
+ }
26898
+ const skipping = this.callingTurnOf(agentName, enrichedCall);
26899
+ if (skipping) {
26900
+ skipping.privateEnd = true;
26901
+ delete skipping.unpostedSpeech;
26902
+ }
26903
+ const wake = readSkipReplyWake(enrichedCall.input);
26904
+ if (wake.kind === 'wake' && this.eventGate) {
26905
+ const armedAt = Date.now();
26906
+ const { inMs, armed } = this.eventGate.armSelfWake(agentName, wake.seconds, 'skip_reply');
26907
+ // The heartbeat's tick or reminder due before the timer: if it wakes
26908
+ // the agent, its turn cancels the timer, so the confirmation names
26909
+ // it and the time the self-wake would have left.
26910
+ const earlierWake = armed ? this.heartbeatWakeBefore(agentName, armedAt + inMs) : undefined;
26911
+ console.error(armed
26912
+ ? `[self-wake] agent=${agentName} armed in ${Math.round(inMs / 1000)}s (skip_reply)`
26913
+ + (earlierWake ? `; due sooner, in ~${earlierWake.inSeconds}s: ${logDetail(earlierWake.what)}` : '')
26914
+ : `[self-wake] agent=${agentName} not armed: the framework has stopped (skip_reply)`);
26915
+ this.dispatchChannelToolCall(agentName, enrichedCall, earlierWake);
26916
+ return;
26917
+ }
26918
+ }
26919
+ this.dispatchChannelToolCall(agentName, enrichedCall);
26920
+ return;
26921
+ }
26922
+ // Client-side programmatic tool calling: run a script that calls the
26923
+ // agent's other tools in-process (script-inner calls come back through
26924
+ // this very dispatcher with waiter-tracked call IDs).
26925
+ case 'code-execution': {
26926
+ this.dispatchCodeExecutionToolCall(agentName, enrichedCall);
26927
+ return;
26928
+ }
26929
+ // drafts: the resident's own held drafts (list/read/resend/dismiss).
26930
+ // Answered by the framework; resend publishes through the registry's
26931
+ // publish executor, so the result arrives asynchronously.
26932
+ case 'drafts': {
26933
+ const startedAt = Date.now();
26934
+ this.emitTrace({ type: 'tool:started', module: 'framework', tool: 'drafts', callId: enrichedCall.id, input: enrichedCall.input });
26935
+ void this.draftsToolResult(agentName, enrichedCall.input, this.callingTurnOf(agentName, enrichedCall), enrichedCall.id)
26936
+ .then((result) => {
26937
+ this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'drafts', callId: enrichedCall.id, durationMs: Date.now() - startedAt });
26938
+ this.pushEvent({ type: 'tool-result', callId: enrichedCall.id, toolName: enrichedCall.name, agentName, moduleName: 'framework', result });
26939
+ });
26940
+ return;
26941
+ }
26942
+ // prose_help: on-demand routing reference for explicit-prose agents.
26943
+ // Answered inline — the grammar is a tool RESULT (model-requested), never
26944
+ // ambient context (see PROSE_ROUTING_HELP / teach-by-bounce).
26945
+ case 'prose-help': {
26946
+ this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'prose_help', callId: enrichedCall.id, durationMs: 0 });
26476
26947
  this.pushEvent({
26477
26948
  type: 'tool-result',
26478
26949
  callId: enrichedCall.id,
26479
26950
  toolName: enrichedCall.name,
26480
26951
  agentName,
26481
- moduleName: 'tune-out',
26482
- result,
26952
+ moduleName: 'framework',
26953
+ result: { success: true, data: PROSE_ROUTING_HELP },
26483
26954
  });
26484
- });
26485
- return;
26486
- }
26487
- // Route sleep / wake tools
26488
- if ((enrichedCall.name === 'sleep' || enrichedCall.name === 'wake') && this.eventGate) {
26489
- this.dispatchSleepToolCall(agentName, enrichedCall);
26490
- return;
26491
- }
26492
- // Route the heartbeat's tools (heartbeat--…)
26493
- if (this.heartbeat?.ownsTool(enrichedCall.name)) {
26494
- this.dispatchHeartbeatToolCall(agentName, enrichedCall);
26495
- return;
26496
- }
26497
- // Route wake-rule tools (runtime gate.json policy add/remove)
26498
- if ((enrichedCall.name === 'wake_add_rule' || enrichedCall.name === 'wake_remove_rule') && this.eventGate) {
26499
- this.dispatchWakeRuleToolCall(agentName, enrichedCall);
26500
- return;
26501
- }
26502
- // Route event_tags (tag/ontology discovery)
26503
- if (enrichedCall.name === 'event_tags' && this.eventGate) {
26504
- this.dispatchEventTagsToolCall(agentName, enrichedCall);
26505
- return;
26506
- }
26507
- // Route synthesized 'think' (private reasoning), 'journal' (private
26508
- // long-form notes) and 'skip_reply' (deliberate stay-silent) tools — handled by the channel registry like the other
26509
- // synthesized channel tools, but they aren't `channel_`-prefixed so they
26510
- // need an explicit route here.
26511
- if ((enrichedCall.name === 'think' || enrichedCall.name === 'journal' || enrichedCall.name === 'skip_reply') && this.channelRegistry) {
26512
- // skip_reply(wake_in_seconds): arm a gate self-wake so "not replying
26513
- // NOW" can also mean "back in a moment" — ends the turn, then wakes
26514
- // the agent after N seconds unless something else wakes it first
26515
- // (any turn start cancels the pending self-wake). Armed here because
26516
- // the framework owns the EventGate; the registry only words the
26517
- // confirmation. A wake the host can't set is refused, so the model
26518
- // learns that its turn remains open and no wake was armed: any
26519
- // wake_in_seconds without a gate, and one that isn't a number of
26520
- // seconds with one.
26521
- if (enrichedCall.name === 'skip_reply') {
26522
- const error = this.skipReplyRefusal(enrichedCall);
26523
- if (error !== undefined) {
26524
- const wakeValue = enrichedCall.input?.wake_in_seconds;
26525
- 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)')}`);
26526
- const result = this.refusedResult(agentName, enrichedCall.id, error);
26527
- this.emitTrace({ type: 'tool:failed', module: 'channels', tool: enrichedCall.name, callId: enrichedCall.id, error });
26528
- this.pushEvent({ type: 'tool-result', callId: enrichedCall.id, toolName: enrichedCall.name, agentName, moduleName: 'channels', result });
26529
- return;
26530
- }
26531
- const skipping = this.callingTurnOf(agentName, enrichedCall);
26532
- if (skipping) {
26533
- skipping.privateEnd = true;
26534
- delete skipping.unpostedSpeech;
26535
- }
26536
- const wake = readSkipReplyWake(enrichedCall.input);
26537
- if (wake.kind === 'wake' && this.eventGate) {
26538
- const armedAt = Date.now();
26539
- const { inMs, armed } = this.eventGate.armSelfWake(agentName, wake.seconds, 'skip_reply');
26540
- // The heartbeat's tick or reminder due before the timer: if it wakes
26541
- // the agent, its turn cancels the timer, so the confirmation names
26542
- // it and the time the self-wake would have left.
26543
- const earlierWake = armed ? this.heartbeatWakeBefore(agentName, armedAt + inMs) : undefined;
26544
- console.error(armed
26545
- ? `[self-wake] agent=${agentName} armed in ${Math.round(inMs / 1000)}s (skip_reply)`
26546
- + (earlierWake ? `; due sooner, in ~${earlierWake.inSeconds}s: ${logDetail(earlierWake.what)}` : '')
26547
- : `[self-wake] agent=${agentName} not armed: the framework has stopped (skip_reply)`);
26548
- this.dispatchChannelToolCall(agentName, enrichedCall, earlierWake);
26549
- return;
26550
- }
26955
+ return;
26551
26956
  }
26552
- this.dispatchChannelToolCall(agentName, enrichedCall);
26553
- return;
26554
- }
26555
- // Client-side programmatic tool calling: run a script that calls the
26556
- // agent's other tools in-process (script-inner calls come back through
26557
- // this very dispatcher with waiter-tracked call IDs).
26558
- if (enrichedCall.name === CODE_EXECUTION_TOOL_NAME && this.codeExecution) {
26559
- this.dispatchCodeExecutionToolCall(agentName, enrichedCall);
26560
- return;
26561
- }
26562
- // drafts: the resident's own held drafts (list/read/resend/dismiss).
26563
- // Answered by the framework; resend publishes through the registry's
26564
- // publish executor, so the result arrives asynchronously.
26565
- if (enrichedCall.name === 'drafts' && this.channelRegistry) {
26566
- const startedAt = Date.now();
26567
- this.emitTrace({ type: 'tool:started', module: 'framework', tool: 'drafts', callId: enrichedCall.id, input: enrichedCall.input });
26568
- void this.draftsToolResult(agentName, enrichedCall.input, this.callingTurnOf(agentName, enrichedCall), enrichedCall.id)
26569
- .then((result) => {
26570
- this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'drafts', callId: enrichedCall.id, durationMs: Date.now() - startedAt });
26571
- this.pushEvent({ type: 'tool-result', callId: enrichedCall.id, toolName: enrichedCall.name, agentName, moduleName: 'framework', result });
26572
- });
26573
- return;
26574
- }
26575
- // prose_help: on-demand routing reference for explicit-prose agents.
26576
- // Answered inline — the grammar is a tool RESULT (model-requested), never
26577
- // ambient context (see PROSE_ROUTING_HELP / teach-by-bounce).
26578
- if (enrichedCall.name === 'prose_help') {
26579
- this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'prose_help', callId: enrichedCall.id, durationMs: 0 });
26580
- this.pushEvent({
26581
- type: 'tool-result',
26582
- callId: enrichedCall.id,
26583
- toolName: enrichedCall.name,
26584
- agentName,
26585
- moduleName: 'framework',
26586
- result: { success: true, data: PROSE_ROUTING_HELP },
26587
- });
26588
- return;
26957
+ case 'module':
26958
+ break;
26959
+ default:
26960
+ route;
26589
26961
  }
26590
26962
  const sepIndex = enrichedCall.name.indexOf('--');
26591
26963
  const moduleName = sepIndex >= 0 ? enrichedCall.name.substring(0, sepIndex) : 'unknown';
@@ -26936,6 +27308,7 @@ export class AgentFramework {
26936
27308
  const agent = this.agents.get(agentName);
26937
27309
  if ((agent && !this.agentSees(agent, metadata)) || isOwnScheduleWrite(metadata) || !isConversationalInjection(metadata))
26938
27310
  return;
27311
+ this.rearmDeadlineCuts?.delete(agentName);
26939
27312
  if (this.turnRearms?.delete(agentName))
26940
27313
  console.error(`[inference-rearm] agent=${agentName} re-send count started again: a message was said to it`);
26941
27314
  }
@@ -27656,11 +28029,9 @@ export class AgentFramework {
27656
28029
  * at a tool boundary whose stream declares it carries the messages
27657
28030
  * injected there (markContextConsumed). So a notice such a stream carried
27658
28031
  * is under it. One a boundary stored without sending (a turn a tool ended,
27659
- * a budget restart) is above it, and so is one injected into a stream that
27660
- * doesn't declare it carries injections, which may have dropped it:
27661
- * counted as unread, it goes to the primary even if the agent heard it,
27662
- * rather than to no one where the stream dropped it. null when the window
27663
- * can't be read, which is logged.
28032
+ * a budget restart, the direct API's boundary, which has no stream) is
28033
+ * above it until a compile sends it, and counts as unread. null when the
28034
+ * window can't be read, which is logged.
27664
28035
  */
27665
28036
  releasedWindow(agent, held) {
27666
28037
  try {
@@ -28765,6 +29136,7 @@ export class AgentFramework {
28765
29136
  const ended = this.exhaustedTurns?.get(agentName);
28766
29137
  this.exhaustedTurns?.delete(agentName);
28767
29138
  const rearmed = this.exhaustingRearms?.delete(agentName) === true;
29139
+ const madeAgain = this.freshCallsCut?.delete(agentName) === true;
28768
29140
  // The reason is the failure's own message, and a provider can echo the
28769
29141
  // whole rejected request in it: one household's [inference-failed]
28770
29142
  // marker carried ~1.7 MB of its own request back into its context. Every
@@ -28815,24 +29187,25 @@ export class AgentFramework {
28815
29187
  // (2) Agent-facing chronicle marker (no inference triggered → no loop).
28816
29188
  const agent = this.agents.get(agentName);
28817
29189
  if (agent && !(rearmed && !ended) && process.env.SUPPRESS_INFERENCE_FAILED_MARKER !== '1') {
28818
- const cause = [errorType, httpStatus !== undefined ? `HTTP ${httpStatus}` : undefined]
28819
- .filter((part) => part !== undefined)
28820
- .join(', ');
29190
+ const timedOut = errorType === 'timeout';
29191
+ const causeParts = [errorType, httpStatus !== undefined ? `HTTP ${httpStatus}` : undefined]
29192
+ .filter((part) => part !== undefined);
29193
+ const cause = timedOut && causeParts.length === 1 ? '' : causeParts.join(', ');
28821
29194
  try {
28822
29195
  agent.getContextManager().addMessage('user', [{
28823
29196
  type: 'text',
28824
29197
  text: (ended
28825
- ? `[${NOTICE_TAG.inferenceFailed}] Your previous turn did not complete: it ` +
28826
- `failed${cause ? ` (${suppliedValue(cause)})` : ''}.` +
29198
+ ? `[${NOTICE_TAG.inferenceFailed}] Your previous turn did not complete: ` +
29199
+ `${timedOut ? 'its last call timed out' : 'it failed'}${cause ? ` (${suppliedValue(cause)})` : ''}.` +
29200
+ `${madeAgain ? ` ${MADE_AGAIN}` : ''}` +
28827
29201
  `${ended.roundsRan ? ' Its earlier rounds ran, and their tool calls ran as their results show.' : ''}` +
28828
29202
  `${ended.callsCutOff ? ` ${CALLS_CUT_OFF}` : ''}` +
28829
29203
  `${ended.liveSpeech ? ` ${LIVE_SPEECH_UNRECEIPTED}` : ''}` +
28830
29204
  `${ended.speechRouting ? ` ${SPEECH_STILL_ROUTING}` : ''} Reason: `
28831
- : `[${NOTICE_TAG.inferenceFailed}] Your previous turn did not complete: the model ` +
28832
- `call failed${cause ? ` (${suppliedValue(cause)})` : ''} and produced no response, so nothing was sent. Reason: `) +
28833
- `${failureShown(markerReason)}. If this recurs with the same cause, change approach ` +
28834
- `rather than retrying identically (e.g. drop an oversized attachment ` +
28835
- `or an unsupported setting).`,
29205
+ : `[${NOTICE_TAG.inferenceFailed}] Your previous turn did not complete: ` +
29206
+ `the model call ${timedOut ? 'timed out' : 'failed'}${cause ? ` (${suppliedValue(cause)})` : ''} ` +
29207
+ `and produced no response, so nothing was sent.${madeAgain ? ` ${MADE_AGAIN}` : ''} Reason: `) +
29208
+ `${failureShown(markerReason)}. ${timedOut ? TIMEOUT_ADVICE : FAILURE_ADVICE}`,
28836
29209
  }], { system: true, kind: 'inference-failed', reason: markerReason, consecutive: streak, ...classification, ...cutAt(markerReason) });
28837
29210
  }
28838
29211
  catch (err) {
@@ -29043,7 +29416,7 @@ export class AgentFramework {
29043
29416
  return source;
29044
29417
  });
29045
29418
  // Server-initiated inference router (Step 6)
29046
- this.inferenceRouter = new InferenceRouter(this.membrane, this.hookOrchestrator, this.featureSetManager, inferenceRouting ?? null, (event) => this.emitTrace(event));
29419
+ this.inferenceRouter = new InferenceRouter(this.membrane, this.hookOrchestrator, this.featureSetManager, inferenceRouting ?? null, (event) => this.emitTrace(event), () => this.inferenceBounds);
29047
29420
  // Checkpoint manager (Step 8), built and loaded at the top
29048
29421
  this.checkpointManager = checkpoints;
29049
29422
  // Channel registry (Step 7)
@@ -29076,7 +29449,15 @@ export class AgentFramework {
29076
29449
  const scope = coordinator.getAllExcept();
29077
29450
  if (!scope || scope.params.except.serverId !== serverId || scope.params.except.channelId !== channelId)
29078
29451
  return undefined;
29079
- coordinator.cancelAllExcept('channel-close', 'the excepted channel was closed');
29452
+ let released;
29453
+ try {
29454
+ released = coordinator.cancelAllExcept('channel-close', 'the excepted channel was closed');
29455
+ }
29456
+ catch (err) {
29457
+ throw new Error(`The close wasn't recorded: ${err instanceof Error ? err.message : String(err)}`);
29458
+ }
29459
+ if (!released.ok)
29460
+ throw new Error(`The close wasn't recorded: ${released.error}`);
29080
29461
  return 'The focus on it ended all the same, and what the other channels held was delivered.';
29081
29462
  },
29082
29463
  focusChannelResolver: () => this.tuneOutCoordinator?.focusChannel() ?? null,
@@ -29248,13 +29629,11 @@ export class AgentFramework {
29248
29629
  attempt: 0,
29249
29630
  willRetry: !!left?.willReconnect,
29250
29631
  });
29251
- // So the server doesn't vanish quietly: alert at once, since no
29252
- // attempt follows, for what needs the operator. That's a
29253
- // protocol-version verdict, as the reconnect path alerts for one, or
29254
- // a launch that couldn't be reaped, whose reap nothing retries on its
29255
- // own.
29256
- if (error instanceof McplProtocolVersionError || left?.holdsUnreapedChild) {
29257
- this.opsAlert('mcpl-down', config.id, `MCPL server unreachable at startup: ${message}`);
29632
+ // So the server doesn't vanish quietly: nothing connects it again on
29633
+ // its own, so alert at once. One that connected before its setup
29634
+ // threw is still connected, and gets no alert.
29635
+ if (!left?.willReconnect && !left?.isConnected) {
29636
+ this.opsAlert('mcpl-down', config.id, `MCPL server not connected at startup, and this host won't retry it on its own: ${message}`);
29258
29637
  }
29259
29638
  }
29260
29639
  }
@@ -31068,9 +31447,10 @@ export class AgentFramework {
31068
31447
  }
31069
31448
  /**
31070
31449
  * Restart an MCPL server at runtime: disconnect, then reconnect with the
31071
- * same (or an updated) config. This actually respawns a stdio child —
31072
- * unlike `reconnect: true`, which only retries after transport-level
31073
- * failures. Throws if the server has no stored config and none is given.
31450
+ * same (or an updated) config. This respawns a stdio child at once,
31451
+ * whatever its state; `reconnect: true` spawns a fresh child only after
31452
+ * the connection fails or its child exits. Throws if the server has no
31453
+ * stored config and none is given.
31074
31454
  * What its connect would refuse is refused before the running server is
31075
31455
  * torn down. Until the connect, a connect or a restart started meanwhile
31076
31456
  * is refused the id and the tool prefix the restart moves the server to,
@@ -31470,9 +31850,9 @@ export class AgentFramework {
31470
31850
  // request turned away at stop gets, so it can send it again to
31471
31851
  // the host's next run. Optional chaining: prototype-built
31472
31852
  // harnesses leave the queue unset.
31473
- const stopping = !!this.queue?.isClosed;
31853
+ const answer = this.queue?.isClosed ? hostStoppedWhileHandling() : { code: -32603, message: 'Internal error' };
31474
31854
  try {
31475
- tracked.respondError?.(stopping ? -32000 : -32603, stopping ? 'the host stopped while handling this request' : 'Internal error');
31855
+ tracked.respondError?.(answer.code, answer.message);
31476
31856
  }
31477
31857
  catch (sendError) {
31478
31858
  responseError = recorded(describeCause(sendError));
@@ -31638,6 +32018,7 @@ export class AgentFramework {
31638
32018
  // Chunks go out on the transport the request came on, as its
31639
32019
  // answer does.
31640
32020
  ...(responder.sendChunk ? { sendChunk: responder.sendChunk } : {}),
32021
+ ...(responder.signal ? { signal: responder.signal } : {}),
31641
32022
  });
31642
32023
  }
31643
32024
  });
@@ -31905,14 +32286,15 @@ export class AgentFramework {
31905
32286
  // that next one (see refreshMcplTools).
31906
32287
  this.noteMcplServerClosed(connection.id);
31907
32288
  this.dropMcplToolClasses(connection.id);
32289
+ // The transport's own words only: a WebSocket server's close text
32290
+ // stays off the trace.
32291
+ const reason = info?.reason !== undefined && !info.reasonFromPeer ? boundFailureText(info.reason) : null;
31908
32292
  this.emitTrace({
31909
32293
  type: 'mcpl:server-closed',
31910
32294
  serverId: connection.id,
31911
32295
  code: code ?? null,
31912
32296
  signal: signal ?? null,
31913
- // The transport's own words only: a WebSocket server's close text
31914
- // stays off the trace.
31915
- reason: info?.reason !== undefined && !info.reasonFromPeer ? boundFailureText(info.reason) : null,
32297
+ reason,
31916
32298
  willReconnect: connection.willReconnect,
31917
32299
  });
31918
32300
  this.emitTrace({ type: 'module:removed', moduleName: `mcpl:${connection.id}` });
@@ -31922,6 +32304,14 @@ export class AgentFramework {
31922
32304
  // RFC-014: its toolsets go with it; a next connection reads them again.
31923
32305
  this.toolsetHub?.get(connection.id)?.detach(connection);
31924
32306
  this.handleToolsListChanged(connection.id);
32307
+ // Lost on its own with no reconnect coming: nothing in this host
32308
+ // connects it again on its own. A close the host made itself (stop(),
32309
+ // a disconnect, a restart) is byHost, a loss once stop() has begun is
32310
+ // part of the close the host is making, and a child that ended as
32311
+ // asked was stopped: none of them gets an alert.
32312
+ if (!info?.byHost && !this.stopHasBegun() && !endedAsAsked({ code, signal, pipeFailed: info?.pipeFailed })) {
32313
+ this.opsAlert('mcpl-down', connection.id, `MCPL server closed${closeWords(reason, code, signal)}, and this host won't reconnect it on its own`);
32314
+ }
31925
32315
  }
31926
32316
  });
31927
32317
  }
@@ -31951,6 +32341,7 @@ export class AgentFramework {
31951
32341
  type: 'mcpl:server-connect-failed',
31952
32342
  serverId: connection.id,
31953
32343
  error: recorded(params.error),
32344
+ ...(params.stderrTail === undefined ? {} : { stderrTail: recorded(params.stderrTail) }),
31954
32345
  attempt: params.attempt,
31955
32346
  willRetry: connection.willReconnect,
31956
32347
  });
@@ -31969,7 +32360,7 @@ export class AgentFramework {
31969
32360
  connection.on('error', (err) => {
31970
32361
  this.emitTrace({ type: 'mcpl:server-error', serverId: connection.id, error: recorded(err.message) });
31971
32362
  });
31972
- connection.on('close', () => {
32363
+ connection.on('close', (info) => {
31973
32364
  // A closed provider's class hints are stale (RFC-008), as for MCPL,
31974
32365
  // and so is a listing already under way.
31975
32366
  this.noteMcplServerClosed(connection.id);
@@ -31977,8 +32368,9 @@ export class AgentFramework {
31977
32368
  this.emitTrace({
31978
32369
  type: 'mcpl:server-closed',
31979
32370
  serverId: connection.id,
31980
- code: null,
31981
- signal: null,
32371
+ code: info.code ?? null,
32372
+ signal: info.signal ?? null,
32373
+ reason: info.reason,
31982
32374
  willReconnect: connection.willReconnect,
31983
32375
  });
31984
32376
  this.emitTrace({ type: 'module:removed', moduleName: `mcpl:${connection.id}` });
@@ -31999,6 +32391,13 @@ export class AgentFramework {
31999
32391
  // Gone for good: its tools leave the agents' list, as a
32000
32392
  // disconnect's do.
32001
32393
  this.handleToolsListChanged(connection.id);
32394
+ // Lost on its own: nothing in this host connects it again on its
32395
+ // own. A loss once stop() has begun is part of the close the host
32396
+ // is making, and a child that ended as asked was stopped: neither
32397
+ // gets an alert.
32398
+ if (!this.stopHasBegun() && !endedAsAsked(info)) {
32399
+ 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`);
32400
+ }
32002
32401
  });
32003
32402
  }
32004
32403
  });
@@ -33132,7 +33531,7 @@ export class AgentFramework {
33132
33531
  * (same path as gate.json load), so an invalid rule is surfaced as a tool
33133
33532
  * error and nothing is written.
33134
33533
  */
33135
- dispatchWakeRuleToolCall(agentName, call) {
33534
+ dispatchWakeRuleToolCall(agentName, call, deliver = this.pushToolResult(agentName, call, 'gate')) {
33136
33535
  this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
33137
33536
  // What wake_add_rule's input held that does nothing, checkToolInput's notes and
33138
33537
  // then the gate's on a rule it saved: a refusal names it too, so the call that
@@ -33147,13 +33546,22 @@ export class AgentFramework {
33147
33546
  module: 'gate', tool: call.name, callId: call.id, durationMs: 0,
33148
33547
  ...(result.isError ? { error: result.error } : {}),
33149
33548
  });
33150
- this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'gate', result });
33549
+ deliver(result);
33151
33550
  };
33152
33551
  try {
33153
33552
  if (call.name === 'wake_remove_rule') {
33154
33553
  const input = (call.input ?? {});
33155
33554
  if (typeof input.name !== 'string' || !input.name) {
33156
- finish({ success: false, error: 'wake_remove_rule: `name` (string) is required', isError: true });
33555
+ const edit = this.eventGate.ruleNamesForChange();
33556
+ const names = 'names' in edit ? edit.names.map((name) => quotedValue(name)) : [];
33557
+ finish({
33558
+ success: false,
33559
+ error: `wake_remove_rule: name is ${shownValue(input.name)}, where the name of a rule is needed, so nothing was removed. `
33560
+ + ('refused' in edit
33561
+ ? edit.refused
33562
+ : (names.length > 0 ? `The rules a change edits are ${names.join(', ')}.` : 'There is no rule to remove.') + edit.note),
33563
+ isError: true,
33564
+ });
33157
33565
  return;
33158
33566
  }
33159
33567
  const removed = this.removeGatePolicy(input.name);
@@ -33183,9 +33591,8 @@ export class AgentFramework {
33183
33591
  if (behaviorSources.length === 0) {
33184
33592
  finish({
33185
33593
  success: false,
33186
- error: this.judgeWired
33187
- ? 'wake_add_rule: specify exactly one behavior — `behavior` ("always"/"defer"/"skip"), `debounceMs`, `rateLimit`, `passiveSample`, or `judge`.'
33188
- : 'wake_add_rule: specify exactly one behavior — `behavior` ("always"/"defer"/"skip"), `debounceMs`, `rateLimit`, or `passiveSample`.',
33594
+ error: 'wake_add_rule got no behavior, so nothing was saved. Give one of behavior ("always", "defer" or "skip"), '
33595
+ + (this.judgeWired ? 'debounceMs, rateLimit, passiveSample or judge.' : 'debounceMs, rateLimit or passiveSample.'),
33189
33596
  isError: true,
33190
33597
  });
33191
33598
  return;
@@ -33193,7 +33600,7 @@ export class AgentFramework {
33193
33600
  if (behaviorSources.length > 1) {
33194
33601
  finish({
33195
33602
  success: false,
33196
- error: `wake_add_rule: give only one behavior, got [${behaviorSources.join(', ')}].`,
33603
+ error: `wake_add_rule got more than one behavior (${behaviorSources.join(', ')}), so nothing was saved. Give only one of them.`,
33197
33604
  isError: true,
33198
33605
  });
33199
33606
  return;
@@ -33234,7 +33641,7 @@ export class AgentFramework {
33234
33641
  if (placementSources.length > 1) {
33235
33642
  finish({
33236
33643
  success: false,
33237
- error: `wake_add_rule: give at most one placement, got [${placementSources.join(', ')}].`,
33644
+ error: `wake_add_rule got more than one placement (${placementSources.join(', ')}), so nothing was saved. Give only one of them.`,
33238
33645
  isError: true,
33239
33646
  });
33240
33647
  return;
@@ -33254,10 +33661,18 @@ export class AgentFramework {
33254
33661
  for (const field of ['insertBefore', 'insertAfter']) {
33255
33662
  const anchor = input[field];
33256
33663
  if (anchor !== undefined && (typeof anchor !== 'string' || anchor.length === 0)) {
33664
+ const edit = this.eventGate.ruleNamesForChange();
33665
+ const names = 'names' in edit ? edit.names.filter((name) => name !== input.name).map((name) => quotedValue(name)) : [];
33257
33666
  finish({
33258
33667
  success: false,
33259
- error: `wake_add_rule: ${field} must be the name of a rule, and it's ${shownValue(anchor)}, so nothing `
33260
- + `was saved. The rules are: [${this.getGatePolicyNames().join(', ')}].`,
33668
+ error: `wake_add_rule: ${field} must be the name of a rule, and it's ${shownValue(anchor)}, so nothing was saved. `
33669
+ + ('refused' in edit
33670
+ ? edit.refused
33671
+ : (names.length > 0
33672
+ ? `The other rules a change edits are ${names.join(', ')}. Give ${field} one of them to place the rule beside it, or leave it out`
33673
+ : `There is no other rule to place it beside, so leave ${field} out`)
33674
+ + ' to add the rule at the end, or, where a rule already has its name, to replace that rule where it is.'
33675
+ + edit.note),
33261
33676
  isError: true,
33262
33677
  });
33263
33678
  return;
@@ -33285,9 +33700,12 @@ export class AgentFramework {
33285
33700
  // (af#105 — a prepended catch-all sampler silently disabled the
33286
33701
  // addressed-message wakes for 12h before anyone could see why).
33287
33702
  const before = new Map(this.eventGate.probeTable().map((r) => [r.probe, r]));
33288
- const policy = this.addGatePolicy(rawPolicy, {
33703
+ const fieldNames = Object.fromEntries(Object.entries(AgentFramework.WAKE_ADD_RULE_FIELDS)
33704
+ .filter(([field]) => !(field.startsWith('behavior.') && typeof input.behavior === 'object')));
33705
+ const policy = this.eventGate.addPolicy(rawPolicy, {
33289
33706
  ...(position ? { position } : {}),
33290
33707
  onIgnoredFields: (notes) => { ignored.push(...notes); },
33708
+ fieldNames,
33291
33709
  });
33292
33710
  const after = this.eventGate.probeTable();
33293
33711
  const probes = after.map((r) => {
@@ -33589,22 +34007,20 @@ export class AgentFramework {
33589
34007
  });
33590
34008
  return decision;
33591
34009
  }
33592
- /**
33593
- * Make delivered messages durable now, including those the delivery left
33594
- * in the deferred queue: a tune-out release flips durable epoch state
33595
- * first, so its dump must not be lost to a crash in between (#232 gap 6).
33596
- */
33597
34010
  commitDeliveries() {
34011
+ let durable = true;
33598
34012
  if (this.deferredMessages.length > 0) {
33599
34013
  this.deferredWritesPersisted = true;
33600
- this.persistDeferredWrites();
34014
+ durable = this.persistDeferredWrites();
33601
34015
  }
33602
34016
  try {
33603
34017
  this.store.sync();
33604
34018
  }
33605
34019
  catch (err) {
33606
34020
  console.error('[tune-out] could not sync the store after a release:', err);
34021
+ return false;
33607
34022
  }
34023
+ return durable;
33608
34024
  }
33609
34025
  /** The history-reading tool the primary resident actually has, if any (#232 gap 11). */
33610
34026
  residentHistoryTool() {
@@ -33614,7 +34030,7 @@ export class AgentFramework {
33614
34030
  const names = this.getAllTools().map((t) => t.name).filter((n) => primary.canUseTool(n));
33615
34031
  return names.find((n) => /(^|--)(fetch_history|channel_read|read_history|history)$/.test(n)) ?? null;
33616
34032
  }
33617
- dispatchSleepToolCall(agentName, call) {
34033
+ dispatchSleepToolCall(agentName, call, deliver = this.pushToolResult(agentName, call, 'gate')) {
33618
34034
  // The gate's sleep quiets the whole agent, every conversation of it, so a
33619
34035
  // conversation fork or an ephemeral run neither sets one nor ends the
33620
34036
  // agent's own. Refused before the tool runs, as a fork's channel_open is.
@@ -33633,7 +34049,7 @@ export class AgentFramework {
33633
34049
  ? `Nothing was set: a sleep quiets the whole agent, not one ${one}.${comeBack}`
33634
34050
  : `Nothing was changed: a sleep is the agent's, so this ${one} can't end one.`;
33635
34051
  this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: `${agentName}: ${error}` });
33636
- this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'gate', result: this.refusedResult(agentName, call.id, error) });
34052
+ deliver(this.refusedResult(agentName, call.id, error));
33637
34053
  return;
33638
34054
  }
33639
34055
  this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
@@ -33641,7 +34057,7 @@ export class AgentFramework {
33641
34057
  const input = (call.input ?? {});
33642
34058
  const finish = (result) => {
33643
34059
  this.emitTrace({ type: 'tool:completed', module: 'gate', tool: call.name, callId: call.id, durationMs: 0 });
33644
- this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'gate', result });
34060
+ deliver(result);
33645
34061
  };
33646
34062
  if (call.name === 'wake') {
33647
34063
  const was = gate.clearSleep();
@@ -33762,31 +34178,36 @@ export class AgentFramework {
33762
34178
  */
33763
34179
  /** RFC-005 on-demand dereference: [ref id] → workspace file, via the
33764
34180
  * origin-bound, ceiling-bounded, digest-verified host fetcher. */
33765
- dispatchFetchReferenceToolCall(agentName, call) {
34181
+ dispatchFetchReferenceToolCall(agentName, call, deliver = this.pushToolResult(agentName, call, 'workspace')) {
33766
34182
  this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
33767
34183
  const startTime = Date.now();
33768
34184
  void (async () => {
33769
- const args = (call.input ?? {});
33770
- const refId = String(args.ref_id ?? '');
33771
- const record = referenceRegistry.get(refId);
33772
34185
  let result;
33773
- if (!record) {
33774
- // Vector 20: a stale or unknown id is a defined miss, never a
33775
- // different record.
33776
- result = { success: false, isError: true,
33777
- error: `unknown reference: ${refId || '(missing ref_id)'} — it may have been evicted; ids are never reused` };
34186
+ try {
34187
+ const args = (call.input ?? {});
34188
+ const refId = String(args.ref_id ?? '');
34189
+ const record = referenceRegistry.get(refId);
34190
+ if (!record) {
34191
+ // Vector 20: a stale or unknown id is a defined miss, never a
34192
+ // different record.
34193
+ result = { success: false, isError: true,
34194
+ error: `unknown reference: ${refId || '(missing ref_id)'} — it may have been evicted; ids are never reused` };
34195
+ }
34196
+ else {
34197
+ const maxBytes = typeof args.max_bytes === 'number'
34198
+ ? Math.min(args.max_bytes, DEFAULT_FETCH_MAX_BYTES) : undefined;
34199
+ const outcome = await this.getReferenceFetcher().fetch(record, { maxBytes });
34200
+ result = outcome.ok
34201
+ ? { success: true, data: JSON.stringify({ path: outcome.path, bytes: outcome.bytes,
34202
+ mime_type: outcome.mimeType, digest_verified: outcome.digestVerified ?? 'no digest claimed' }) }
34203
+ // The fetcher says what happened as a clause about the reference,
34204
+ // and what calling again can do as a sentence of its own.
34205
+ : { success: false, isError: true,
34206
+ error: `fetch_reference couldn't fetch "${refId}": ${outcome.error}. ${outcome.remedy}` };
34207
+ }
33778
34208
  }
33779
- else {
33780
- const maxBytes = typeof args.max_bytes === 'number'
33781
- ? Math.min(args.max_bytes, DEFAULT_FETCH_MAX_BYTES) : undefined;
33782
- const outcome = await this.getReferenceFetcher().fetch(record, { maxBytes });
33783
- result = outcome.ok
33784
- ? { success: true, data: JSON.stringify({ path: outcome.path, bytes: outcome.bytes,
33785
- mime_type: outcome.mimeType, digest_verified: outcome.digestVerified ?? 'no digest claimed' }) }
33786
- // The fetcher says what happened as a clause about the reference,
33787
- // and what calling again can do as a sentence of its own.
33788
- : { success: false, isError: true,
33789
- error: `fetch_reference couldn't fetch "${refId}": ${outcome.error}. ${outcome.remedy}` };
34209
+ catch (error) {
34210
+ result = { success: false, isError: true, error: `fetch_reference couldn't answer: ${thrownText(error)}` };
33790
34211
  }
33791
34212
  this.emitTrace({
33792
34213
  type: result.isError ? 'tool:failed' : 'tool:completed',
@@ -33794,13 +34215,10 @@ export class AgentFramework {
33794
34215
  durationMs: Date.now() - startTime,
33795
34216
  ...(result.isError ? { error: result.error } : {}),
33796
34217
  });
33797
- this.pushEvent({
33798
- type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'workspace',
33799
- result: { ...result, isError: result.isError ?? false },
33800
- });
34218
+ deliver({ ...result, isError: result.isError ?? false });
33801
34219
  })();
33802
34220
  }
33803
- dispatchReadImageToolCall(agentName, call) {
34221
+ dispatchReadImageToolCall(agentName, call, deliver = this.pushToolResult(agentName, call, 'workspace')) {
33804
34222
  this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
33805
34223
  const finish = (result) => {
33806
34224
  this.emitTrace({
@@ -33811,7 +34229,7 @@ export class AgentFramework {
33811
34229
  durationMs: 0,
33812
34230
  ...(result.isError ? { error: result.error } : {}),
33813
34231
  });
33814
- this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'workspace', result });
34232
+ deliver(result);
33815
34233
  };
33816
34234
  void (async () => {
33817
34235
  try {
@@ -33893,14 +34311,11 @@ export class AgentFramework {
33893
34311
  * every sibling in the round to settle; if one never does, fail closed
33894
34312
  * rather than trust an index the batch may still change. Sibling saves
33895
34313
  * produce no images and are not waited on (two saves in one batch would
33896
- * otherwise deadlock into a double timeout). A script's own calls
33897
- * (`pytc-…`) skip the barrier: the batch's pending siblings include the
33898
- * code_execution call running that script, so waiting on them would wait
33899
- * on itself.
34314
+ * otherwise deadlock into a double timeout). Only the round's own calls
34315
+ * wait: a call that isn't among its pending calls, a script's (`pytc-…`)
34316
+ * or an awaited call's, counts the images the agent has now.
33900
34317
  */
33901
34318
  async awaitSiblingToolCalls(agent, call) {
33902
- if (call.id.startsWith('pytc-'))
33903
- return;
33904
34319
  const BARRIER_MS = 120_000;
33905
34320
  const POLL_MS = 20;
33906
34321
  const startedAt = Date.now();
@@ -33909,6 +34324,8 @@ export class AgentFramework {
33909
34324
  // Idle (puppet path) or the round was torn down: nothing to wait on.
33910
34325
  if (state.status !== 'waiting_for_tools')
33911
34326
  return;
34327
+ if (!state.pending.has(call.id))
34328
+ return;
33912
34329
  const open = [...state.pending.values()].filter((p) => p.id !== call.id && p.name !== 'save_recent_image');
33913
34330
  if (open.length === 0)
33914
34331
  return;
@@ -33935,7 +34352,7 @@ export class AgentFramework {
33935
34352
  * nothing. It never slides to an older image — a filename must not
33936
34353
  * authenticate bytes from another surface.
33937
34354
  */
33938
- dispatchSaveImageToolCall(agentName, call) {
34355
+ dispatchSaveImageToolCall(agentName, call, deliver = this.pushToolResult(agentName, call, 'workspace')) {
33939
34356
  this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
33940
34357
  const finish = (result) => {
33941
34358
  this.emitTrace({
@@ -33946,7 +34363,7 @@ export class AgentFramework {
33946
34363
  durationMs: 0,
33947
34364
  ...(result.isError ? { error: result.error } : {}),
33948
34365
  });
33949
- this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'workspace', result });
34366
+ deliver(result);
33950
34367
  };
33951
34368
  void (async () => {
33952
34369
  try {
@@ -34057,21 +34474,36 @@ export class AgentFramework {
34057
34474
  let count = 1;
34058
34475
  /** Direct-ref mode: the one ref to find in the scannable context. */
34059
34476
  let wantedRef = null;
34060
- if (input.ref !== undefined) {
34477
+ // A selector is in use only when it carries a value (fieldInUse), so a
34478
+ // caller whose provider presents every property as required can still
34479
+ // choose one, whichever of the two it fills the other selector with.
34480
+ // Any other value is checked, never read as absent: a whitespace or
34481
+ // malformed ref, or an index that isn't an integer, is refused rather
34482
+ // than turned into a different image than the one the caller named.
34483
+ // A malformed ref beside an index or count in use says how to leave a
34484
+ // selector unused, since the caller may have meant the positional one, as
34485
+ // the conflict refusal below does for a well-formed ref. No refusal of one
34486
+ // selector on its own says it: a ref alone is the caller's one selector,
34487
+ // and dropping it would save the newest image; an index and a count are
34488
+ // one selector, and dropping either changes which images are saved.
34489
+ if (fieldInUse(input.ref)) {
34061
34490
  if (typeof input.ref !== 'string' || !TOOL_IMAGE_REF_RE.test(input.ref)) {
34062
- throw new Error('save_recent_image: `ref` must look like "img_k7x3q2_7" (copy it from the image placeholder in your context)');
34491
+ const positionalInUse = fieldInUse(input.index) || fieldInUse(input.count);
34492
+ throw new Error('save_recent_image: `ref` must look like "img_k7x3q2_7" (copy it from the image placeholder in your context)' +
34493
+ (positionalInUse ? ' (pass null, or leave it out, for a selector you are not using)' : ''));
34063
34494
  }
34064
- if (input.index !== undefined || input.count !== undefined) {
34065
- throw new Error('save_recent_image: `ref` is mutually exclusive with `index`/`count`');
34495
+ if (fieldInUse(input.index) || fieldInUse(input.count)) {
34496
+ throw new Error('save_recent_image: `ref` is mutually exclusive with `index`/`count` — pass null (or omit) ' +
34497
+ 'for the selector you are not using');
34066
34498
  }
34067
34499
  wantedRef = input.ref;
34068
34500
  }
34069
34501
  else {
34070
- index = input.index === undefined ? 0 : Number(input.index);
34502
+ index = fieldInUse(input.index) ? integerField(input.index) : 0;
34071
34503
  if (!Number.isInteger(index) || index < 0) {
34072
34504
  throw new Error('save_recent_image: `index` must be a non-negative integer');
34073
34505
  }
34074
- count = input.count === undefined ? 1 : Number(input.count);
34506
+ count = fieldInUse(input.count) ? integerField(input.count) : 1;
34075
34507
  const MAX_COUNT = 20;
34076
34508
  if (!Number.isInteger(count) || count < 1 || count > MAX_COUNT) {
34077
34509
  throw new Error(`save_recent_image: \`count\` must be an integer in 1..${MAX_COUNT}`);
@@ -34314,7 +34746,7 @@ export class AgentFramework {
34314
34746
  ? 'No setting reads differently after this call.'
34315
34747
  : `Changed before the refusal: ${changed.join(', ')}; no other setting reads differently.`;
34316
34748
  }
34317
- dispatchAgentSettingsToolCall(agentName, call) {
34749
+ dispatchAgentSettingsToolCall(agentName, call, deliver = this.pushToolResult(agentName, call, 'agent')) {
34318
34750
  this.emitTrace({
34319
34751
  type: 'tool:started',
34320
34752
  module: 'agent',
@@ -34502,14 +34934,7 @@ export class AgentFramework {
34502
34934
  durationMs: 0,
34503
34935
  ...(result.isError ? { error: result.error } : {}),
34504
34936
  });
34505
- this.pushEvent({
34506
- type: 'tool-result',
34507
- callId: call.id,
34508
- toolName: call.name,
34509
- agentName,
34510
- moduleName: 'agent',
34511
- result,
34512
- });
34937
+ deliver(result);
34513
34938
  }
34514
34939
  /** Aggregate the event-tag vocabulary: reserved chat:* core + each connected
34515
34940
  * server's declared tag ontology + gate.js status. */
@@ -34533,7 +34958,7 @@ export class AgentFramework {
34533
34958
  'in gate.js. Unknown/undeclared tags are tolerated (open ontologies).',
34534
34959
  };
34535
34960
  }
34536
- dispatchEventTagsToolCall(agentName, call) {
34961
+ dispatchEventTagsToolCall(agentName, call, deliver = this.pushToolResult(agentName, call, 'gate')) {
34537
34962
  this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
34538
34963
  let result;
34539
34964
  try {
@@ -34546,36 +34971,22 @@ export class AgentFramework {
34546
34971
  this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
34547
34972
  result = { success: false, error: err.message, isError: true };
34548
34973
  }
34549
- this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'gate', result });
34974
+ deliver(result);
34550
34975
  }
34551
- dispatchGateToolCall(agentName, call) {
34976
+ dispatchGateToolCall(agentName, call, deliver = this.pushToolResult(agentName, call, 'gate')) {
34552
34977
  this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
34553
34978
  const startTime = Date.now();
34554
34979
  this.eventGate.handleToolCall()
34555
34980
  .then((result) => {
34556
34981
  const durationMs = Date.now() - startTime;
34557
34982
  this.emitTrace({ type: 'tool:completed', module: 'gate', tool: call.name, callId: call.id, durationMs });
34558
- this.pushEvent({
34559
- type: 'tool-result',
34560
- callId: call.id,
34561
- toolName: call.name,
34562
- agentName,
34563
- moduleName: 'gate',
34564
- result,
34565
- });
34983
+ deliver(result);
34566
34984
  })
34567
34985
  .catch((error) => {
34568
34986
  const err = error instanceof Error ? error : new Error(String(error));
34569
34987
  this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: err.message });
34570
34988
  this.toolLifecycleEmitter?.markDispatchFailure(agentName, call.id);
34571
- this.pushEvent({
34572
- type: 'tool-result',
34573
- callId: call.id,
34574
- toolName: call.name,
34575
- agentName,
34576
- moduleName: 'gate',
34577
- result: { success: false, error: err.message, isError: true },
34578
- });
34989
+ deliver({ success: false, error: err.message, isError: true });
34579
34990
  });
34580
34991
  }
34581
34992
  /** The channel context one server is told for an agent's inference