@animalabs/agent-framework 0.28.0 → 0.29.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 (240) hide show
  1. package/README.md +88 -25
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +4 -1
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/api/mcp-server-core.d.ts.map +1 -1
  6. package/dist/src/api/mcp-server-core.js +3 -12
  7. package/dist/src/api/mcp-server-core.js.map +1 -1
  8. package/dist/src/api/server.d.ts.map +1 -1
  9. package/dist/src/api/server.js +7 -3
  10. package/dist/src/api/server.js.map +1 -1
  11. package/dist/src/auto-continue.d.ts +2 -0
  12. package/dist/src/auto-continue.d.ts.map +1 -1
  13. package/dist/src/auto-continue.js +5 -2
  14. package/dist/src/auto-continue.js.map +1 -1
  15. package/dist/src/code-execution/isolate/runner.d.ts +7 -5
  16. package/dist/src/code-execution/isolate/runner.d.ts.map +1 -1
  17. package/dist/src/code-execution/isolate/runner.js +7 -5
  18. package/dist/src/code-execution/isolate/runner.js.map +1 -1
  19. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  20. package/dist/src/code-execution/isolate/scripts.js +3 -2
  21. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  22. package/dist/src/framework.d.ts +164 -36
  23. package/dist/src/framework.d.ts.map +1 -1
  24. package/dist/src/framework.js +1120 -479
  25. package/dist/src/framework.js.map +1 -1
  26. package/dist/src/gate/event-gate.d.ts +34 -10
  27. package/dist/src/gate/event-gate.d.ts.map +1 -1
  28. package/dist/src/gate/event-gate.js +118 -48
  29. package/dist/src/gate/event-gate.js.map +1 -1
  30. package/dist/src/gate/index.d.ts +1 -1
  31. package/dist/src/gate/index.d.ts.map +1 -1
  32. package/dist/src/gate/index.js +1 -1
  33. package/dist/src/gate/index.js.map +1 -1
  34. package/dist/src/gate/types.d.ts +12 -2
  35. package/dist/src/gate/types.d.ts.map +1 -1
  36. package/dist/src/heartbeat/heartbeat.d.ts +18 -0
  37. package/dist/src/heartbeat/heartbeat.d.ts.map +1 -1
  38. package/dist/src/heartbeat/heartbeat.js +34 -8
  39. package/dist/src/heartbeat/heartbeat.js.map +1 -1
  40. package/dist/src/held-writes.d.ts +3 -3
  41. package/dist/src/held-writes.js +3 -3
  42. package/dist/src/host-presentation.d.ts +170 -0
  43. package/dist/src/host-presentation.d.ts.map +1 -0
  44. package/dist/src/host-presentation.js +561 -0
  45. package/dist/src/host-presentation.js.map +1 -0
  46. package/dist/src/index.d.ts +2 -2
  47. package/dist/src/index.d.ts.map +1 -1
  48. package/dist/src/index.js +1 -1
  49. package/dist/src/index.js.map +1 -1
  50. package/dist/src/line-reader.d.ts +4 -0
  51. package/dist/src/line-reader.d.ts.map +1 -0
  52. package/dist/src/line-reader.js +59 -0
  53. package/dist/src/line-reader.js.map +1 -0
  54. package/dist/src/mcpl/channel-registry.d.ts +34 -6
  55. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  56. package/dist/src/mcpl/channel-registry.js +131 -53
  57. package/dist/src/mcpl/channel-registry.js.map +1 -1
  58. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  59. package/dist/src/mcpl/conversation-router.js +2 -1
  60. package/dist/src/mcpl/conversation-router.js.map +1 -1
  61. package/dist/src/mcpl/convert-block.d.ts +1 -0
  62. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  63. package/dist/src/mcpl/convert-block.js +16 -4
  64. package/dist/src/mcpl/convert-block.js.map +1 -1
  65. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  66. package/dist/src/mcpl/hook-orchestrator.js +4 -3
  67. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  68. package/dist/src/mcpl/inbound-source.d.ts +3 -1
  69. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  70. package/dist/src/mcpl/inbound-source.js +3 -1
  71. package/dist/src/mcpl/inbound-source.js.map +1 -1
  72. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  73. package/dist/src/mcpl/modern-connection.js +11 -7
  74. package/dist/src/mcpl/modern-connection.js.map +1 -1
  75. package/dist/src/mcpl/protocol-family.d.ts +3 -2
  76. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  77. package/dist/src/mcpl/protocol-family.js +25 -3
  78. package/dist/src/mcpl/protocol-family.js.map +1 -1
  79. package/dist/src/mcpl/push-handler.js +2 -2
  80. package/dist/src/mcpl/push-handler.js.map +1 -1
  81. package/dist/src/mcpl/references.d.ts +12 -40
  82. package/dist/src/mcpl/references.d.ts.map +1 -1
  83. package/dist/src/mcpl/references.js +29 -23
  84. package/dist/src/mcpl/references.js.map +1 -1
  85. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  86. package/dist/src/mcpl/server-connection.js +20 -20
  87. package/dist/src/mcpl/server-connection.js.map +1 -1
  88. package/dist/src/mcpl/skip-reply-wake.d.ts +25 -0
  89. package/dist/src/mcpl/skip-reply-wake.d.ts.map +1 -1
  90. package/dist/src/mcpl/skip-reply-wake.js +25 -0
  91. package/dist/src/mcpl/skip-reply-wake.js.map +1 -1
  92. package/dist/src/mcpl/tool-presence.d.ts.map +1 -1
  93. package/dist/src/mcpl/tool-presence.js +18 -17
  94. package/dist/src/mcpl/tool-presence.js.map +1 -1
  95. package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
  96. package/dist/src/mcpl/tool-signature.js +17 -16
  97. package/dist/src/mcpl/tool-signature.js.map +1 -1
  98. package/dist/src/mcpl/toolset-host.d.ts +8 -26
  99. package/dist/src/mcpl/toolset-host.d.ts.map +1 -1
  100. package/dist/src/mcpl/toolset-host.js +16 -6
  101. package/dist/src/mcpl/toolset-host.js.map +1 -1
  102. package/dist/src/mcpl/toolsets-module.d.ts +14 -25
  103. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  104. package/dist/src/mcpl/toolsets-module.js +180 -50
  105. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  106. package/dist/src/mcpl/transport.d.ts +1 -1
  107. package/dist/src/mcpl/transport.d.ts.map +1 -1
  108. package/dist/src/mcpl/transport.js +16 -16
  109. package/dist/src/mcpl/transport.js.map +1 -1
  110. package/dist/src/mcpl/types.d.ts +28 -12
  111. package/dist/src/mcpl/types.d.ts.map +1 -1
  112. package/dist/src/mcpl/types.js.map +1 -1
  113. package/dist/src/module-registry.d.ts +6 -7
  114. package/dist/src/module-registry.d.ts.map +1 -1
  115. package/dist/src/module-registry.js +6 -7
  116. package/dist/src/module-registry.js.map +1 -1
  117. package/dist/src/modules/api/index.d.ts.map +1 -1
  118. package/dist/src/modules/api/index.js +4 -2
  119. package/dist/src/modules/api/index.js.map +1 -1
  120. package/dist/src/modules/discord/index.d.ts.map +1 -1
  121. package/dist/src/modules/discord/index.js +33 -25
  122. package/dist/src/modules/discord/index.js.map +1 -1
  123. package/dist/src/modules/history/index.js +8 -6
  124. package/dist/src/modules/history/index.js.map +1 -1
  125. package/dist/src/modules/sender-name.d.ts +7 -0
  126. package/dist/src/modules/sender-name.d.ts.map +1 -0
  127. package/dist/src/modules/sender-name.js +10 -0
  128. package/dist/src/modules/sender-name.js.map +1 -0
  129. package/dist/src/modules/workspace/answers.d.ts +5 -1
  130. package/dist/src/modules/workspace/answers.d.ts.map +1 -1
  131. package/dist/src/modules/workspace/answers.js +16 -1
  132. package/dist/src/modules/workspace/answers.js.map +1 -1
  133. package/dist/src/modules/workspace/direct-write.d.ts.map +1 -1
  134. package/dist/src/modules/workspace/direct-write.js +6 -4
  135. package/dist/src/modules/workspace/direct-write.js.map +1 -1
  136. package/dist/src/modules/workspace/effects.d.ts +4 -2
  137. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  138. package/dist/src/modules/workspace/effects.js +50 -40
  139. package/dist/src/modules/workspace/effects.js.map +1 -1
  140. package/dist/src/modules/workspace/index.d.ts +1 -0
  141. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  142. package/dist/src/modules/workspace/index.js +66 -36
  143. package/dist/src/modules/workspace/index.js.map +1 -1
  144. package/dist/src/modules/workspace/observe.d.ts +51 -20
  145. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  146. package/dist/src/modules/workspace/observe.js +120 -82
  147. package/dist/src/modules/workspace/observe.js.map +1 -1
  148. package/dist/src/modules/workspace/reconcile.d.ts +7 -1
  149. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  150. package/dist/src/modules/workspace/reconcile.js +133 -37
  151. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  152. package/dist/src/notice-tags.d.ts +79 -0
  153. package/dist/src/notice-tags.d.ts.map +1 -0
  154. package/dist/src/notice-tags.js +83 -0
  155. package/dist/src/notice-tags.js.map +1 -0
  156. package/dist/src/reasoning-loss-notice.d.ts.map +1 -1
  157. package/dist/src/reasoning-loss-notice.js +5 -3
  158. package/dist/src/reasoning-loss-notice.js.map +1 -1
  159. package/dist/src/silent-heartbeat.d.ts +1 -53
  160. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  161. package/dist/src/silent-heartbeat.js +56 -3
  162. package/dist/src/silent-heartbeat.js.map +1 -1
  163. package/dist/src/supplied-text.d.ts +35 -0
  164. package/dist/src/supplied-text.d.ts.map +1 -0
  165. package/dist/src/supplied-text.js +141 -0
  166. package/dist/src/supplied-text.js.map +1 -0
  167. package/dist/src/tool-args.d.ts.map +1 -1
  168. package/dist/src/tool-args.js +9 -8
  169. package/dist/src/tool-args.js.map +1 -1
  170. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  171. package/dist/src/tool-image-ledger.js +2 -1
  172. package/dist/src/tool-image-ledger.js.map +1 -1
  173. package/dist/src/tool-presentation.d.ts.map +1 -1
  174. package/dist/src/tool-presentation.js +5 -4
  175. package/dist/src/tool-presentation.js.map +1 -1
  176. package/dist/src/tune-out/coordinator.d.ts +3 -2
  177. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  178. package/dist/src/tune-out/coordinator.js +24 -26
  179. package/dist/src/tune-out/coordinator.js.map +1 -1
  180. package/dist/src/types/agent.d.ts +23 -9
  181. package/dist/src/types/agent.d.ts.map +1 -1
  182. package/dist/src/types/framework.d.ts +43 -0
  183. package/dist/src/types/framework.d.ts.map +1 -1
  184. package/dist/src/types/module.d.ts +22 -11
  185. package/dist/src/types/module.d.ts.map +1 -1
  186. package/package.json +2 -2
  187. package/src/agent.ts +4 -1
  188. package/src/api/mcp-server-core.ts +3 -14
  189. package/src/api/server.ts +7 -3
  190. package/src/auto-continue.ts +8 -2
  191. package/src/code-execution/isolate/runner.ts +7 -5
  192. package/src/code-execution/isolate/scripts.ts +3 -2
  193. package/src/framework.ts +1132 -492
  194. package/src/gate/event-gate.ts +128 -47
  195. package/src/gate/index.ts +1 -1
  196. package/src/gate/types.ts +12 -2
  197. package/src/heartbeat/heartbeat.ts +48 -9
  198. package/src/held-writes.ts +3 -3
  199. package/src/host-presentation.ts +605 -0
  200. package/src/index.ts +2 -2
  201. package/src/line-reader.ts +58 -0
  202. package/src/mcpl/channel-registry.ts +137 -52
  203. package/src/mcpl/conversation-router.ts +2 -1
  204. package/src/mcpl/convert-block.ts +17 -4
  205. package/src/mcpl/hook-orchestrator.ts +4 -3
  206. package/src/mcpl/inbound-source.ts +3 -1
  207. package/src/mcpl/modern-connection.ts +11 -7
  208. package/src/mcpl/protocol-family.ts +24 -3
  209. package/src/mcpl/push-handler.ts +2 -2
  210. package/src/mcpl/references.ts +31 -23
  211. package/src/mcpl/server-connection.ts +20 -18
  212. package/src/mcpl/skip-reply-wake.ts +35 -0
  213. package/src/mcpl/tool-presence.ts +18 -17
  214. package/src/mcpl/tool-signature.ts +17 -16
  215. package/src/mcpl/toolset-host.ts +18 -6
  216. package/src/mcpl/toolsets-module.ts +142 -50
  217. package/src/mcpl/transport.ts +18 -14
  218. package/src/mcpl/types.ts +29 -12
  219. package/src/module-registry.ts +6 -7
  220. package/src/modules/api/index.ts +4 -2
  221. package/src/modules/discord/index.ts +37 -25
  222. package/src/modules/history/index.ts +8 -6
  223. package/src/modules/sender-name.ts +9 -0
  224. package/src/modules/workspace/answers.ts +20 -2
  225. package/src/modules/workspace/direct-write.ts +4 -3
  226. package/src/modules/workspace/effects.ts +50 -40
  227. package/src/modules/workspace/index.ts +58 -34
  228. package/src/modules/workspace/observe.ts +139 -88
  229. package/src/modules/workspace/reconcile.ts +159 -36
  230. package/src/notice-tags.ts +84 -0
  231. package/src/reasoning-loss-notice.ts +5 -3
  232. package/src/silent-heartbeat.ts +4 -3
  233. package/src/supplied-text.ts +154 -0
  234. package/src/tool-args.ts +9 -8
  235. package/src/tool-image-ledger.ts +2 -1
  236. package/src/tool-presentation.ts +5 -4
  237. package/src/tune-out/coordinator.ts +25 -27
  238. package/src/types/agent.ts +23 -9
  239. package/src/types/framework.ts +44 -0
  240. package/src/types/module.ts +23 -11
@@ -32,20 +32,23 @@ import { checkToolPattern, describeUnmatchedPattern } from './mcpl/tool-pattern-
32
32
  import { BUILTIN_TOOL_CLASSES, normalizeClassTable, parseDeclaredClasses, resolveToolClass } from './mcpl/tool-classes.js';
33
33
  import { PushHandler } from './mcpl/push-handler.js';
34
34
  import { readSkipReplyWake, skipReplyWakeRefusal } from './mcpl/skip-reply-wake.js';
35
- import { convertBlock } from './mcpl/convert-block.js';
35
+ import { convertBlocks } from './mcpl/convert-block.js';
36
36
  import { PushCoalescer, CoalesceError, PUSH_COALESCING_SUPPORT, validateCoalesceMember, validateCoalescedContent, coalescingSubjectKey, } from './mcpl/push-coalescer.js';
37
37
  import { isSilentHeartbeatMarker, isVisiblyEmptyContent, NON_STRING_TEXT_PLACEHOLDER } from './mcpl/visible-content.js';
38
38
  import { isProseTargetToken, parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js';
39
39
  import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js';
40
40
  import { InferenceRouter } from './mcpl/inference-router.js';
41
- import { ChannelRegistry, TUNE_OUT_REPLACED, connectorText, desiredStateInLog, destinationKey, journalToolDescription, NON_HOLDER_CHANNEL_DESCRIPTIONS, qualifiedChannelName, sameChannel } from './mcpl/channel-registry.js';
41
+ import { ChannelRegistry, TUNE_OUT_REPLACED, connectorText, desiredStateInLog, destinationKey, journalToolDescription, NON_HOLDER_CHANNEL_DESCRIPTIONS, NO_CHANNEL_EXAMPLE, NO_PERSON_EXAMPLE, qualifiedChannelName, sameChannel } from './mcpl/channel-registry.js';
42
42
  import { KEEP_IDLE_MS, KEEP_NEWEST, ProseDraftStore, draftState, uncertainAttempt } from './prose-drafts.js';
43
43
  import { ConversationRouter, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js';
44
44
  import { INBOUND_SOURCE_KEY, sourceBodyDigest } from './mcpl/inbound-source.js';
45
+ import { markNoticeOpenings, nameToTypeBack, quotedValue, showsAsItself, suppliedValue } from './supplied-text.js';
46
+ import { NOTICE_TAG } from './notice-tags.js';
45
47
  import { safeSlice } from './safe-slice.js';
46
48
  import { SEND_TOOLS, bareToolName } from './send-tools.js';
47
49
  import { boundedErrorMessage, errorMessage } from './error-message.js';
48
50
  import { ProviderWaits, EVERY_MODEL, isReleaseModel, withoutControls } from './provider-waits.js';
51
+ import { Refusal, errnoCode, errnoWayOn, systemWords } from './modules/workspace/answers.js';
49
52
  import { goesNative, nonErrorResultText, nonErrorResultTextOrThrow, isContentArray, resultClaimsOrThrow, scriptViewClaims, shownToScript, shownToScriptFromServer, toolResultDataToHistoryString, truncateForHistory, DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, } from './tool-result-history.js';
50
53
  import { TOOL_IMAGE_REF_RE, ToolImageLedger, cutStoredToolResultToFit, formatKeptImageSlots, parseImagePlaceholders, sha256Hex, storedImageSlots, toolResultCutAt, toolResultShare, } from './tool-image-ledger.js';
51
54
  import { randomUUID, createHash } from 'node:crypto';
@@ -278,6 +281,11 @@ const HOST_METADATA_KEYS = [
278
281
  * marker too. A marker that wakes the agent can still set that turn's speech
279
282
  * channel (agent-framework#189); a `chat:system` notice can't (systemNotice).
280
283
  */
284
+ const isOwnScheduleWrite = (metadata) => {
285
+ const m = metadata;
286
+ return m?.serverId === 'heartbeat' || m?.source === 'gate:self-wake'
287
+ || (Array.isArray(m?.tags) && m.tags.includes('script:wake'));
288
+ };
281
289
  const isConversationalInjection = (metadata) => {
282
290
  if (!metadata)
283
291
  return true;
@@ -517,18 +525,30 @@ const ambientMark = (tags) => Array.isArray(tags) && tags.includes('chat:ambient
517
525
  * something anyone said there, so it never picks where the turn's plain
518
526
  * speech goes. */
519
527
  const systemNoticeMark = (tags) => isSystemNotice(tags) ? { systemNotice: true } : {};
528
+ /**
529
+ * A wake from the heartbeat: a tick or a reminder, from the framework's own
530
+ * heartbeat or from an MCPL server whose id is `heartbeat` (heartbeat-mcpl),
531
+ * which carries no channel. A silent tick isn't one here: its turn keeps
532
+ * every plain word private already (suppressProse). Its turn's plain speech
533
+ * has no destination (TurnRouting.heartbeat).
534
+ */
535
+ function isHeartbeatWake(trigger) {
536
+ if (trigger === undefined || trigger.channelId || trigger.suppressProse === true)
537
+ return false;
538
+ return trigger.heartbeatTurn ?? trigger.source === 'heartbeat';
539
+ }
520
540
  // parseProsePrefix lives in ./mcpl/prose-grammar.ts. The outgoing stream
521
541
  // (Spec 14.3) needs no grammar of its own: it carries what delivery
522
542
  // published.
523
543
  /** One-time primer appended when an agent's proseRouting mode changes. */
524
544
  function proseModePrimer(mode, helpAvailable) {
525
545
  if (mode === 'locus') {
526
- return '[prose-routing] Mode change: your plain text auto-routes to the ' +
546
+ return `[${NOTICE_TAG.proseRouting}] Mode change: your plain text auto-routes to the ` +
527
547
  'conversational locus again, unless its server declared that channel inbound-only. ' +
528
548
  '`>>` destination prefixes are no longer needed.';
529
549
  }
530
550
  if (mode === 'hybrid') {
531
- return '[prose-routing] Hybrid routing enabled: unprefixed text still lands in the ' +
551
+ return `[${NOTICE_TAG.proseRouting}] Hybrid routing enabled: unprefixed text still lands in the ` +
532
552
  'current conversational locus, unless its server declared that channel inbound-only. ' +
533
553
  'A leading `>>>destination` envelope instead routes ' +
534
554
  'that prose to one uniquely resolved authorized Discord or Eidoverse channel. The ' +
@@ -544,7 +564,7 @@ function proseModePrimer(mode, helpAvailable) {
544
564
  // content — the safest role there is). Nothing is injected into the system
545
565
  // prompt: those bytes belong to the recipe, and the deepest KV prefix must
546
566
  // not vary with framework code.
547
- return ('[prose-routing] Output mode changed: plain text is delivered only with a ' +
567
+ return (`[${NOTICE_TAG.proseRouting}] Output mode changed: plain text is delivered only with a ` +
548
568
  'destination prefix line, e.g. ">>#channel-name your text".' +
549
569
  (helpAvailable ? ' The prose_help tool shows the full syntax.' : ''));
550
570
  }
@@ -579,27 +599,28 @@ const PROSE_HELP_TOOL = {
579
599
  description: 'Show the plain-text output routing syntax (destination prefixes, resend token, skip).',
580
600
  inputSchema: { type: 'object', properties: {} },
581
601
  };
582
- /** "1 registered utility", "2 registered utilities". */
583
- const registeredUtilities = (n) => `${n} registered ${n === 1 ? 'utility' : 'utilities'}`;
584
- /** The single slot behind which every rarely-used capability lives. Only
585
- * advertised when at least one module contributes utilities — with none
586
- * registered the surface is byte-identical to before this existed. */
587
- const UTILS_TOOL = {
588
- name: 'utils',
589
- description: 'Rarely-used utilities, behind one slot instead of each costing a tool. ' +
590
- '"list" names the utilities you can use, with one-liners; "describe" returns one utility\'s full ' +
591
- 'input schema; "run" invokes it (args are validated against that schema — a ' +
592
- 'miss bounces back with the schema and the specific error).',
593
- inputSchema: {
594
- type: 'object',
595
- properties: {
596
- action: { type: 'string', enum: ['list', 'describe', 'run'], description: 'What to do.' },
597
- name: { type: 'string', description: 'Utility name from list (required for describe/run).' },
598
- args: { type: 'object', description: "Arguments for run, per the utility's schema." },
599
- },
600
- required: ['action'],
601
- },
602
- };
602
+ /** What a call of the removed `utils` tool is answered: where its utilities went. */
603
+ const UTILS_REMOVED = `utils was removed: module utilities are in the host's built-in toolset now. ` +
604
+ `${META_TOOL_NAMES.look} {toolset:"${BUILTIN_TOOLSET}"} lists them, ${META_TOOL_NAMES.open} {toolset:"${BUILTIN_TOOLSET}"} opens it, ` +
605
+ `and ${META_TOOL_NAMES.act} {tool, args} calls one. Nothing was run.`;
606
+ /**
607
+ * The framework's own tools that belong to the host's built-in toolset
608
+ * ("settings", host-presentation.ts) rather than the tool list: the ones an
609
+ * agent reaches for rarely. Modules place theirs with Module.toolPlacement,
610
+ * and every module utility is there too.
611
+ */
612
+ export const BUILTIN_TOOLSET_TOOLS = new Set([
613
+ 'agent_settings',
614
+ 'wake_add_rule',
615
+ 'wake_remove_rule',
616
+ 'gate_status',
617
+ 'event_tags',
618
+ 'set_tool_visibility',
619
+ 'set_tool_description',
620
+ 'heartbeat--heartbeat_status',
621
+ 'heartbeat--heartbeat_trigger',
622
+ 'workspace--status',
623
+ ]);
603
624
  const readExtensionSettings = (ext, agentName) => {
604
625
  const values = ext.get(agentName);
605
626
  return Object.fromEntries(Object.entries(values).map(([key, value]) => [key, value === undefined ? 'undefined' : JSON.stringify(value)]));
@@ -614,6 +635,19 @@ const settingNumber = (name, value) => {
614
635
  return n;
615
636
  throw new Error(`${name} must be a number, not ${shownValue(value)}`);
616
637
  };
638
+ const DEFAULT_TURN_REARM_MS = [2 * 60_000, 8 * 60_000, 32 * 60_000];
639
+ const turnRearmDelays = (value) => {
640
+ if (value === undefined)
641
+ return DEFAULT_TURN_REARM_MS;
642
+ if (value === false)
643
+ return [];
644
+ if (!Array.isArray(value) || value.length === 0
645
+ || ![...value].every((ms) => typeof ms === 'number' && Number.isFinite(ms) && ms > 0)) {
646
+ throw new Error('FrameworkConfig.exhaustedTurnRearmMs must be false, or a list of one or more delays in ms, ' +
647
+ `each a number above 0, not ${shownValue(value)}`);
648
+ }
649
+ return [...value];
650
+ };
617
651
  const settingBoolean = (name, value) => {
618
652
  if (typeof value === 'boolean')
619
653
  return value;
@@ -621,22 +655,17 @@ const settingBoolean = (name, value) => {
621
655
  return value === 'true';
622
656
  throw new Error(`${name} must be true or false, not ${shownValue(value)}`);
623
657
  };
624
- /** First sentence (or line) of a description — what `utils list` shows. */
625
- const utilityOneLiner = (description) => {
626
- const line = description.split('\n')[0];
627
- const dot = line.indexOf('. ');
628
- return safeSlice(dot >= 0 ? line.slice(0, dot + 1) : line, 0, 160);
629
- };
630
658
  import { CheckpointManager } from './mcpl/checkpoint-manager.js';
631
659
  import { isToolAllowed } from './mcpl/tool-policy.js';
632
- import { asCalled, cutOffRefusal, markedInputRefusal, toolArgsProblems } from './tool-args.js';
660
+ import { asCalled, cutOffRefusal, markedInputRefusal } from './tool-args.js';
633
661
  import { cutOffRound } from './cut-off-round.js';
634
- import { EventGate, checkToolInput, formatShadowWarning, shownValue, wakeLineText } from './gate/event-gate.js';
662
+ import { EventGate, MCPL_GATE_EVENT_TYPES, checkToolInput, formatShadowWarning, shownValue, wakeLineText } from './gate/event-gate.js';
635
663
  import { ReplyJudge } from './gate/reply-judge.js';
636
664
  import { HEARTBEAT_CHANGING_TOOLS, Heartbeat, isHeartbeatMcplServer } from './heartbeat/heartbeat.js';
637
665
  import { EPHEMERAL_USAGE_NAME, UsageTracker, persistedUsageProblem } from './usage/usage-tracker.js';
638
666
  import { McplProtocolVersionError, McplRequestError, McplUnreapedLaunchError, stderrTailOf, toolsetDeclarationsOf, withStderr } from './mcpl/server-connection.js';
639
667
  import { ToolsetHub, TOOLSETS_MODULE, META_TOOL_NAMES } from './mcpl/toolset-host.js';
668
+ import { HostPresentation, BUILTIN_TOOLSET } from './host-presentation.js';
640
669
  import { isEmptyChange } from './mcpl/tool-presence.js';
641
670
  import { ToolsetsModule, resolveAct, withLookHint } from './mcpl/toolsets-module.js';
642
671
  import { ModernMcpConnection } from './mcpl/modern-connection.js';
@@ -797,7 +826,29 @@ const TURN_RETRY = Symbol('turn-retry');
797
826
  * record. A symbol, as TURN_RETRY is, so spreads and rests copy it.
798
827
  */
799
828
  const MERGED_BATCH = Symbol('merged-batch');
829
+ const REARMED = Symbol('rearmed');
800
830
  const TURN_CARRY = Symbol('turn-carry');
831
+ function isRearmed(request) {
832
+ return request !== undefined && request[REARMED] === true;
833
+ }
834
+ function waitAlertsOnItsOwn(wait) {
835
+ return wait.until === null || wait.until - wait.setAt > PROVIDER_WAIT_ALERT_MS;
836
+ }
837
+ // membrane 0.8.0 marks a deadline it reports as run out with this registered
838
+ // symbol (on a sent request, or the Anthropic SDK's timeout, which covers its
839
+ // connect timeout too), and doesn't export its reader.
840
+ const MEMBRANE_DEADLINE_EXPIRY = Symbol.for('membrane.deadlineExpiry');
841
+ function isTransientFailure(error) {
842
+ if (!isMembraneError(error) || error.retryable !== true)
843
+ return false;
844
+ if (error[MEMBRANE_DEADLINE_EXPIRY] === true)
845
+ return false;
846
+ return error.type === 'server' || error.type === 'network' || error.type === 'timeout' || error.type === 'rate_limit';
847
+ }
848
+ function gotPartway(routing) {
849
+ return routing.roundsRan === true || (routing.unflushedRounds ?? 0) > 0;
850
+ }
851
+ const REARM_REASON = 'exhausted-turn-rearm';
801
852
  function mergedBatch(request) {
802
853
  return request[MERGED_BATCH];
803
854
  }
@@ -953,7 +1004,7 @@ function ephemeralRunStopped(name, observed) {
953
1004
  }
954
1005
  const CALLS_CUT_OFF = "A round's tool calls were started, but its stream ended before their results came back, so that round isn't in your context; those calls may have taken effect.";
955
1006
  const SPEECH_STILL_ROUTING = 'Some of its plain speech was still being routed when it ended, so it may yet be posted; check the channel before sending it again.';
956
- const LIVE_SPEECH_UNRECEIPTED = 'Plain speech its rounds routed live was handed on for delivery as written, and no [delivered] line was written for it.';
1007
+ const LIVE_SPEECH_UNRECEIPTED = `Plain speech its rounds routed live was handed on for delivery as written, and no [${NOTICE_TAG.delivered}] line was written for it.`;
957
1008
  /** The reason on the inference:aborted that ends a turn whose retry stop()
958
1009
  * cancelled, whichever frame was waiting to retry: beginAgentTurn's, when
959
1010
  * the stream couldn't start, or driveStream's, when it failed once started;
@@ -1475,7 +1526,9 @@ const WAITING_TRACE_EVERY_MS = 60_000;
1475
1526
  /** A stated wait recorded to hold a model longer than this, or until
1476
1527
  * released, raises an ops alert. It is honoured in full either way: a hold
1477
1528
  * that long is a quota-scale event or a provider's mistake, and a person
1478
- * decides which (release-provider-wait). Shorter waits are pacing. */
1529
+ * decides which (release-provider-wait). Shorter waits are pacing, until
1530
+ * waits in a row hold one turn longer than this (noteStatedWaitRun,
1531
+ * endStatedWaitRun). */
1479
1532
  const PROVIDER_WAIT_ALERT_MS = 60 * 60_000;
1480
1533
  /**
1481
1534
  * A MembraneError, from this framework's copy of membrane or from another: the
@@ -1816,9 +1869,13 @@ function failureDescription(error) {
1816
1869
  return `${head}: ${String(error.message)}`;
1817
1870
  }
1818
1871
  catch {
1819
- return '[failure could not be described]';
1872
+ return UNDESCRIBED_FAILURE;
1820
1873
  }
1821
1874
  }
1875
+ const UNDESCRIBED_FAILURE = `[${NOTICE_TAG.undescribedFailure}]`;
1876
+ function failureShown(text) {
1877
+ return text === UNDESCRIBED_FAILURE ? text : suppliedValue(text);
1878
+ }
1822
1879
  /**
1823
1880
  * A bounded string describing a failure, for logs that would otherwise print
1824
1881
  * the error object; it is one line unless one of its parts has newlines, as a
@@ -2090,6 +2147,17 @@ const JUDGE_PARAMETER = {
2090
2147
  },
2091
2148
  required: ['debounceMs'],
2092
2149
  };
2150
+ /** Whether a server's configuration resolves to the MCPL (legacy) family,
2151
+ * the one that can negotiate RFC-014 tool hints. A configuration that names
2152
+ * no usable transport is refused elsewhere; here it has no hints. */
2153
+ function isLegacyFamily(config) {
2154
+ try {
2155
+ return resolveServerBinding(config).family === 'legacy';
2156
+ }
2157
+ catch {
2158
+ return false;
2159
+ }
2160
+ }
2093
2161
  export class AgentFramework {
2094
2162
  toolPresentations = new Map();
2095
2163
  presentationPreviews = new WeakMap();
@@ -2332,8 +2400,6 @@ export class AgentFramework {
2332
2400
  * by the object, not the run's record (ephemeralRuns), which its cleanup
2333
2401
  * drops before a cancelled stream may count its last usage. */
2334
2402
  ephemeralAgents = new WeakSet();
2335
- /** Warn only once per Agent instance after utilities are registered. */
2336
- emptyUtilityPermissionWarnings = new WeakSet();
2337
2403
  /** Physical ephemeral frames deliberately disposed by their run watchdog/caller.
2338
2404
  * The name is single-generation, so these frames still own their terminal
2339
2405
  * typing/outgoing close even after runEphemeralToCompletion deregisters them. */
@@ -2342,6 +2408,10 @@ export class AgentFramework {
2342
2408
  * completes (inference:completed), not when a call answers mid-turn.
2343
2409
  * Drives hard-down escalation — see noteInferenceExhausted. */
2344
2410
  consecutiveInferenceFailures = new Map();
2411
+ turnRearmMs = DEFAULT_TURN_REARM_MS;
2412
+ turnRearms = new Map();
2413
+ exhaustingRearms = new Set();
2414
+ statedWaitRuns = new Map();
2345
2415
  /** N consecutive failed inferences ⇒ the agent is treated as hard-down and
2346
2416
  * escalated loudly to stderr. */
2347
2417
  inferenceFailureEscalationThreshold = 3;
@@ -2586,15 +2656,16 @@ export class AgentFramework {
2586
2656
  releasesAfterResume = new Map();
2587
2657
  managerReleases = new WeakMap();
2588
2658
  releasedByCleanup = new WeakSet();
2589
- // context-manager 0.23.0: a strategy that keeps its context, as
2590
- // AutobiographicalStrategy does (hostContext, autobiographical.ts:2849),
2659
+ // context-manager 0.23.1: a strategy that keeps its context, as
2660
+ // AutobiographicalStrategy does (hostContext, autobiographical.ts:2897),
2591
2661
  // keeps its manager reachable while its work is pending, since the
2592
2662
  // context's tools and systemPrompt getters close over the manager
2593
- // (context-manager.ts:2321-2337). So such a released manager is collected
2663
+ // (context-manager.ts:2343-2359). So such a released manager is collected
2594
2664
  // only once nothing of its work can still read the store, and stop()
2595
2665
  // closes, before an owned store, each one alive.
2596
2666
  openManagers = new Set();
2597
2667
  openManagerRefs = new WeakMap();
2668
+ storeSees = new WeakMap();
2598
2669
  collectedManagers = new FinalizationRegistry((ref) => this.openManagers.delete(ref));
2599
2670
  /** The coalescing commit in flight, if any (see commitCoalescingDurable). */
2600
2671
  coalescingCommit = null;
@@ -2732,10 +2803,15 @@ export class AgentFramework {
2732
2803
  /** Host capabilities advertised during the MCP handshake — stored so servers
2733
2804
  * can be connected at runtime (connectMcplServer) after initialization. */
2734
2805
  mcplHostCapabilities = null;
2735
- /** MCPL RFC-014: every toolHints server's tool presence, for the
2806
+ /** MCPL RFC-014: every hinted server's tool presence, for the
2736
2807
  * `toolsets` module's meta-tools and reminders. Made when the first
2737
- * server configured with `toolHints: true` is set up; null until then. */
2808
+ * server with tool hints (any not configured `toolHints: false`) is set
2809
+ * up; null until then. */
2738
2810
  toolsetHub = null;
2811
+ /** The host's own toolsets: the built-in "settings" and MCPL server tabs. */
2812
+ hostPresentation = null;
2813
+ /** MCPL servers kept disconnected as closed tabs, with the configuration an open connects them with. */
2814
+ closedTabConfigMap;
2739
2815
  /** Inference routing policy from FrameworkConfig — stored so the MCPL
2740
2816
  * subsystem can be lazily initialized by connectMcplServer when the
2741
2817
  * framework started with zero configured servers. */
@@ -2996,6 +3072,7 @@ export class AgentFramework {
2996
3072
  }
2997
3073
  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));
2998
3074
  framework.providerHoldHook = config.providerHold;
3075
+ framework.turnRearmMs = turnRearmDelays(config.exhaustedTurnRearmMs);
2999
3076
  // Before any delivery.
3000
3077
  await framework.reconcileDiscordAwarenessAtStartup();
3001
3078
  // Restore persisted usage data (if any) from prior session. The names it
@@ -3025,9 +3102,6 @@ export class AgentFramework {
3025
3102
  for (const module of config.modules) {
3026
3103
  await framework.addModule(module);
3027
3104
  }
3028
- for (const agent of framework.agents.values()) {
3029
- framework.warnEmptyUtilityPermissions(agent);
3030
- }
3031
3105
  for (const [agentName, presentation] of framework.toolPresentations) {
3032
3106
  const workspace = framework.getWorkspaceModule();
3033
3107
  if (!workspace)
@@ -3090,6 +3164,8 @@ export class AgentFramework {
3090
3164
  upcomingWakes: (agentName) => framework.upcomingWakesFor(agentName),
3091
3165
  });
3092
3166
  framework.judgeWired = judge !== undefined;
3167
+ const configuredServerIds = new Set((config.mcplServers ?? []).map((server) => server.id));
3168
+ const heartbeatOn = config.heartbeat !== undefined && config.heartbeat !== null && config.heartbeat !== false;
3093
3169
  framework.eventGate = new EventGate({
3094
3170
  configPath,
3095
3171
  initialConfig: config.gate.config,
@@ -3149,11 +3225,22 @@ export class AgentFramework {
3149
3225
  // debounce can fire).
3150
3226
  // A batched wake's summary names each channel as the agent names it
3151
3227
  // back, with its server where another server shares its id.
3152
- channelShown: (channel) => framework.channelRegistry?.nameShown(channel) ?? channel.channelId,
3228
+ channelShown: (channel) => framework.channelRegistry?.nameShown(channel) ?? suppliedValue(channel.channelId),
3153
3229
  // A batched wake for one message still newest in the window hands its
3154
3230
  // `[Gate: …]` line to the wake, for a fresh start of the wake's turn
3155
3231
  // to decide (placeWakeLines).
3156
3232
  isNewestEntry: (entry) => framework.isNewestContextEntry(entry),
3233
+ // What a source can send, for the rule-order notes: an MCPL server's
3234
+ // events reach the gate as its pushes and channel messages only (a
3235
+ // module's events carry a source of the module's own, such as "cli"
3236
+ // or "mcpl:<id>"), and the heartbeat's ticks as pushes.
3237
+ eventTypesFrom: (source) => {
3238
+ if (configuredServerIds.has(source) || framework.mcplServerConfigs.has(source))
3239
+ return MCPL_GATE_EVENT_TYPES;
3240
+ if (source === 'heartbeat' && heartbeatOn)
3241
+ return ['mcpl:push-event'];
3242
+ return undefined;
3243
+ },
3157
3244
  resolveRouteChannel: (info) => {
3158
3245
  if (info.eventType === 'mcpl:channel-incoming')
3159
3246
  return info.channelId || undefined;
@@ -3281,16 +3368,19 @@ export class AgentFramework {
3281
3368
  });
3282
3369
  }
3283
3370
  }
3371
+ // The host's own toolsets (host-presentation.ts): the built-in "settings"
3372
+ // and each MCPL server's tab, per agent, kept beside the store.
3373
+ framework.hostPresentation = framework.makeHostPresentation(config.storePath);
3284
3374
  // Initialize MCPL subsystems if configured
3285
3375
  if (config.mcplServers && config.mcplServers.length > 0) {
3286
3376
  // Check every server's config before any is brought up: its boolean
3287
3377
  // settings, and its tool prefix, which mustn't collide with a module
3288
3378
  // name or another server's prefix.
3289
3379
  const moduleNames = new Set(config.modules.map(m => m.name));
3290
- // RFC-014: a server configured with toolHints brings the toolsets
3291
- // module, whose name no prefix may take either.
3292
- if (config.mcplServers.some((serverConfig) => serverConfig.toolHints === true))
3293
- moduleNames.add(TOOLSETS_MODULE);
3380
+ // The name `toolsets` is the framework's from creation: its toolsets
3381
+ // module (or a module given here that holds the name) takes it, so no
3382
+ // prefix may.
3383
+ moduleNames.add(TOOLSETS_MODULE);
3294
3384
  const prefixesSeen = new Map(); // prefix → serverId
3295
3385
  for (const serverConfig of config.mcplServers) {
3296
3386
  assertServerConfigBooleans(serverConfig);
@@ -3309,9 +3399,28 @@ export class AgentFramework {
3309
3399
  }
3310
3400
  prefixesSeen.set(prefix, serverConfig.id);
3311
3401
  }
3312
- if (config.mcplServers.some((serverConfig) => serverConfig.toolHints === true))
3402
+ framework.reserveToolsetsName(config.modules, config.mcplServers);
3403
+ if (framework.toolsetsNameHeldAtStart === null)
3313
3404
  await framework.ensureToolsetsModule();
3314
- await framework.initializeMcpl(config.mcplServers, config.inferenceRouting);
3405
+ // A server kept as a closed tab (by its configured presentation, or as
3406
+ // an agent last left it) isn't connected until an agent opens it.
3407
+ let closed = framework.hostPresentation.startClosed(config.mcplServers);
3408
+ if (framework.toolsetsNameHeldAtStart !== null && closed.size > 0) {
3409
+ // No agent could open a closed tab: the meta-tools aren't the framework's.
3410
+ console.error(`[toolsets] ${[...closed].join(', ')} ${closed.size === 1 ? 'is' : 'are'} connected at startup though kept as a closed tab: ` +
3411
+ `the name "${TOOLSETS_MODULE}" is ${framework.toolsetsNameHeldAtStart}, so no agent could open ${closed.size === 1 ? 'it' : 'them'}`);
3412
+ closed = new Set();
3413
+ }
3414
+ for (const serverConfig of config.mcplServers) {
3415
+ if (closed.has(serverConfig.id))
3416
+ framework.closedTabConfigs().set(serverConfig.id, serverConfig);
3417
+ }
3418
+ await framework.initializeMcpl(config.mcplServers.filter((c) => !closed.has(c.id)), config.inferenceRouting);
3419
+ }
3420
+ else if (!config.modules.some((m) => m.name === TOOLSETS_MODULE)) {
3421
+ // The meta-tools are every agent's: the built-in toolset needs them,
3422
+ // MCPL or none.
3423
+ await framework.ensureToolsetsModule();
3315
3424
  }
3316
3425
  // Tune-out coordinator (issue #77): needs both the subconscious resident
3317
3426
  // and the MCPL channel subsystem; created after both exist.
@@ -4172,7 +4281,7 @@ export class AgentFramework {
4172
4281
  * only its own model, which auxiliary admission checks per call. */
4173
4282
  cooldownBlocksAuxiliary(agentName) {
4174
4283
  const cooldown = this.providerAccelerationCooldowns.get(agentName);
4175
- return cooldown !== undefined && cooldown.waitModel === undefined;
4284
+ return cooldown !== undefined && cooldown.waitModel === undefined && cooldown.rearm === undefined;
4176
4285
  }
4177
4286
  acquirePrimaryProviderGate(agentName) {
4178
4287
  const gate = this.providerGate(agentName);
@@ -4298,23 +4407,50 @@ export class AgentFramework {
4298
4407
  }
4299
4408
  const cooldown = this.providerAccelerationCooldowns.get(agentName);
4300
4409
  if (cooldown && cooldown.heldRequests.length > 0 && Number.isFinite(cooldown.until)) {
4301
- wakes.push({ at: cooldown.until, certain: true, what: 'a turn held by a provider cooldown, when it ends' });
4302
- }
4303
- // The heartbeat's ticks and reminders, which the agent's rules decide as
4304
- // they decide a heartbeat push: one the rules would hold back is listed
4305
- // as only a "maybe".
4306
- if (this.heartbeat && this.heartbeatTargets().includes(agentName)) {
4307
- const probe = (kind) => !this.eventGate
4308
- || this.eventGate.probe(this.heartbeatGateEvent({ kind, eventId: 'probe', text: 'probe' })).trigger;
4309
- const tickWakes = probe('heartbeat');
4310
- const reminderWakes = probe('reminder');
4311
- for (const w of this.heartbeat.upcomingWakes()) {
4312
- const isReminder = w.what.startsWith('its reminder');
4313
- wakes.push((isReminder ? reminderWakes : tickWakes) ? w : { ...w, certain: false });
4314
- }
4410
+ wakes.push({ at: cooldown.until, certain: true, what: cooldown.rearm
4411
+ ? `a turn whose retries ran out, sent again (${cooldown.rearm.number} of ${cooldown.rearm.of})`
4412
+ : 'a turn held by a provider cooldown, when it ends' });
4315
4413
  }
4414
+ wakes.push(...this.heartbeatWakesFor(agentName));
4316
4415
  return wakes;
4317
4416
  }
4417
+ /**
4418
+ * The heartbeat's ticks and reminders for `agentName`, soonest first, which
4419
+ * the agent's rules decide as they decide a heartbeat push: one the rules
4420
+ * would hold back is only a "maybe" (`certain` false). Each is probed as
4421
+ * the event its delivery is judged by (deliverHeartbeat): a silent tick as
4422
+ * silent, with the text it will bring, so a rule on the content or the
4423
+ * silent flag decides the probe as it will decide the wake. None for an
4424
+ * agent the heartbeat doesn't wake. `whose` words them for the judge ("its
4425
+ * heartbeat") or for the agent ("your heartbeat").
4426
+ */
4427
+ heartbeatWakesFor(agentName, whose = 'its') {
4428
+ if (!this.heartbeat || !this.heartbeatTargets().includes(agentName))
4429
+ return [];
4430
+ const wakes = (w) => !this.eventGate
4431
+ || this.eventGate.probe(this.heartbeatGateEvent({ kind: w.kind, eventId: 'probe', text: w.text, silent: w.silent })).trigger;
4432
+ return this.heartbeat.scheduledWakes(whose).map((w) => (wakes(w) ? w : { ...w, certain: false }));
4433
+ }
4434
+ /**
4435
+ * The heartbeat's wake due before a self-wake due at `selfWakeAt` (ms), for
4436
+ * the skip_reply that armed it: the soonest of its ticks and reminders that
4437
+ * the agent's rules, as they stand, let wake it. The confirmation says when
4438
+ * it's due, not that it will wake the agent (skipReplyNote), so a probe
4439
+ * that can't foresee the gate at the wake's time (a gate.js, a rate limit's
4440
+ * tokens then) leaves it true. A failure here costs only that line: the
4441
+ * self-wake is armed already, and the confirmation goes out without it.
4442
+ */
4443
+ heartbeatWakeBefore(agentName, selfWakeAt) {
4444
+ try {
4445
+ const now = Date.now();
4446
+ const first = this.heartbeatWakesFor(agentName, 'your').find((w) => w.certain && w.at < selfWakeAt);
4447
+ return first ? { inSeconds: Math.max(1, Math.round((first.at - now) / 1000)), what: first.what } : undefined;
4448
+ }
4449
+ catch (err) {
4450
+ console.error(`[self-wake] agent=${agentName}: the heartbeat's wakes couldn't be read for skip_reply's note: ${logDetail(err instanceof Error ? err.message : String(err))}`);
4451
+ return undefined;
4452
+ }
4453
+ }
4318
4454
  /** The agents the heartbeat wakes: the residents, not conversation forks,
4319
4455
  * ephemeral runs or the subconscious, which keep windows of their own. */
4320
4456
  heartbeatTargets() {
@@ -4723,8 +4859,63 @@ export class AgentFramework {
4723
4859
  console.error(`[provider-cooldown] agent=${agent.name} ` +
4724
4860
  `${hostHold ? `host hold (${hostHold.reason})` : 'organization acceleration 429'} — ` +
4725
4861
  `holding primary/auxiliary for ${delayMs}ms; ${held.length} request(s) retained`);
4862
+ if (wait && trigger && (wait.until === null || wait.until > now))
4863
+ this.noteStatedWaitRun(agent.name, wait, now);
4726
4864
  return true;
4727
4865
  }
4866
+ rearmExhaustedTurn(agent, error, trigger, ownsProviderGate, routing) {
4867
+ if (!ownsProviderGate || trigger === undefined || this.providerAdmissionClosed || this.turnRearmMs.length === 0)
4868
+ return;
4869
+ const done = this.turnRearms.get(agent.name) ?? 0;
4870
+ const notSent = (why) => console.error(`[inference-rearm] agent=${agent.name} turn not re-armed: ${why}`);
4871
+ if (!isTransientFailure(error)) {
4872
+ if (done > 0)
4873
+ notSent(`its failure isn't transient (${isMembraneError(error) ? error.type : boundFailureText(error.message, FAILURE_SUMMARY_MAX_CHARS)})`);
4874
+ return;
4875
+ }
4876
+ const continuation = isTurnContinuation(trigger.reason);
4877
+ const speaking = routing?.locus ?? routing?.triggerChannel;
4878
+ if (!speaking && routing?.noChannel && !trigger.channelId) {
4879
+ notSent('it would have to guess a channel');
4880
+ return;
4881
+ }
4882
+ if (done >= this.turnRearmMs.length) {
4883
+ notSent(`its ${this.turnRearmMs.length} re-arms are spent until a turn completes or a message is said to it`);
4884
+ return;
4885
+ }
4886
+ const delayMs = this.turnRearmMs[done];
4887
+ const rearm = { number: done + 1, of: this.turnRearmMs.length };
4888
+ this.turnRearms.set(agent.name, rearm.number);
4889
+ const request = { ...trigger, [REARMED]: true };
4890
+ delete request[TURN_RETRY];
4891
+ delete request[MERGED_BATCH];
4892
+ if (continuation) {
4893
+ request.reason = REARM_REASON;
4894
+ delete request.counterparty;
4895
+ }
4896
+ if (speaking) {
4897
+ request.channelId = speaking.channelId;
4898
+ request.channelServerId = speaking.serverId;
4899
+ request.addressed = true;
4900
+ }
4901
+ const existing = this.providerAccelerationCooldowns.get(agent.name);
4902
+ if (existing) {
4903
+ this.holdRequest(existing.heldRequests, request);
4904
+ }
4905
+ else {
4906
+ const now = Date.now();
4907
+ const timer = setTimeout(() => this.releaseProviderAccelerationCooldown(agent.name), this.providerHoldTimerMs(delayMs));
4908
+ timer.unref?.();
4909
+ this.providerAccelerationCooldowns.set(agent.name, {
4910
+ startedAt: now, until: now + delayMs, timer, heldRequests: [request], failures: 0, rearm,
4911
+ reason: `its turn's retries ran out (${boundFailureText(error.message, FAILURE_SUMMARY_MAX_CHARS)}), ` +
4912
+ `so it is sent again (${rearm.number} of ${rearm.of})`,
4913
+ });
4914
+ }
4915
+ console.error(`[inference-rearm] agent=${agent.name} turn sent again ` +
4916
+ (existing ? 'when the provider hold already on the agent ends' : `in ${delayMs}ms`) +
4917
+ ` (${rearm.number} of ${rearm.of})`);
4918
+ }
4728
4919
  /**
4729
4920
  * A provider's stated wait on a turn that doesn't own provider admission:
4730
4921
  * a conversation agent's or an ephemeral run's. Left to the error policy,
@@ -4842,8 +5033,10 @@ export class AgentFramework {
4842
5033
  const requests = cooldown.heldRequests.length > 0 ? cooldown.heldRequests : [{ agentName,
4843
5034
  reason: 'provider-acceleration-retry', source: 'framework', timestamp: Date.now() }];
4844
5035
  this.pendingRequests.push(...requests);
4845
- console.error(`[provider-cooldown] agent=${agentName} released after ${Date.now() - cooldown.startedAt}ms — ` +
4846
- `fresh compile queued with ${requests.length} retained request(s)`);
5036
+ console.error(cooldown.rearm
5037
+ ? `[inference-rearm] agent=${agentName} turn queued again (${cooldown.rearm.number} of ${cooldown.rearm.of})`
5038
+ : `[provider-cooldown] agent=${agentName} released after ${Date.now() - cooldown.startedAt}ms — ` +
5039
+ `fresh compile queued with ${requests.length} retained request(s)`);
4847
5040
  }
4848
5041
  /**
4849
5042
  * Wait an error policy's retry delay, all of it: the delay can be a
@@ -4921,13 +5114,47 @@ export class AgentFramework {
4921
5114
  : `until ${new Date(wait.until).toISOString()}`;
4922
5115
  const stated = `${error.type}${error.httpStatus !== undefined ? ` HTTP ${error.httpStatus}` : ''}, retry-after ${String(error.retryAfterMs)}ms`;
4923
5116
  console.error(`[provider-wait] agent=${agentName} model=${model} holds ${until}: ${stated}`);
4924
- if (remaining > PROVIDER_WAIT_ALERT_MS) {
5117
+ if (waitAlertsOnItsOwn(wait)) {
4925
5118
  this.opsAlert('provider-wait', agentName, `provider wait on ${model} holds ${until} (${stated}: ${reason}); ` +
4926
5119
  'if the provider did not mean that, release it with release-provider-wait', { data: { model, until: wait.until === null ? null : new Date(wait.until).toISOString() } });
4927
5120
  }
4928
5121
  }
4929
5122
  return wait;
4930
5123
  }
5124
+ noteStatedWaitRun(agentName, wait, now) {
5125
+ let run = this.statedWaitRuns.get(agentName);
5126
+ if (!run) {
5127
+ run = { since: now, holds: 0, alerted: false, model: wait.model, reason: wait.reason, heldUntil: now };
5128
+ this.statedWaitRuns.set(agentName, run);
5129
+ }
5130
+ run.holds++;
5131
+ run.model = wait.model;
5132
+ run.reason = wait.reason;
5133
+ if (wait.until !== null)
5134
+ run.heldUntil = Math.max(run.heldUntil, wait.until);
5135
+ if (waitAlertsOnItsOwn(wait))
5136
+ run.alerted = true;
5137
+ if (now - run.since > PROVIDER_WAIT_ALERT_MS)
5138
+ this.alertStatedWaitRun(agentName, run, now);
5139
+ }
5140
+ alertStatedWaitRun(agentName, run, now) {
5141
+ if (run.alerted)
5142
+ return;
5143
+ run.alerted = true;
5144
+ const since = new Date(run.since).toISOString();
5145
+ this.opsAlert('provider-wait', agentName, `a turn's ${run.holds} stated waits in a row on ${run.model} span ${Math.round((now - run.since) / 60_000)} min ` +
5146
+ `since ${since}: ${run.reason}; a provider stating waits that long in a row points to the account's rate limit ` +
5147
+ "or quota, or this host's load", { data: { model: run.model, since, holds: run.holds } });
5148
+ }
5149
+ endStatedWaitRun(agentName) {
5150
+ const run = this.statedWaitRuns?.get(agentName);
5151
+ if (!run)
5152
+ return;
5153
+ this.statedWaitRuns.delete(agentName);
5154
+ const end = Math.min(Date.now(), run.heldUntil);
5155
+ if (end - run.since > PROVIDER_WAIT_ALERT_MS)
5156
+ this.alertStatedWaitRun(agentName, run, end);
5157
+ }
4931
5158
  /**
4932
5159
  * Hold this agent's primary turns for a provider wait on its model. The
4933
5160
  * requests are kept and released together, into one fresh compile, when
@@ -4961,8 +5188,11 @@ export class AgentFramework {
4961
5188
  this.holdRequest(held, request);
4962
5189
  // An acceleration or host hold already parks the whole agent; its release
4963
5190
  // re-checks this wait at admission.
4964
- if (existing && existing.waitModel === undefined)
5191
+ if (existing && existing.waitModel === undefined) {
5192
+ if (failed)
5193
+ this.noteStatedWaitRun(agent.name, wait, now);
4965
5194
  return true;
5195
+ }
4966
5196
  if (existing)
4967
5197
  clearTimeout(existing.timer);
4968
5198
  const timer = setTimeout(() => this.releaseProviderAccelerationCooldown(agent.name), this.providerHoldTimerMs(until - now));
@@ -4976,6 +5206,8 @@ export class AgentFramework {
4976
5206
  console.error(`[provider-wait] agent=${agent.name} model=${wait.model} holding primary turns ` +
4977
5207
  `${wait.until === null ? 'until released' : `until ${new Date(wait.until).toISOString()}`}; ${held.length} request(s) retained`);
4978
5208
  }
5209
+ if (failed)
5210
+ this.noteStatedWaitRun(agent.name, wait, now);
4979
5211
  return true;
4980
5212
  }
4981
5213
  /**
@@ -5029,6 +5261,9 @@ export class AgentFramework {
5029
5261
  if (model === EVERY_MODEL)
5030
5262
  model = undefined;
5031
5263
  const released = this.providerWaits?.release(agentName, model, by) ?? [];
5264
+ const run = this.statedWaitRuns?.get(agentName);
5265
+ if (run && released.some(({ wait }) => wait.model === run.model))
5266
+ run.heldUntil = Math.min(run.heldUntil, Date.now());
5032
5267
  for (const { wait, release } of released) {
5033
5268
  console.error(`[provider-wait] agent=${agentName} model=${wait.model} released by ${by} (${release})`);
5034
5269
  }
@@ -5108,6 +5343,8 @@ export class AgentFramework {
5108
5343
  clearTimeout(cooldown.timer);
5109
5344
  this.providerAccelerationCooldowns.clear();
5110
5345
  this.providerAccelerationRecoveries.clear();
5346
+ this.turnRearms?.clear();
5347
+ this.exhaustingRearms?.clear();
5111
5348
  for (const gate of this.providerGates.values()) {
5112
5349
  gate.primaryDepth = 0;
5113
5350
  gate.primaryPending = false;
@@ -5513,6 +5750,12 @@ export class AgentFramework {
5513
5750
  if (claimedBy) {
5514
5751
  throw new Error(`Module "${module.name}" collides with the toolPrefix "${module.name}" the restart of "${claimedBy.restarting}" holds`);
5515
5752
  }
5753
+ // A closed tab keeps its prefix for the connect its next open makes.
5754
+ for (const [id, config] of this.closedTabConfigs()) {
5755
+ if ((config.toolPrefix ?? `mcpl--${id}`) === module.name) {
5756
+ throw new Error(`Module "${module.name}" collides with MCPL server "${id}"'s toolPrefix "${module.name}" (a closed tab)`);
5757
+ }
5758
+ }
5516
5759
  this.resetCompletedStop('addModule()');
5517
5760
  await this.moduleRegistry.addModule(module);
5518
5761
  // The workspace judges whether an agent's history holds a message as the
@@ -5675,12 +5918,12 @@ export class AgentFramework {
5675
5918
  agent.getContextManager().addMessage('user', [{
5676
5919
  type: 'text',
5677
5920
  text: (before === 'sending'
5678
- ? `[turn-interrupted] Your previous turn was stopped before it sent anything — ` +
5921
+ ? `[${NOTICE_TAG.turnInterrupted}] Your previous turn was stopped before it sent anything — ` +
5679
5922
  `a deliberate stop, not a failure.`
5680
5923
  : before === 'finishing'
5681
- ? `[turn-interrupted] Your previous turn was stopped before it finished — ` +
5924
+ ? `[${NOTICE_TAG.turnInterrupted}] Your previous turn was stopped before it finished — ` +
5682
5925
  `a deliberate stop, not a failure.`
5683
- : `[turn-interrupted] Your previous turn was cancelled mid-stream — ` +
5926
+ : `[${NOTICE_TAG.turnInterrupted}] Your previous turn was cancelled mid-stream — ` +
5684
5927
  `a deliberate stop, not a failure. Whatever you were still ` +
5685
5928
  `producing when it stopped was cut off there.`) + handedOn,
5686
5929
  }], { system: true, kind: 'turn-interrupted', reason });
@@ -5818,8 +6061,10 @@ export class AgentFramework {
5818
6061
  : [];
5819
6062
  return [
5820
6063
  ...moduleTools,
5821
- // Utilities collapse into one slot; absent entirely when none exist.
5822
- ...(this.moduleRegistry.getAllUtilities().length > 0 ? [UTILS_TOOL] : []),
6064
+ // Utilities: callable tools, presented in the built-in toolset
6065
+ // (placedInBuiltin), never in the tool list. One named like a tool of
6066
+ // its module is that tool's: the tool takes the name.
6067
+ ...this.moduleRegistry.getAllUtilities().filter((u) => !moduleTools.some((t) => t.name === u.name)),
5823
6068
  ...this.mcplTools,
5824
6069
  ...channelTools,
5825
6070
  ...gateTools,
@@ -5888,6 +6133,8 @@ export class AgentFramework {
5888
6133
  setMcplServerConfig(config) {
5889
6134
  this.mcplServerConfigs.set(config.id, config);
5890
6135
  referenceRegistry.setServerAddress(config.id, serverAddressOf(config), this);
6136
+ // The gate's rule-order notes read which sources are servers.
6137
+ this.eventGate?.sourcesChanged();
5891
6138
  }
5892
6139
  /** Register every RFC-005 reference in an MCPL peer's tool-result content
5893
6140
  * against its server, then fetch by `mode`:
@@ -6175,7 +6422,7 @@ export class AgentFramework {
6175
6422
  return undefined;
6176
6423
  const reach = this.residentHoldsTraffic(who) ? ' Messages there that address you, such as an @-mention, still reach you.' : '';
6177
6424
  const open = this.canAgentUseTool(who, 'channel_open') && !registry.lifecycleWithheld(channel.serverId)
6178
- ? ` channel_open with channelId "${channel.channelId}" and serverId "${channel.serverId}" opens it.`
6425
+ ? ` channel_open with channelId ${quotedValue(channel.channelId)} and serverId "${channel.serverId}" opens it.`
6179
6426
  : '';
6180
6427
  return `${this.channelShown(channel)} stays closed: you ${how} it, and your words there don't open it.${reach}${open}`;
6181
6428
  }
@@ -6265,14 +6512,14 @@ export class AgentFramework {
6265
6512
  const body = closedNote ?? tunedOutNote;
6266
6513
  if (!body)
6267
6514
  return answer;
6268
- const tag = closedNote ? '[closed channel]' : '[tuned out]';
6515
+ const tag = `[${closedNote ? NOTICE_TAG.closedChannel : NOTICE_TAG.tunedOut}]`;
6269
6516
  if (forScript) {
6270
6517
  const window = this.noticeTargetFor(agentName, 'closed-channel');
6271
6518
  try {
6272
6519
  if (window) {
6273
6520
  this.addMessage('user', [{
6274
6521
  type: 'text',
6275
- text: `${tag} Your script's ${toolName}: ${body}${this.closedChannelNoteOff(agentName)}`,
6522
+ text: `${tag} Your script's ${suppliedValue(toolName)}: ${body}${this.closedChannelNoteOff(agentName)}`,
6276
6523
  }], { system: true, kind: 'channel-notice' }, window);
6277
6524
  }
6278
6525
  }
@@ -6430,16 +6677,6 @@ export class AgentFramework {
6430
6677
  },
6431
6678
  };
6432
6679
  }
6433
- warnEmptyUtilityPermissions(agent) {
6434
- if (agent.allowedTools === 'all' || !agent.allowedTools.includes('utils')
6435
- || this.emptyUtilityPermissionWarnings.has(agent))
6436
- return;
6437
- const utilities = this.moduleRegistry.getAllUtilities();
6438
- if (utilities.length === 0 || utilities.some(tool => agent.canUseTool(tool.name)))
6439
- return;
6440
- console.warn(`[utils] Agent '${agent.name}': ${registeredUtilities(utilities.length)}, 0 permitted; none permitted by this agent's allowedTools. Add each required module--utility name to allowedTools.`);
6441
- this.emptyUtilityPermissionWarnings.add(agent);
6442
- }
6443
6680
  getToolsForAgent(agentName, snapshot) {
6444
6681
  const agent = this.agents.get(agentName);
6445
6682
  // Model-facing advice names skip_reply only where dispatch would let this
@@ -6457,11 +6694,8 @@ export class AgentFramework {
6457
6694
  : t);
6458
6695
  return [...SUBCONSCIOUS_TOOLS, ...basics];
6459
6696
  }
6460
- if (agent)
6461
- this.warnEmptyUtilityPermissions(agent);
6462
- const hasPermittedUtility = !agent || this.moduleRegistry.getAllUtilities().some(tool => agent.canUseTool(tool.name));
6463
6697
  return [...this.getAllTools(), ...(this.toolPresentations.has(agentName) ? presentationTools(this.toolPresentations.get(agentName).config.cataloguePath) : [])]
6464
- .filter(tool => tool.name !== 'utils' || hasPermittedUtility).map((tool) => {
6698
+ .map((tool) => {
6465
6699
  if (tool.name === 'think') {
6466
6700
  return this.buildThinkTool(snapshot?.sameRoundThinkTextPolicy
6467
6701
  ?? this.getAgentRuntimeSettings(agentName).sameRoundThinkTextPolicy, canSkipReply);
@@ -6491,7 +6725,7 @@ export class AgentFramework {
6491
6725
  return null;
6492
6726
  const tools = this.availableToolsForPresentation(agentName, snapshot);
6493
6727
  const sources = new Map();
6494
- for (const tool of this.moduleRegistry.getAllTools()) {
6728
+ for (const tool of [...this.moduleRegistry.getAllTools(), ...this.moduleRegistry.getAllUtilities()]) {
6495
6729
  sources.set(tool.name, `Module: ${tool.name.split('--')[0]}`);
6496
6730
  }
6497
6731
  // An MCPL tool's source is the server a call of it reaches, by the one
@@ -6506,8 +6740,8 @@ export class AgentFramework {
6506
6740
  return presentation.resolve(tools, sources);
6507
6741
  }
6508
6742
  advertisedToolsForAgent(agentName, snapshot) {
6509
- return this.inspectToolPresentation(agentName, snapshot)?.advertised
6510
- ?? this.availableToolsForPresentation(agentName, snapshot);
6743
+ return this.headerTools(agentName, this.inspectToolPresentation(agentName, snapshot)?.advertised
6744
+ ?? this.availableToolsForPresentation(agentName, snapshot));
6511
6745
  }
6512
6746
  /** Compression can revisit calls to hidden tools; never apply visibility here. */
6513
6747
  compressionToolsForAgent(agentName, snapshot) {
@@ -7020,8 +7254,10 @@ export class AgentFramework {
7020
7254
  * CURRENT settings — maintenance may have moved the folded floor and the
7021
7255
  * branch generation, so verdicts are computed at resume time, never reused.
7022
7256
  * Throws ResumeBlockedError (with all failing verdicts) unless `force`.
7023
- * Unavailable previews warn and pass — refusing to resume because the
7024
- * strategy cannot preview would hold hosts hostage to a diagnostic.
7257
+ * Unavailable previews pass, with a warning naming the reason unless it is
7258
+ * `no_preview_support`, a strategy with nothing to preview — refusing to
7259
+ * resume because the strategy cannot preview would hold hosts hostage to a
7260
+ * diagnostic.
7025
7261
  */
7026
7262
  async resume(opts) {
7027
7263
  this.refuseOperatorAfterStop('resume');
@@ -7898,7 +8134,8 @@ export class AgentFramework {
7898
8134
  throw new Error(`Agent not found: ${agentName}`);
7899
8135
  }
7900
8136
  const presentation = this.inspectToolPresentation(agentName);
7901
- const tools = presentation?.advertised ?? this.availableToolsForPresentation(agentName);
8137
+ const availableTools = presentation?.available ?? this.availableToolsForPresentation(agentName);
8138
+ const tools = this.headerTools(agentName, presentation?.advertised ?? availableTools);
7902
8139
  const capture = (request) => {
7903
8140
  if (presentation)
7904
8141
  this.presentationPreviews.set(request, presentation);
@@ -7911,7 +8148,7 @@ export class AgentFramework {
7911
8148
  // Default: no dynamic injection gathering → no inference, no Chronicle
7912
8149
  // writes of its own, no external RPC. Opt in explicitly.
7913
8150
  if (!opts?.injections) {
7914
- return capture(await agent.buildActivationRequest(tools, undefined, opts?.budget, presentation?.available ?? tools, { dryRun: true }));
8151
+ return capture(await agent.buildActivationRequest(tools, undefined, opts?.budget, availableTools, { dryRun: true }));
7915
8152
  }
7916
8153
  // Full-fidelity path: mirrors startAgentStream's injection gathering.
7917
8154
  // NOT transparent — see the doc comment above.
@@ -7951,7 +8188,7 @@ export class AgentFramework {
7951
8188
  const plan = this.planInjectionStores(agent, injections);
7952
8189
  const held = plan !== undefined && this.slotHeldFrom(agent);
7953
8190
  const placed = new Set(plan ? (held ? [...plan.leftAt, ...plan.rows.map((row) => row.at)] : plan.leftAt) : []);
7954
- return capture(await agent.buildActivationRequest(tools, plan ? (injections ?? []).filter((_, at) => placed.has(at)) : injections, opts?.budget, presentation?.available ?? tools, {
8191
+ return capture(await agent.buildActivationRequest(tools, plan ? (injections ?? []).filter((_, at) => placed.has(at)) : injections, opts?.budget, availableTools, {
7955
8192
  dryRun: true,
7956
8193
  ...(plan && !held ? { appendedMessages: plan.rows.map(({ participant, content }) => ({ participant, content })) } : {}),
7957
8194
  }));
@@ -7969,7 +8206,13 @@ export class AgentFramework {
7969
8206
  * see `AutobiographicalStrategy.previewContext` and
7970
8207
  * `Agent.compileWithInjections`.
7971
8208
  *
7972
- * Returns null when the agent's strategy has no fold plan (non-adaptive).
8209
+ * Returns null when the agent's strategy has nothing to preview: one without
8210
+ * `previewContext`, or a non-adaptive `AutobiographicalStrategy` whose select
8211
+ * fits the budget, built from the framework's own copy of context-manager.
8212
+ * Built from another copy, such a strategy throws its `previewContext
8213
+ * requires adaptiveResolution` error here instead, since the manager
8214
+ * recognises only its own copy's `NoFoldPlanToPreviewError`. Over the
8215
+ * budget, a non-adaptive strategy's preview is an infeasible one.
7973
8216
  */
7974
8217
  previewContextSettings(agentName, budgetTokens, overrides, opts) {
7975
8218
  const agent = this.agents.get(agentName);
@@ -8005,9 +8248,9 @@ export class AgentFramework {
8005
8248
  *
8006
8249
  * Never throws for preview-layer reasons: strategy without previewContext,
8007
8250
  * preview overlap, stale branch generation, the hierarchical (non-
8008
- * adaptive) path, and a context manager that refuses its queries (its
8009
- * strategy's initialization pending or failed) all come back as
8010
- * `{available: false, reason}`. Unknown agent still throws, matching the
8251
+ * adaptive) path within its budget, and a context manager that refuses its
8252
+ * queries (its strategy's initialization pending or failed) all come back
8253
+ * as `{available: false, reason}`. Unknown agent still throws, matching the
8011
8254
  * sibling accessors, and so does a patch whose values the agent refuses,
8012
8255
  * whatever its context manager's state. Whether the strategy takes live
8013
8256
  * tail and pace settings is the manager's to say, so while it refuses, a
@@ -9066,16 +9309,18 @@ export class AgentFramework {
9066
9309
  description: 'Add or replace a wake rule (a gate.json policy) at runtime — no need to ' +
9067
9310
  'hand-edit the file. The rule is validated and hot-applied immediately. ' +
9068
9311
  'A rule with the same `name` replaces the existing one in place (add a ' +
9069
- 'position to move it). ⚠️ ORDERING IS SEMANTICS: first match wins, and a ' +
9070
- 'matched rule CONSUMES the event — a broad rule placed early makes every ' +
9071
- 'later rule unreachable for its scope, including your addressed-message ' +
9072
- '(DM/mention) wake rules. Prefer `insertBefore`/`insertAfter` to place a ' +
9073
- 'rule relative to a specific existing rule; use `position: "prepend"` only ' +
9074
- 'when the rule really must beat EVERYTHING. For "additionally wake me ' +
9075
- 'every Nth event / at most N per hour" governors, set `passthrough: true` ' +
9076
- 'so non-firing matches fall through to your normal rules instead of ' +
9077
- 'swallowing them. The result includes a before/after probe table and any ' +
9078
- 'shadow warnings — READ THEM before ending the turn. Two common shapes:\n' +
9312
+ 'position to move it). Rules are checked in order and the first one that ' +
9313
+ 'matches an event decides it, so the rules below never see that event: a ' +
9314
+ 'broad rule placed early takes events from them, your DM and mention ' +
9315
+ 'rules included. Place a rule next to a particular one with ' +
9316
+ '`insertBefore`/`insertAfter`; `position: "prepend"` puts it above all of ' +
9317
+ 'them. For a rule that only adds wakes ("also wake me every Nth event", ' +
9318
+ '"at most N an hour"), set `passthrough: true`: the events it doesn\'t ' +
9319
+ 'wake for go on to your other rules. The result shows how a few common ' +
9320
+ 'events are decided before and after the change. Its `shadowWarnings` ' +
9321
+ 'are notes on your whole rule list, not only this change, wherever an ' +
9322
+ 'earlier rule takes events a later one could get: each says whether that ' +
9323
+ 'matters and what to do about it. Two common shapes:\n' +
9079
9324
  '• Watch a FILE/workspace path: match on `mount` + `pathGlob`, e.g. ' +
9080
9325
  '`{ name: "watch-notes", match: { scope: ["workspace:modified"], mount: "project", pathGlob: "notes/*.md" }, behavior: "always" }`.\n' +
9081
9326
  '• Watch a CHANNEL: match on `source` + `channel` (and/or `tagsAny: ["chat:ambient"]`), ' +
@@ -9564,14 +9809,14 @@ export class AgentFramework {
9564
9809
  const ep = this.rewindEpisode.get(agent.name);
9565
9810
  const count = (ep?.count ?? 0) + 1;
9566
9811
  const why = cause === 'refusal'
9567
- ? `the model refused on them (content filter: ${category}). Their content is ` +
9812
+ ? `the model refused on them (content filter: ${failureShown(category)}). Their content is ` +
9568
9813
  `not reproduced here (so this note can't re-trigger the filter); the ` +
9569
9814
  `originals remain in the raw record`
9570
- : `the model API kept refusing the requests that carried them (${category}), ` +
9815
+ : `the model API kept refusing the requests that carried them (${failureShown(category)}), ` +
9571
9816
  `so that the conversation could go on. Their content is not reproduced ` +
9572
9817
  `here (so this note can't trigger the same refusal); the originals remain ` +
9573
9818
  `in the raw record`;
9574
- const text = `[refusal-rewind] The ${count} most recent turn(s) were set aside because ` +
9819
+ const text = `[${NOTICE_TAG.refusalRewind}] The ${count} most recent turn(s) were set aside because ` +
9575
9820
  `${why}. You are clear to continue — if you ` +
9576
9821
  `were mid-task, take a different approach; otherwise carry on with whatever ` +
9577
9822
  `is now in front of you, or briefly acknowledge the gap and ask what's next.`;
@@ -11673,6 +11918,8 @@ export class AgentFramework {
11673
11918
  }
11674
11919
  }
11675
11920
  async createAgent(config) {
11921
+ const sees = (metadata) => !metadata?.tuneOut
11922
+ && !isAnotherAgentsInjection(metadata, config.name);
11676
11923
  // Create context manager for this agent
11677
11924
  const contextManager = await ContextManager.open({
11678
11925
  store: this.store,
@@ -11693,10 +11940,10 @@ export class AgentFramework {
11693
11940
  // Another agent's stored context injections (stored-injections.ts) are
11694
11941
  // that agent's context, not conversation: they stay out of this view,
11695
11942
  // and so out of its folds, as the residents share this slot.
11696
- viewFilter: (message) => !message.metadata?.tuneOut
11697
- && !isAnotherAgentsInjection(message.metadata, config.name),
11943
+ viewFilter: (message) => sees(message.metadata),
11698
11944
  });
11699
11945
  this.trackManager(contextManager);
11946
+ this.storeSees.set(contextManager, sees);
11700
11947
  const agent = new Agent(config, contextManager, this.membrane);
11701
11948
  this.adoptToolResultGuard(agent);
11702
11949
  agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
@@ -11753,6 +12000,7 @@ export class AgentFramework {
11753
12000
  // oversized tool results / attachments under the same policy.
11754
12001
  maxMessageTokens: primaryConfig.strategy?.maxMessageTokens,
11755
12002
  });
12003
+ const sees = (metadata) => !isAnotherAgentsInjection(metadata, name);
11756
12004
  const contextManager = await ContextManager.open({
11757
12005
  store: this.store,
11758
12006
  namespace: `subconscious/${primaryName}`,
@@ -11763,12 +12011,13 @@ export class AgentFramework {
11763
12011
  // residents' stored context injections (stored-injections.ts): their
11764
12012
  // context, which this agent never saw while they were placed unstored.
11765
12013
  auxiliaryMessageViews: [{}],
11766
- viewFilter: (message) => !isAnotherAgentsInjection(message.metadata, name),
12014
+ viewFilter: (message) => sees(message.metadata),
11767
12015
  strategy,
11768
12016
  membrane: this.membrane,
11769
12017
  debugLogContext: !!process.env.DEBUG_CONTEXT,
11770
12018
  });
11771
12019
  this.trackManager(contextManager);
12020
+ this.storeSees.set(contextManager, sees);
11772
12021
  // Inherit the principal's inference settings wholesale (fork-template
11773
12022
  // idiom): reasoning effort (thinking), provider params, token ceilings,
11774
12023
  // caching, refusal handling, timezone — a same-model side-process must
@@ -12005,6 +12254,7 @@ export class AgentFramework {
12005
12254
  flushedTurn.roundsRan = true;
12006
12255
  if (flushedTurn.unflushedRounds)
12007
12256
  flushedTurn.unflushedRounds--;
12257
+ this.endStatedWaitRun(agent.name);
12008
12258
  }
12009
12259
  if (flushedTurn && replySent === null && !flushedTurn.privateEnd && !flushedTurn.silent
12010
12260
  && pendingBlocks.some((b) => b.type === 'text' && b.text?.trim())) {
@@ -12069,11 +12319,12 @@ export class AgentFramework {
12069
12319
  // with everything queued behind it — the queue keeps its order —
12070
12320
  // for the turn-end flush, where no reply follows it in this stream.
12071
12321
  //
12072
- // A message a tune-out holds from the residents is drained and
12073
- // stored like any other, but not injected: every resident's
12074
- // viewFilter leaves it out of the next compile as well, so the
12075
- // window's compiled prefix and the live request still match without
12076
- // it (#232 gap 5).
12322
+ // A message the agent's own view filter leaves out, such as one a
12323
+ // tune-out holds from the residents or another agent's stored
12324
+ // context injection, is drained and stored like any other, but not
12325
+ // injected: the next compile leaves it out as well, so the window's
12326
+ // compiled prefix and the live request still match without it
12327
+ // (#232 gap 5).
12077
12328
  //
12078
12329
  // A stream that declares it can't carry injected messages
12079
12330
  // (supportsInjectedMessages false: membrane's XML prefill path,
@@ -12105,14 +12356,11 @@ export class AgentFramework {
12105
12356
  // counts as stored, as what landed of it (writeDrainedInOrder).
12106
12357
  const stored = this.writeDrainedInOrder(deferred, `tool boundary for ${agent.name}`, (msg) => agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id)));
12107
12358
  for (const [msg, { id, content }] of stored) {
12359
+ this.noteSpokenTo(agent.name, msg.metadata);
12108
12360
  // Every other resident reads what this boundary stores; for its
12109
12361
  // own agent, the boundary weighs what it injects below.
12110
12362
  this.noteForOtherResidents(agent, msg.metadata, id);
12111
- // A message a tune-out holds from the residents is stored (the
12112
- // subconscious reads it) and left out of every resident's
12113
- // compiled view; injecting it here would put in the live turn
12114
- // what the next compile hides (#232 gap 5).
12115
- if (msg.metadata?.tuneOut)
12363
+ if (!this.agentSees(agent, msg.metadata))
12116
12364
  continue;
12117
12365
  heard.push({ msg, id, content });
12118
12366
  }
@@ -12125,10 +12373,12 @@ export class AgentFramework {
12125
12373
  const rendered = this.renderForInjection(agent, [...(clock ? [clock.id] : []), ...heard.map((h) => h.id)]);
12126
12374
  const first = clock ? 1 : 0;
12127
12375
  // A null is a message the render places before its window. Under
12128
- // the memory strategy that's one before its recent window, when
12129
- // what this boundary stored is bigger than it, or any message
12130
- // while a head window a topic change reset hasn't filled yet. The
12131
- // next compile still sends it, raw and in order, after the tool
12376
+ // context-manager 0.23.0's memory strategy that's one before its
12377
+ // recent window, when what this boundary stored is bigger than it,
12378
+ // or any message while a head window a topic change reset hasn't
12379
+ // filled yet; from 0.23.1 that strategy gives no null for a
12380
+ // message its view holds. The next compile still sends such a
12381
+ // message, raw and in order, after the tool
12132
12382
  // result, so leaving it out would change the prefix as surely as
12133
12383
  // any form, and the agent wouldn't hear it (context-manager#275's
12134
12384
  // review, R1-1). It goes in as stored, which is the form the next
@@ -12139,13 +12389,12 @@ export class AgentFramework {
12139
12389
  // cap reaches it. And a body the strategy stored in parts goes in
12140
12390
  // whole, joined as the store reads it back, where the next
12141
12391
  // compile sends it in more than one piece.
12142
- // That rests on two things. The only view filter this framework
12143
- // gives a store is the tune-out stamp (createAgent's; the other
12144
- // stores it opens have none), and a stamped message is skipped
12145
- // above, so no null here means a message the view hides. And the
12146
- // store and the render run with nothing between them, so nothing
12147
- // can put a summary in place of what this flush stored, or move
12148
- // windowed passthrough's anchor past it, before the render. A
12392
+ // That rests on two things. A message the agent's own view filter
12393
+ // leaves out is skipped above, by that filter, so no null here
12394
+ // means a message the view hides. And the store and the render
12395
+ // run with nothing between them, so nothing can put a summary in
12396
+ // place of what this flush stored, or move windowed passthrough's
12397
+ // anchor past it, before the render. A
12149
12398
  // write found here that landed at an earlier flush
12150
12399
  // (writeDrainedInOrder) is the exception: by now a summary may
12151
12400
  // stand in for it, or the anchor be past it, and then it goes in
@@ -12503,6 +12752,10 @@ export class AgentFramework {
12503
12752
  // Dispatch to all modules, tracking responses with module names
12504
12753
  const responses = [];
12505
12754
  for (const module of this.moduleRegistry.getAllModules()) {
12755
+ // The framework's own toolsets module answers no event; every agent has
12756
+ // it now, so it isn't listed beside the modules that do.
12757
+ if (module instanceof ToolsetsModule)
12758
+ continue;
12506
12759
  try {
12507
12760
  const processState = this.moduleRegistry.createProcessState(module.name);
12508
12761
  const response = await module.onProcess(event, processState);
@@ -12589,6 +12842,7 @@ export class AgentFramework {
12589
12842
  // surface carries where the framework records none.
12590
12843
  const eventChannel = event.metadata?.channelId;
12591
12844
  const namesChannel = typeof eventChannel === 'string' && eventChannel !== '';
12845
+ const localInput = event.type === 'external-message' || event.type === 'api:message';
12592
12846
  let added;
12593
12847
  if (response.addMessages) {
12594
12848
  for (const [i, msg] of response.addMessages.entries()) {
@@ -12601,7 +12855,7 @@ export class AgentFramework {
12601
12855
  if (surface)
12602
12856
  metadata = { ...metadata, [INBOUND_SOURCE_KEY]: surface };
12603
12857
  const placement = {};
12604
- const id = this.addMessage(msg.participant, msg.content, metadata, { placement });
12858
+ const id = this.addMessage(msg.participant, localInput ? markNoticeOpenings(msg.content) : msg.content, metadata, { placement });
12605
12859
  if (response.addMessages.length === 1 && !surface && !namesChannel && metadata?.channelId === undefined)
12606
12860
  added = placement;
12607
12861
  this.emitTrace({ type: 'message:added', messageId: id, source: event.type });
@@ -12816,6 +13070,7 @@ export class AgentFramework {
12816
13070
  missedCharacters: typeof event.metadata?.missedCharacters === 'number'
12817
13071
  ? event.metadata.missedCharacters
12818
13072
  : undefined,
13073
+ targetAgents: event.targetAgents,
12819
13074
  });
12820
13075
  if (invitation) {
12821
13076
  incomingContent.push(invitation.block);
@@ -13397,11 +13652,6 @@ export class AgentFramework {
13397
13652
  * explicit-prose agent's natural plain reply bounced with nowhere to go
13398
13653
  * (Rhys's first portal mention: a 2,320-char reply stranded).
13399
13654
  *
13400
- * Wording: option 1 teaches the `>>` destination prefix, which delivers for
13401
- * BOTH legacy (host-inferred) and explicit prose-routing agents. The
13402
- * previous "simply write your reply as normal text" was a false promise
13403
- * under explicit routing (the metadata flags it set had no consumers).
13404
- *
13405
13655
  * Human-readable names wherever the surface provided them — a mind reading
13406
13656
  * this should see WHO addressed it WHERE, not a wall of snowflake ids. Raw
13407
13657
  * ids remain only inside the tool-argument instructions, where they are the
@@ -13460,8 +13710,11 @@ export class AgentFramework {
13460
13710
  // only where the name lacks one, by proseTargetFor's rule.
13461
13711
  const bareLabel = label && label !== opts.channelId ? label.replace(/^#/, '') : undefined;
13462
13712
  const hashLabel = bareLabel ? `#${bareLabel}` : undefined;
13463
- const channelLabel = hashLabel ?? `"${opts.channelId}"`;
13464
- const place = opts.guildName ? `${channelLabel} in "${opts.guildName}"` : channelLabel;
13713
+ const channelId = quotedValue(opts.channelId);
13714
+ const messageId = quotedValue(opts.messageId);
13715
+ const authorName = suppliedValue(opts.authorName);
13716
+ const channelLabel = hashLabel ? suppliedValue(hashLabel) : channelId;
13717
+ const place = opts.guildName ? `${channelLabel} in "${suppliedValue(opts.guildName)}"` : channelLabel;
13465
13718
  // The label is for reading; the prefix must be ONE token that resolves back
13466
13719
  // here, on this server (a DM label "DM: alice" quoted verbatim parses as
13467
13720
  // target "#DM:"). A registry that can name targets is authoritative: no safe
@@ -13470,7 +13723,7 @@ export class AgentFramework {
13470
13723
  const namer = this.channelRegistry;
13471
13724
  const target = typeof namer.proseTargetFor === 'function'
13472
13725
  ? namer.proseTargetFor(channel)
13473
- : [hashLabel, opts.channelId].find((t) => !!t && isProseTargetToken(t));
13726
+ : [hashLabel, opts.channelId].find((t) => !!t && isProseTargetToken(t) && showsAsItself(t));
13474
13727
  const inboundOnlyServer = this.channelRegistry.inboundOnlyServer(channel);
13475
13728
  // What replying does here is the registry's to say (speechOpens): a reply
13476
13729
  // opens a channel nobody decided to keep closed, and leaves one the agent
@@ -13480,23 +13733,39 @@ export class AgentFramework {
13480
13733
  const replyOpens = this.channelRegistry.speechOpens(channel);
13481
13734
  // The decline is the way to reply without opening a channel, except in a
13482
13735
  // tuned-out one, which channel_decline refuses: the tune-out ends first.
13483
- const withoutOpening = this.channelRegistry.getDesiredState(opts.serverId, opts.channelId) === 'tuned-out'
13736
+ const tunedOut = this.channelRegistry.getDesiredState(opts.serverId, opts.channelId) === 'tuned-out';
13737
+ const withoutOpening = tunedOut
13484
13738
  ? ''
13485
13739
  : " To reply without opening it, decline it first (option 3): a channel you've declined stays closed when you speak there.";
13740
+ const named = [...new Set(opts.targetAgents ?? [])].filter((name) => this.agents.has(name));
13741
+ const woken = named.length === 1 ? this.agents.get(named[0]) : undefined;
13742
+ const mode = (woken ?? this.deliveryAgent())?.proseRouting ?? 'locus';
13743
+ const prefix = mode === 'hybrid' ? '>>>' : '>>';
13744
+ const delivered = replyOpens
13745
+ ? `it will be delivered there, which opens the channel.${withoutOpening}`
13746
+ : 'it will be delivered there, and the channel stays closed.';
13747
+ const sendTool = `check whether ${opts.serverId} exposes a send tool that can address it`;
13748
+ const plainSpeechRule = `your plain speech lands where the latest word on where it lands says: ` +
13749
+ `a [${NOTICE_TAG.routing}] notice, or channel_open's result. If that is ${this.channelShown(channel)}, ` +
13750
+ `write your reply with no prefix;`;
13486
13751
  const replyOption = inboundOnlyServer
13487
13752
  ? `1. Replying there isn't available: ${inboundOnlyServer} declared this channel inbound-only, so nothing ` +
13488
13753
  `you write is posted into it. To speak there, use ${inboundOnlyServer}'s own tools.\n`
13489
- : target
13490
- ? replyOpens
13491
- ? `1. Reply — write your reply this turn prefixed with ">>${target}"; it will be delivered there, which opens ` +
13492
- `the channel.${withoutOpening}\n`
13493
- : `1. Reply — write your reply this turn prefixed with ">>${target}"; it will be delivered there, and the ` +
13494
- `channel stays closed.\n`
13495
- : `1. Reply — no single-word target reaches this channel unambiguously, ` +
13496
- `so a ">>" prefix can't name it; check whether ${opts.serverId} exposes a send tool that can address it.` +
13497
- (replyOpens
13498
- ? ` Replying there opens the channel.${withoutOpening}\n`
13499
- : ` Replying there leaves the channel closed.\n`);
13754
+ : mode === 'disabled'
13755
+ ? `1. Replying in plain text isn't available: your plain text is never published. To speak there, ${sendTool}.\n`
13756
+ : mode === 'locus' && tunedOut
13757
+ ? `1. Reply — ${plainSpeechRule} it will be delivered there. If that is another channel, or you see none, to reply here, ` +
13758
+ `${sendTool}.\n`
13759
+ : mode === 'locus'
13760
+ ? `1. Reply — ${plainSpeechRule} ${delivered} If that is another channel, or you see none, to reply here, ` +
13761
+ `${sendTool}, or open it (option 2), whose result says where your plain speech lands.\n`
13762
+ : target
13763
+ ? `1. Reply — write your reply this turn prefixed with "${prefix}${target}"; ${delivered}\n`
13764
+ : `1. Reply — no single-word target reaches this channel unambiguously, ` +
13765
+ `so a "${prefix}" prefix can't name it; ${sendTool}.` +
13766
+ (replyOpens
13767
+ ? ` Replying there opens the channel.${withoutOpening}\n`
13768
+ : ` Replying there leaves the channel closed.\n`);
13500
13769
  const missedCount = opts.missedMessages ?? 0;
13501
13770
  const missedNote = missedCount > 0
13502
13771
  ? `While closed, ${missedCount} message${missedCount === 1 ? '' : 's'}` +
@@ -13505,18 +13774,18 @@ export class AgentFramework {
13505
13774
  : '';
13506
13775
  const block = {
13507
13776
  type: 'text',
13508
- text: `\n[Channel invitation] ${opts.authorName} addressed you in ${place} — ` +
13777
+ text: `\n[${NOTICE_TAG.channelInvitation}] ${authorName} addressed you in ${place} — ` +
13509
13778
  `a channel you haven't opened. Messages there that address you, such as an @-mention, ` +
13510
13779
  `reach you whether or not you open it. ` +
13511
13780
  missedNote +
13512
13781
  `Your options:\n` +
13513
13782
  replyOption +
13514
- `2. Open the channel — call channel_open with channelId "${opts.channelId}" and serverId "${opts.serverId}"` +
13783
+ `2. Open the channel — call channel_open with channelId ${channelId} and serverId "${opts.serverId}"` +
13515
13784
  (maxBackscroll > 0
13516
- ? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId "${opts.messageId}".\n`
13785
+ ? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId ${messageId}.\n`
13517
13786
  : `.\n`) +
13518
- `3. Stay out — call channel_decline with channelId "${opts.channelId}", serverId "${opts.serverId}", ` +
13519
- `and messageId "${opts.messageId}" (optionally set acknowledge to an emoji like 👀 so ${opts.authorName} isn't left hanging).\n` +
13787
+ `3. Stay out — call channel_decline with channelId ${channelId}, serverId "${opts.serverId}", ` +
13788
+ `and messageId ${messageId} (optionally set acknowledge to an emoji like 👀 so ${authorName} isn't left hanging).\n` +
13520
13789
  `Doing nothing is also fine.`,
13521
13790
  };
13522
13791
  return {
@@ -14132,7 +14401,7 @@ export class AgentFramework {
14132
14401
  messageId: identity.messageId ?? str(origin.messageId) ?? occ.eventId,
14133
14402
  threadId: identity.threadId ?? str(origin.threadId),
14134
14403
  author: identity.author ?? { id: str(origin.authorId) ?? '', name: str(origin.authorName) ?? str(origin.authorId) ?? 'Someone' },
14135
- content: materialized ? materialized.map((block) => convertBlock(block, 'attachment on push event', occ.serverId)) : push.content,
14404
+ content: materialized ? convertBlocks(materialized, 'attachment on push event', occ.serverId) : push.content,
14136
14405
  timestamp: occ.timestamp,
14137
14406
  metadata: origin,
14138
14407
  tags: push.tags,
@@ -14153,7 +14422,7 @@ export class AgentFramework {
14153
14422
  ...occ.event.event,
14154
14423
  eventId: occ.eventId,
14155
14424
  timestamp: occ.timestamp,
14156
- ...(materialized ? { content: materialized.map((block) => convertBlock(block, 'attachment on push event', occ.serverId)) } : {}),
14425
+ ...(materialized ? { content: convertBlocks(materialized, 'attachment on push event', occ.serverId) } : {}),
14157
14426
  coalescingSubject,
14158
14427
  // §5.2: the materialized occurrence is consumed by the request being
14159
14428
  // assembled; the wake that started it is not repeated.
@@ -14290,21 +14559,13 @@ export class AgentFramework {
14290
14559
  // Every agent that can read this message must not have compiled past it.
14291
14560
  // Residents share one message slot, so a message one of them read is
14292
14561
  // history for all of them (the never-rewrite rule is about the message,
14293
- // not the agent that happened to be asked). A tune-out-diverted message
14294
- // never enters a resident's compiled view; only the subconscious reads
14295
- // it through its merged view — no subconscious, nobody reads it.
14296
- let readers;
14297
- if (message.metadata?.tuneOut) {
14298
- const subconscious = this.subconsciousAgentName ? this.agents.get(this.subconsciousAgentName) : undefined;
14299
- if (!subconscious)
14300
- return true;
14301
- readers = [subconscious];
14302
- }
14303
- else {
14304
- readers = this.sharedSlotAgents.has(agent)
14305
- ? [...this.sharedSlotAgents].filter((a) => this.agents.get(a.name) === a)
14306
- : [agent];
14307
- }
14562
+ // not the agent that happened to be asked). A reader is one whose view
14563
+ // filter keeps the message: a tune-out-diverted message never enters a
14564
+ // resident's compiled view, and only the subconscious reads it through
14565
+ // its merged view — no subconscious, nobody reads it.
14566
+ const readers = (this.sharedSlotAgents.has(agent)
14567
+ ? [...this.sharedSlotAgents].filter((a) => this.agents.get(a.name) === a)
14568
+ : [agent]).filter((a) => this.agentSees(a, message.metadata));
14308
14569
  const branch = cm.currentBranch().name;
14309
14570
  const ts = message.timestamp instanceof Date ? message.timestamp.getTime() : Number(message.timestamp);
14310
14571
  for (const reader of readers) {
@@ -14442,6 +14703,7 @@ export class AgentFramework {
14442
14703
  tags: event.tags,
14443
14704
  missedMessages: typeof origin.missedMessages === 'number' ? origin.missedMessages : undefined,
14444
14705
  missedCharacters: typeof origin.missedCharacters === 'number' ? origin.missedCharacters : undefined,
14706
+ targetAgents: event.targetAgents,
14445
14707
  });
14446
14708
  if (invitation) {
14447
14709
  content.push(invitation.block);
@@ -14661,7 +14923,8 @@ export class AgentFramework {
14661
14923
  // A provider wait binds its own model; with a different primary model
14662
14924
  // selected now, the turn proceeds (the wait stays recorded).
14663
14925
  const otherModel = providerCooldown.waitModel !== undefined && providerCooldown.waitModel !== agent.model;
14664
- if (!otherModel && (now < providerCooldown.until || this.extendHostProviderHold(agentName, providerCooldown))) {
14926
+ if (!otherModel && !providerCooldown.rearm
14927
+ && (now < providerCooldown.until || this.extendHostProviderHold(agentName, providerCooldown))) {
14665
14928
  for (const req of requests)
14666
14929
  this.holdRequest(providerCooldown.heldRequests, req);
14667
14930
  const recovery = this.providerAccelerationRecoveries.get(agentName);
@@ -14674,6 +14937,10 @@ export class AgentFramework {
14674
14937
  // the held causes into this one current compile.
14675
14938
  clearTimeout(providerCooldown.timer);
14676
14939
  this.providerAccelerationCooldowns.delete(agentName);
14940
+ if (providerCooldown.rearm) {
14941
+ console.error(`[inference-rearm] agent=${agentName} turn queued again with a wake ` +
14942
+ `(${providerCooldown.rearm.number} of ${providerCooldown.rearm.of})`);
14943
+ }
14677
14944
  const recovery = this.providerAccelerationRecoveries.get(agentName);
14678
14945
  if (recovery)
14679
14946
  recovery.releasedAt = now;
@@ -14754,7 +15021,10 @@ export class AgentFramework {
14754
15021
  // and a wake for a deferred write or a stored message is dropped
14755
15022
  // once the agent is done with that write: neither may absorb an
14756
15023
  // unrelated wake that would vanish with it.
14757
- const key = `${request.reason}|${request.addressed ? 'a' : 'n'}|${request.coalescingSubject ?? ''}|${request.readsDeferredWrite ?? ''}|${request.readsStoredMessage ?? ''}`;
15024
+ // A heartbeat's wake keeps its own key, so a later channel-less wake
15025
+ // can't absorb it: resumed together, they make a heartbeat's batch
15026
+ // (heartbeatTurn), as they would have without the quiesce.
15027
+ const key = `${request.reason}|${request.addressed ? 'a' : 'n'}|${isHeartbeatWake(request) ? 'h' : ''}|${request.coalescingSubject ?? ''}|${request.readsDeferredWrite ?? ''}|${request.readsStoredMessage ?? ''}`;
14758
15028
  const prev = newestByKey.get(key);
14759
15029
  const kept = !prev || request.timestamp >= prev.timestamp ? request : prev;
14760
15030
  // The wake it absorbs hands on its lines (WakeLine): each event's
@@ -14955,13 +15225,14 @@ export class AgentFramework {
14955
15225
  // an open channel — legacy last-inbound semantics).
14956
15226
  // A chat:system notice (RFC-001 rev 3: weather in a world, someone
14957
15227
  // joining) names no channel at all: nobody spoke there, so a turn it
14958
- // wakes alone takes the global fallback, as a heartbeat does, and the
14959
- // agent sends there explicitly if it means to.
15228
+ // wakes alone takes the global fallback, and the agent sends there
15229
+ // explicitly if it means to.
14960
15230
  // Ambient chatter must not outrank an addressed message just by being
14961
15231
  // newest (2026-07-21 Cairn lounge misroute, turn-start variant).
14962
15232
  // Non-channel wakes (heartbeats, module events, reactions — which never
14963
15233
  // carry channelId) leave both undefined → global fallback, which an
14964
- // ephemeral run never takes (takesGlobalFallback).
15234
+ // ephemeral run never takes (takesGlobalFallback), except a heartbeat's
15235
+ // turn, which takes none (heartbeatTurn below).
14965
15236
  // Track the winning REQUEST, not just its channel: channel, addressed
14966
15237
  // and counterparty must come from the same message, or a batch of
14967
15238
  // "ambient from A, then addressed from B" would report B's channel
@@ -15042,6 +15313,9 @@ export class AgentFramework {
15042
15313
  wakeLines: mergeWakeLines(requests),
15043
15314
  suppressProse: silentOnly ? trigger?.suppressProse : undefined,
15044
15315
  silentHeartbeat: silentOnly ? trigger?.silentHeartbeat : undefined,
15316
+ // A heartbeat's turn when any cause is a heartbeat's wake and none
15317
+ // names a channel, whatever the batch's order (isHeartbeatWake).
15318
+ heartbeatTurn: !channelReq && requests.some((r) => isHeartbeatWake(r)),
15045
15319
  // The channel and its server from the one request, so the turn's
15046
15320
  // channel is that message's, never one server's id with another's.
15047
15321
  channelId: channelReq?.channelId,
@@ -15070,6 +15344,10 @@ export class AgentFramework {
15070
15344
  start[TURN_CARRY] = carry;
15071
15345
  else
15072
15346
  delete start[TURN_CARRY];
15347
+ if (requests.every(isRearmed))
15348
+ start[REARMED] = true;
15349
+ else
15350
+ delete start[REARMED];
15073
15351
  // A start that carries a turn on and took in wakes that don't records
15074
15352
  // the batch it was merged from (MERGED_BATCH): until it takes its
15075
15353
  // token it stands for them all.
@@ -15291,7 +15569,7 @@ export class AgentFramework {
15291
15569
  if (hold.kind === 'place') {
15292
15570
  const { place } = hold;
15293
15571
  const where = this.channelShown(place.to);
15294
- return `your ${place.tool} ${said(place.round)} ${place.delivered ? 'delivered' : 'may have posted'} to ${where}, ` +
15572
+ return `your ${suppliedValue(place.tool)} ${said(place.round)} ${place.delivered ? 'delivered' : 'may have posted'} to ${where}, ` +
15295
15573
  `where this was going${lasts(place.round, true)}`;
15296
15574
  }
15297
15575
  const why = hold.why === 'error'
@@ -15304,7 +15582,7 @@ export class AgentFramework {
15304
15582
  ? "it hadn't answered when this speech was routed"
15305
15583
  : "it hadn't answered by the end of its round";
15306
15584
  const going = target ? `, and this was going to ${this.channelShown(target)}` : '';
15307
- return `the framework can't tell ${hold.why === 'error' || hold.why === 'no-answer' ? 'whether or where' : 'where'} your ${hold.tool} ${said(hold.round)} posted (${why})${going}${lasts(hold.round, false)}`;
15585
+ return `the framework can't tell ${hold.why === 'error' || hold.why === 'no-answer' ? 'whether or where' : 'where'} your ${suppliedValue(hold.tool)} ${said(hold.round)} posted (${why})${going}${lasts(hold.round, false)}`;
15308
15586
  }
15309
15587
  /**
15310
15588
  * Hold plain speech as drafts (src/prose-drafts.ts) instead of
@@ -15378,7 +15656,7 @@ export class AgentFramework {
15378
15656
  const r = registry.resolveProseTarget(name, { forSpeech: true, own });
15379
15657
  return 'channelId' in r && sameChannel(r, target) ? name : undefined;
15380
15658
  };
15381
- const destination = JSON.stringify((target && (resent(registry?.proseTargetFor(target)) ?? resent(qualifiedChannelName(target)))) ?? '#channel');
15659
+ const destination = quotedValue((target && (resent(registry?.proseTargetFor(target)) ?? resent(qualifiedChannelName(target)))) ?? NO_CHANNEL_EXAMPLE);
15382
15660
  // Words copied from a draft that may already have been posted resend
15383
15661
  // only with the resident's confirmation; the rest resend freely.
15384
15662
  const free = held.filter((d) => draftState(d) !== 'unconfirmed');
@@ -15393,7 +15671,7 @@ export class AgentFramework {
15393
15671
  ];
15394
15672
  // Named: residents that share one message slot (#197) share this window.
15395
15673
  // "Not sent" only when no copied words may already have been posted.
15396
- return (`[drafts] ${agentName}: ${risky.length > 0 ? '' : 'not sent — '}` +
15674
+ return (`[${NOTICE_TAG.drafts}] ${agentName}: ${risky.length > 0 ? '' : 'not sent — '}` +
15397
15675
  `${many ? `${held.length} plain-speech segments` : 'a plain-speech segment'} ` +
15398
15676
  `held as draft${many ? 's' : ''} ${ids.join(', ')} — ${because ? `because ${because}` : AgentFramework.DRAFT_REASON_TEXT[reason]}. ` +
15399
15677
  `${resend.join('; ')}; drafts(action: "dismiss", draftIds: [${quoted(held)}]) sets ${many ? 'them' : 'it'} aside.`);
@@ -15410,8 +15688,9 @@ export class AgentFramework {
15410
15688
  /** A destination as the agent is shown it: its label, with its name as
15411
15689
  * ChannelRegistry.nameShown gives it. */
15412
15690
  destinationText(d) {
15413
- const label = d.label && d.label !== d.channelId ? `${d.label.startsWith('#') || d.label.startsWith('DM') ? d.label : `#${d.label}`} ` : '';
15414
- return `${label}(${this.channelRegistry?.nameShown(d) ?? d.channelId})`;
15691
+ const shown = d.label && d.label !== d.channelId ? suppliedValue(d.label) : '';
15692
+ const label = shown ? `${shown.startsWith('#') || shown.startsWith('DM') ? '' : '#'}${shown} ` : '';
15693
+ return `${label}(${this.channelRegistry?.nameShown(d) ?? suppliedValue(d.channelId)})`;
15415
15694
  }
15416
15695
  /** One line per draft: id, state, when, why, size, preview. */
15417
15696
  draftLine(draft) {
@@ -15441,7 +15720,7 @@ export class AgentFramework {
15441
15720
  const risky = uncertainAttempt(draft);
15442
15721
  if (risky) {
15443
15722
  return `${draft.id}'s attempt to ${this.destinationText(risky.destination)} at ` +
15444
- `${this.draftTime(risky.at)} may already have been posted: ${risky.outcome?.reason ?? this.noOutcomeText(draft, risky)}`;
15723
+ `${this.draftTime(risky.at)} may already have been posted: ${risky.outcome?.reason !== undefined ? suppliedValue(risky.outcome.reason) : this.noOutcomeText(draft, risky)}`;
15445
15724
  }
15446
15725
  const inherited = draft.inheritedRisk;
15447
15726
  const owner = inherited.sourceDraftId ?? inherited.draftId;
@@ -15450,7 +15729,7 @@ export class AgentFramework {
15450
15729
  ? `${owner}'s attempt to ${this.destinationText(inherited.destination)}` +
15451
15730
  (inherited.at !== undefined ? ` at ${this.draftTime(inherited.at)}` : '')
15452
15731
  : owner;
15453
- return `${draft.id} includes the words of ${inherited.draftId}${via}, and ${where} may already have been posted: ${inherited.reason}`;
15732
+ return `${draft.id} includes the words of ${inherited.draftId}${via}, and ${where} may already have been posted: ${suppliedValue(inherited.reason)}`;
15454
15733
  }
15455
15734
  /** Whether an attempt with no outcome is still out: its draft's latest,
15456
15735
  * while that draft is being sent. Otherwise it never got one (a crash). */
@@ -15469,7 +15748,7 @@ export class AgentFramework {
15469
15748
  return `${draft.id}: not delivered`;
15470
15749
  return `${draft.id}: delivered — confirmed at ${this.draftTime(delivered.outcome.at)} to ` +
15471
15750
  `${this.destinationText(delivered.destination)}` +
15472
- (delivered.outcome.messageId ? `, message ${delivered.outcome.messageId}` : '') +
15751
+ (delivered.outcome.messageId ? `, message ${nameToTypeBack(delivered.outcome.messageId)}` : '') +
15473
15752
  ' (a record of that delivery, not a check that the message is still there)';
15474
15753
  }
15475
15754
  /**
@@ -15514,7 +15793,7 @@ export class AgentFramework {
15514
15793
  const shown = pending.slice(0, 8).map((d) => `${d.id} (${this.draftStateText(d)}; ${AgentFramework.DRAFT_REASON_SHORT[d.reason]})`);
15515
15794
  const rest = pending.slice(shown.length).map((d) => d.id);
15516
15795
  const more = rest.length > 0 ? ` · and ${rest.length} more: ${rest.join(', ')}` : '';
15517
- const text = `[drafts] ${agent.name}: ${pending.length === 1 ? 'an open draft' : `${pending.length} open drafts`} of yours ` +
15796
+ const text = `[${NOTICE_TAG.drafts}] ${agent.name}: ${pending.length === 1 ? 'an open draft' : `${pending.length} open drafts`} of yours ` +
15518
15797
  `that no recent notice on this branch names: ${shown.join(' · ')}${more}. ` +
15519
15798
  'drafts(action: "list") shows your open drafts; resend or dismiss them by id.';
15520
15799
  let mid;
@@ -15751,17 +16030,17 @@ export class AgentFramework {
15751
16030
  const blocks = drafts.map((d) => {
15752
16031
  const lines = [
15753
16032
  `${d.id} — ${draftState(d)}; held ${this.draftTime(d.heldAt)} (${AgentFramework.DRAFT_REASON_TEXT[d.reason]})` +
15754
- (d.note ? `; ${d.note}` : ''),
16033
+ (d.note ? `; ${suppliedValue(d.note)}` : ''),
15755
16034
  ...(d.inheritedRisk && draftState(d) === 'unconfirmed' && !uncertainAttempt(d) ? [` ${this.riskText(d)}`] : []),
15756
16035
  ...d.attempts.map((a) => {
15757
16036
  const o = a.outcome;
15758
16037
  const status = !o
15759
16038
  ? (this.attemptIsOut(d, a) ? 'being sent right now — no outcome yet' : 'outcome never recorded — it may have been posted')
15760
16039
  : o.status === 'delivered'
15761
- ? `delivered${o.messageId ? `, message ${o.messageId}` : ''}`
16040
+ ? `delivered${o.messageId ? `, message ${nameToTypeBack(o.messageId)}` : ''}`
15762
16041
  : o.status === 'unknown'
15763
- ? `UNKNOWN — may have been posted: ${o.reason ?? 'no valid receipt'}`
15764
- : `failed, nothing posted: ${o.reason ?? 'refused'}`;
16042
+ ? `UNKNOWN — may have been posted: ${o.reason !== undefined ? suppliedValue(o.reason) : 'no valid receipt'}`
16043
+ : `failed, nothing posted: ${o.reason !== undefined ? suppliedValue(o.reason) : 'refused'}`;
15765
16044
  return ` attempt ${this.draftTime(a.at)} → ${this.destinationText(a.destination)}: ${status}` +
15766
16045
  (o?.status === 'unknown' && o.detail !== undefined ? `; connector detail: ${connectorText(o.detail)}` : '') +
15767
16046
  (a.confirmedDuplicate ? ' (resent knowing it might duplicate)' : '');
@@ -15854,8 +16133,10 @@ export class AgentFramework {
15854
16133
  // its turn a name the turn's own channel answers to means that channel.
15855
16134
  const resolved = registry.resolveProseTarget(spec, { forSpeech: true, own });
15856
16135
  if ('error' in resolved) {
15857
- const cand = resolved.candidates?.length ? ` It could mean: ${resolved.candidates.join(', ')}.` : '';
15858
- return refuse(`Destination "${spec}" did not resolve: ${resolved.error}.${cand} Nothing was sent.`);
16136
+ const cand = resolved.candidates?.length
16137
+ ? ` It could mean: ${resolved.candidates.join(', ')}.${registry.publishRouteFor(resolved.candidates) ?? ''}`
16138
+ : '';
16139
+ return refuse(`Destination "${spec}" did not resolve: ${suppliedValue(resolved.error)}.${cand} Nothing was sent.`);
15859
16140
  }
15860
16141
  named = resolved;
15861
16142
  }
@@ -15863,9 +16144,9 @@ export class AgentFramework {
15863
16144
  named = home;
15864
16145
  }
15865
16146
  const target = registry.resolveDestination({ serverId: named.serverId, channelId: named.channelId });
15866
- // Channels named as the agent names them back (nameShown).
16147
+ // Channels named as the agent names them back (quotedShown).
15867
16148
  if ('error' in target)
15868
- return refuse(`Destination "${spec || registry.nameShown(home)}": ${target.error}. Nothing was sent.`);
16149
+ return refuse(`Destination ${spec ? `"${spec}"` : registry.quotedShown(home)}: ${suppliedValue(target.error)}. Nothing was sent.`);
15869
16150
  const destination = target.destination;
15870
16151
  if (home && !sameChannel(destination, home)) {
15871
16152
  return refuse(`This conversation is bound to channel ${registry.quotedShown(home)}; resending to ${registry.quotedShown(destination)} ` +
@@ -15954,7 +16235,7 @@ export class AgentFramework {
15954
16235
  // it, so say what the draft reads as now rather than guess.
15955
16236
  const unrecorded = recorded ? '' : ` (recording this outcome failed; the draft reads as ${this.draftStateNow(agentName, d.id)} now)`;
15956
16237
  if (outcome.status === 'delivered') {
15957
- lines.push(`${d.id}: delivered to ${where}${outcome.messageId ? `, message ${outcome.messageId}` : ''}${unrecorded}.`);
16238
+ lines.push(`${d.id}: delivered to ${where}${outcome.messageId ? `, message ${nameToTypeBack(outcome.messageId)}` : ''}${unrecorded}.`);
15958
16239
  sendingTurn?.engaged.add(destinationKey(destination));
15959
16240
  resender?.notePublished(destinationKey(destination), readThrough);
15960
16241
  continue;
@@ -15965,9 +16246,9 @@ export class AgentFramework {
15965
16246
  // The connector's data may say what a multi-part send already posted.
15966
16247
  const detail = outcome.status === 'unknown' && outcome.detail !== undefined ? ` Connector detail: ${connectorText(outcome.detail)}` : '';
15967
16248
  lines.push((outcome.status === 'unknown'
15968
- ? `${d.id}: delivery to ${where} NOT confirmed — it may or may not have been posted: ${outcome.reason ?? 'no valid receipt'}. ` +
16249
+ ? `${d.id}: delivery to ${where} NOT confirmed — it may or may not have been posted: ${outcome.reason !== undefined ? suppliedValue(outcome.reason) : 'no valid receipt'}. ` +
15969
16250
  `Sending it again could post it twice, so a resend needs confirmDuplicate: true${unrecorded}.`
15970
- : `${d.id}: not sent to ${where} — ${outcome.reason ?? 'refused'}. Nothing was posted by this attempt${unrecorded}.` +
16251
+ : `${d.id}: not sent to ${where} — ${outcome.reason !== undefined ? suppliedValue(outcome.reason) : 'refused'}. Nothing was posted by this attempt${unrecorded}.` +
15971
16252
  (stillAtRisk
15972
16253
  ? ` The draft stays unconfirmed: ${this.riskText(after)}.`
15973
16254
  : ' The draft stays held.')) + detail);
@@ -15993,6 +16274,37 @@ export class AgentFramework {
15993
16274
  }
15994
16275
  return undefined;
15995
16276
  }
16277
+ /** Why a heartbeat's turn holds its plain speech, in a held-draft notice ("because …"). */
16278
+ static HEARTBEAT_HOLD_BECAUSE = 'this turn came from your heartbeat, not from a channel, so its plain speech has no destination ' +
16279
+ 'and is kept for you to send where you choose';
16280
+ /**
16281
+ * Locus mode's delivery of plain speech to the turn's frozen locus. A
16282
+ * heartbeat's turn with no locus of its own (TurnRouting.heartbeat) holds
16283
+ * the words as a draft instead of failing the delivery: nobody was waiting
16284
+ * for them, and the agent may send them where it chooses.
16285
+ */
16286
+ async routeLocusSpeech(agent, round, text, locus) {
16287
+ if (locus === null && round.routing.heartbeat) {
16288
+ this.holdHeartbeatProse(agent, [text], round);
16289
+ return null;
16290
+ }
16291
+ return this.channelRegistry.routeSpeech(agent.name, text, locus);
16292
+ }
16293
+ /** The mark a framework wake takes from a heartbeat's turn with no locus
16294
+ * of its own (InferenceRequest.heartbeatTurn), so the turn it opens is a
16295
+ * heartbeat's too; nothing for any other turn. */
16296
+ heartbeatTurnMark(routing) {
16297
+ return routing.heartbeat && !routing.locus ? { heartbeatTurn: true } : {};
16298
+ }
16299
+ /** Hold a heartbeat turn's plain speech as drafts ('no-destination'), named by `round`'s notice. */
16300
+ holdHeartbeatProse(agent, texts, round) {
16301
+ console.error(`[prose] ${agent.name}: heartbeat turn — ${texts.length} plain-speech segment(s) held as drafts (no destination)`);
16302
+ return this.holdProseDrafts(agent, texts, 'no-destination', {
16303
+ routing: round.routing,
16304
+ notice: round.draftsRound ? 'later' : round.notice,
16305
+ because: AgentFramework.HEARTBEAT_HOLD_BECAUSE,
16306
+ });
16307
+ }
15996
16308
  /** Record a successful plain-prose delivery for this turn's receipt. */
15997
16309
  recordProseDelivery(routing, outcome) {
15998
16310
  if (!outcome?.delivered)
@@ -16120,10 +16432,10 @@ export class AgentFramework {
16120
16432
  // reports itself in its tool result. Disabled mode holds no drafts, so
16121
16433
  // it is never uncertain.
16122
16434
  const text = shown.length > 0
16123
- ? `[delivered] plain speech → ${shown.join(' · ')}${suppressedNote ? ` · ${suppressedNote}` : ''}${closedNote}`
16435
+ ? `[${NOTICE_TAG.delivered}] plain speech → ${shown.join(' · ')}${suppressedNote ? ` · ${suppressedNote}` : ''}${closedNote}`
16124
16436
  : agent.proseRouting === 'disabled'
16125
- ? `[delivered] plain speech → no channel — ${suppressedNote}`
16126
- : `[delivered] nothing${uncertain ? ' confirmed' : ''} — ${suppressedNote}`;
16437
+ ? `[${NOTICE_TAG.delivered}] plain speech → no channel — ${suppressedNote}`
16438
+ : `[${NOTICE_TAG.delivered}] nothing${uncertain ? ' confirmed' : ''} — ${suppressedNote}`;
16127
16439
  try {
16128
16440
  const mid = agent.getContextManager().addMessage('user', [{ type: 'text', text }], {
16129
16441
  system: true,
@@ -16142,7 +16454,7 @@ export class AgentFramework {
16142
16454
  try {
16143
16455
  agent.getContextManager().addMessage('user', [{
16144
16456
  type: 'text',
16145
- text: `[speech-not-posted] Nothing you wrote before your tool calls in the rounds just above was posted. ` +
16457
+ text: `[${NOTICE_TAG.speechNotPosted}] Nothing you wrote before your tool calls in the rounds just above was posted. ` +
16146
16458
  `If any of it should go out, send it again.`,
16147
16459
  }], { system: true, kind: 'speech-not-posted' });
16148
16460
  delete routing.unpostedSpeech;
@@ -16153,7 +16465,7 @@ export class AgentFramework {
16153
16465
  }
16154
16466
  noteFailedTurnEnd(agent, routing) {
16155
16467
  const callsCutOff = (routing.unflushedRounds ?? 0) > 0;
16156
- if (routing.roundsRan || callsCutOff) {
16468
+ if (gotPartway(routing)) {
16157
16469
  this.exhaustedTurns?.set(agent.name, {
16158
16470
  roundsRan: routing.roundsRan === true, callsCutOff, liveSpeech: routing.liveSpeech === true,
16159
16471
  speechRouting: (routing.speechRouting ?? 0) > 0,
@@ -16187,12 +16499,32 @@ export class AgentFramework {
16187
16499
  if (!target)
16188
16500
  return;
16189
16501
  try {
16190
- this.addMessage('user', [{ type: 'text', text: `[closed channel] ${notes.join(' ')}${this.closedChannelNoteOff(agent.name)}` }], { system: true, kind: 'delivery-receipt' }, target);
16502
+ this.addMessage('user', [{ type: 'text', text: `[${NOTICE_TAG.closedChannel}] ${notes.join(' ')}${this.closedChannelNoteOff(agent.name)}` }], { system: true, kind: 'delivery-receipt' }, target);
16191
16503
  }
16192
16504
  catch (err) {
16193
16505
  console.error('closed-channel note append failed:', err);
16194
16506
  }
16195
16507
  }
16508
+ /**
16509
+ * Tell a heartbeat's turn, before it compiles, that its plain speech has
16510
+ * no destination (TurnRouting.heartbeat): the last `[routing]` notice may
16511
+ * name a channel, and this turn's words won't go there. It leaves the
16512
+ * announced baseline alone (announceLocusIfChanged).
16513
+ */
16514
+ noticeHeartbeatTurn(agentName) {
16515
+ const agent = this.agents.get(agentName);
16516
+ if (!agent)
16517
+ return;
16518
+ const text = `[${NOTICE_TAG.routing}] This turn came from your heartbeat, not from a channel, so plain speech here isn't posted: ` +
16519
+ 'it\'s held as a draft you can send with the drafts tool. An explicit send reaches a channel now.';
16520
+ try {
16521
+ const id = agent.getContextManager().addMessage('user', [{ type: 'text', text }], { system: true, kind: 'routing-notice' });
16522
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
16523
+ }
16524
+ catch (err) {
16525
+ console.error('noticeHeartbeatTurn: failed to record the notice:', err);
16526
+ }
16527
+ }
16196
16528
  /**
16197
16529
  * Announce-on-change locus notice (turn-frozen routing). Appends a durable
16198
16530
  * `[routing]` window message when the turn's effective outbound locus
@@ -16217,16 +16549,16 @@ export class AgentFramework {
16217
16549
  let text;
16218
16550
  if (locus === null) {
16219
16551
  text =
16220
- '[routing] Your plain speech currently has no channel — it stays in ' +
16552
+ `[${NOTICE_TAG.routing}] Your plain speech currently has no channel — it stays in ` +
16221
16553
  'your archive. Use an explicit send tool to reach a channel.';
16222
16554
  }
16223
16555
  else {
16224
16556
  const shown = this.channelShown(locus);
16225
16557
  const inboundOnlyServer = this.channelRegistry?.inboundOnlyServer(locus);
16226
16558
  text = inboundOnlyServer
16227
- ? `[routing] The conversation is now in ${shown}. ${inboundOnlySpeechNote(inboundOnlyServer)} ` +
16559
+ ? `[${NOTICE_TAG.routing}] The conversation is now in ${shown}. ${inboundOnlySpeechNote(inboundOnlyServer)} ` +
16228
16560
  'Other channels need an explicit send tool.'
16229
- : `[routing] Your plain speech now lands in ${shown}. ` +
16561
+ : `[${NOTICE_TAG.routing}] Your plain speech now lands in ${shown}. ` +
16230
16562
  'Other channels need an explicit send tool.';
16231
16563
  }
16232
16564
  try {
@@ -16499,18 +16831,23 @@ export class AgentFramework {
16499
16831
  }
16500
16832
  return { newestRowAt, lastClockAt: undefined };
16501
16833
  }
16834
+ agentSees(agent, metadata) {
16835
+ const sees = this.storeSees.get(agent.getContextManager());
16836
+ return !sees || sees(metadata);
16837
+ }
16502
16838
  /**
16503
16839
  * Weigh a message that no tool boundary of the agent's open logical turn
16504
16840
  * will weigh: one that would re-pin the locus becomes one of the turn's
16505
- * arrivals (TurnRouting.arrivals). A message tune-out diverted
16506
- * (metadata.tuneOut) never enters a resident's compiled view, so the agent
16507
- * won't see it, and it moves nothing.
16841
+ * arrivals (TurnRouting.arrivals). A message the agent's own view leaves
16842
+ * out, such as one a tune-out diverted (metadata.tuneOut), never enters
16843
+ * its compiled view, so the agent won't see it, and it moves nothing.
16508
16844
  */
16509
16845
  noteArrival(agentName, metadata, messageId) {
16510
16846
  const open = this.openLogical.get(agentName);
16511
16847
  if (!open)
16512
16848
  return;
16513
- if (metadata?.tuneOut)
16849
+ const agent = this.agents.get(agentName);
16850
+ if (agent && !this.agentSees(agent, metadata))
16514
16851
  return;
16515
16852
  if (isConversationalInjection(metadata))
16516
16853
  (open.heard ??= new Map()).set(messageId, ++lastHeardAt);
@@ -16538,6 +16875,7 @@ export class AgentFramework {
16538
16875
  return;
16539
16876
  for (const reader of this.sharedSlotAgents) {
16540
16877
  if (reader !== agent && this.agents.get(reader.name) === reader && this.isOrdinaryResident(reader)) {
16878
+ this.noteSpokenTo(reader.name, metadata);
16541
16879
  this.noteArrival(reader.name, metadata, messageId);
16542
16880
  }
16543
16881
  }
@@ -16604,9 +16942,9 @@ export class AgentFramework {
16604
16942
  const content = [{
16605
16943
  type: 'text',
16606
16944
  text: inboundOnlyServer
16607
- ? `[routing] The conversation moved to ${shown}. ${inboundOnlySpeechNote(inboundOnlyServer)} ` +
16945
+ ? `[${NOTICE_TAG.routing}] The conversation moved to ${shown}. ${inboundOnlySpeechNote(inboundOnlyServer)} ` +
16608
16946
  'Other channels need an explicit send tool.'
16609
- : `[routing] The conversation moved to ${shown} — your plain ` +
16947
+ : `[${NOTICE_TAG.routing}] The conversation moved to ${shown} — your plain ` +
16610
16948
  'speech now lands there for the rest of this turn. Other ' +
16611
16949
  'channels need an explicit send tool.',
16612
16950
  }];
@@ -16742,7 +17080,7 @@ export class AgentFramework {
16742
17080
  }
16743
17081
  const sender = this.agentForPrimary(agentName);
16744
17082
  return [{
16745
- text: `[channels] Now open: ${shown}: a message from ${sender.first} into the closed channel opened it; ${sender.again} has ended.` +
17083
+ text: `[${NOTICE_TAG.channels}] Now open: ${shown}: a message from ${sender.first} into the closed channel opened it; ${sender.again} has ended.` +
16746
17084
  optOut(this.holdsChannelTraffic(primary, refs), this.canCloseChannels(primary, refs)),
16747
17085
  metadata: { system: true, kind: 'channel-notice', speaker: agentName },
16748
17086
  told: `the primary, by name (${agentName} has ended)`,
@@ -16762,13 +17100,13 @@ export class AgentFramework {
16762
17100
  const tellPrimary = !!agentName && !canClose && primary !== undefined && this.agents.has(primary) &&
16763
17101
  this.canCloseChannels(primary, refs);
16764
17102
  const readerText = (told) => (cause === 'subscription-policy'
16765
- ? `[channels] Selected for opening by subscription policy: ${shown}. ` +
17103
+ ? `[${NOTICE_TAG.channels}] Selected for opening by subscription policy: ${shown}. ` +
16766
17104
  (plural
16767
17105
  ? "The host opens them, and if one doesn't open, tries again when its server next registers it; " +
16768
17106
  'channel_list shows whether each is open yet.'
16769
17107
  : 'The host opens it, and if that fails, tries again when its server next registers it; ' +
16770
17108
  "channel_list shows whether it's open yet.")
16771
- : `[channels] Now open: ${shown}: your own message into the closed channel opened it.`) +
17109
+ : `[${NOTICE_TAG.channels}] Now open: ${shown}: your own message into the closed channel opened it.`) +
16772
17110
  optOut(this.holdsChannelTraffic(who, refs), canClose) + told;
16773
17111
  const reader = {
16774
17112
  text: readerText(tellPrimary ? ` ${this.primaryForReader(agentName, primary)} has been told and can close ${plural ? 'them' : 'it'}.` : ''),
@@ -16785,7 +17123,7 @@ export class AgentFramework {
16785
17123
  if (tellPrimary) {
16786
17124
  const sender = this.agentForPrimary(agentName);
16787
17125
  notices.push({
16788
- text: `[channels] Now open: ${shown}: a message from ${sender.first} into the closed channel opened it, and ${sender.again} can't close ${plural ? 'them' : 'it'}.` +
17126
+ text: `[${NOTICE_TAG.channels}] Now open: ${shown}: a message from ${sender.first} into the closed channel opened it, and ${sender.again} can't close ${plural ? 'them' : 'it'}.` +
16789
17127
  optOut(this.holdsChannelTraffic(primary, refs), true),
16790
17128
  metadata: { system: true, kind: 'channel-notice', speaker: agentName },
16791
17129
  told: `the primary, by name (${agentName} can't close ${plural ? 'them' : 'it'})`,
@@ -16967,7 +17305,7 @@ export class AgentFramework {
16967
17305
  primary !== undefined && this.agents.has(primary) &&
16968
17306
  this.canCloseChannels(primary, [{ serverId, channelId: channel.channelId }]);
16969
17307
  const sender = ended ? this.agentForPrimary(agentName) : undefined;
16970
- const readerText = (told) => `[channels] Not open: ${shown}. ` +
17308
+ const readerText = (told) => `[${NOTICE_TAG.channels}] Not open: ${shown}. ` +
16971
17309
  (ended
16972
17310
  ? `A reply from ${sender.first} went out there, but opening the channel failed; ${sender.again} has ended.`
16973
17311
  : 'Your reply there went out, but opening the channel failed.') +
@@ -16994,7 +17332,7 @@ export class AgentFramework {
16994
17332
  const primaryCanOpen = !this.conversationAgentHomes.has(primary) && this.canAgentUseTool(primary, 'channel_open');
16995
17333
  const reader = this.agentForPrimary(who);
16996
17334
  notices.push({
16997
- text: `[channels] Not open: ${shown}. A reply from ${reader.first} went out there, but opening the channel failed, and ${reader.again} can't close it.` +
17335
+ text: `[${NOTICE_TAG.channels}] Not open: ${shown}. A reply from ${reader.first} went out there, but opening the channel failed, and ${reader.again} can't close it.` +
16998
17336
  selected + ', and channel_close withdraws that.' +
16999
17337
  (primaryCanOpen && !hostRetries ? ' channel_open tries again.' : ''),
17000
17338
  metadata: { system: true, kind: 'channel-notice', speaker: who },
@@ -17034,6 +17372,7 @@ export class AgentFramework {
17034
17372
  reason: 'prose-continuation',
17035
17373
  source: 'framework',
17036
17374
  timestamp: Date.now(),
17375
+ ...this.heartbeatTurnMark(routing),
17037
17376
  });
17038
17377
  }
17039
17378
  /**
@@ -17102,7 +17441,7 @@ export class AgentFramework {
17102
17441
  }
17103
17442
  this.autoContinueStreaks.set(name, count);
17104
17443
  console.error(`[continue] ${name}: turn ended (${stopReason}) without skip_reply — continuation ${count} of ${config.maxConsecutive}` +
17105
- (routing.locus ? ` in ${this.channelRegistry?.nameShown(routing.locus) ?? routing.locus.channelId}` : ''));
17444
+ (routing.locus ? ` in ${this.channelRegistry?.nameShown(routing.locus) ?? suppliedValue(routing.locus.channelId)}` : ''));
17106
17445
  // The next turn speaks where this one did (locus and hybrid modes): the
17107
17446
  // locus's server and id, as the framework's own wakes name a channel. A
17108
17447
  // turn that had no channel (noChannel: its wake named one that resolves
@@ -17120,6 +17459,7 @@ export class AgentFramework {
17120
17459
  timestamp: Date.now(),
17121
17460
  ...channel,
17122
17461
  autoContinueRun: { stop, ...(opts.heldAfterSend ? { heldAfterSend: true } : {}) },
17462
+ ...this.heartbeatTurnMark(routing),
17123
17463
  });
17124
17464
  }
17125
17465
  /** How an autoContinue agent stops a run, as its `[continue]` row says it,
@@ -17140,11 +17480,12 @@ export class AgentFramework {
17140
17480
  * starts (startAgentStream), with the run's count as it stands and the
17141
17481
  * agent's cap. False when it couldn't be stored.
17142
17482
  */
17143
- writeAutoContinueNotice(agent, mark) {
17483
+ writeAutoContinueNotice(agent, mark, heartbeat = false) {
17144
17484
  const count = this.autoContinueStreaks.get(agent.name) ?? 1;
17145
17485
  const max = agent.autoContinue?.maxConsecutive ?? count;
17146
17486
  const text = autoContinueNotice({
17147
17487
  agentName: agent.name, stop: mark.stop, last: count >= max, heldAfterSend: mark.heldAfterSend,
17488
+ heartbeat,
17148
17489
  });
17149
17490
  const before = this.storedCountOf(agent);
17150
17491
  let id;
@@ -17341,7 +17682,7 @@ export class AgentFramework {
17341
17682
  const target = this.noticeTargetFor(agent.name, 'empty-{{unsent}}');
17342
17683
  try {
17343
17684
  if (target)
17344
- this.addMessage('user', [{ type: 'text', text: `[prose-routing] ${reason}, so nothing was sent. drafts(action: "list") shows your open drafts.` }], { system: true, kind: 'prose-bounce' }, target);
17685
+ this.addMessage('user', [{ type: 'text', text: `[${NOTICE_TAG.proseRouting}] ${reason}, so nothing was sent. drafts(action: "list") shows your open drafts.` }], { system: true, kind: 'prose-bounce' }, target);
17345
17686
  }
17346
17687
  catch (err) {
17347
17688
  console.error('[drafts] empty-unsent notice failed:', err);
@@ -17363,7 +17704,7 @@ export class AgentFramework {
17363
17704
  else if (draftState(draft) === 'unconfirmed') {
17364
17705
  why = this.riskText(draft);
17365
17706
  next = 'sending that draft again could post it twice. To send it anyway, use ' +
17366
- `drafts(action: "resend", draftIds: ["${draft.id}"], destination: "#channel", confirmDuplicate: true)`;
17707
+ `drafts(action: "resend", draftIds: ["${draft.id}"], destination: ${quotedValue(NO_CHANNEL_EXAMPLE)}, confirmDuplicate: true)`;
17367
17708
  }
17368
17709
  else {
17369
17710
  return { draft };
@@ -17381,7 +17722,7 @@ export class AgentFramework {
17381
17722
  ? ` Your message is held as draft ${held.id}, with {{unsent}} written out; since it includes ${draft.id}'s words, it ` +
17382
17723
  `can be resent only with confirmDuplicate: true${afterResult}.`
17383
17724
  : ' Your message could not be held as a draft; its words remain in your history.';
17384
- const text = `[prose-routing] {{unsent}} is draft ${draft.id}, ${why.startsWith('which') ? why : `and ${why}`}. ` +
17725
+ const text = `[${NOTICE_TAG.proseRouting}] {{unsent}} is draft ${draft.id}, ${why.startsWith('which') ? why : `and ${why}`}. ` +
17385
17726
  `Nothing was sent; ${next}.${kept}`;
17386
17727
  const target = this.noticeTargetFor(agent.name, 'refused-{{unsent}}');
17387
17728
  try {
@@ -17440,7 +17781,7 @@ export class AgentFramework {
17440
17781
  }
17441
17782
  catch (err) {
17442
17783
  console.error(`[drafts] ${agent.name}: outcome of ${unsent.id} not recorded:`, err);
17443
- const text = `[prose-routing] {{unsent}} was draft ${unsent.id}; its send came back ${outcome.status}, but recording that ` +
17784
+ const text = `[${NOTICE_TAG.proseRouting}] {{unsent}} was draft ${unsent.id}; its send came back ${outcome.status}, but recording that ` +
17444
17785
  `failed, so the draft reads as ${this.draftStateNow(agent.name, unsent.id)} now. drafts(action: "read") shows it.`;
17445
17786
  const target = this.noticeTargetFor(agent.name, 'unrecorded-outcome');
17446
17787
  try {
@@ -17523,14 +17864,14 @@ export class AgentFramework {
17523
17864
  // not bounced, so its words are not re-held as a fresh draft that
17524
17865
  // invites a duplicate.
17525
17866
  if (outcome.status === 'failed') {
17526
- this.bounceProse(agent, parsed.body, `delivery to ${this.channelRegistry?.nameShown(resolved) ?? resolved.channelId} failed: ${outcome.reason ?? 'refused'}`, undefined, '>>>', round, { substituted: unsent.draft });
17867
+ this.bounceProse(agent, parsed.body, `delivery to ${this.channelRegistry?.nameShown(resolved) ?? suppliedValue(resolved.channelId)} failed: ${outcome.reason !== undefined ? suppliedValue(outcome.reason) : 'refused'}`, undefined, '>>>', round, { substituted: unsent.draft });
17527
17868
  }
17528
17869
  }
17529
17870
  }
17530
17871
  catch (err) {
17531
17872
  delete routing.pin;
17532
17873
  routing.hybridHold = 'failed';
17533
- this.bounceProse(agent, parsed.body, `sending to ${this.channelRegistry?.nameShown(resolved) ?? resolved.channelId} threw: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>>', round, { substituted: unsent.draft, outcome: 'unknown' });
17874
+ this.bounceProse(agent, parsed.body, `sending to ${this.channelRegistry?.nameShown(resolved) ?? suppliedValue(resolved.channelId)} threw: ${suppliedValue(err instanceof Error ? err.message : String(err))}`, undefined, '>>>', round, { substituted: unsent.draft, outcome: 'unknown' });
17534
17875
  }
17535
17876
  return;
17536
17877
  }
@@ -17549,6 +17890,10 @@ export class AgentFramework {
17549
17890
  this.holdProseDrafts(agent, [envelope], 'no-destination', round.draftsRound ? { ...round, notice: 'later' } : round);
17550
17891
  return;
17551
17892
  }
17893
+ if (!sticky && locus === null && routing.heartbeat) {
17894
+ this.holdHeartbeatProse(agent, [envelope], round);
17895
+ return;
17896
+ }
17552
17897
  const target = sticky ?? locus;
17553
17898
  try {
17554
17899
  const outcome = await this.channelRegistry.routeSpeech(agent.name, envelope, target);
@@ -17584,7 +17929,7 @@ export class AgentFramework {
17584
17929
  }
17585
17930
  };
17586
17931
  if (this.draftsSending.has(`${agent.name}\u0000${draft.id}`)) {
17587
- tell(`[prose-routing] {{unsent}} is draft ${draft.id}, which is being sent right now. It was not set aside, ` +
17932
+ tell(`[${NOTICE_TAG.proseRouting}] {{unsent}} is draft ${draft.id}, which is being sent right now. It was not set aside, ` +
17588
17933
  'since that can\'t call back words already on their way; wait for that result.', 'in-flight-dismiss');
17589
17934
  return;
17590
17935
  }
@@ -17601,13 +17946,13 @@ export class AgentFramework {
17601
17946
  if (now === 'dismissed')
17602
17947
  this.emitTrace({ type: 'prose:drafts-dismissed', agentName: agent.name, draftIds: [draft.id] });
17603
17948
  const named = this.proseDrafts.latestBounce(agent.name)?.id === draft.id;
17604
- tell(`[prose-routing] Recording setting aside draft ${draft.id}, which {{unsent}} named, failed ` +
17949
+ tell(`[${NOTICE_TAG.proseRouting}] Recording setting aside draft ${draft.id}, which {{unsent}} named, failed ` +
17605
17950
  `(${err instanceof Error ? err.message : String(err)}): it reads as ${now} now, and {{unsent}} ` +
17606
17951
  `${named ? 'still names it' : 'names nothing'}.${now === 'dismissed' && risk ? ` Note that ${risk}.` : ''}`, 'failed-dismiss');
17607
17952
  return;
17608
17953
  }
17609
17954
  if (risk)
17610
- tell(`[prose-routing] Draft ${draft.id} is set aside. Note that ${risk}.`, 'at-risk-dismiss');
17955
+ tell(`[${NOTICE_TAG.proseRouting}] Draft ${draft.id} is set aside. Note that ${risk}.`, 'at-risk-dismiss');
17611
17956
  }
17612
17957
  async deliverProseEnvelope(agent, envelope, round) {
17613
17958
  const name = agent.name;
@@ -17669,12 +18014,12 @@ export class AgentFramework {
17669
18014
  if (!('draft' in words)) {
17670
18015
  const held = this.holdProseDrafts(agent, [{ text: words.text, ...(words.inheritedRisk ? { inheritedRisk: words.inheritedRisk } : {}) }], 'no-destination', { ...round, notice: 'later' });
17671
18016
  if (held.length === 0 && words.text.trim().length > 0) {
17672
- this.bounceProse(agent, rawText, 'no destination prefix, and this turn has no destination now', undefined, '>>', round);
18017
+ this.bounceProse(agent, rawText, 'no destination prefix, and this turn has no destination now', undefined, '>>', round, { unprefixed: true });
17673
18018
  }
17674
18019
  }
17675
18020
  return;
17676
18021
  }
17677
- this.bounceProse(agent, rawText, 'no destination prefix, and this turn has no destination now', undefined, '>>', round);
18022
+ this.bounceProse(agent, rawText, 'no destination prefix, and this turn has no destination now', undefined, '>>', round, { unprefixed: true });
17678
18023
  return;
17679
18024
  }
17680
18025
  targetChannel = sticky;
@@ -17706,11 +18051,11 @@ export class AgentFramework {
17706
18051
  else if (outcome.status === 'failed') {
17707
18052
  // Unknown deliveries are marker-only: offering a fresh draft would
17708
18053
  // invite a duplicate. Keep the authored body for the re-bounce rule.
17709
- this.bounceProse(agent, envelopeBody, `delivery to ${this.channelRegistry?.nameShown(targetChannel) ?? targetChannel.channelId} failed: ${outcome.reason ?? 'refused'}`, undefined, '>>', round, { substituted: unsent.draft });
18054
+ this.bounceProse(agent, envelopeBody, `delivery to ${this.channelRegistry?.nameShown(targetChannel) ?? suppliedValue(targetChannel.channelId)} failed: ${outcome.reason !== undefined ? suppliedValue(outcome.reason) : 'refused'}`, undefined, '>>', round, { substituted: unsent.draft });
17710
18055
  }
17711
18056
  }
17712
18057
  catch (err) {
17713
- this.bounceProse(agent, envelopeBody, `sending to ${this.channelRegistry?.nameShown(targetChannel) ?? targetChannel.channelId} threw: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>', round, { substituted: unsent.draft, outcome: 'unknown' });
18058
+ this.bounceProse(agent, envelopeBody, `sending to ${this.channelRegistry?.nameShown(targetChannel) ?? suppliedValue(targetChannel.channelId)} threw: ${suppliedValue(err instanceof Error ? err.message : String(err))}`, undefined, '>>', round, { substituted: unsent.draft, outcome: 'unknown' });
17714
18059
  }
17715
18060
  if (!delivered && this.channelRegistry.inboundOnlyServer(targetChannel) !== undefined) {
17716
18061
  // The channel is inbound-only by now: its server declared it after the
@@ -17801,7 +18146,7 @@ export class AgentFramework {
17801
18146
  // Words with no draft behind them, whose send threw, may have gone
17802
18147
  // out. Like an unknown outcome, that is marker-only: holding them as a
17803
18148
  // draft would invite a duplicate post.
17804
- const notice = `[prose-routing] Your text (${text.length} chars) may not have been delivered — ${reason}. ` +
18149
+ const notice = `[${NOTICE_TAG.proseRouting}] Your text (${text.length} chars) may not have been delivered — ${suppliedValue(reason)}. ` +
17805
18150
  "It isn't held as a draft, since a resend could post it twice; the words remain in your history.";
17806
18151
  const target = this.noticeTargetFor(name, 'unknown-send');
17807
18152
  try {
@@ -17827,13 +18172,22 @@ export class AgentFramework {
17827
18172
  // The bounce notice below names the draft, so no separate held notice.
17828
18173
  draft = this.holdProseDrafts(agent, [{ text: words.text, ...(inherited ? { inheritedRisk: inherited } : {}) }], 'bounced', { routing: round.routing, note: reason, notice: 'later' })[0];
17829
18174
  }
17830
- const streak = (this.proseBounceStreaks.get(name) ?? 0) + 1;
17831
- this.proseBounceStreaks.set(name, streak);
17832
- const cand = candidates?.length ? ` Known channels it could mean: ${candidates.join(', ')}.` : '';
18175
+ // A heartbeat's turn came from no channel: its bounce is noticed, and
18176
+ // waits for the agent's next turn, rather than starting one of its own,
18177
+ // so it neither wakes the agent nor counts toward the bounce-wake cap.
18178
+ // Only words with no destination: a `>>` or `>>>` target that fails was
18179
+ // meant for someone, and its bounce wakes the agent as any does.
18180
+ const quiet = round.routing.heartbeat === true && after.unprefixed === true;
18181
+ const streak = (this.proseBounceStreaks.get(name) ?? 0) + (quiet ? 0 : 1);
18182
+ if (!quiet)
18183
+ this.proseBounceStreaks.set(name, streak);
18184
+ const cand = candidates?.length
18185
+ ? ` Known channels it could mean: ${candidates.map(suppliedValue).join(', ')}.${this.channelRegistry?.publishRouteFor(candidates) ?? ''}`
18186
+ : '';
17833
18187
  // The examples name the first candidate that is one token, as a target
17834
- // takes it, so they show the form to write, such as a server-qualified
17835
- // `a:general`; with none, a generic `#channel`.
17836
- const example = candidates?.find(isProseTargetToken) ?? '#channel';
18188
+ // takes it, and shows as itself, so they show the form to write, such as a server-qualified
18189
+ // `a:general`; with none, the generic `#<channel>`.
18190
+ const example = candidates?.find((c) => isProseTargetToken(c) && showsAsItself(c)) ?? NO_CHANNEL_EXAMPLE;
17837
18191
  // A bare {{unsent}} whose send failed re-bounces the draft it sent, which
17838
18192
  // a later bounce may have displaced as the latest; and {{unsent}} refuses
17839
18193
  // a draft that may already have been posted. Then only the drafts tool
@@ -17844,11 +18198,11 @@ export class AgentFramework {
17844
18198
  ? 'It could not be held as a draft; the words remain in your history.'
17845
18199
  : byToken
17846
18200
  ? `It is held as draft ${draft.id}; nothing is lost. To deliver it unchanged, reply with a destination plus the ` +
17847
- `token {{unsent}}, e.g. "${prefix}${example} {{unsent}}" or "${prefix}@person {{unsent}}", or use ` +
17848
- `drafts(action: "resend", draftIds: ["${draft.id}"], destination: "${example}"). ` +
18201
+ `token {{unsent}}, e.g. "${prefix}${example} {{unsent}}" or "${prefix}${NO_PERSON_EXAMPLE} {{unsent}}", or use ` +
18202
+ `drafts(action: "resend", draftIds: ["${draft.id}"], destination: ${quotedValue(example)}). ` +
17849
18203
  `"${prefix}skip_reply {{unsent}}" sets it aside.`
17850
18204
  : `It is held as draft ${draft.id}; nothing is lost. To deliver it unchanged, use ` +
17851
- `drafts(action: "resend", draftIds: ["${draft.id}"], destination: "${example}"` +
18205
+ `drafts(action: "resend", draftIds: ["${draft.id}"], destination: ${quotedValue(example)}` +
17852
18206
  `${inherited || ownRisk ? ', confirmDuplicate: true' : ''}); ` +
17853
18207
  `drafts(action: "dismiss", draftIds: ["${draft.id}"]) sets it aside.`;
17854
18208
  const risky = inherited && draft
@@ -17863,7 +18217,7 @@ export class AgentFramework {
17863
18217
  // A send that threw has no known outcome: the notice doesn't say it
17864
18218
  // wasn't delivered.
17865
18219
  const delivered = after.outcome === 'unknown' ? 'may not have been delivered' : 'was not delivered';
17866
- const notice = `[prose-routing] Your text (${text.length} chars) ${delivered} — ${reason}.${cand} ${kept}${risky}` +
18220
+ const notice = `[${NOTICE_TAG.proseRouting}] Your text (${text.length} chars) ${delivered} — ${suppliedValue(reason)}.${cand} ${kept}${risky}` +
17867
18221
  (prefix === '>>' && this.canAgentUseTool(agent.name, 'prose_help') ? ' The prose_help tool shows the full syntax.' : '');
17868
18222
  const target = this.noticeTargetFor(name, 'prose-bounce');
17869
18223
  if (target) {
@@ -17887,8 +18241,8 @@ export class AgentFramework {
17887
18241
  }
17888
18242
  const capped = streak > AgentFramework.PROSE_BOUNCE_WAKE_CAP;
17889
18243
  console.error(`[prose] ${name}: BOUNCED ${text.length} chars — ${reason}` +
17890
- (capped ? ' (bounce-wake cap reached; notice appended without wake)' : ''));
17891
- if (!capped && target) {
18244
+ (quiet ? ' (a heartbeat turn: notice appended without wake)' : capped ? ' (bounce-wake cap reached; notice appended without wake)' : ''));
18245
+ if (!capped && !quiet && target) {
17892
18246
  // Wake so the resend can happen immediately — an unsent DM reply must
17893
18247
  // not sit invisible until the next unrelated event.
17894
18248
  this.pendingRequests.push({
@@ -17896,6 +18250,7 @@ export class AgentFramework {
17896
18250
  reason: 'prose-bounce',
17897
18251
  source: 'framework',
17898
18252
  timestamp: Date.now(),
18253
+ ...this.heartbeatTurnMark(round.routing),
17899
18254
  });
17900
18255
  }
17901
18256
  }
@@ -18105,6 +18460,7 @@ export class AgentFramework {
18105
18460
  stopReason: 'exhausted', speech: '', error: error instanceof Error ? error.message : String(error),
18106
18461
  }, this.openLogical.get(agent.name));
18107
18462
  }
18463
+ this.endStatedWaitRun(agent.name);
18108
18464
  // The agent's window at the event gate ends here too, while this start
18109
18465
  // still holds the turn token, as at the provider-wait hold above. A
18110
18466
  // start that resumes a turn (a budget restart, a tool-result-guard
@@ -18602,13 +18958,16 @@ export class AgentFramework {
18602
18958
  // without the mark, so a retry doesn't write it again. A row that can't
18603
18959
  // be stored gives back the run's count, and withdraws a turn the
18604
18960
  // continuation alone caused: it would wake to nothing it can read.
18961
+ // A framework wake that continues a heartbeat's turn (heartbeatTurn) is
18962
+ // one too, and isn't told again what the turn that started the run was.
18963
+ const continuesHeartbeat = trigger?.heartbeatTurn === true && trigger.source === 'framework';
18605
18964
  if (attempt === 0 && !resumesTurn && trigger?.autoContinueRun) {
18606
18965
  const mark = trigger.autoContinueRun;
18607
18966
  const { autoContinueRun: _mark, ...unmarked } = trigger;
18608
18967
  trigger = unmarked;
18609
18968
  // Another reader's turn may have started since the scheduler checked.
18610
18969
  const busy = this.sharedSlotBusy(agent);
18611
- if (busy || !this.writeAutoContinueNotice(agent, mark)) {
18970
+ if (busy || !this.writeAutoContinueNotice(agent, mark, trigger.heartbeatTurn === true)) {
18612
18971
  this.giveBackAutoContinue(agent.name, busy ? "another resident's turn is alive in the shared slot" : 'its row could not be stored');
18613
18972
  if (mark.sole) {
18614
18973
  console.error(`[continue] ${agent.name}: turn not started (the continuation was its only cause)`);
@@ -18747,9 +19106,10 @@ export class AgentFramework {
18747
19106
  this.noticeHeadMoves(agent);
18748
19107
  if (opensTurn) {
18749
19108
  // The turn's trigger channel (item-3 redux): the channel of a
18750
- // channel/DM-triggered turn, with its server, and none for a heartbeat
18751
- // / no-trigger turn, which falls back to the global default rather
18752
- // than inheriting a previous turn's channel. A wake whose channel,
19109
+ // channel/DM-triggered turn, with its server, and none for a
19110
+ // no-trigger turn, which doesn't inherit a previous turn's channel: a
19111
+ // heartbeat's takes no locus at all (TurnRouting.heartbeat), and any
19112
+ // other falls back to the global default. A wake whose channel,
18753
19113
  // given without its server, names no one registered channel (a host's
18754
19114
  // id that two servers' channels answer to, or that none does) has none
18755
19115
  // either, and takes no fallback: it was for a channel, and the latest
@@ -18766,6 +19126,12 @@ export class AgentFramework {
18766
19126
  routing.noChannel = true;
18767
19127
  else
18768
19128
  delete routing.noChannel;
19129
+ // A heartbeat's turn has no channel, and takes no fallback: its plain
19130
+ // speech is held as a draft (TurnRouting.heartbeat).
19131
+ if (isHeartbeatWake(trigger))
19132
+ routing.heartbeat = true;
19133
+ else
19134
+ delete routing.heartbeat;
18769
19135
  // Resolve the turn's outbound locus now: the home channel for forks,
18770
19136
  // else the trigger channel, else the global default, which an
18771
19137
  // ephemeral run never takes (takesGlobalFallback). Ambient mid-turn
@@ -18773,8 +19139,8 @@ export class AgentFramework {
18773
19139
  // chatter and reactions hijacked it before, 2026-07-21); only an
18774
19140
  // addressed message, a follow-up in a channel the turn engaged
18775
19141
  // (repinTurnLocus) or the agent's own channel_open moves it, between
18776
- // rounds. The eager snapshot protects heartbeat turns from a moving
18777
- // defaultPublishChannel. Explicit mode uses a turn-scoped `>>` target
19142
+ // rounds. The eager snapshot protects a no-trigger turn that takes the
19143
+ // fallback (a timer's) from a moving defaultPublishChannel. Explicit mode uses a turn-scoped `>>` target
18778
19144
  // and disabled mode has no prose target, so neither resolves or
18779
19145
  // announces a locus. If the effective locus differs from the last one
18780
19146
  // announced, a durable `[routing]` notice goes into the window BEFORE
@@ -18788,8 +19154,17 @@ export class AgentFramework {
18788
19154
  routing.locus = locus;
18789
19155
  noteTurnChannel(routing, locus);
18790
19156
  }
18791
- if (attempt === 0)
19157
+ // A heartbeat's turn speaks nowhere by design. It's told so up front,
19158
+ // once per heartbeat (a continuation of its run isn't told again),
19159
+ // without moving the announced baseline, so the next channel turn
19160
+ // doesn't announce its channel again.
19161
+ if (attempt === 0 && routing.heartbeat) {
19162
+ if (!continuesHeartbeat)
19163
+ this.noticeHeartbeatTurn(agent.name);
19164
+ }
19165
+ else if (attempt === 0) {
18792
19166
  this.announceLocusIfChanged(agent.name, locus);
19167
+ }
18793
19168
  }
18794
19169
  }
18795
19170
  else {
@@ -18856,7 +19231,9 @@ export class AgentFramework {
18856
19231
  const presentation = this.inspectToolPresentation(agent.name, requestSnapshot);
18857
19232
  const compressionTools = presentation?.available
18858
19233
  ?? structuredClone(this.availableToolsForPresentation(agent.name, requestSnapshot));
18859
- const tools = presentation?.advertised ?? compressionTools;
19234
+ // The tool list: what the agent's presentation advertises, less the
19235
+ // host's own toolsets (the built-in one, servers it has as tabs).
19236
+ const tools = this.headerTools(agent.name, presentation?.advertised ?? compressionTools);
18860
19237
  // Gather context from modules (pull-based) and MCPL hooks (push-based)
18861
19238
  // Both produce ContextInjection[] that get merged before inference.
18862
19239
  let injections;
@@ -19120,7 +19497,8 @@ export class AgentFramework {
19120
19497
  this.endHeldStart(agent, checkWindow, turnToken, turnStartedAt, routing);
19121
19498
  return false;
19122
19499
  }
19123
- const action = statedWait === 'fail' ? { retry: false } : this.errorPolicy.onInferenceError(err, agent.name, attempt);
19500
+ const action = statedWait === 'fail' || (isRearmed(trigger) && !gotPartway(routing))
19501
+ ? { retry: false } : this.errorPolicy.onInferenceError(err, agent.name, attempt);
19124
19502
  if (action.retry) {
19125
19503
  // A policy's delay can be a provider's retry-after: wait all of it.
19126
19504
  await this.waitForRetry(action.delayMs, checkWindow, agent);
@@ -19181,6 +19559,8 @@ export class AgentFramework {
19181
19559
  speech: '',
19182
19560
  ...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
19183
19561
  }, routing);
19562
+ if (isRearmed(trigger) && isTransientFailure(err))
19563
+ this.exhaustingRearms.add(agent.name);
19184
19564
  this.emitTrace({
19185
19565
  type: 'inference:exhausted',
19186
19566
  agentName: agent.name,
@@ -19200,6 +19580,7 @@ export class AgentFramework {
19200
19580
  if (action.emit) {
19201
19581
  this.pushEvent(action.emit);
19202
19582
  }
19583
+ this.rearmExhaustedTurn(agent, err, trigger, ownsProviderGate, routing);
19203
19584
  }
19204
19585
  // No handoff on either failure branch: the terminal branch deleted the
19205
19586
  // token above; the retry branch's recursive startAgentStream replaced
@@ -19484,9 +19865,9 @@ export class AgentFramework {
19484
19865
  }
19485
19866
  };
19486
19867
  };
19487
- const enqueueSpeech = (text, locus, onPublished) => {
19868
+ const enqueueSpeech = (text, locus, onPublished, round) => {
19488
19869
  chainSpeech(routingSpeech(async () => {
19489
- const outcome = await this.channelRegistry.routeSpeech(agent.name, text, locus);
19870
+ const outcome = await this.routeLocusSpeech(agent, round ?? { routing: streamRouting, notice: 'later' }, text, locus);
19490
19871
  this.recordProseDelivery(streamRouting, outcome);
19491
19872
  if (outcome?.delivered) {
19492
19873
  streamRouting.liveSpeech = true;
@@ -20008,9 +20389,11 @@ export class AgentFramework {
20008
20389
  console.error(`[routing] ${agent.name}: mid-turn round [${toolNames.join(', ')}] -> prose NOT routed (same_round_think_text_policy=private)`);
20009
20390
  }
20010
20391
  else {
20011
- console.error(`[routing] ${agent.name}: mid-turn round [${toolNames.join(', ')}] -> routing ${roundSegments.length} prose segment(s) live -> ${roundLocus ?? '(default)'}`);
20392
+ console.error(roundLocus === null && streamRouting.heartbeat
20393
+ ? `[routing] ${agent.name}: mid-turn round [${toolNames.join(', ')}] -> ${roundSegments.length} prose segment(s) held as drafts (a heartbeat turn)`
20394
+ : `[routing] ${agent.name}: mid-turn round [${toolNames.join(', ')}] -> routing ${roundSegments.length} prose segment(s) live -> ${roundLocus ?? '(default)'}`);
20012
20395
  for (const seg of roundSegments) {
20013
- enqueueSpeech(seg, roundLocus, roundPublished);
20396
+ enqueueSpeech(seg, roundLocus, roundPublished, roundOpts);
20014
20397
  }
20015
20398
  }
20016
20399
  };
@@ -20311,7 +20694,7 @@ export class AgentFramework {
20311
20694
  // self-seed. Exact raw output remains in the provider ledger.
20312
20695
  agent.getContextManager().addMessage('user', [{
20313
20696
  type: 'text',
20314
- text: '[tool-boundary] No tool was called.',
20697
+ text: `[${NOTICE_TAG.toolBoundary}] No tool was called.`,
20315
20698
  }], {
20316
20699
  system: true,
20317
20700
  kind: 'tool-wrapper-prose-contained',
@@ -20350,7 +20733,7 @@ export class AgentFramework {
20350
20733
  else if (cut?.unnamed) {
20351
20734
  agent.getContextManager().addMessage('user', [{
20352
20735
  type: 'text',
20353
- text: '[tool-boundary] Your response was cut off at the output limit inside a tool-call block, before any call in it ' +
20736
+ text: `[${NOTICE_TAG.toolBoundary}] Your response was cut off at the output limit inside a tool-call block, before any call in it ` +
20354
20737
  'was named, so nothing ran. To make a call, send less in one response.',
20355
20738
  }], { system: true, kind: 'cut-off-tool-block', ...this.silentTurnRowMetadata(agent.name) });
20356
20739
  }
@@ -20617,7 +21000,7 @@ export class AgentFramework {
20617
21000
  console.error(`[routing] ${agent.name}: text-only turn -> routing speech -> ${locus ? qualifiedChannelName(locus) : '(none)'}`);
20618
21001
  for (const segment of speechSegments) {
20619
21002
  try {
20620
- const outcome = await this.channelRegistry.routeSpeech(agent.name, segment, locus);
21003
+ const outcome = await this.routeLocusSpeech(agent, textOnlyHold, segment, locus);
20621
21004
  this.recordProseDelivery(streamRouting, outcome);
20622
21005
  if (outcome?.delivered)
20623
21006
  endPublished(outcome, segment, outcome.messageId);
@@ -20737,7 +21120,7 @@ export class AgentFramework {
20737
21120
  // Deliver sequentially (await each) so the segments land in order.
20738
21121
  for (const seg of segments) {
20739
21122
  try {
20740
- const outcome = await this.channelRegistry.routeSpeech(agent.name, seg, locus);
21123
+ const outcome = await this.routeLocusSpeech(agent, trailingHold, seg, locus);
20741
21124
  this.recordProseDelivery(streamRouting, outcome);
20742
21125
  if (outcome?.delivered)
20743
21126
  endPublished(outcome, seg, outcome.messageId);
@@ -20883,7 +21266,8 @@ export class AgentFramework {
20883
21266
  this.eventGate?.onInferenceEnded(agent.name);
20884
21267
  break;
20885
21268
  }
20886
- const action = statedWait === 'fail' ? { retry: false } : this.errorPolicy.onInferenceError(err, agent.name, attempt);
21269
+ const action = statedWait === 'fail' || (isRearmed(trigger) && !gotPartway(streamRouting))
21270
+ ? { retry: false } : this.errorPolicy.onInferenceError(err, agent.name, attempt);
20887
21271
  if (action.retry) {
20888
21272
  // A stop made during the wait reaches no stream: the check after
20889
21273
  // the wait ends the turn, as the guard's check after its wait
@@ -20945,6 +21329,8 @@ export class AgentFramework {
20945
21329
  speech: '',
20946
21330
  ...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
20947
21331
  }, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting : undefined, streamRouting);
21332
+ if (isRearmed(trigger) && isTransientFailure(err))
21333
+ this.exhaustingRearms.add(agent.name);
20948
21334
  this.emitTrace({
20949
21335
  type: 'inference:exhausted',
20950
21336
  agentName: agent.name,
@@ -20963,6 +21349,7 @@ export class AgentFramework {
20963
21349
  if (action.emit) {
20964
21350
  this.pushEvent(action.emit);
20965
21351
  }
21352
+ this.rearmExhaustedTurn(agent, err, trigger, ownsProviderGate, streamRouting);
20966
21353
  }
20967
21354
  break;
20968
21355
  }
@@ -21037,6 +21424,7 @@ export class AgentFramework {
21037
21424
  agentName: agent.name,
21038
21425
  error: terminal,
21039
21426
  });
21427
+ this.rearmExhaustedTurn(agent, new Error(terminal), trigger, ownsProviderGate, streamRouting);
21040
21428
  // Postmortem 2026-05-28 P2 #7: persist the abort to the
21041
21429
  // inference log so future investigations can attribute the
21042
21430
  // terminal cause without relying on live in-memory reducer
@@ -21313,12 +21701,15 @@ export class AgentFramework {
21313
21701
  speech: '',
21314
21702
  ...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
21315
21703
  }, streamRouting);
21704
+ if (isRearmed(trigger) && isTransientFailure(err))
21705
+ this.exhaustingRearms.add(agent.name);
21316
21706
  this.emitTrace({
21317
21707
  type: 'inference:exhausted',
21318
21708
  agentName: agent.name,
21319
21709
  error: err.message,
21320
21710
  ...this.classifyInferenceError(err),
21321
21711
  });
21712
+ this.rearmExhaustedTurn(agent, err, trigger, ownsProviderGate, streamRouting);
21322
21713
  // Postmortem 2026-05-28 P2 #7: catch-path failures (stream itself
21323
21714
  // threw) were previously only visible via in-memory trace listeners.
21324
21715
  // Persist to inference log for forensic attribution.
@@ -21537,8 +21928,7 @@ export class AgentFramework {
21537
21928
  * exists only through the private closure handed to ModuleRegistry
21538
21929
  * (ctx.callTool → executeToolCallFrom with {kind:'module'}), which is exempt
21539
21930
  * from agent allowlists. Registered agents' allowedTools is enforced at
21540
- * dispatch, including namespaced utility names; utils only grants the
21541
- * list/describe/run slot. Genuinely unidentified public callers (including
21931
+ * dispatch, including namespaced utility names. Genuinely unidentified public callers (including
21542
21932
  * unknown legacy names) remain unrestricted. Framework-created identities
21543
21933
  * are refused while unregistered, even if their configured list allows it.
21544
21934
  */
@@ -21735,6 +22125,9 @@ export class AgentFramework {
21735
22125
  this.emitTrace({ type: 'tool:failed', module: 'framework', tool: call.name, callId: call.id, error: traced });
21736
22126
  return failure;
21737
22127
  };
22128
+ // utils was removed: said whatever the allowlist names (dispatchToolCall).
22129
+ if (call.name === 'utils')
22130
+ return failed({ success: false, isError: true, error: this.utilsRemoved(origin.kind === 'agent' ? origin.agentName : call.callerAgentName) });
21738
22131
  if (origin.kind === 'agent') {
21739
22132
  const failure = this.agentToolPermissionFailure(origin.agentName, call, false);
21740
22133
  if (failure)
@@ -21754,6 +22147,15 @@ export class AgentFramework {
21754
22147
  if (call.name === CODE_EXECUTION_TOOL_NAME && this.codeExecution) {
21755
22148
  return this.runCodeExecution(call.callerAgentName ?? '__ephemeral__', call);
21756
22149
  }
22150
+ // toolsets--act on a host toolset's tool (the built-in one, a server's
22151
+ // tab) goes where a programmatic call of that tool goes, under its name.
22152
+ if (call.name === META_TOOL_NAMES.act && call.callerAgentName !== undefined) {
22153
+ const host = this.hostPresentation?.resolveAct(call.callerAgentName, call.input) ?? null;
22154
+ if (host?.kind === 'refused')
22155
+ return failed({ success: false, error: host.error, isError: true });
22156
+ if (host?.kind === 'call')
22157
+ return this.executeToolCallFrom({ ...call, name: host.name, input: host.args }, origin);
22158
+ }
21757
22159
  // RFC-014: toolsets--act, resolved to the dynamic tool it calls, goes
21758
22160
  // where a programmatic call of that tool goes, with its instance.
21759
22161
  if (call.name === META_TOOL_NAMES.act && this.toolsetHub && this.mcplServerRegistry) {
@@ -21797,21 +22199,13 @@ export class AgentFramework {
21797
22199
  if (call.name === 'drafts' && this.channelRegistry && origin.kind === 'agent') {
21798
22200
  return this.draftsToolResult(origin.agentName, call.input);
21799
22201
  }
21800
- // The utils meta-tool, for callers that await a tool's result. A call
21801
- // through executeToolCall (the puppet, a host) reaches what its agent's
21802
- // allowedTools permits, as the model's does, and an unidentified caller
21803
- // is unrestricted, as executeToolCall's doc says; a module, through
21804
- // callTool, is trusted and reaches every utility.
21805
22202
  // The heartbeat's tools, for callers that await a tool's result (an
21806
- // in-process call, a puppet, a module's callTool). A module is trusted,
21807
- // as it is for utils; an agent is held to what dispatch holds it to.
22203
+ // in-process call, a puppet, a module's callTool). A module is trusted;
22204
+ // an agent is held to what dispatch holds it to.
21808
22205
  if (this.heartbeat?.ownsTool(call.name)) {
21809
22206
  const refusal = origin.kind === 'module' ? null : this.heartbeatRefusal(call.callerAgentName ?? '__ephemeral__', call);
21810
22207
  return refusal ? { success: false, error: refusal, isError: true } : this.runHeartbeatTool(call);
21811
22208
  }
21812
- if (call.name === 'utils') {
21813
- return this.handleUtilsToolCall(call.callerAgentName ?? '__ephemeral__', call, { inProcess: true, trustedModule: origin.kind === 'module' });
21814
- }
21815
22209
  // Module tools. The caller uses the result in process, so it gets the
21816
22210
  // module's own result, as the module returned it, less a refusal it
21817
22211
  // claims, which is the dispatcher's to say.
@@ -21855,6 +22249,22 @@ export class AgentFramework {
21855
22249
  (this.toolsetActCalls ??= new WeakMap()).set(inner, target.instance !== undefined ? { instance: target.instance } : {});
21856
22250
  return { ok: true, call: inner, serverId: target.serverId, prefix, ...(target.instance !== undefined ? { instance: target.instance } : {}) };
21857
22251
  }
22252
+ /** A toolsets--act call a host toolset resolved (HostPresentation.resolveAct):
22253
+ * its refusal answered, or the tool's own call dispatched in its place,
22254
+ * with the act's id, so its result is the act's. */
22255
+ dispatchHostAct(agentName, call, host, turnId) {
22256
+ if (host.kind === 'refused') {
22257
+ this.emitTrace({ type: 'tool:failed', module: TOOLSETS_MODULE, tool: call.name, callId: call.id, error: host.error });
22258
+ this.pushEvent({
22259
+ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: TOOLSETS_MODULE,
22260
+ result: this.refusedResult(agentName, call.id, host.error),
22261
+ });
22262
+ return;
22263
+ }
22264
+ const inner = { ...call, name: host.name, input: host.args };
22265
+ this.toolLifecycleEmitter?.retarget(agentName, call.id, inner);
22266
+ this.dispatchToolCall(agentName, inner, turnId);
22267
+ }
21858
22268
  /**
21859
22269
  * RFC-014 §8.2: a toolsets--act call from the agent (the model's, or a
21860
22270
  * script's) goes down the path a direct call of the server's tool takes
@@ -22669,17 +23079,17 @@ export class AgentFramework {
22669
23079
  spillNotice(headLength, content, shown, bound, readCap, written) {
22670
23080
  if (written && written.error === undefined) {
22671
23081
  const canRaise = bound === undefined || cutStoredToolResultToFit(content, bound, shown, bound).head.length > headLength;
22672
- return `\n\n[truncated — showing ${headLength} of ${content.length} chars; full content: workspace file ${written.path}`
23082
+ return `\n\n[${NOTICE_TAG.truncated} — showing ${headLength} of ${content.length} chars; full content: workspace file ${written.path}`
22673
23083
  + `. ${this.spillPageAdvice(written.path, readCap, canRaise)}]`;
22674
23084
  }
22675
23085
  if (written) {
22676
23086
  // A workspace was there and the write FAILED — say that, loudly and
22677
23087
  // distinctly. Telling the agent "no writable workspace" would teach
22678
23088
  // them their residence lacks a capability it actually has.
22679
- return `\n\n[truncated — showing ${headLength} of ${content.length} chars; spill to workspace file ${written.path} FAILED`
23089
+ return `\n\n[${NOTICE_TAG.truncated} — showing ${headLength} of ${content.length} chars; spill to workspace file ${written.path} FAILED`
22680
23090
  + ` (${written.error}); content over the cap was not retained]`;
22681
23091
  }
22682
- return '\n\n[truncated — original was ' + content.length + ' chars; ' + this.noFileMountText() + ', full content not retained]';
23092
+ return `\n\n[${NOTICE_TAG.truncated} — original was ${content.length} chars; ${this.noFileMountText()}, full content not retained]`;
22683
23093
  }
22684
23094
  /**
22685
23095
  * The length of the notice a cut of `content` that keeps no head would
@@ -23539,6 +23949,17 @@ export class AgentFramework {
23539
23949
  * script left running, which no call watches, has none.
23540
23950
  */
23541
23951
  dispatchToolCall(agentName, call, turnId) {
23952
+ // utils was removed; its utilities are in the built-in toolset. Said
23953
+ // whatever the allowlist names, so an old call learns where they went.
23954
+ if (call.name === 'utils') {
23955
+ const removed = this.utilsRemoved(agentName);
23956
+ this.emitTrace({ type: 'tool:failed', module: 'framework', tool: call.name, callId: call.id, error: removed });
23957
+ this.pushEvent({
23958
+ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'framework',
23959
+ result: this.refusedResult(agentName, call.id, removed),
23960
+ });
23961
+ return;
23962
+ }
23542
23963
  const refusal = this.agentToolPermissionFailure(agentName, call) ?? this.markedInputFailure(agentName, call);
23543
23964
  if (refusal) {
23544
23965
  this.pushEvent({
@@ -23559,6 +23980,16 @@ export class AgentFramework {
23559
23980
  this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'tool-presentation', result });
23560
23981
  return;
23561
23982
  }
23983
+ // toolsets--act on a host toolset's tool (the built-in one, a server's
23984
+ // tab): the call of that tool, under its own name, down the path a
23985
+ // direct call of it takes, permissions included.
23986
+ if (enrichedCall.name === META_TOOL_NAMES.act) {
23987
+ const host = this.hostPresentation?.resolveAct(agentName, enrichedCall.input) ?? null;
23988
+ if (host) {
23989
+ this.dispatchHostAct(agentName, call, host, turnId);
23990
+ return;
23991
+ }
23992
+ }
23562
23993
  // RFC-014: toolsets--act calls a server's dynamic tool, down the same
23563
23994
  // path as a direct call of it (dispatchToolsetAct).
23564
23995
  if (enrichedCall.name === META_TOOL_NAMES.act && this.toolsetHub && this.mcplServerRegistry) {
@@ -23584,11 +24015,6 @@ export class AgentFramework {
23584
24015
  this.dispatchChannelToolCall(agentName, enrichedCall);
23585
24016
  return;
23586
24017
  }
23587
- // Route the agent's typed, allowlisted hot-settings surface.
23588
- if (enrichedCall.name === 'utils') {
23589
- this.dispatchUtilsToolCall(agentName, enrichedCall);
23590
- return;
23591
- }
23592
24018
  if (enrichedCall.name === 'agent_settings') {
23593
24019
  this.dispatchAgentSettingsToolCall(agentName, enrichedCall);
23594
24020
  return;
@@ -23621,7 +24047,7 @@ export class AgentFramework {
23621
24047
  void this.tuneOutCoordinator
23622
24048
  .handleSubconsciousTool(enrichedCall.name, enrichedCall.input, this.callOwnChannels(agentName, enrichedCall))
23623
24049
  // A rejection is answered with a failed result, as a framework tool's
23624
- // dispatch failure is (dispatchUtilsToolCall), never left unhandled.
24050
+ // dispatch failure is (dispatchAgentSettingsToolCall), never left unhandled.
23625
24051
  .catch((err) => ({ success: false, error: thrownText(err), isError: true }))
23626
24052
  .then((result) => {
23627
24053
  // Through pushEvent, like every other tool's result, so one that
@@ -23688,10 +24114,18 @@ export class AgentFramework {
23688
24114
  }
23689
24115
  const wake = readSkipReplyWake(enrichedCall.input);
23690
24116
  if (wake.kind === 'wake' && this.eventGate) {
24117
+ const armedAt = Date.now();
23691
24118
  const { inMs, armed } = this.eventGate.armSelfWake(agentName, wake.seconds, 'skip_reply');
24119
+ // The heartbeat's tick or reminder due before the timer: if it wakes
24120
+ // the agent, its turn cancels the timer, so the confirmation names
24121
+ // it and the time the self-wake would have left.
24122
+ const earlierWake = armed ? this.heartbeatWakeBefore(agentName, armedAt + inMs) : undefined;
23692
24123
  console.error(armed
23693
24124
  ? `[self-wake] agent=${agentName} armed in ${Math.round(inMs / 1000)}s (skip_reply)`
24125
+ + (earlierWake ? `; due sooner, in ~${earlierWake.inSeconds}s: ${logDetail(earlierWake.what)}` : '')
23694
24126
  : `[self-wake] agent=${agentName} not armed: the framework has stopped (skip_reply)`);
24127
+ this.dispatchChannelToolCall(agentName, enrichedCall, earlierWake);
24128
+ return;
23695
24129
  }
23696
24130
  }
23697
24131
  this.dispatchChannelToolCall(agentName, enrichedCall);
@@ -23853,7 +24287,7 @@ export class AgentFramework {
23853
24287
  // messages flush at the next turn's START (before its compile — riding
23854
24288
  // their own wake), at the agent's next tool boundary (where they are
23855
24289
  // ALSO injected into the live stream — hear-while-acting — all but a
23856
- // message a tune-out holds from the residents), or in driveStream's
24290
+ // message the agent's own view leaves out), or in driveStream's
23857
24291
  // finally when the turn ends.
23858
24292
  // Quiesce (issue #122): the window exists to run compression/refold
23859
24293
  // against a FROZEN context — module events and api message.send must not
@@ -24044,18 +24478,20 @@ export class AgentFramework {
24044
24478
  }
24045
24479
  }
24046
24480
  /** Whether `entry`'s own stored entry is the newest in `agent`'s window,
24047
- * clock rows and stored context injections after it aside: a channel
24048
- * message by its server, channel and message id, a push event by its
24049
- * server and event id, and never one a tune-out holds. Past
24481
+ * clock rows, stored context injections and rows the agent's own view
24482
+ * leaves out after it aside: a channel message by its server, channel and
24483
+ * message id, a push event by its server and event id, and never one the
24484
+ * agent's view leaves out, such as one a tune-out holds. Past
24050
24485
  * NEWEST_SCAN_ROWS such rows, it answers no. */
24051
24486
  isNewestIn(agent, entry) {
24052
24487
  // A clock row the framework wrote after it (a turn start's, an event's),
24053
24488
  // or a stored context injection (any agent's, a start's), isn't a
24054
24489
  // message a reply could be meant for, so it doesn't count.
24055
24490
  const newest = agent.getContextManager().queryMessages({ limit: AgentFramework.NEWEST_SCAN_ROWS, reverse: true }).messages
24056
- .find((m) => m.metadata?.kind !== CLOCK_ROW_KIND && !storedInjectionMarkOf(m.metadata));
24491
+ .find((m) => m.metadata?.kind !== CLOCK_ROW_KIND && !storedInjectionMarkOf(m.metadata)
24492
+ && this.agentSees(agent, m.metadata));
24057
24493
  const md = newest?.metadata;
24058
- if (!md || 'tuneOut' in md || entry.serverId === undefined || md.serverId !== entry.serverId)
24494
+ if (!md || entry.serverId === undefined || md.serverId !== entry.serverId)
24059
24495
  return false;
24060
24496
  if (entry.eventType === 'mcpl:channel-incoming') {
24061
24497
  return entry.messageId !== undefined && md.messageId === entry.messageId && md.channelId === entry.channelId;
@@ -24074,11 +24510,19 @@ export class AgentFramework {
24074
24510
  ? this.agents.get(this.primaryAgentName)
24075
24511
  : this.agents.values().next().value;
24076
24512
  }
24513
+ noteSpokenTo(agentName, metadata) {
24514
+ const agent = this.agents.get(agentName);
24515
+ if ((agent && !this.agentSees(agent, metadata)) || isOwnScheduleWrite(metadata) || !isConversationalInjection(metadata))
24516
+ return;
24517
+ if (this.turnRearms?.delete(agentName))
24518
+ console.error(`[inference-rearm] agent=${agentName} re-send count started again: a message was said to it`);
24519
+ }
24077
24520
  /** The notes addMessage takes for a message it stored: an arrival for the
24078
24521
  * agent when none of its turns is alive or the message was assembled for
24079
24522
  * the start now beginning (no tool boundary will weigh it), and one for
24080
24523
  * every other resident's open turn. */
24081
24524
  noteStored(agent, metadata, stored, bypassDeferralFor) {
24525
+ this.noteSpokenTo(agent.name, metadata);
24082
24526
  if (!this.activeTurnTokens.has(agent.name) || bypassDeferralFor === agent.name) {
24083
24527
  this.noteArrival(agent.name, metadata, stored);
24084
24528
  }
@@ -24525,6 +24969,7 @@ export class AgentFramework {
24525
24969
  const stored = this.writeDrainedInOrder(this.drainDeferredFor(agent.name), `${occasion} flush for ${agent.name}`, (msg) => agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id)));
24526
24970
  for (const [msg, { id }] of stored) {
24527
24971
  this.emitTrace({ type: 'message:added', messageId: id, source: `deferred-flush:${occasion}` });
24972
+ this.noteSpokenTo(agent.name, msg.metadata);
24528
24973
  this.noteArrival(agent.name, msg.metadata, id);
24529
24974
  this.noteForOtherResidents(agent, msg.metadata, id);
24530
24975
  }
@@ -25017,18 +25462,18 @@ export class AgentFramework {
25017
25462
  const outcome = failure.outcome === 'failed' || failure.outcome === 'unknown' ? failure.outcome : undefined;
25018
25463
  const detail = typeof failure.detail === 'string' ? failure.detail : undefined;
25019
25464
  const size = textLen !== undefined ? ` (${textLen} chars)` : '';
25020
- const why = reason !== undefined ? ` (${reason})` : '';
25465
+ const why = reason !== undefined ? ` (${suppliedValue(reason)})` : '';
25021
25466
  // A notice queued by an earlier build names no server: its channel is
25022
25467
  // the one registered channel its id means, or shown by its id alone.
25023
25468
  const named = this.channelNamed(failure.channelId, typeof failure.serverId === 'string' ? failure.serverId : undefined);
25024
- const where = named ? this.channelForNotice(named) : failure.channelId;
25469
+ const where = named ? this.channelForNotice(named) : suppliedValue(failure.channelId);
25025
25470
  const text = outcome === 'unknown'
25026
- ? `[discord-send-failed] Delivery of a reply by ${speaker}${size} to ${where} was not confirmed${why}. ` +
25471
+ ? `[${NOTICE_TAG.discordSendFailed}] Delivery of a reply by ${speaker}${size} to ${where} was not confirmed${why}. ` +
25027
25472
  `It may or may not have been posted, so sending it again could post it twice. ${speaker} has ended.`
25028
- : `[discord-send-failed] A reply by ${speaker}${size} could not be delivered to ${where}${why}. ` +
25473
+ : `[${NOTICE_TAG.discordSendFailed}] A reply by ${speaker}${size} could not be delivered to ${where}${why}. ` +
25029
25474
  `Nothing was posted. ${speaker} has ended.`;
25030
25475
  return {
25031
- content: [{ type: 'text', text: text + (detail !== undefined ? ` Connector detail: ${detail}` : '') }],
25476
+ content: [{ type: 'text', text: text + (detail !== undefined ? ` Connector detail: ${suppliedValue(detail)}` : '') }],
25032
25477
  metadata: {
25033
25478
  system: true, kind: 'discord-send-failed', channelId: failure.channelId, speaker,
25034
25479
  // The channel's server, as the marker it passes on carries it.
@@ -25055,17 +25500,16 @@ export class AgentFramework {
25055
25500
  * channel no string picks alone, its id and its server.
25056
25501
  */
25057
25502
  channelShown(channel) {
25058
- const name = this.channelRegistry?.nameShown(channel) ?? channel.channelId;
25503
+ const name = this.channelRegistry?.nameShown(channel) ?? suppliedValue(channel.channelId);
25059
25504
  const label = this.channelRegistry?.getDescriptor(channel)?.label;
25060
- return label && label !== channel.channelId
25061
- ? `${label.startsWith('#') ? label : `#${label}`} (${name})`
25062
- : name;
25505
+ const shown = label && label !== channel.channelId ? suppliedValue(label) : undefined;
25506
+ return shown ? `${shown.startsWith('#') ? '' : '#'}${shown} (${name})` : name;
25063
25507
  }
25064
25508
  /** A channel named in a notice with the label it was given there: the
25065
25509
  * label, with its name as ChannelRegistry.nameShown gives it. */
25066
25510
  labelledChannel(channel) {
25067
- const name = this.channelRegistry?.nameShown(channel) ?? channel.channelId;
25068
- return channel.label && channel.label !== channel.channelId ? `${channel.label} (${name})` : name;
25511
+ const name = this.channelRegistry?.nameShown(channel) ?? suppliedValue(channel.channelId);
25512
+ return channel.label && channel.label !== channel.channelId ? `${suppliedValue(channel.label)} (${name})` : name;
25069
25513
  }
25070
25514
  /**
25071
25515
  * A module's write through its context (ModuleContext.addMessage):
@@ -25481,6 +25925,11 @@ export class AgentFramework {
25481
25925
  if (name && this.exhaustionRewinds.get(name)) {
25482
25926
  this.exhaustionRewinds.set(name, 0);
25483
25927
  }
25928
+ if (name)
25929
+ this.turnRearms?.delete(name);
25930
+ }
25931
+ if (event.type === 'inference:completed' || event.type === 'inference:exhausted' || event.type === 'inference:aborted') {
25932
+ this.endStatedWaitRun(event.agentName);
25484
25933
  }
25485
25934
  const traceEvent = {
25486
25935
  ...event,
@@ -25788,7 +26237,7 @@ export class AgentFramework {
25788
26237
  streak,
25789
26238
  // The refused agent's own view: a subagent's refusal names no channel.
25790
26239
  // Named as the agent is shown it, so a shared id names its server.
25791
- locus: ((channel) => (channel ? this.channelRegistry?.nameShown(channel) ?? channel.channelId : null))(this.channelRegistry?.incomingFor(agentName)?.channel),
26240
+ locus: ((channel) => (channel ? this.channelRegistry?.nameShown(channel) ?? suppliedValue(channel.channelId) : null))(this.channelRegistry?.incomingFor(agentName)?.channel),
25792
26241
  tokens: tokens ?? null,
25793
26242
  });
25794
26243
  if (streak >= 2) {
@@ -25853,6 +26302,7 @@ export class AgentFramework {
25853
26302
  noteInferenceExhausted(agentName, rawReason, retryable, rawErrorType, detail) {
25854
26303
  const ended = this.exhaustedTurns?.get(agentName);
25855
26304
  this.exhaustedTurns?.delete(agentName);
26305
+ const rearmed = this.exhaustingRearms?.delete(agentName) === true;
25856
26306
  // The reason is the failure's own message, and a provider can echo the
25857
26307
  // whole rejected request in it: one household's [inference-failed]
25858
26308
  // marker carried ~1.7 MB of its own request back into its context. Every
@@ -25902,7 +26352,7 @@ export class AgentFramework {
25902
26352
  this.logFailure({ agent: agentName, consecutive: streak, reason, kind: 'inference-exhausted', ...classification, ...cutAt(reason) });
25903
26353
  // (2) Agent-facing chronicle marker (no inference triggered → no loop).
25904
26354
  const agent = this.agents.get(agentName);
25905
- if (agent && process.env.SUPPRESS_INFERENCE_FAILED_MARKER !== '1') {
26355
+ if (agent && !(rearmed && !ended) && process.env.SUPPRESS_INFERENCE_FAILED_MARKER !== '1') {
25906
26356
  const cause = [errorType, httpStatus !== undefined ? `HTTP ${httpStatus}` : undefined]
25907
26357
  .filter((part) => part !== undefined)
25908
26358
  .join(', ');
@@ -25910,15 +26360,15 @@ export class AgentFramework {
25910
26360
  agent.getContextManager().addMessage('user', [{
25911
26361
  type: 'text',
25912
26362
  text: (ended
25913
- ? `[inference-failed] Your previous turn did not complete: it ` +
25914
- `failed${cause ? ` (${cause})` : ''}.` +
26363
+ ? `[${NOTICE_TAG.inferenceFailed}] Your previous turn did not complete: it ` +
26364
+ `failed${cause ? ` (${suppliedValue(cause)})` : ''}.` +
25915
26365
  `${ended.roundsRan ? ' Its earlier rounds ran, and their tool calls ran as their results show.' : ''}` +
25916
26366
  `${ended.callsCutOff ? ` ${CALLS_CUT_OFF}` : ''}` +
25917
26367
  `${ended.liveSpeech ? ` ${LIVE_SPEECH_UNRECEIPTED}` : ''}` +
25918
26368
  `${ended.speechRouting ? ` ${SPEECH_STILL_ROUTING}` : ''} Reason: `
25919
- : `[inference-failed] Your previous turn did not complete: the model ` +
25920
- `call failed${cause ? ` (${cause})` : ''} and produced no response, so nothing was sent. Reason: `) +
25921
- `${markerReason}. If this recurs with the same cause, change approach ` +
26369
+ : `[${NOTICE_TAG.inferenceFailed}] Your previous turn did not complete: the model ` +
26370
+ `call failed${cause ? ` (${suppliedValue(cause)})` : ''} and produced no response, so nothing was sent. Reason: `) +
26371
+ `${failureShown(markerReason)}. If this recurs with the same cause, change approach ` +
25922
26372
  `rather than retrying identically (e.g. drop an oversized attachment ` +
25923
26373
  `or an unsupported setting).`,
25924
26374
  }], { system: true, kind: 'inference-failed', reason: markerReason, consecutive: streak, ...classification, ...cutAt(markerReason) });
@@ -26168,13 +26618,14 @@ export class AgentFramework {
26168
26618
  // channel hijacks it. An opening start resolves the turn's locus from
26169
26619
  // it (the trigger channel then), and the channel context names it, so
26170
26620
  // a later start of the turn is told where its unprefixed speech lands
26171
- // unless a hybrid `>>>` destination takes it. Empty for a heartbeat /
26172
- // no-trigger turn until its locus is resolved, so that resolution
26173
- // takes the global fallback (none for an ephemeral run); the turn then
26174
- // keeps that channel.
26621
+ // unless a hybrid `>>>` destination takes it. Empty for a no-trigger
26622
+ // turn until its locus is resolved, so that resolution takes the
26623
+ // global fallback (none for an ephemeral run), and the turn then keeps
26624
+ // that channel; null for a heartbeat's turn, which takes none
26625
+ // (TurnRouting.heartbeat).
26175
26626
  activeChannelResolver: (agentName) => {
26176
26627
  const routing = this.turnRouting.get(agentName);
26177
- return routing?.locus ?? routing?.triggerChannel ?? (routing?.noChannel ? null : undefined);
26628
+ return routing?.locus ?? routing?.triggerChannel ?? (routing?.noChannel || routing?.heartbeat ? null : undefined);
26178
26629
  },
26179
26630
  // An ephemeral run (a subagent) never takes the process-global
26180
26631
  // fallback: its prose is its caller's result, and the latest inbound
@@ -26230,11 +26681,11 @@ export class AgentFramework {
26230
26681
  ? (outcome === 'unknown'
26231
26682
  // A request went out and no valid receipt came back: it may
26232
26683
  // have been posted (in part or whole). Never claim it wasn't.
26233
- ? `[discord-send-failed] Delivery of your previous reply (${textLen} chars) to ${where} was not confirmed (${reason}). It may or may not have been posted, so sending it again could post it twice. It is saved in your archive.`
26234
- : `[discord-send-failed] Your previous reply (${textLen} chars) could not be delivered to ${where} (${reason}). Nothing was posted; it is saved in your archive.`)
26235
- : `[send-undeliverable] Your previous reply (${textLen} chars) had no channel to go to — ${reason}. This is a routing/configuration situation, not a channel failure. This route delivered it nowhere; if another surface showed it (a reply thread, the console), that copy stands. It is saved in your archive.`;
26684
+ ? `[${NOTICE_TAG.discordSendFailed}] Delivery of your previous reply (${textLen} chars) to ${where} was not confirmed (${suppliedValue(reason)}). It may or may not have been posted, so sending it again could post it twice. It is saved in your archive.`
26685
+ : `[${NOTICE_TAG.discordSendFailed}] Your previous reply (${textLen} chars) could not be delivered to ${where} (${suppliedValue(reason)}). Nothing was posted; it is saved in your archive.`)
26686
+ : `[${NOTICE_TAG.sendUndeliverable}] Your previous reply (${textLen} chars) had no channel to go to — ${suppliedValue(reason)}. This is a routing/configuration situation, not a channel failure. This route delivered it nowhere; if another surface showed it (a reply thread, the console), that copy stands. It is saved in your archive.`;
26236
26687
  // A registered speaker's notice is addressed to it (noticeTargetFor).
26237
- this.addMessage('user', [{ type: 'text', text: text + (detail !== undefined ? ` Connector detail: ${detail}` : '') }], metadata, { forAgent: conversationId });
26688
+ this.addMessage('user', [{ type: 'text', text: text + (detail !== undefined ? ` Connector detail: ${suppliedValue(detail)}` : '') }], metadata, { forAgent: conversationId });
26238
26689
  }
26239
26690
  catch (err) {
26240
26691
  console.error('onRouteFailure: failed to record send-failure marker:', err);
@@ -26351,6 +26802,9 @@ export class AgentFramework {
26351
26802
  restartClaims() {
26352
26803
  return (this.mcpRestartClaims ??= { ids: new Map(), prefixes: new Map() });
26353
26804
  }
26805
+ closedTabConfigs() {
26806
+ return (this.closedTabConfigMap ??= new Map());
26807
+ }
26354
26808
  legacyConnectAborts() {
26355
26809
  return (this.legacyConnectAbortMap ??= new Map());
26356
26810
  }
@@ -26898,14 +27352,63 @@ export class AgentFramework {
26898
27352
  'it stays registered until a disconnect, restart or shutdown closes it:', closeError instanceof Error ? closeError.message : closeError);
26899
27353
  });
26900
27354
  }
27355
+ /** RFC-014: who held the name `toolsets` at startup (a module given to
27356
+ * create(); a server's toolPrefix of it is refused), so a server that
27357
+ * leaves toolHints out has none for the life of this framework; null when
27358
+ * the name was free. */
27359
+ toolsetsNameHeldAtStart = null;
27360
+ /** At startup: note who holds the name the meta-tools need, and say which
27361
+ * servers that leaves without hints. */
27362
+ reserveToolsetsName(modules, servers) {
27363
+ const module = modules.find((m) => m.name === TOOLSETS_MODULE);
27364
+ this.toolsetsNameHeldAtStart = module ? `module "${TOOLSETS_MODULE}"` : null;
27365
+ if (!this.toolsetsNameHeldAtStart)
27366
+ return;
27367
+ // Those that would have had hints: MCPL servers that leave the setting out.
27368
+ const defaulted = servers
27369
+ .filter((s) => (s.toolHints === undefined || s.toolHints === null) && isLegacyFamily(s))
27370
+ .map((s) => s.id);
27371
+ if (defaulted.length) {
27372
+ console.error(`[mcpl] tool hints (RFC-014) are off for ${defaulted.join(', ')}, which leave toolHints out: the name "${TOOLSETS_MODULE}" ` +
27373
+ `the meta-tools need is ${this.toolsetsNameHeldAtStart}. Free the name to give them hints, or set toolHints: false on them to keep them off without this line.`);
27374
+ }
27375
+ }
27376
+ /**
27377
+ * RFC-014: whether a server has tool hints. Only an MCPL (legacy-family)
27378
+ * server can: a modern MCP server never has them. `true` and `false` are
27379
+ * as configured. Left out (or null) they're on, wherever the name
27380
+ * `toolsets` the meta-tools need is free: no other module holds it and no
27381
+ * server's prefix is it, now or at startup. So a configuration that loaded
27382
+ * when hints were opt-in still loads. A name held at startup stays held
27383
+ * for the framework's life.
27384
+ */
27385
+ hintsFor(config) {
27386
+ if (config.toolHints === false || !isLegacyFamily(config))
27387
+ return false;
27388
+ if (config.toolHints === true)
27389
+ return true;
27390
+ if (this.toolsetsNameHeldAtStart !== null)
27391
+ return false;
27392
+ if ((config.toolPrefix ?? `mcpl--${config.id}`) === TOOLSETS_MODULE)
27393
+ return false;
27394
+ const module = this.moduleRegistry.getAllModules().find((m) => m.name === TOOLSETS_MODULE);
27395
+ if (module && !(module instanceof ToolsetsModule))
27396
+ return false;
27397
+ // A restart moving its server to the prefix holds the name as a claim.
27398
+ if (this.restartClaims().prefixes.has(TOOLSETS_MODULE))
27399
+ return false;
27400
+ const owner = this.mcplPrefixMap.get(TOOLSETS_MODULE);
27401
+ return owner === undefined || owner === config.id;
27402
+ }
26901
27403
  /**
26902
27404
  * The host capabilities offered to one server: the host's own, plus
26903
- * RFC-014's `toolHints` where its configuration asks for them. Without it,
26904
- * the very object every server is offered.
27405
+ * RFC-014's `toolHints` unless its configuration turns them off
27406
+ * (`toolHints: false`). With them off, the very object every server is
27407
+ * offered.
26905
27408
  */
26906
- mcplHostCapabilitiesFor(config) {
27409
+ mcplHostCapabilitiesFor(config, hints = this.hintsFor(config)) {
26907
27410
  const base = this.mcplHostCapabilities;
26908
- return config.toolHints === true ? { ...base, toolHints: true } : base;
27411
+ return hints ? { ...base, toolHints: true } : base;
26909
27412
  }
26910
27413
  /** RFC-014: every toolHints server's presence, made on first use. */
26911
27414
  toolsets() {
@@ -26923,20 +27426,181 @@ export class AgentFramework {
26923
27426
  return undefined;
26924
27427
  return this.mcplTools.some((t) => t.name === name) ? 'static' : 'dynamic';
26925
27428
  },
27429
+ hostOpen: (agentName) => this.hostPresentation?.openMarkers(agentName) ?? [],
26926
27430
  }));
26927
27431
  }
26928
- /** RFC-014: the `toolsets` module (toolsets--open, --act, --look,
26929
- * --close), registered once, by the first server configured with
26930
- * toolHints. Its tools don't change after that. */
27432
+ /**
27433
+ * The host's own toolsets (host-presentation.ts): the built-in "settings"
27434
+ * toolset and each MCPL server's tab. State is kept beside the store, in
27435
+ * `config/toolsets.json`; with no store path, in memory only.
27436
+ */
27437
+ makeHostPresentation(storePath) {
27438
+ return new HostPresentation({
27439
+ // As the agent is shown them: its description overrides applied.
27440
+ available: (agentName) => (this.agents.has(agentName) ? this.compressionToolsForAgent(agentName) : this.getAllTools()),
27441
+ placedInBuiltin: (name) => this.placedInBuiltin(name),
27442
+ hidden: (agentName) => {
27443
+ if (!this.agents.has(agentName))
27444
+ return new Set();
27445
+ const snapshot = this.inspectToolPresentation(agentName);
27446
+ return new Set(snapshot?.entries.filter((e) => !e.visible).map((e) => e.name) ?? []);
27447
+ },
27448
+ serverOf: (name) => this.resolveMcplTool(name)?.[0] ?? null,
27449
+ servers: () => {
27450
+ const out = new Map();
27451
+ for (const [id, config] of this.mcplServerConfigs) {
27452
+ out.set(id, { id, configured: config.presentation ?? 'full', connected: !!this.mcplServerRegistry?.getServer(id) || this.modernServers().has(id) });
27453
+ }
27454
+ for (const [id, config] of this.closedTabConfigs()) {
27455
+ if (!out.has(id))
27456
+ out.set(id, { id, configured: config.presentation ?? 'full', connected: false });
27457
+ }
27458
+ return [...out.values()];
27459
+ },
27460
+ canUseMeta: (agentName) => this.hostToolsetsFor(agentName),
27461
+ disconnect: async (serverId) => {
27462
+ const config = this.mcplServerConfigs.get(serverId);
27463
+ if (!config)
27464
+ throw new Error(`no MCPL server "${serverId}" is connected here`);
27465
+ this.refuseOperatorAfterStop('disconnect MCPL server');
27466
+ // As a transient close: its checkpoint trees and RFC-014 state stay
27467
+ // for the connect its next open makes.
27468
+ await this.trackOperatorOperation('mcpl:disconnect', () => this.runDisconnectMcplServer(serverId, { keepToolsets: true, keepCheckpoints: true }));
27469
+ this.closedTabConfigs().set(serverId, config);
27470
+ },
27471
+ reconnect: async (serverId) => {
27472
+ const config = this.closedTabConfigs().get(serverId);
27473
+ if (!config)
27474
+ throw new Error(`no MCPL server "${serverId}" is kept as a closed tab here`);
27475
+ this.refuseOperatorAfterStop('connect MCPL server');
27476
+ await this.trackOperatorOperation('mcpl:connect', () => this.runConnectMcplServer(config, undefined, { reopen: true }));
27477
+ },
27478
+ log: (line) => console.error(line),
27479
+ isUtility: (name) => this.isModuleUtility(name),
27480
+ residents: () => this.heartbeatTargets(),
27481
+ }, storePath ? join(storePath, 'config', 'toolsets.json') : null);
27482
+ }
27483
+ /** Whether a name is a module utility (and not a tool of its module, which takes the name). */
27484
+ isModuleUtility(name) {
27485
+ const sep = name.indexOf('--');
27486
+ if (sep <= 0)
27487
+ return false;
27488
+ const module = this.moduleRegistry.getModule(name.slice(0, sep));
27489
+ if (!module)
27490
+ return false;
27491
+ const bare = name.slice(sep + 2);
27492
+ try {
27493
+ return !module.getTools().some((t) => t.name === bare) && (module.getUtilities?.()?.some((t) => t.name === bare) ?? false);
27494
+ }
27495
+ catch {
27496
+ return false;
27497
+ }
27498
+ }
27499
+ /** Whether an agent has the host's own toolsets: the meta-tools registered, and
27500
+ * usable by it (the subconscious keeps its own small surface). */
27501
+ /**
27502
+ * What a call of the removed `utils` is answered: where utilities went, as
27503
+ * this agent can reach them. One that can't use the built-in toolset is
27504
+ * told to call a utility by its own name and which ones it may call.
27505
+ */
27506
+ utilsRemoved(agentName) {
27507
+ if (agentName === undefined || this.hostToolsetsFor(agentName))
27508
+ return UTILS_REMOVED;
27509
+ const agent = this.agents.get(agentName);
27510
+ const why = this.moduleRegistry.getAllModules().find((m) => m.name === TOOLSETS_MODULE) instanceof ToolsetsModule
27511
+ ? `its allowedTools doesn't permit ${META_TOOL_NAMES.open} and ${META_TOOL_NAMES.act}`
27512
+ : `this host's module named "${TOOLSETS_MODULE}" isn't the framework's`;
27513
+ const mine = this.getAllTools().filter((t) => this.isModuleUtility(t.name) && (agent?.canUseTool(t.name) ?? true)).map((t) => t.name);
27514
+ return `utils was removed: module utilities are in the host's built-in toolset now, which this agent can't use (${why}). ` +
27515
+ `Call a utility by its own name instead${mine.length ? `; this agent may call ${mine.join(', ')}` : '; this agent may call none'}. Nothing was run.`;
27516
+ }
27517
+ hostToolsetsFor(agentName) {
27518
+ if (agentName === this.subconsciousAgentName)
27519
+ return false;
27520
+ const module = this.moduleRegistry.getAllModules().find((m) => m.name === TOOLSETS_MODULE);
27521
+ if (!(module instanceof ToolsetsModule))
27522
+ return false;
27523
+ const agent = this.agents.get(agentName);
27524
+ if (!agent)
27525
+ return true;
27526
+ return agent.canUseTool(META_TOOL_NAMES.open) && agent.canUseTool(META_TOOL_NAMES.act);
27527
+ }
27528
+ /**
27529
+ * Whether a host tool belongs to the built-in toolset: the framework's own
27530
+ * rarely-used ones (BUILTIN_TOOLSET_TOOLS), a module's that it places there
27531
+ * (Module.toolPlacement), and every module utility.
27532
+ */
27533
+ placedInBuiltin(name) {
27534
+ if (BUILTIN_TOOLSET_TOOLS.has(name))
27535
+ return true;
27536
+ const sep = name.indexOf('--');
27537
+ if (sep <= 0)
27538
+ return false;
27539
+ const module = this.moduleRegistry.getModule(name.slice(0, sep));
27540
+ if (!module)
27541
+ return false;
27542
+ const bare = name.slice(sep + 2);
27543
+ // A module whose listing throws keeps its tools where they are.
27544
+ try {
27545
+ if (module.getTools().some((t) => t.name === bare))
27546
+ return module.toolPlacement?.(bare) === 'builtin';
27547
+ return module.getUtilities?.()?.some((t) => t.name === bare) ?? false;
27548
+ }
27549
+ catch {
27550
+ return false;
27551
+ }
27552
+ }
27553
+ /**
27554
+ * The tool list an agent's requests carry, from the tools it may call: its
27555
+ * host presentation applied (the built-in toolset's tools, the servers it
27556
+ * has as tabs and its hidden tools left out; a utility never in it), as
27557
+ * every request of a registered agent has it (with its own tool
27558
+ * presentation, hidden tools and descriptions, applied first). For a stream that isn't
27559
+ * registered yet, such as a fork whose orientation is written before its
27560
+ * run starts, the host says whether it will be able to call
27561
+ * `toolsets--open` and `toolsets--act` (`canUseMeta`); left out, the agent
27562
+ * registered under that name is asked. Presentation it chose itself (open
27563
+ * toolsets, tabs) is read under that name, so a new name starts from the
27564
+ * configured presentation.
27565
+ */
27566
+ presentedTools(agentName, tools, canUseMeta) {
27567
+ // A registered agent's own presentation first, as its requests have it
27568
+ // before the host's: the tools it hid left out, its descriptions applied.
27569
+ const snapshot = this.agents.has(agentName) ? this.inspectToolPresentation(agentName) : undefined;
27570
+ if (snapshot) {
27571
+ const hidden = new Set(snapshot.entries.filter((e) => !e.visible).map((e) => e.name));
27572
+ const shown = new Map(snapshot.advertised.map((t) => [t.name, t]));
27573
+ tools = tools.filter((t) => !hidden.has(t.name)).map((t) => shown.get(t.name) ?? t);
27574
+ }
27575
+ if (!this.hostPresentation)
27576
+ return tools;
27577
+ if (canUseMeta === undefined)
27578
+ return this.hostPresentation.header(agentName, tools);
27579
+ // As hostToolsetsFor decides for a registered agent: the subconscious and
27580
+ // a host whose module named toolsets isn't the framework's present nothing.
27581
+ const meta = canUseMeta && agentName !== this.subconsciousAgentName
27582
+ && this.moduleRegistry.getAllModules().find((m) => m.name === TOOLSETS_MODULE) instanceof ToolsetsModule;
27583
+ return this.hostPresentation.header(agentName, tools, meta);
27584
+ }
27585
+ /** The host presentation of a server's tools, for the tool list: the agent's
27586
+ * view, less the built-in toolset and the servers it has as tabs. */
27587
+ headerTools(agentName, tools) {
27588
+ return this.hostPresentation ? this.hostPresentation.header(agentName, tools) : tools;
27589
+ }
27590
+ /** The `toolsets` module (toolsets--open, --act, --look, --close): the
27591
+ * meta-tools of the host's own toolsets and of RFC-014 servers' toolsets.
27592
+ * Registered once, at create(), unless a module given to create() holds
27593
+ * the name; a server with toolHints: true then can't have hints, and its
27594
+ * connect is refused here. Its tools don't change after that. */
26931
27595
  async ensureToolsetsModule() {
26932
27596
  const hub = this.toolsets();
26933
27597
  const existing = this.moduleRegistry.getAllModules().find((m) => m.name === TOOLSETS_MODULE);
26934
27598
  if (existing instanceof ToolsetsModule)
26935
27599
  return;
26936
27600
  if (existing) {
26937
- throw new Error(`A module named "${TOOLSETS_MODULE}" is registered, and a server configured with toolHints needs that name for its meta-tools`);
27601
+ throw new Error(`A module named "${TOOLSETS_MODULE}" is registered, and a server configured with toolHints: true needs that name for its meta-tools: set toolHints: false on it (or leave the setting out: it then has no hints here), or free the name`);
26938
27602
  }
26939
- await this.addModule(new ToolsetsModule(hub, (call) => this.changeTaker(call)));
27603
+ await this.addModule(new ToolsetsModule(hub, (call) => this.changeTaker(call), this.hostPresentation));
26940
27604
  }
26941
27605
  /** RFC-014 §5.3: after a (re)connect whose policy has settled, read what
26942
27606
  * the server holds open and adopt it. In the background: its notices are
@@ -26969,7 +27633,7 @@ export class AgentFramework {
26969
27633
  return false;
26970
27634
  }
26971
27635
  try {
26972
- this.addMessage('user', [{ type: 'text', text: `MCPL server "${serverId}", tool changes:\n${text}` }], { system: true, kind: 'mcpl-toolsets', serverId, ...(opts.invite ? { invite: true } : {}) });
27636
+ this.addMessage('user', [{ type: 'text', text: `[${NOTICE_TAG.toolsets}] MCPL server "${serverId}", tool changes:\n${text}` }], { system: true, kind: 'mcpl-toolsets', serverId, ...(opts.invite ? { invite: true } : {}) });
26973
27637
  return true;
26974
27638
  }
26975
27639
  catch (error) {
@@ -27119,20 +27783,24 @@ export class AgentFramework {
27119
27783
  let registeredEpoch = 0;
27120
27784
  const abort = new AbortController();
27121
27785
  this.legacyConnectAborts().set(config.id, abort);
27786
+ // RFC-014: decided once for this connect, so the meta-tools, the offer
27787
+ // and the read tap agree though the state hintsFor reads can change
27788
+ // across the awaits below.
27789
+ const hints = this.hintsFor(config);
27122
27790
  try {
27123
- // RFC-014: a toolHints server's meta-tools exist before it connects.
27124
- if (config.toolHints === true)
27791
+ // RFC-014: a hinted server's meta-tools exist before it connects.
27792
+ if (hints)
27125
27793
  await this.ensureToolsetsModule();
27126
27794
  // Its policy exchange is in flight from its registration until the
27127
27795
  // exchange settles: an opener that runs meanwhile (a resume, startup's
27128
27796
  // staged open) passes the connection by, so nothing opens its planes
27129
27797
  // before its grant exists.
27130
- connection = await this.mcplServerRegistry.addServer(connectionConfig, this.mcplHostCapabilitiesFor(config), abort.signal, (registering) => {
27798
+ connection = await this.mcplServerRegistry.addServer(connectionConfig, this.mcplHostCapabilitiesFor(config, hints), abort.signal, (registering) => {
27131
27799
  registeredEpoch = registering.transportEpoch;
27132
27800
  this.mcplPolicyExchanges.set(registering, registeredEpoch);
27133
27801
  // RFC-014 §6.1: hints apply as the connection reads them, from its
27134
27802
  // first message, so the tap is on before any I/O can deliver one.
27135
- if (config.toolHints === true)
27803
+ if (hints)
27136
27804
  this.toolsets().controllerFor(config).attach(registering);
27137
27805
  });
27138
27806
  }
@@ -27444,7 +28112,7 @@ export class AgentFramework {
27444
28112
  // instances; toolHints withdrawn makes every tool static. Its impact is
27445
28113
  // surface-changed, applied, with no grant consequence (§17.6).
27446
28114
  const declarations = toolsetDeclarationsOf(fetched);
27447
- const toolsetsChanged = config.toolHints === true
28115
+ const toolsetsChanged = this.hintsFor(config)
27448
28116
  && JSON.stringify(declarations) !== JSON.stringify(connection.toolsetDeclarations);
27449
28117
  connection.toolsetDeclarations = declarations;
27450
28118
  if (toolsetsChanged)
@@ -27727,7 +28395,7 @@ export class AgentFramework {
27727
28395
  }
27728
28396
  /** connectMcplServer's work, for a restart's connect too: the id and tool
27729
28397
  * prefix its `claim` holds are free to it, and refused to everyone else. */
27730
- async runConnectMcplServer(config, claim) {
28398
+ async runConnectMcplServer(config, claim, opts = {}) {
27731
28399
  assertServerConfigBooleans(config);
27732
28400
  // Lazily bring up the MCPL subsystem — a framework that started with zero
27733
28401
  // configured servers can still deploy its first one at runtime.
@@ -27753,8 +28421,31 @@ export class AgentFramework {
27753
28421
  // registers. A server already registered was refused just above, so the
27754
28422
  // read never reaches a registered server's trees.
27755
28423
  this.checkpointManager?.loadHostStates(config.id);
28424
+ // An operator's connect ends a closed tab as it begins, as a restart
28425
+ // does: one that fails once its configuration is taken leaves an
28426
+ // ordinary server that's down, under that configuration; one refused
28427
+ // before (its transport, the awareness ledger) changes nothing, and the
28428
+ // closed tab is put back. Whether it was one is the kept configuration's
28429
+ // to say. An agent's open (reopen) ends it only once connected, so one
28430
+ // that fails leaves the closed tab to open again.
28431
+ const keptClosedTab = opts.reopen ? undefined : this.closedTabConfigs().get(config.id);
28432
+ const closedWas = keptClosedTab ? this.hostPresentation?.closedState(config.id) : undefined;
28433
+ if (keptClosedTab) {
28434
+ this.closedTabConfigs().delete(config.id);
28435
+ this.hostPresentation?.noteConnected(config.id, true);
28436
+ }
27756
28437
  const oldToolNames = new Set(this.mcplTools.map(t => t.name));
27757
- const connection = await this.connectMcplServerInternal(config, false, claim);
28438
+ let connection;
28439
+ try {
28440
+ connection = await this.connectMcplServerInternal(config, false, claim);
28441
+ }
28442
+ catch (error) {
28443
+ if (keptClosedTab && !this.mcplServerConfigs.has(config.id) && !this.closedTabConfigs().has(config.id)) {
28444
+ this.closedTabConfigs().set(config.id, keptClosedTab);
28445
+ this.hostPresentation?.restoreClosed(config.id, closedWas);
28446
+ }
28447
+ throw error;
28448
+ }
27758
28449
  // A stop() that began during the connect's setup or its tool listing
27759
28450
  // ends the connect: it doesn't wait on a server for a framework that is
27760
28451
  // stopping, and the teardown closes what the connect set up.
@@ -27777,6 +28468,9 @@ export class AgentFramework {
27777
28468
  if (this.mcpServerOps?.get(config.id)?.kind === 'disconnect' || !AgentFramework.mcpConnectionServes(connection)) {
27778
28469
  throw new Error(`MCP server "${config.id}" was not connected: it was disconnected or lost while its tools were being listed`);
27779
28470
  }
28471
+ // Connected, however it was asked for: not a closed tab any more.
28472
+ const wasClosedTab = this.closedTabConfigs().delete(config.id);
28473
+ this.hostPresentation?.noteConnected(config.id, wasClosedTab);
27780
28474
  this.emitMcplToolDiff(oldToolNames, config.id);
27781
28475
  }
27782
28476
  /**
@@ -27803,7 +28497,17 @@ export class AgentFramework {
27803
28497
  const claim = this.restartClaims().ids.get(id);
27804
28498
  if (claim)
27805
28499
  claim.endedBy = `MCP server "${id}" was disconnected, which ends the restart of "${claim.restarting}"`;
27806
- return this.trackOperatorOperation('mcpl:disconnect', () => this.runDisconnectMcplServer(id));
28500
+ // A closed tab removed by its operator: there's nothing to connect it
28501
+ // again with, and what its disconnect kept for that connect (checkpoint
28502
+ // trees, RFC-014 state) goes, as any disconnect removes it.
28503
+ if (this.closedTabConfigs().delete(id) && !this.mcplServerConfigs.has(id)) {
28504
+ this.checkpointManager?.removeServer(id);
28505
+ this.toolsetHub?.forget(id);
28506
+ this.hostPresentation?.noteRemoved(id);
28507
+ return;
28508
+ }
28509
+ await this.trackOperatorOperation('mcpl:disconnect', () => this.runDisconnectMcplServer(id));
28510
+ this.hostPresentation?.noteRemoved(id);
27807
28511
  }
27808
28512
  async runDisconnectMcplServer(id, opts = {}) {
27809
28513
  if (!this.mcplServerRegistry) {
@@ -27856,7 +28560,10 @@ export class AgentFramework {
27856
28560
  // already transiently closed (reconnect pending) emits no second 'close'
27857
28561
  // from close(), and the transient path deliberately preserves checkpoints.
27858
28562
  this.featureSetManager?.removeServer(id);
27859
- this.checkpointManager?.removeServer(id);
28563
+ // A closed tab (host-presentation.ts) keeps its checkpoint trees for
28564
+ // the connect its next open makes, as a transient close does.
28565
+ if (!opts.keepCheckpoints)
28566
+ this.checkpointManager?.removeServer(id);
27860
28567
  if (!opts.keepToolsets)
27861
28568
  this.toolsetHub?.forget(id);
27862
28569
  // Only this server's own claims, as the prefix map holds them rather
@@ -27869,6 +28576,7 @@ export class AgentFramework {
27869
28576
  }
27870
28577
  this.mcplServerConfigs.delete(id);
27871
28578
  referenceRegistry.forgetServerAddress(id, this);
28579
+ this.eventGate?.sourcesChanged();
27872
28580
  await this.refreshMcplTools();
27873
28581
  this.emitMcplToolDiff(oldToolNames, id);
27874
28582
  }
@@ -27920,7 +28628,8 @@ export class AgentFramework {
27920
28628
  // A stop(), or a disconnect of the id it moves the server to, that
27921
28629
  // begins after these checks isn't held off: it ends the restart, as it
27922
28630
  // would end a connect.
27923
- const config = newConfig ?? this.mcplServerConfigs.get(id);
28631
+ // A closed tab restarts from the configuration it's kept with.
28632
+ const config = newConfig ?? this.mcplServerConfigs.get(id) ?? this.closedTabConfigs().get(id);
27924
28633
  if (!config) {
27925
28634
  throw new Error(`MCPL server "${id}" is not configured`);
27926
28635
  }
@@ -27962,7 +28671,17 @@ export class AgentFramework {
27962
28671
  this.restartClaims().ids.set(config.id, claim);
27963
28672
  this.restartClaims().prefixes.set(prefix, claim);
27964
28673
  try {
27965
- await this.runDisconnectMcplServer(id, { keepToolsets: config.id === id && config.toolHints === true });
28674
+ await this.runDisconnectMcplServer(id, { keepToolsets: config.id === id && this.hintsFor(config) });
28675
+ // A restart ends a closed tab as it begins: the operator connects the
28676
+ // server now, and one whose connect fails is left an ordinary server
28677
+ // that's down. Moved to another id, the old id's tab is no more.
28678
+ if (this.closedTabConfigs().has(id)) {
28679
+ if (config.id === id)
28680
+ this.hostPresentation?.noteConnected(id, true);
28681
+ else
28682
+ this.hostPresentation?.noteRemoved(id);
28683
+ this.closedTabConfigs().delete(id);
28684
+ }
27966
28685
  try {
27967
28686
  await this.runConnectMcplServer(config, claim);
27968
28687
  }
@@ -28076,6 +28795,17 @@ export class AgentFramework {
28076
28795
  return prefixesMeetError(config.id, prefix, otherId, other).message;
28077
28796
  }
28078
28797
  }
28798
+ // A closed tab keeps its prefix (and the prefixes it would meet) for the
28799
+ // connect its next open makes.
28800
+ for (const [otherId, other] of this.closedTabConfigs()) {
28801
+ if (otherId === config.id || otherId === leaving || this.mcplServerConfigs.has(otherId))
28802
+ continue;
28803
+ const otherPrefix = other.toolPrefix ?? `mcpl--${otherId}`;
28804
+ if (otherPrefix === prefix)
28805
+ return `MCPL server "${config.id}" toolPrefix "${prefix}" collides with server "${otherId}" (a closed tab)`;
28806
+ if (prefixesMeet(prefix, otherPrefix))
28807
+ return prefixesMeetError(config.id, prefix, otherId, otherPrefix).message;
28808
+ }
28079
28809
  for (const [other, holder] of this.restartClaims().prefixes) {
28080
28810
  if (holder !== claim && other !== prefix && prefixesMeet(prefix, other)) {
28081
28811
  return `MCPL server "${config.id}" toolPrefix "${prefix}" meets the toolPrefix "${other}" the restart of ` +
@@ -28107,7 +28837,12 @@ export class AgentFramework {
28107
28837
  */
28108
28838
  listMcplServers() {
28109
28839
  const result = [];
28110
- for (const [id, config] of this.mcplServerConfigs) {
28840
+ // Closed tabs too: configured, kept disconnected until an agent opens one.
28841
+ const configured = [...this.mcplServerConfigs];
28842
+ for (const [id, config] of this.closedTabConfigs())
28843
+ if (!this.mcplServerConfigs.has(id))
28844
+ configured.push([id, config]);
28845
+ for (const [id, config] of configured) {
28111
28846
  const prefix = config.toolPrefix ?? `mcpl--${id}`;
28112
28847
  const modern = this.modernServers().get(id) ?? null;
28113
28848
  const connection = modern ? null : this.mcplServerRegistry?.getServer(id) ?? null;
@@ -28131,6 +28866,7 @@ export class AgentFramework {
28131
28866
  result.push({
28132
28867
  id,
28133
28868
  connected,
28869
+ closedTab: this.closedTabConfigs().has(id),
28134
28870
  retrying: !connected && (modern?.willReconnect ?? connection?.willReconnect ?? false),
28135
28871
  family: binding?.family ?? null,
28136
28872
  protocolVersion: modern?.protocolVersion ?? connection?.protocolVersion ?? null,
@@ -29125,7 +29861,7 @@ export class AgentFramework {
29125
29861
  moduleName: `mcpl:${serverId}:tools-refreshed`,
29126
29862
  });
29127
29863
  if (newTools.length > 0) {
29128
- const toolList = newTools.map(t => t.name).join(', ');
29864
+ const toolList = newTools.map(t => suppliedValue(t.name)).join(', ');
29129
29865
  this.pushEvent({
29130
29866
  type: 'external-message',
29131
29867
  source: `mcpl:${serverId}`,
@@ -29703,7 +30439,7 @@ export class AgentFramework {
29703
30439
  /**
29704
30440
  * Dispatch a synthesized channel tool call.
29705
30441
  */
29706
- dispatchChannelToolCall(agentName, call) {
30442
+ dispatchChannelToolCall(agentName, call, earlierWake) {
29707
30443
  // Conversation forks act only on their home channel. channel_publish and
29708
30444
  // channel_close default a missing channelId to home and reject foreign
29709
30445
  // ones; channel_open is rejected outright — opening channels mutates
@@ -29729,7 +30465,7 @@ export class AgentFramework {
29729
30465
  };
29730
30466
  // The home quoted for a sentence (quotedShown), as the agent names it
29731
30467
  // back, whose id-and-server form carries its own quotes.
29732
- const homeQuoted = this.channelRegistry?.quotedShown(home) ?? JSON.stringify(home.channelId);
30468
+ const homeQuoted = this.channelRegistry?.quotedShown(home) ?? quotedValue(home.channelId);
29733
30469
  // A home its server no longer registers (a channels/changed update
29734
30470
  // removed it, or this host removed its server; a dropped connection
29735
30471
  // keeps the entry) takes nothing, and the fork has nowhere else to
@@ -29811,7 +30547,9 @@ export class AgentFramework {
29811
30547
  const publisher = call.name === 'channel_publish' && callingTurn ? this.agents.get(agentName) : undefined;
29812
30548
  const readThrough = publisher ? this.publishReadThrough(publisher, call.id) : null;
29813
30549
  const published = {};
29814
- this.channelRegistry.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName, own: callingTurn ? this.ownChannels(callingTurn) : [] }, published)
30550
+ this.channelRegistry.handleChannelToolCall(call.name, call.input, {
30551
+ kind: 'agent', agentName, own: callingTurn ? this.ownChannels(callingTurn) : [], ...(earlierWake ? { earlierWake } : {}),
30552
+ }, published)
29815
30553
  .then((result) => {
29816
30554
  const destination = published.outcome?.status === 'delivered' ? published.outcome.destination : undefined;
29817
30555
  if (publisher && destination)
@@ -29861,13 +30599,15 @@ export class AgentFramework {
29861
30599
  noteTurnChannel(callingTurn, opened);
29862
30600
  this.lastAnnouncedLocus.set(agentName, opened);
29863
30601
  const inboundOnlyServer = this.channelRegistry?.inboundOnlyServer(opened);
30602
+ // The routing word comes first, ahead of any backscroll, so a
30603
+ // result cut to its head for being long keeps it.
29864
30604
  result = {
29865
30605
  ...result,
29866
30606
  data: {
29867
- ...result.data,
29868
30607
  routing: inboundOnlyServer
29869
30608
  ? `${inboundOnlySpeechNote(inboundOnlyServer)} Other channels need an explicit send tool.`
29870
30609
  : 'Your plain speech now lands in this channel. Other channels need an explicit send tool.',
30610
+ ...result.data,
29871
30611
  },
29872
30612
  };
29873
30613
  console.error(`[routing] ${agentName}: channel_open -> pin moved to ${qualifiedChannelName(opened)} (announced in tool result)`);
@@ -30202,7 +30942,7 @@ export class AgentFramework {
30202
30942
  // answered for it.
30203
30943
  const now = this.channelRegistry.getTuneOutState(channel.serverId, channelId);
30204
30944
  const error = now?.params.epochId === tuneOut.params.epochId
30205
- ? alreadyTunedOutText(shownNow(), now.params, openError ?? 'Its transport is open now, so its messages reach the subconscious.')
30945
+ ? alreadyTunedOutText(this.channelRegistry.quotedShown(channel), now.params, openError ?? 'Its transport is open now, so its messages reach the subconscious.')
30206
30946
  : TUNE_OUT_REPLACED;
30207
30947
  this.pushEvent({
30208
30948
  type: 'tool-result',
@@ -30341,6 +31081,9 @@ export class AgentFramework {
30341
31081
  console.error(`[sleep] ${agentName}: no prose target this turn — sleep announcement not posted (explicit mode never guesses)`);
30342
31082
  }
30343
31083
  }
31084
+ else if (!callingTurn.locus && callingTurn.heartbeat) {
31085
+ console.error(`[sleep] ${agentName}: a heartbeat turn has no channel — sleep announcement not posted`);
31086
+ }
30344
31087
  else {
30345
31088
  const locus = callingTurn.locus ?? null;
30346
31089
  registry.routeSpeech(agentName, announcement(locus), locus).then(noted).catch((err) => {
@@ -30523,7 +31266,7 @@ export class AgentFramework {
30523
31266
  return;
30524
31267
  if (Date.now() - startedAt > BARRIER_MS) {
30525
31268
  throw new Error(`save_recent_image: ${open.length} sibling tool call(s) in this batch still pending after ` +
30526
- `${Math.round(BARRIER_MS / 1000)}s (${open.map((p) => `${p.name} (call ${p.id})`).join(', ')}) — their results ` +
31269
+ `${Math.round(BARRIER_MS / 1000)}s (${open.map((p) => `${suppliedValue(p.name)} (call ${p.id})`).join(', ')}) — their results ` +
30527
31270
  'may carry newer images than anything in your context, so no index can be trusted yet. Nothing written. ' +
30528
31271
  'Save again once they have finished.');
30529
31272
  }
@@ -30570,7 +31313,7 @@ export class AgentFramework {
30570
31313
  throw new Error('save_recent_image: `path` (mount-prefixed) is required');
30571
31314
  }
30572
31315
  const ledger = this.toolImageLedgerFor(agentName);
30573
- const describeTool = (c) => `${c.toolName} (call ${c.toolCallId})`;
31316
+ const describeTool = (c) => `${suppliedValue(c.toolName)} (call ${c.toolCallId})`;
30574
31317
  const unavailable = (status) => status === 'evicted'
30575
31318
  ? 'its bytes were evicted from the bounded retention budget'
30576
31319
  : 'this process has no record of that ref (refs are per-process and a restart clears them; ' +
@@ -30773,9 +31516,10 @@ export class AgentFramework {
30773
31516
  const content = messages[i]?.content;
30774
31517
  if (!Array.isArray(content))
30775
31518
  continue;
30776
- // Another agent's stored context injection isn't in this agent's
30777
- // view (stored-injections.ts), so its images aren't either.
30778
- if (isAnotherAgentsInjection(messages[i]?.metadata, agentName))
31519
+ // A row the agent's own view leaves out, such as another agent's
31520
+ // stored context injection (stored-injections.ts), isn't in its
31521
+ // window, so its images aren't either.
31522
+ if (!this.agentSees(agent, messages[i]?.metadata))
30779
31523
  continue;
30780
31524
  const messagesBack = total - (start + i);
30781
31525
  for (let b = content.length - 1; b >= 0 && collecting(); b--) {
@@ -30847,7 +31591,8 @@ export class AgentFramework {
30847
31591
  // error says that whether the entry changed isn't known
30848
31592
  // (writeBinary); the images before it are saved, and the
30849
31593
  // failure says so, as a returned one does.
30850
- failed(error);
31594
+ console.error(`[save_recent_image] writing "${destination}" failed:`, error);
31595
+ failed(thrownWords(error));
30851
31596
  return;
30852
31597
  }
30853
31598
  const inChronicle = result.data?.inChronicle === true;
@@ -30884,117 +31629,6 @@ export class AgentFramework {
30884
31629
  }
30885
31630
  })();
30886
31631
  }
30887
- /** The `utils` meta-tool: list/describe/run over module utilities
30888
- * (Module.getUtilities). Shared by the model-facing dispatcher, which a
30889
- * running code_execution script's calls take too, and executeToolCallFrom,
30890
- * which the puppet and a host reach through executeToolCall and a module
30891
- * through callTool. */
30892
- async handleUtilsToolCall(agentName, call, options = {}) {
30893
- const input = (call.input ?? {});
30894
- const allUtilities = this.moduleRegistry.getAllUtilities();
30895
- const utilities = allUtilities.filter(def => options.trustedModule || this.canAgentUseTool(agentName, def.name));
30896
- const counts = `${registeredUtilities(allUtilities.length)}, ${utilities.length} permitted`;
30897
- const available = () => utilities.length
30898
- ? `Available: ${utilities.map((u) => u.name).join(', ')} (${counts})`
30899
- : allUtilities.length
30900
- ? `${counts}; none permitted by this agent's allowedTools. Add each required module--utility name to allowedTools.`
30901
- : `${counts} (none registered)`;
30902
- switch (input.action) {
30903
- case 'list':
30904
- if (allUtilities.length > 0 && utilities.length === 0) {
30905
- return { success: false, isError: true, error: available() };
30906
- }
30907
- return {
30908
- success: true,
30909
- data: utilities.map((u) => ({ name: u.name, description: utilityOneLiner(u.description) })),
30910
- };
30911
- case 'describe': {
30912
- const def = allUtilities.find((u) => u.name === input.name);
30913
- if (!def) {
30914
- return { success: false, isError: true, error: `No utility "${String(input.name)}". ${available()}` };
30915
- }
30916
- if (!options.trustedModule) {
30917
- const refusal = this.agentToolPermissionFailure(agentName, { ...call, name: def.name }, false);
30918
- if (refusal)
30919
- return refusal;
30920
- }
30921
- return { success: true, data: def };
30922
- }
30923
- case 'run': {
30924
- const def = allUtilities.find((u) => u.name === input.name);
30925
- if (!def) {
30926
- return { success: false, isError: true, error: `No utility "${String(input.name)}", so nothing was done. ${available()}` };
30927
- }
30928
- if (!options.trustedModule) {
30929
- // The allowed outer utils call owns its lifecycle and terminal trace;
30930
- // refusing the utility does not create or refuse a separate call.
30931
- const refusal = this.agentToolPermissionFailure(agentName, { ...call, name: def.name }, false);
30932
- if (refusal)
30933
- return refusal;
30934
- }
30935
- const args = input.args ?? {};
30936
- const problems = toolArgsProblems(args, def.inputSchema, { values: true, field: 'args' });
30937
- if (problems.length > 0) {
30938
- // Teach by bounce: the error carries the schema, so the retry needs
30939
- // no describe round-trip.
30940
- return {
30941
- success: false,
30942
- isError: true,
30943
- error: `Nothing was done: ${def.name} ${problems.join('; ')}. Schema for ${def.name}: ${JSON.stringify(def.inputSchema)}`,
30944
- };
30945
- }
30946
- // Same dispatch as a first-class tool call — the module cannot tell
30947
- // which surface the call came through. The registry checks the call
30948
- // against the utility resolved here, even where one of the module's
30949
- // tools shares its name. An in-process caller gets the module's own
30950
- // result; the model's path takes the result's stored form.
30951
- //
30952
- // The result goes where the utils call's does. On the model's path it
30953
- // is stored or handed to a script as that call's result, so it may be
30954
- // as long as that call's may (resultMaxCharsFor). An in-process caller
30955
- // gets it back, and the utils call carries what that caller may take,
30956
- // if anything does: the puppet's carries the agent's cap, since the
30957
- // puppet stores the pair under it.
30958
- const resultMaxChars = options.inProcess ? call.resultMaxChars : this.resultMaxCharsFor(agentName, call.id);
30959
- const utilityCall = {
30960
- id: call.id, name: def.name, input: args, callerAgentName: agentName,
30961
- ...(call.callerTurnId !== undefined ? { callerTurnId: call.callerTurnId } : {}),
30962
- ...(resultMaxChars !== undefined ? { resultMaxChars } : {}),
30963
- };
30964
- return options.inProcess
30965
- ? this.moduleRegistry.handleToolCall(utilityCall, { definition: def })
30966
- : this.moduleRegistry.storableToolCall(utilityCall, { definition: def });
30967
- }
30968
- default:
30969
- return { success: false, isError: true, error: 'action must be "list", "describe", or "run"' };
30970
- }
30971
- }
30972
- dispatchUtilsToolCall(agentName, call) {
30973
- this.emitTrace({ type: 'tool:started', module: 'framework', tool: 'utils', callId: call.id, input: call.input });
30974
- const startedAt = Date.now();
30975
- void this.handleUtilsToolCall(agentName, call)
30976
- .catch((err) => ({
30977
- success: false,
30978
- error: err instanceof Error ? err.message : String(err),
30979
- isError: true,
30980
- }))
30981
- .then((result) => {
30982
- if (result.success) {
30983
- this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'utils', callId: call.id, durationMs: Date.now() - startedAt });
30984
- }
30985
- else {
30986
- this.emitTrace({ type: 'tool:failed', module: 'framework', tool: 'utils', callId: call.id, error: result.error ?? 'unknown error' });
30987
- }
30988
- this.pushEvent({
30989
- type: 'tool-result',
30990
- callId: call.id,
30991
- toolName: call.name,
30992
- agentName,
30993
- moduleName: 'framework',
30994
- result,
30995
- });
30996
- });
30997
- }
30998
31632
  readAgentSettings(agentName, touched) {
30999
31633
  const s = this.getAgentRuntimeSettings(agentName);
31000
31634
  const saved = this.agents.get(agentName)?.getRuntimeSettingsOverrides() ?? {};
@@ -31326,4 +31960,11 @@ export class AgentFramework {
31326
31960
  };
31327
31961
  }
31328
31962
  }
31963
+ function thrownWords(error) {
31964
+ if (error instanceof Refusal)
31965
+ return error.message;
31966
+ const code = errnoCode(error);
31967
+ const on = errnoWayOn(code, "Calling again may work; if it fails the same way, whoever runs this host can find the whole error in this host's log.");
31968
+ return `${systemWords(code, error).replace(/\.$/, '')}. ${on}`;
31969
+ }
31329
31970
  //# sourceMappingURL=framework.js.map