@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
package/src/framework.ts CHANGED
@@ -91,8 +91,8 @@ import { ToolLifecycleEmitter, parseToolObserveParams, type ScriptCallOrigin } f
91
91
  import { checkToolPattern, describeUnmatchedPattern, type PatternServer } from './mcpl/tool-pattern-check.js';
92
92
  import { BUILTIN_TOOL_CLASSES, normalizeClassTable, parseDeclaredClasses, resolveToolClass, type EffectiveToolClass, type ToolClass, type ToolClassSource } from './mcpl/tool-classes.js';
93
93
  import { PushHandler, type McplPushEvent } from './mcpl/push-handler.js';
94
- import { readSkipReplyWake, skipReplyWakeRefusal } from './mcpl/skip-reply-wake.js';
95
- import { convertBlock } from './mcpl/convert-block.js';
94
+ import { readSkipReplyWake, skipReplyWakeRefusal, type SkipReplyEarlierWake } from './mcpl/skip-reply-wake.js';
95
+ import { convertBlocks } from './mcpl/convert-block.js';
96
96
  import {
97
97
  PushCoalescer, CoalesceError, PUSH_COALESCING_SUPPORT, validateCoalesceMember, validateCoalescedContent,
98
98
  coalescingSubjectKey, type AssemblyResult, type CoalescedOccurrence, type CoalescingPlacement, type CoalescingSnapshot,
@@ -103,16 +103,19 @@ import { isSilentHeartbeatMarker, isVisiblyEmptyContent, NON_STRING_TEXT_PLACEHO
103
103
  import { isProseTargetToken, parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js';
104
104
  import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js';
105
105
  import { InferenceRouter } from './mcpl/inference-router.js';
106
- import { ChannelRegistry, TUNE_OUT_REPLACED, connectorText, desiredStateInLog, destinationKey, journalToolDescription, NON_HOLDER_CHANNEL_DESCRIPTIONS, qualifiedChannelName, sameChannel, type ChannelRef, type ChannelToolOrigin, type PublishDestination, type PublishOutcome, type SelectedForOpening } from './mcpl/channel-registry.js';
106
+ import { ChannelRegistry, TUNE_OUT_REPLACED, connectorText, desiredStateInLog, destinationKey, journalToolDescription, NON_HOLDER_CHANNEL_DESCRIPTIONS, NO_CHANNEL_EXAMPLE, NO_PERSON_EXAMPLE, qualifiedChannelName, sameChannel, type ChannelRef, type ChannelToolOrigin, type PublishDestination, type PublishOutcome, type SelectedForOpening } from './mcpl/channel-registry.js';
107
107
  import { KEEP_IDLE_MS, KEEP_NEWEST, ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftAttempt, type DraftReason, type DraftState, type InheritedRisk } from './prose-drafts.js';
108
108
  import { ConversationRouter, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js';
109
109
  import { INBOUND_SOURCE_KEY, sourceBodyDigest, type InboundAcceptanceObserver, type InboundChannelSource, type InboundLane, type InboundSource, type InboundUnscopedSource } from './mcpl/inbound-source.js';
110
+ import { markNoticeOpenings, nameToTypeBack, quotedValue, showsAsItself, suppliedValue } from './supplied-text.js';
111
+ import { NOTICE_TAG } from './notice-tags.js';
110
112
  import type { IncomingMessageFacts, RoutingDecision } from './mcpl/conversation-router.js';
111
113
  import { safeSlice } from './safe-slice.js';
112
114
  import { SEND_TOOLS, bareToolName } from './send-tools.js';
113
115
  import { boundedErrorMessage, errorMessage } from './error-message.js';
114
116
  import { ProviderWaits, EVERY_MODEL, isReleaseModel, withoutControls, type ProviderWait } from './provider-waits.js';
115
117
  import type { WorkspaceModule } from './modules/workspace/index.js';
118
+ import { Refusal, errnoCode, errnoWayOn, systemWords } from './modules/workspace/answers.js';
116
119
  import type { ToolResultGuard, WithheldSpill } from './tool-result-guard.js';
117
120
  import {
118
121
  goesNative,
@@ -408,6 +411,12 @@ const HOST_METADATA_KEYS = [
408
411
  * marker too. A marker that wakes the agent can still set that turn's speech
409
412
  * channel (agent-framework#189); a `chat:system` notice can't (systemNotice).
410
413
  */
414
+ const isOwnScheduleWrite = (metadata?: MessageMetadata): boolean => {
415
+ const m = metadata as { serverId?: unknown; source?: unknown; tags?: unknown } | undefined;
416
+ return m?.serverId === 'heartbeat' || m?.source === 'gate:self-wake'
417
+ || (Array.isArray(m?.tags) && m.tags.includes('script:wake'));
418
+ };
419
+
411
420
  const isConversationalInjection = (metadata?: MessageMetadata): boolean => {
412
421
  if (!metadata) return true;
413
422
  const m = metadata as Record<string, unknown>;
@@ -669,6 +678,20 @@ const ambientMark = (tags?: string[]): { ambient?: true } =>
669
678
  const systemNoticeMark = (tags?: string[]): { systemNotice?: true } =>
670
679
  isSystemNotice(tags) ? { systemNotice: true } : {};
671
680
 
681
+ /**
682
+ * A wake from the heartbeat: a tick or a reminder, from the framework's own
683
+ * heartbeat or from an MCPL server whose id is `heartbeat` (heartbeat-mcpl),
684
+ * which carries no channel. A silent tick isn't one here: its turn keeps
685
+ * every plain word private already (suppressProse). Its turn's plain speech
686
+ * has no destination (TurnRouting.heartbeat).
687
+ */
688
+ function isHeartbeatWake(
689
+ trigger: Pick<InferenceRequest, 'source' | 'channelId' | 'suppressProse' | 'heartbeatTurn'> | undefined,
690
+ ): boolean {
691
+ if (trigger === undefined || trigger.channelId || trigger.suppressProse === true) return false;
692
+ return trigger.heartbeatTurn ?? trigger.source === 'heartbeat';
693
+ }
694
+
672
695
  // parseProsePrefix lives in ./mcpl/prose-grammar.ts. The outgoing stream
673
696
  // (Spec 14.3) needs no grammar of its own: it carries what delivery
674
697
  // published.
@@ -676,12 +699,12 @@ const systemNoticeMark = (tags?: string[]): { systemNotice?: true } =>
676
699
  /** One-time primer appended when an agent's proseRouting mode changes. */
677
700
  function proseModePrimer(mode: 'explicit' | 'hybrid' | 'locus' | 'disabled', helpAvailable: boolean): string {
678
701
  if (mode === 'locus') {
679
- return '[prose-routing] Mode change: your plain text auto-routes to the ' +
702
+ return `[${NOTICE_TAG.proseRouting}] Mode change: your plain text auto-routes to the ` +
680
703
  'conversational locus again, unless its server declared that channel inbound-only. ' +
681
704
  '`>>` destination prefixes are no longer needed.';
682
705
  }
683
706
  if (mode === 'hybrid') {
684
- return '[prose-routing] Hybrid routing enabled: unprefixed text still lands in the ' +
707
+ return `[${NOTICE_TAG.proseRouting}] Hybrid routing enabled: unprefixed text still lands in the ` +
685
708
  'current conversational locus, unless its server declared that channel inbound-only. ' +
686
709
  'A leading `>>>destination` envelope instead routes ' +
687
710
  'that prose to one uniquely resolved authorized Discord or Eidoverse channel. The ' +
@@ -698,7 +721,7 @@ function proseModePrimer(mode: 'explicit' | 'hybrid' | 'locus' | 'disabled', hel
698
721
  // prompt: those bytes belong to the recipe, and the deepest KV prefix must
699
722
  // not vary with framework code.
700
723
  return (
701
- '[prose-routing] Output mode changed: plain text is delivered only with a ' +
724
+ `[${NOTICE_TAG.proseRouting}] Output mode changed: plain text is delivered only with a ` +
702
725
  'destination prefix line, e.g. ">>#channel-name your text".' +
703
726
  (helpAvailable ? ' The prose_help tool shows the full syntax.' : '')
704
727
  );
@@ -738,29 +761,29 @@ const PROSE_HELP_TOOL: import('./types/index.js').ToolDefinition = {
738
761
  inputSchema: { type: 'object' as const, properties: {} },
739
762
  };
740
763
 
741
- /** "1 registered utility", "2 registered utilities". */
742
- const registeredUtilities = (n: number): string => `${n} registered ${n === 1 ? 'utility' : 'utilities'}`;
764
+ /** What a call of the removed `utils` tool is answered: where its utilities went. */
765
+ const UTILS_REMOVED = `utils was removed: module utilities are in the host's built-in toolset now. ` +
766
+ `${META_TOOL_NAMES.look} {toolset:"${BUILTIN_TOOLSET}"} lists them, ${META_TOOL_NAMES.open} {toolset:"${BUILTIN_TOOLSET}"} opens it, ` +
767
+ `and ${META_TOOL_NAMES.act} {tool, args} calls one. Nothing was run.`;
743
768
 
744
- /** The single slot behind which every rarely-used capability lives. Only
745
- * advertised when at least one module contributes utilities — with none
746
- * registered the surface is byte-identical to before this existed. */
747
- const UTILS_TOOL: import('./types/index.js').ToolDefinition = {
748
- name: 'utils',
749
- description:
750
- 'Rarely-used utilities, behind one slot instead of each costing a tool. ' +
751
- '"list" names the utilities you can use, with one-liners; "describe" returns one utility\'s full ' +
752
- 'input schema; "run" invokes it (args are validated against that schema — a ' +
753
- 'miss bounces back with the schema and the specific error).',
754
- inputSchema: {
755
- type: 'object' as const,
756
- properties: {
757
- action: { type: 'string', enum: ['list', 'describe', 'run'], description: 'What to do.' },
758
- name: { type: 'string', description: 'Utility name from list (required for describe/run).' },
759
- args: { type: 'object', description: "Arguments for run, per the utility's schema." },
760
- },
761
- required: ['action'],
762
- },
763
- };
769
+ /**
770
+ * The framework's own tools that belong to the host's built-in toolset
771
+ * ("settings", host-presentation.ts) rather than the tool list: the ones an
772
+ * agent reaches for rarely. Modules place theirs with Module.toolPlacement,
773
+ * and every module utility is there too.
774
+ */
775
+ export const BUILTIN_TOOLSET_TOOLS: ReadonlySet<string> = new Set([
776
+ 'agent_settings',
777
+ 'wake_add_rule',
778
+ 'wake_remove_rule',
779
+ 'gate_status',
780
+ 'event_tags',
781
+ 'set_tool_visibility',
782
+ 'set_tool_description',
783
+ 'heartbeat--heartbeat_status',
784
+ 'heartbeat--heartbeat_trigger',
785
+ 'workspace--status',
786
+ ]);
764
787
 
765
788
  type SettingsReading = {
766
789
  core: Record<string, string>;
@@ -784,31 +807,39 @@ const settingNumber = (name: string, value: unknown): number => {
784
807
  throw new Error(`${name} must be a number, not ${shownValue(value)}`);
785
808
  };
786
809
 
810
+ const DEFAULT_TURN_REARM_MS: readonly number[] = [2 * 60_000, 8 * 60_000, 32 * 60_000];
811
+
812
+ const turnRearmDelays = (value: unknown): readonly number[] => {
813
+ if (value === undefined) return DEFAULT_TURN_REARM_MS;
814
+ if (value === false) return [];
815
+ if (!Array.isArray(value) || value.length === 0
816
+ || ![...value].every((ms) => typeof ms === 'number' && Number.isFinite(ms) && ms > 0)) {
817
+ throw new Error('FrameworkConfig.exhaustedTurnRearmMs must be false, or a list of one or more delays in ms, ' +
818
+ `each a number above 0, not ${shownValue(value)}`);
819
+ }
820
+ return [...value];
821
+ };
822
+
787
823
  const settingBoolean = (name: string, value: unknown): boolean => {
788
824
  if (typeof value === 'boolean') return value;
789
825
  if (value === 'true' || value === 'false') return value === 'true';
790
826
  throw new Error(`${name} must be true or false, not ${shownValue(value)}`);
791
827
  };
792
828
 
793
- /** First sentence (or line) of a description — what `utils list` shows. */
794
- const utilityOneLiner = (description: string): string => {
795
- const line = description.split('\n')[0]!;
796
- const dot = line.indexOf('. ');
797
- return safeSlice(dot >= 0 ? line.slice(0, dot + 1) : line, 0, 160);
798
- };
799
829
 
800
830
  import { CheckpointManager } from './mcpl/checkpoint-manager.js';
801
831
  import { isToolAllowed } from './mcpl/tool-policy.js';
802
832
  import { asCalled, cutOffRefusal, markedInputRefusal, toolArgsProblems } from './tool-args.js';
803
833
  import { cutOffRound, type CutOffCall } from './cut-off-round.js';
804
- import { EventGate, checkToolInput, formatShadowWarning, shownValue, wakeLineText } from './gate/event-gate.js';
834
+ import { EventGate, MCPL_GATE_EVENT_TYPES, checkToolInput, formatShadowWarning, shownValue, wakeLineText } from './gate/event-gate.js';
805
835
  import { ReplyJudge } from './gate/reply-judge.js';
806
- import { HEARTBEAT_CHANGING_TOOLS, Heartbeat, isHeartbeatMcplServer, type HeartbeatWake } from './heartbeat/heartbeat.js';
836
+ import { HEARTBEAT_CHANGING_TOOLS, Heartbeat, isHeartbeatMcplServer, type HeartbeatScheduledWake, type HeartbeatWake } from './heartbeat/heartbeat.js';
807
837
  import type { GateEntryRef, GateEventInfo, WakeLine } from './gate/types.js';
808
838
  import { EPHEMERAL_USAGE_NAME, UsageTracker, persistedUsageProblem, type PersistedUsageState } from './usage/usage-tracker.js';
809
839
  import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js';
810
840
  import { McplProtocolVersionError, McplRequestError, McplUnreapedLaunchError, stderrTailOf, toolsetDeclarationsOf, withStderr, type McplConnectionCloseInfo, type McplServerConnection } from './mcpl/server-connection.js';
811
841
  import { ToolsetHub, TOOLSETS_MODULE, META_TOOL_NAMES, type ToolsetController } from './mcpl/toolset-host.js';
842
+ import { HostPresentation, BUILTIN_TOOLSET, type HostActTarget } from './host-presentation.js';
812
843
  import { isEmptyChange, type Presence } from './mcpl/tool-presence.js';
813
844
  import { ToolsetsModule, resolveAct, withLookHint, type TakenChange } from './mcpl/toolsets-module.js';
814
845
  import { ModernMcpConnection } from './mcpl/modern-connection.js';
@@ -1143,13 +1174,41 @@ const TURN_RETRY: unique symbol = Symbol('turn-retry');
1143
1174
  * record. A symbol, as TURN_RETRY is, so spreads and rests copy it.
1144
1175
  */
1145
1176
  const MERGED_BATCH: unique symbol = Symbol('merged-batch');
1177
+ const REARMED: unique symbol = Symbol('rearmed');
1146
1178
  const TURN_CARRY: unique symbol = Symbol('turn-carry');
1147
1179
  interface TurnCarry {
1148
1180
  silence: TurnSilence | null;
1149
1181
  sends: SendsReached | null;
1150
1182
  read: number;
1151
1183
  }
1152
- type MarkedRequest = InferenceRequest & { [TURN_RETRY]?: true; [MERGED_BATCH]?: InferenceRequest[]; [TURN_CARRY]?: TurnCarry };
1184
+ type MarkedRequest = InferenceRequest & { [TURN_RETRY]?: true; [MERGED_BATCH]?: InferenceRequest[]; [TURN_CARRY]?: TurnCarry; [REARMED]?: true };
1185
+
1186
+ function isRearmed(request: InferenceRequest | undefined): boolean {
1187
+ return request !== undefined && (request as MarkedRequest)[REARMED] === true;
1188
+ }
1189
+
1190
+ interface StatedWaitRun { since: number; holds: number; alerted: boolean; model: string; reason: string; heldUntil: number }
1191
+
1192
+ function waitAlertsOnItsOwn(wait: ProviderWait): boolean {
1193
+ return wait.until === null || wait.until - wait.setAt > PROVIDER_WAIT_ALERT_MS;
1194
+ }
1195
+
1196
+ // membrane 0.8.0 marks a deadline it reports as run out with this registered
1197
+ // symbol (on a sent request, or the Anthropic SDK's timeout, which covers its
1198
+ // connect timeout too), and doesn't export its reader.
1199
+ const MEMBRANE_DEADLINE_EXPIRY: unique symbol = Symbol.for('membrane.deadlineExpiry');
1200
+
1201
+ function isTransientFailure(error: Error): boolean {
1202
+ if (!isMembraneError(error) || error.retryable !== true) return false;
1203
+ if ((error as { [MEMBRANE_DEADLINE_EXPIRY]?: unknown })[MEMBRANE_DEADLINE_EXPIRY] === true) return false;
1204
+ return error.type === 'server' || error.type === 'network' || error.type === 'timeout' || error.type === 'rate_limit';
1205
+ }
1206
+
1207
+ function gotPartway(routing: TurnRouting): boolean {
1208
+ return routing.roundsRan === true || (routing.unflushedRounds ?? 0) > 0;
1209
+ }
1210
+
1211
+ const REARM_REASON = 'exhausted-turn-rearm';
1153
1212
 
1154
1213
  function mergedBatch(request: InferenceRequest): InferenceRequest[] | undefined {
1155
1214
  return (request as MarkedRequest)[MERGED_BATCH];
@@ -1274,6 +1333,17 @@ interface TurnRouting {
1274
1333
  * A fork's home still applies.
1275
1334
  */
1276
1335
  noChannel?: true;
1336
+ /**
1337
+ * The turn was opened by a heartbeat tick or reminder (isHeartbeatWake): a
1338
+ * wake that came from no channel, so nobody is waiting for its words. It
1339
+ * takes no locus and no global fallback, in its locus or in what servers
1340
+ * are told. Its plain speech with no destination of its own (a `>>` or
1341
+ * `>>>` target, a re-pin, the agent's channel_open) is held as a draft the
1342
+ * agent can resend, in every routing mode, rather than posted where the
1343
+ * latest inbound message came from; and a bounce of its speech requests no
1344
+ * wake, so a heartbeat can't start a loop of turns.
1345
+ */
1346
+ heartbeat?: true;
1277
1347
  /**
1278
1348
  * Locus and hybrid mode: where the turn's unpinned plain prose lands.
1279
1349
  * Frozen when the turn opens (home channel for a fork, else the trigger
@@ -1281,8 +1351,9 @@ interface TurnRouting {
1281
1351
  * system markers can't move it (2026-07-21 Cairn lounge misroute). Since
1282
1352
  * 2026-07-31 a re-pin moves it (repinTurnLocus): an addressed message, or a
1283
1353
  * follow-up in a channel the turn engaged. The agent's own channel_open
1284
- * moves it too. Unset in explicit and disabled mode, and for a turn with
1285
- * nowhere to speak.
1354
+ * moves it too. Unset in explicit and disabled mode, for a turn with
1355
+ * nowhere to speak, and for a heartbeat's turn until a re-pin or a
1356
+ * channel_open gives it one (TurnRouting.heartbeat).
1286
1357
  */
1287
1358
  locus?: ChannelRef;
1288
1359
  /**
@@ -1363,10 +1434,11 @@ interface TurnRouting {
1363
1434
  * for a re-inference, flushed at the end of a stream that didn't settle
1364
1435
  * the turn, or stored by the start that continues it. So is one a boundary
1365
1436
  * drained but couldn't re-pin for, being over budget or without a live
1366
- * stream. A message stored for another ordinary resident is one whenever
1367
- * it lands, since residents read one message slot and a boundary weighs
1368
- * only the messages drained for its own agent (noteForOtherResidents). A
1369
- * message tune-out diverted isn't one, since a resident's compiled view
1437
+ * stream. A message stored for another ordinary resident is one when it
1438
+ * lands, since residents read one message slot and a boundary weighs only
1439
+ * the messages drained for its own agent (noteForOtherResidents). A message
1440
+ * the agent's own view leaves out, such as one tune-out diverted or another
1441
+ * agent's stored context injection, isn't one, since its compiled view
1370
1442
  * leaves it out. A boundary that re-pins clears them, since what it injects
1371
1443
  * is newer. The turn's next start clears them all, and in locus or hybrid
1372
1444
  * mode first re-pins to the newest one still in the agent's store, as a
@@ -1598,7 +1670,7 @@ function ephemeralRunStopped(name: string, observed?: string): Error {
1598
1670
 
1599
1671
  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.";
1600
1672
  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.';
1601
- 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.';
1673
+ 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.`;
1602
1674
 
1603
1675
  /** The reason on the inference:aborted that ends a turn whose retry stop()
1604
1676
  * cancelled, whichever frame was waiting to retry: beginAgentTurn's, when
@@ -2327,6 +2399,7 @@ interface ProviderAccelerationCooldown {
2327
2399
  /** Its held requests were taken out by dropContinuations: release reopens
2328
2400
  * admission without synthesising an inference in their place. */
2329
2401
  emptiedByDrop?: boolean;
2402
+ rearm?: { number: number; of: number };
2330
2403
  /** Set when this hold is a provider wait (a stated retry-after) for the
2331
2404
  * agent's primary model: it holds primary turns only. Auxiliary calls are
2332
2405
  * admitted per model by the provider waits themselves, so a compression
@@ -2383,7 +2456,9 @@ const WAITING_TRACE_EVERY_MS = 60_000;
2383
2456
  /** A stated wait recorded to hold a model longer than this, or until
2384
2457
  * released, raises an ops alert. It is honoured in full either way: a hold
2385
2458
  * that long is a quota-scale event or a provider's mistake, and a person
2386
- * decides which (release-provider-wait). Shorter waits are pacing. */
2459
+ * decides which (release-provider-wait). Shorter waits are pacing, until
2460
+ * waits in a row hold one turn longer than this (noteStatedWaitRun,
2461
+ * endStatedWaitRun). */
2387
2462
  const PROVIDER_WAIT_ALERT_MS = 60 * 60_000;
2388
2463
  /**
2389
2464
  * A MembraneError, from this framework's copy of membrane or from another: the
@@ -2856,10 +2931,16 @@ function failureDescription(error: unknown): string {
2856
2931
  const head = classification.length > 0 ? `${name} (${classification.join(', ')})` : name;
2857
2932
  return `${head}: ${String(error.message)}`;
2858
2933
  } catch {
2859
- return '[failure could not be described]';
2934
+ return UNDESCRIBED_FAILURE;
2860
2935
  }
2861
2936
  }
2862
2937
 
2938
+ const UNDESCRIBED_FAILURE = `[${NOTICE_TAG.undescribedFailure}]`;
2939
+
2940
+ function failureShown(text: string): string {
2941
+ return text === UNDESCRIBED_FAILURE ? text : suppliedValue(text);
2942
+ }
2943
+
2863
2944
  /**
2864
2945
  * A bounded string describing a failure, for logs that would otherwise print
2865
2946
  * the error object; it is one line unless one of its parts has newlines, as a
@@ -3187,6 +3268,17 @@ const JUDGE_PARAMETER: ToolParameter = {
3187
3268
  required: ['debounceMs'],
3188
3269
  };
3189
3270
 
3271
+ /** Whether a server's configuration resolves to the MCPL (legacy) family,
3272
+ * the one that can negotiate RFC-014 tool hints. A configuration that names
3273
+ * no usable transport is refused elsewhere; here it has no hints. */
3274
+ function isLegacyFamily(config: McplServerConfig): boolean {
3275
+ try {
3276
+ return resolveServerBinding(config).family === 'legacy';
3277
+ } catch {
3278
+ return false;
3279
+ }
3280
+ }
3281
+
3190
3282
  export class AgentFramework {
3191
3283
  private toolPresentations = new Map<string, ToolPresentation>();
3192
3284
  private presentationPreviews = new WeakMap<object, PresentationSnapshot>();
@@ -3433,8 +3525,6 @@ export class AgentFramework {
3433
3525
  * by the object, not the run's record (ephemeralRuns), which its cleanup
3434
3526
  * drops before a cancelled stream may count its last usage. */
3435
3527
  private ephemeralAgents: WeakSet<Agent> = new WeakSet();
3436
- /** Warn only once per Agent instance after utilities are registered. */
3437
- private emptyUtilityPermissionWarnings: WeakSet<Agent> = new WeakSet();
3438
3528
  /** Physical ephemeral frames deliberately disposed by their run watchdog/caller.
3439
3529
  * The name is single-generation, so these frames still own their terminal
3440
3530
  * typing/outgoing close even after runEphemeralToCompletion deregisters them. */
@@ -3443,6 +3533,10 @@ export class AgentFramework {
3443
3533
  * completes (inference:completed), not when a call answers mid-turn.
3444
3534
  * Drives hard-down escalation — see noteInferenceExhausted. */
3445
3535
  private consecutiveInferenceFailures: Map<string, number> = new Map();
3536
+ private turnRearmMs: readonly number[] = DEFAULT_TURN_REARM_MS;
3537
+ private turnRearms: Map<string, number> = new Map();
3538
+ private exhaustingRearms: Set<string> = new Set();
3539
+ private statedWaitRuns: Map<string, StatedWaitRun> = new Map();
3446
3540
  /** N consecutive failed inferences ⇒ the agent is treated as hard-down and
3447
3541
  * escalated loudly to stderr. */
3448
3542
  private readonly inferenceFailureEscalationThreshold = 3;
@@ -3705,15 +3799,16 @@ export class AgentFramework {
3705
3799
  private readonly releasesAfterResume = new Map<string, Agent>();
3706
3800
  private readonly managerReleases = new WeakMap<Agent, ManagerRelease>();
3707
3801
  private readonly releasedByCleanup = new WeakSet<Agent>();
3708
- // context-manager 0.23.0: a strategy that keeps its context, as
3709
- // AutobiographicalStrategy does (hostContext, autobiographical.ts:2849),
3802
+ // context-manager 0.23.1: a strategy that keeps its context, as
3803
+ // AutobiographicalStrategy does (hostContext, autobiographical.ts:2897),
3710
3804
  // keeps its manager reachable while its work is pending, since the
3711
3805
  // context's tools and systemPrompt getters close over the manager
3712
- // (context-manager.ts:2321-2337). So such a released manager is collected
3806
+ // (context-manager.ts:2343-2359). So such a released manager is collected
3713
3807
  // only once nothing of its work can still read the store, and stop()
3714
3808
  // closes, before an owned store, each one alive.
3715
3809
  private readonly openManagers = new Set<WeakRef<ContextManager>>();
3716
3810
  private readonly openManagerRefs = new WeakMap<ContextManager, WeakRef<ContextManager>>();
3811
+ private readonly storeSees = new WeakMap<ContextManager, (metadata: unknown) => boolean>();
3717
3812
  private readonly collectedManagers = new FinalizationRegistry<WeakRef<ContextManager>>((ref) => this.openManagers.delete(ref));
3718
3813
  /** The coalescing commit in flight, if any (see commitCoalescingDurable). */
3719
3814
  private coalescingCommit: Promise<void> | null = null;
@@ -3853,10 +3948,15 @@ export class AgentFramework {
3853
3948
  /** Host capabilities advertised during the MCP handshake — stored so servers
3854
3949
  * can be connected at runtime (connectMcplServer) after initialization. */
3855
3950
  private mcplHostCapabilities: McplHostCapabilities | null = null;
3856
- /** MCPL RFC-014: every toolHints server's tool presence, for the
3951
+ /** MCPL RFC-014: every hinted server's tool presence, for the
3857
3952
  * `toolsets` module's meta-tools and reminders. Made when the first
3858
- * server configured with `toolHints: true` is set up; null until then. */
3953
+ * server with tool hints (any not configured `toolHints: false`) is set
3954
+ * up; null until then. */
3859
3955
  private toolsetHub: ToolsetHub | null = null;
3956
+ /** The host's own toolsets: the built-in "settings" and MCPL server tabs. */
3957
+ private hostPresentation: HostPresentation | null = null;
3958
+ /** MCPL servers kept disconnected as closed tabs, with the configuration an open connects them with. */
3959
+ private closedTabConfigMap?: Map<string, McplServerConfig>;
3860
3960
  /** Inference routing policy from FrameworkConfig — stored so the MCPL
3861
3961
  * subsystem can be lazily initialized by connectMcplServer when the
3862
3962
  * framework started with zero configured servers. */
@@ -4176,6 +4276,7 @@ export class AgentFramework {
4176
4276
  new OperatorLog(operatorLogPath),
4177
4277
  );
4178
4278
  framework.providerHoldHook = config.providerHold;
4279
+ framework.turnRearmMs = turnRearmDelays(config.exhaustedTurnRearmMs);
4179
4280
 
4180
4281
  // Before any delivery.
4181
4282
  await framework.reconcileDiscordAwarenessAtStartup();
@@ -4210,9 +4311,6 @@ export class AgentFramework {
4210
4311
  for (const module of config.modules) {
4211
4312
  await framework.addModule(module);
4212
4313
  }
4213
- for (const agent of framework.agents.values()) {
4214
- framework.warnEmptyUtilityPermissions(agent);
4215
- }
4216
4314
  for (const [agentName, presentation] of framework.toolPresentations) {
4217
4315
  const workspace = framework.getWorkspaceModule();
4218
4316
  if (!workspace) throw new Error('Tool presentation requires workspace');
@@ -4280,6 +4378,8 @@ export class AgentFramework {
4280
4378
  upcomingWakes: (agentName) => framework.upcomingWakesFor(agentName),
4281
4379
  });
4282
4380
  framework.judgeWired = judge !== undefined;
4381
+ const configuredServerIds = new Set((config.mcplServers ?? []).map((server) => server.id));
4382
+ const heartbeatOn = config.heartbeat !== undefined && config.heartbeat !== null && config.heartbeat !== false;
4283
4383
  framework.eventGate = new EventGate({
4284
4384
  configPath,
4285
4385
  initialConfig: config.gate.config,
@@ -4341,11 +4441,20 @@ export class AgentFramework {
4341
4441
  // debounce can fire).
4342
4442
  // A batched wake's summary names each channel as the agent names it
4343
4443
  // back, with its server where another server shares its id.
4344
- channelShown: (channel) => framework.channelRegistry?.nameShown(channel) ?? channel.channelId,
4444
+ channelShown: (channel) => framework.channelRegistry?.nameShown(channel) ?? suppliedValue(channel.channelId),
4345
4445
  // A batched wake for one message still newest in the window hands its
4346
4446
  // `[Gate: …]` line to the wake, for a fresh start of the wake's turn
4347
4447
  // to decide (placeWakeLines).
4348
4448
  isNewestEntry: (entry) => framework.isNewestContextEntry(entry),
4449
+ // What a source can send, for the rule-order notes: an MCPL server's
4450
+ // events reach the gate as its pushes and channel messages only (a
4451
+ // module's events carry a source of the module's own, such as "cli"
4452
+ // or "mcpl:<id>"), and the heartbeat's ticks as pushes.
4453
+ eventTypesFrom: (source) => {
4454
+ if (configuredServerIds.has(source) || framework.mcplServerConfigs.has(source)) return MCPL_GATE_EVENT_TYPES;
4455
+ if (source === 'heartbeat' && heartbeatOn) return ['mcpl:push-event'];
4456
+ return undefined;
4457
+ },
4349
4458
  resolveRouteChannel: (info) => {
4350
4459
  if (info.eventType === 'mcpl:channel-incoming') return info.channelId || undefined;
4351
4460
  if (info.eventType === 'mcpl:push-event') {
@@ -4487,15 +4596,20 @@ export class AgentFramework {
4487
4596
  }
4488
4597
  }
4489
4598
 
4599
+ // The host's own toolsets (host-presentation.ts): the built-in "settings"
4600
+ // and each MCPL server's tab, per agent, kept beside the store.
4601
+ framework.hostPresentation = framework.makeHostPresentation(config.storePath);
4602
+
4490
4603
  // Initialize MCPL subsystems if configured
4491
4604
  if (config.mcplServers && config.mcplServers.length > 0) {
4492
4605
  // Check every server's config before any is brought up: its boolean
4493
4606
  // settings, and its tool prefix, which mustn't collide with a module
4494
4607
  // name or another server's prefix.
4495
4608
  const moduleNames = new Set(config.modules.map(m => m.name));
4496
- // RFC-014: a server configured with toolHints brings the toolsets
4497
- // module, whose name no prefix may take either.
4498
- if (config.mcplServers.some((serverConfig) => serverConfig.toolHints === true)) moduleNames.add(TOOLSETS_MODULE);
4609
+ // The name `toolsets` is the framework's from creation: its toolsets
4610
+ // module (or a module given here that holds the name) takes it, so no
4611
+ // prefix may.
4612
+ moduleNames.add(TOOLSETS_MODULE);
4499
4613
  const prefixesSeen = new Map<string, string>(); // prefix → serverId
4500
4614
  for (const serverConfig of config.mcplServers) {
4501
4615
  assertServerConfigBooleans(serverConfig);
@@ -4518,8 +4632,25 @@ export class AgentFramework {
4518
4632
  prefixesSeen.set(prefix, serverConfig.id);
4519
4633
  }
4520
4634
 
4521
- if (config.mcplServers.some((serverConfig) => serverConfig.toolHints === true)) await framework.ensureToolsetsModule();
4522
- await framework.initializeMcpl(config.mcplServers, config.inferenceRouting);
4635
+ framework.reserveToolsetsName(config.modules, config.mcplServers);
4636
+ if (framework.toolsetsNameHeldAtStart === null) await framework.ensureToolsetsModule();
4637
+ // A server kept as a closed tab (by its configured presentation, or as
4638
+ // an agent last left it) isn't connected until an agent opens it.
4639
+ let closed = framework.hostPresentation.startClosed(config.mcplServers);
4640
+ if (framework.toolsetsNameHeldAtStart !== null && closed.size > 0) {
4641
+ // No agent could open a closed tab: the meta-tools aren't the framework's.
4642
+ console.error(`[toolsets] ${[...closed].join(', ')} ${closed.size === 1 ? 'is' : 'are'} connected at startup though kept as a closed tab: ` +
4643
+ `the name "${TOOLSETS_MODULE}" is ${framework.toolsetsNameHeldAtStart}, so no agent could open ${closed.size === 1 ? 'it' : 'them'}`);
4644
+ closed = new Set();
4645
+ }
4646
+ for (const serverConfig of config.mcplServers) {
4647
+ if (closed.has(serverConfig.id)) framework.closedTabConfigs().set(serverConfig.id, serverConfig);
4648
+ }
4649
+ await framework.initializeMcpl(config.mcplServers.filter((c) => !closed.has(c.id)), config.inferenceRouting);
4650
+ } else if (!config.modules.some((m) => m.name === TOOLSETS_MODULE)) {
4651
+ // The meta-tools are every agent's: the built-in toolset needs them,
4652
+ // MCPL or none.
4653
+ await framework.ensureToolsetsModule();
4523
4654
  }
4524
4655
 
4525
4656
  // Tune-out coordinator (issue #77): needs both the subconscious resident
@@ -5392,7 +5523,7 @@ export class AgentFramework {
5392
5523
  * only its own model, which auxiliary admission checks per call. */
5393
5524
  private cooldownBlocksAuxiliary(agentName: string): boolean {
5394
5525
  const cooldown = this.providerAccelerationCooldowns.get(agentName);
5395
- return cooldown !== undefined && cooldown.waitModel === undefined;
5526
+ return cooldown !== undefined && cooldown.waitModel === undefined && cooldown.rearm === undefined;
5396
5527
  }
5397
5528
  private acquirePrimaryProviderGate(agentName: string): void {
5398
5529
  const gate = this.providerGate(agentName); gate.primaryPending = false; gate.primaryDepth++;
@@ -5505,24 +5636,51 @@ export class AgentFramework {
5505
5636
  }
5506
5637
  const cooldown = this.providerAccelerationCooldowns.get(agentName);
5507
5638
  if (cooldown && cooldown.heldRequests.length > 0 && Number.isFinite(cooldown.until)) {
5508
- wakes.push({ at: cooldown.until, certain: true, what: 'a turn held by a provider cooldown, when it ends' });
5509
- }
5510
- // The heartbeat's ticks and reminders, which the agent's rules decide as
5511
- // they decide a heartbeat push: one the rules would hold back is listed
5512
- // as only a "maybe".
5513
- if (this.heartbeat && this.heartbeatTargets().includes(agentName)) {
5514
- const probe = (kind: 'heartbeat' | 'reminder'): boolean => !this.eventGate
5515
- || this.eventGate.probe(this.heartbeatGateEvent({ kind, eventId: 'probe', text: 'probe' })).trigger;
5516
- const tickWakes = probe('heartbeat');
5517
- const reminderWakes = probe('reminder');
5518
- for (const w of this.heartbeat.upcomingWakes()) {
5519
- const isReminder = w.what.startsWith('its reminder');
5520
- wakes.push((isReminder ? reminderWakes : tickWakes) ? w : { ...w, certain: false });
5521
- }
5639
+ wakes.push({ at: cooldown.until, certain: true, what: cooldown.rearm
5640
+ ? `a turn whose retries ran out, sent again (${cooldown.rearm.number} of ${cooldown.rearm.of})`
5641
+ : 'a turn held by a provider cooldown, when it ends' });
5522
5642
  }
5643
+ wakes.push(...this.heartbeatWakesFor(agentName));
5523
5644
  return wakes;
5524
5645
  }
5525
5646
 
5647
+ /**
5648
+ * The heartbeat's ticks and reminders for `agentName`, soonest first, which
5649
+ * the agent's rules decide as they decide a heartbeat push: one the rules
5650
+ * would hold back is only a "maybe" (`certain` false). Each is probed as
5651
+ * the event its delivery is judged by (deliverHeartbeat): a silent tick as
5652
+ * silent, with the text it will bring, so a rule on the content or the
5653
+ * silent flag decides the probe as it will decide the wake. None for an
5654
+ * agent the heartbeat doesn't wake. `whose` words them for the judge ("its
5655
+ * heartbeat") or for the agent ("your heartbeat").
5656
+ */
5657
+ private heartbeatWakesFor(agentName: string, whose: 'its' | 'your' = 'its'): HeartbeatScheduledWake[] {
5658
+ if (!this.heartbeat || !this.heartbeatTargets().includes(agentName)) return [];
5659
+ const wakes = (w: HeartbeatScheduledWake): boolean => !this.eventGate
5660
+ || this.eventGate.probe(this.heartbeatGateEvent({ kind: w.kind, eventId: 'probe', text: w.text, silent: w.silent })).trigger;
5661
+ return this.heartbeat.scheduledWakes(whose).map((w) => (wakes(w) ? w : { ...w, certain: false }));
5662
+ }
5663
+
5664
+ /**
5665
+ * The heartbeat's wake due before a self-wake due at `selfWakeAt` (ms), for
5666
+ * the skip_reply that armed it: the soonest of its ticks and reminders that
5667
+ * the agent's rules, as they stand, let wake it. The confirmation says when
5668
+ * it's due, not that it will wake the agent (skipReplyNote), so a probe
5669
+ * that can't foresee the gate at the wake's time (a gate.js, a rate limit's
5670
+ * tokens then) leaves it true. A failure here costs only that line: the
5671
+ * self-wake is armed already, and the confirmation goes out without it.
5672
+ */
5673
+ private heartbeatWakeBefore(agentName: string, selfWakeAt: number): SkipReplyEarlierWake | undefined {
5674
+ try {
5675
+ const now = Date.now();
5676
+ const first = this.heartbeatWakesFor(agentName, 'your').find((w) => w.certain && w.at < selfWakeAt);
5677
+ return first ? { inSeconds: Math.max(1, Math.round((first.at - now) / 1000)), what: first.what } : undefined;
5678
+ } catch (err) {
5679
+ 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))}`);
5680
+ return undefined;
5681
+ }
5682
+ }
5683
+
5526
5684
  /** The agents the heartbeat wakes: the residents, not conversation forks,
5527
5685
  * ephemeral runs or the subconscious, which keep windows of their own. */
5528
5686
  private heartbeatTargets(): string[] {
@@ -5905,8 +6063,59 @@ export class AgentFramework {
5905
6063
  console.error(`[provider-cooldown] agent=${agent.name} ` +
5906
6064
  `${hostHold ? `host hold (${hostHold.reason})` : 'organization acceleration 429'} — ` +
5907
6065
  `holding primary/auxiliary for ${delayMs}ms; ${held.length} request(s) retained`);
6066
+ if (wait && trigger && (wait.until === null || wait.until > now)) this.noteStatedWaitRun(agent.name, wait, now);
5908
6067
  return true;
5909
6068
  }
6069
+ private rearmExhaustedTurn(agent: Agent, error: Error, trigger: InferenceRequest | undefined, ownsProviderGate: boolean, routing: TurnRouting | undefined): void {
6070
+ if (!ownsProviderGate || trigger === undefined || this.providerAdmissionClosed || this.turnRearmMs.length === 0) return;
6071
+ const done = this.turnRearms.get(agent.name) ?? 0;
6072
+ const notSent = (why: string) => console.error(`[inference-rearm] agent=${agent.name} turn not re-armed: ${why}`);
6073
+ if (!isTransientFailure(error)) {
6074
+ if (done > 0) notSent(`its failure isn't transient (${isMembraneError(error) ? error.type : boundFailureText(error.message, FAILURE_SUMMARY_MAX_CHARS)})`);
6075
+ return;
6076
+ }
6077
+ const continuation = isTurnContinuation(trigger.reason);
6078
+ const speaking = routing?.locus ?? routing?.triggerChannel;
6079
+ if (!speaking && routing?.noChannel && !trigger.channelId) {
6080
+ notSent('it would have to guess a channel');
6081
+ return;
6082
+ }
6083
+ if (done >= this.turnRearmMs.length) {
6084
+ notSent(`its ${this.turnRearmMs.length} re-arms are spent until a turn completes or a message is said to it`);
6085
+ return;
6086
+ }
6087
+ const delayMs = this.turnRearmMs[done]!;
6088
+ const rearm = { number: done + 1, of: this.turnRearmMs.length };
6089
+ this.turnRearms.set(agent.name, rearm.number);
6090
+ const request: MarkedRequest = { ...trigger, [REARMED]: true };
6091
+ delete request[TURN_RETRY];
6092
+ delete request[MERGED_BATCH];
6093
+ if (continuation) {
6094
+ request.reason = REARM_REASON;
6095
+ delete request.counterparty;
6096
+ }
6097
+ if (speaking) {
6098
+ request.channelId = speaking.channelId;
6099
+ request.channelServerId = speaking.serverId;
6100
+ request.addressed = true;
6101
+ }
6102
+ const existing = this.providerAccelerationCooldowns.get(agent.name);
6103
+ if (existing) {
6104
+ this.holdRequest(existing.heldRequests, request);
6105
+ } else {
6106
+ const now = Date.now();
6107
+ const timer = setTimeout(() => this.releaseProviderAccelerationCooldown(agent.name), this.providerHoldTimerMs(delayMs));
6108
+ timer.unref?.();
6109
+ this.providerAccelerationCooldowns.set(agent.name, {
6110
+ startedAt: now, until: now + delayMs, timer, heldRequests: [request], failures: 0, rearm,
6111
+ reason: `its turn's retries ran out (${boundFailureText(error.message, FAILURE_SUMMARY_MAX_CHARS)}), ` +
6112
+ `so it is sent again (${rearm.number} of ${rearm.of})`,
6113
+ });
6114
+ }
6115
+ console.error(`[inference-rearm] agent=${agent.name} turn sent again ` +
6116
+ (existing ? 'when the provider hold already on the agent ends' : `in ${delayMs}ms`) +
6117
+ ` (${rearm.number} of ${rearm.of})`);
6118
+ }
5910
6119
  /**
5911
6120
  * A provider's stated wait on a turn that doesn't own provider admission:
5912
6121
  * a conversation agent's or an ephemeral run's. Left to the error policy,
@@ -6015,8 +6224,10 @@ export class AgentFramework {
6015
6224
  const requests = cooldown.heldRequests.length > 0 ? cooldown.heldRequests : [{ agentName,
6016
6225
  reason: 'provider-acceleration-retry', source: 'framework', timestamp: Date.now() } satisfies InferenceRequest];
6017
6226
  this.pendingRequests.push(...requests);
6018
- console.error(`[provider-cooldown] agent=${agentName} released after ${Date.now() - cooldown.startedAt}ms — ` +
6019
- `fresh compile queued with ${requests.length} retained request(s)`);
6227
+ console.error(cooldown.rearm
6228
+ ? `[inference-rearm] agent=${agentName} turn queued again (${cooldown.rearm.number} of ${cooldown.rearm.of})`
6229
+ : `[provider-cooldown] agent=${agentName} released after ${Date.now() - cooldown.startedAt}ms — ` +
6230
+ `fresh compile queued with ${requests.length} retained request(s)`);
6020
6231
  }
6021
6232
  /**
6022
6233
  * Wait an error policy's retry delay, all of it: the delay can be a
@@ -6090,7 +6301,7 @@ export class AgentFramework {
6090
6301
  : `until ${new Date(wait.until).toISOString()}`;
6091
6302
  const stated = `${error.type}${error.httpStatus !== undefined ? ` HTTP ${error.httpStatus}` : ''}, retry-after ${String(error.retryAfterMs)}ms`;
6092
6303
  console.error(`[provider-wait] agent=${agentName} model=${model} holds ${until}: ${stated}`);
6093
- if (remaining > PROVIDER_WAIT_ALERT_MS) {
6304
+ if (waitAlertsOnItsOwn(wait)) {
6094
6305
  this.opsAlert('provider-wait', agentName,
6095
6306
  `provider wait on ${model} holds ${until} (${stated}: ${reason}); ` +
6096
6307
  'if the provider did not mean that, release it with release-provider-wait',
@@ -6100,6 +6311,41 @@ export class AgentFramework {
6100
6311
  return wait;
6101
6312
  }
6102
6313
 
6314
+ private noteStatedWaitRun(agentName: string, wait: ProviderWait, now: number): void {
6315
+ let run = this.statedWaitRuns.get(agentName);
6316
+ if (!run) {
6317
+ run = { since: now, holds: 0, alerted: false, model: wait.model, reason: wait.reason, heldUntil: now };
6318
+ this.statedWaitRuns.set(agentName, run);
6319
+ }
6320
+ run.holds++;
6321
+ run.model = wait.model;
6322
+ run.reason = wait.reason;
6323
+ if (wait.until !== null) run.heldUntil = Math.max(run.heldUntil, wait.until);
6324
+ if (waitAlertsOnItsOwn(wait)) run.alerted = true;
6325
+ if (now - run.since > PROVIDER_WAIT_ALERT_MS) this.alertStatedWaitRun(agentName, run, now);
6326
+ }
6327
+
6328
+ private alertStatedWaitRun(agentName: string, run: StatedWaitRun, now: number): void {
6329
+ if (run.alerted) return;
6330
+ run.alerted = true;
6331
+ const since = new Date(run.since).toISOString();
6332
+ this.opsAlert('provider-wait', agentName,
6333
+ `a turn's ${run.holds} stated waits in a row on ${run.model} span ${Math.round((now - run.since) / 60_000)} min ` +
6334
+ `since ${since}: ${run.reason}; a provider stating waits that long in a row points to the account's rate limit ` +
6335
+ "or quota, or this host's load",
6336
+ { data: { model: run.model, since, holds: run.holds } });
6337
+ }
6338
+
6339
+ private endStatedWaitRun(agentName: string): void {
6340
+ const run = this.statedWaitRuns?.get(agentName);
6341
+ if (!run) return;
6342
+ this.statedWaitRuns.delete(agentName);
6343
+ const end = Math.min(Date.now(), run.heldUntil);
6344
+ if (end - run.since > PROVIDER_WAIT_ALERT_MS) this.alertStatedWaitRun(agentName, run, end);
6345
+ }
6346
+
6347
+
6348
+
6103
6349
  /**
6104
6350
  * Hold this agent's primary turns for a provider wait on its model. The
6105
6351
  * requests are kept and released together, into one fresh compile, when
@@ -6130,7 +6376,10 @@ export class AgentFramework {
6130
6376
  for (const request of requests) this.holdRequest(held, request);
6131
6377
  // An acceleration or host hold already parks the whole agent; its release
6132
6378
  // re-checks this wait at admission.
6133
- if (existing && existing.waitModel === undefined) return true;
6379
+ if (existing && existing.waitModel === undefined) {
6380
+ if (failed) this.noteStatedWaitRun(agent.name, wait, now);
6381
+ return true;
6382
+ }
6134
6383
  if (existing) clearTimeout(existing.timer);
6135
6384
  const timer = setTimeout(() => this.releaseProviderAccelerationCooldown(agent.name), this.providerHoldTimerMs(until - now));
6136
6385
  timer.unref?.();
@@ -6143,6 +6392,7 @@ export class AgentFramework {
6143
6392
  console.error(`[provider-wait] agent=${agent.name} model=${wait.model} holding primary turns ` +
6144
6393
  `${wait.until === null ? 'until released' : `until ${new Date(wait.until).toISOString()}`}; ${held.length} request(s) retained`);
6145
6394
  }
6395
+ if (failed) this.noteStatedWaitRun(agent.name, wait, now);
6146
6396
  return true;
6147
6397
  }
6148
6398
 
@@ -6198,6 +6448,8 @@ export class AgentFramework {
6198
6448
  // names every model, the same as omitting it, everywhere below.
6199
6449
  if (model === EVERY_MODEL) model = undefined;
6200
6450
  const released = this.providerWaits?.release(agentName, model, by) ?? [];
6451
+ const run = this.statedWaitRuns?.get(agentName);
6452
+ if (run && released.some(({ wait }) => wait.model === run.model)) run.heldUntil = Math.min(run.heldUntil, Date.now());
6201
6453
  for (const { wait, release } of released) {
6202
6454
  console.error(`[provider-wait] agent=${agentName} model=${wait.model} released by ${by} (${release})`);
6203
6455
  }
@@ -6274,6 +6526,7 @@ export class AgentFramework {
6274
6526
  private cancelProviderAdmission(): void {
6275
6527
  for (const cooldown of this.providerAccelerationCooldowns.values()) clearTimeout(cooldown.timer);
6276
6528
  this.providerAccelerationCooldowns.clear(); this.providerAccelerationRecoveries.clear();
6529
+ this.turnRearms?.clear(); this.exhaustingRearms?.clear();
6277
6530
  for (const gate of this.providerGates.values()) {
6278
6531
  gate.primaryDepth = 0; gate.primaryPending = false;
6279
6532
  for (const resolve of gate.auxiliaryWaiters.splice(0)) resolve();
@@ -6701,6 +6954,12 @@ export class AgentFramework {
6701
6954
  if (claimedBy) {
6702
6955
  throw new Error(`Module "${module.name}" collides with the toolPrefix "${module.name}" the restart of "${claimedBy.restarting}" holds`);
6703
6956
  }
6957
+ // A closed tab keeps its prefix for the connect its next open makes.
6958
+ for (const [id, config] of this.closedTabConfigs()) {
6959
+ if ((config.toolPrefix ?? `mcpl--${id}`) === module.name) {
6960
+ throw new Error(`Module "${module.name}" collides with MCPL server "${id}"'s toolPrefix "${module.name}" (a closed tab)`);
6961
+ }
6962
+ }
6704
6963
  this.resetCompletedStop('addModule()');
6705
6964
  await this.moduleRegistry.addModule(module);
6706
6965
  // The workspace judges whether an agent's history holds a message as the
@@ -6889,12 +7148,12 @@ export class AgentFramework {
6889
7148
  [{
6890
7149
  type: 'text',
6891
7150
  text: (before === 'sending'
6892
- ? `[turn-interrupted] Your previous turn was stopped before it sent anything — ` +
7151
+ ? `[${NOTICE_TAG.turnInterrupted}] Your previous turn was stopped before it sent anything — ` +
6893
7152
  `a deliberate stop, not a failure.`
6894
7153
  : before === 'finishing'
6895
- ? `[turn-interrupted] Your previous turn was stopped before it finished — ` +
7154
+ ? `[${NOTICE_TAG.turnInterrupted}] Your previous turn was stopped before it finished — ` +
6896
7155
  `a deliberate stop, not a failure.`
6897
- : `[turn-interrupted] Your previous turn was cancelled mid-stream — ` +
7156
+ : `[${NOTICE_TAG.turnInterrupted}] Your previous turn was cancelled mid-stream — ` +
6898
7157
  `a deliberate stop, not a failure. Whatever you were still ` +
6899
7158
  `producing when it stopped was cut off there.`) + handedOn,
6900
7159
  }],
@@ -7047,8 +7306,10 @@ export class AgentFramework {
7047
7306
  : [];
7048
7307
  return [
7049
7308
  ...moduleTools,
7050
- // Utilities collapse into one slot; absent entirely when none exist.
7051
- ...(this.moduleRegistry.getAllUtilities().length > 0 ? [UTILS_TOOL] : []),
7309
+ // Utilities: callable tools, presented in the built-in toolset
7310
+ // (placedInBuiltin), never in the tool list. One named like a tool of
7311
+ // its module is that tool's: the tool takes the name.
7312
+ ...this.moduleRegistry.getAllUtilities().filter((u) => !moduleTools.some((t) => t.name === u.name)),
7052
7313
  ...this.mcplTools,
7053
7314
  ...channelTools,
7054
7315
  ...gateTools,
@@ -7120,6 +7381,8 @@ export class AgentFramework {
7120
7381
  private setMcplServerConfig(config: McplServerConfig): void {
7121
7382
  this.mcplServerConfigs.set(config.id, config);
7122
7383
  referenceRegistry.setServerAddress(config.id, serverAddressOf(config), this);
7384
+ // The gate's rule-order notes read which sources are servers.
7385
+ this.eventGate?.sourcesChanged();
7123
7386
  }
7124
7387
 
7125
7388
  /** Register every RFC-005 reference in an MCPL peer's tool-result content
@@ -7409,7 +7672,7 @@ export class AgentFramework {
7409
7672
  if (!how) return undefined;
7410
7673
  const reach = this.residentHoldsTraffic(who) ? ' Messages there that address you, such as an @-mention, still reach you.' : '';
7411
7674
  const open = this.canAgentUseTool(who, 'channel_open') && !registry.lifecycleWithheld(channel.serverId)
7412
- ? ` channel_open with channelId "${channel.channelId}" and serverId "${channel.serverId}" opens it.`
7675
+ ? ` channel_open with channelId ${quotedValue(channel.channelId)} and serverId "${channel.serverId}" opens it.`
7413
7676
  : '';
7414
7677
  return `${this.channelShown(channel)} stays closed: you ${how} it, and your words there don't open it.${reach}${open}`;
7415
7678
  }
@@ -7501,14 +7764,14 @@ export class AgentFramework {
7501
7764
  const tunedOutNote = closedNote ? undefined : this.tunedOutSendNote(agentName, channel);
7502
7765
  const body = closedNote ?? tunedOutNote;
7503
7766
  if (!body) return answer;
7504
- const tag = closedNote ? '[closed channel]' : '[tuned out]';
7767
+ const tag = `[${closedNote ? NOTICE_TAG.closedChannel : NOTICE_TAG.tunedOut}]`;
7505
7768
  if (forScript) {
7506
7769
  const window = this.noticeTargetFor(agentName, 'closed-channel');
7507
7770
  try {
7508
7771
  if (window) {
7509
7772
  this.addMessage('user', [{
7510
7773
  type: 'text',
7511
- text: `${tag} Your script's ${toolName}: ${body}${this.closedChannelNoteOff(agentName)}`,
7774
+ text: `${tag} Your script's ${suppliedValue(toolName)}: ${body}${this.closedChannelNoteOff(agentName)}`,
7512
7775
  }], { system: true, kind: 'channel-notice' } as MessageMetadata, window);
7513
7776
  }
7514
7777
  } catch (err) {
@@ -7677,15 +7940,6 @@ export class AgentFramework {
7677
7940
  };
7678
7941
  }
7679
7942
 
7680
- private warnEmptyUtilityPermissions(agent: Agent): void {
7681
- if (agent.allowedTools === 'all' || !agent.allowedTools.includes('utils')
7682
- || this.emptyUtilityPermissionWarnings.has(agent)) return;
7683
- const utilities = this.moduleRegistry.getAllUtilities();
7684
- if (utilities.length === 0 || utilities.some(tool => agent.canUseTool(tool.name))) return;
7685
- 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.`);
7686
- this.emptyUtilityPermissionWarnings.add(agent);
7687
- }
7688
-
7689
7943
  private getToolsForAgent(
7690
7944
  agentName: string,
7691
7945
  snapshot?: InferenceToolSnapshot,
@@ -7709,10 +7963,8 @@ export class AgentFramework {
7709
7963
  : t);
7710
7964
  return [...SUBCONSCIOUS_TOOLS, ...basics];
7711
7965
  }
7712
- if (agent) this.warnEmptyUtilityPermissions(agent);
7713
- const hasPermittedUtility = !agent || this.moduleRegistry.getAllUtilities().some(tool => agent.canUseTool(tool.name));
7714
7966
  return [...this.getAllTools(), ...(this.toolPresentations.has(agentName) ? presentationTools(this.toolPresentations.get(agentName)!.config.cataloguePath) : [])]
7715
- .filter(tool => tool.name !== 'utils' || hasPermittedUtility).map((tool) => {
7967
+ .map((tool) => {
7716
7968
  if (tool.name === 'think') {
7717
7969
  return this.buildThinkTool(
7718
7970
  snapshot?.sameRoundThinkTextPolicy
@@ -7743,7 +7995,7 @@ export class AgentFramework {
7743
7995
  if (!presentation) return null;
7744
7996
  const tools = this.availableToolsForPresentation(agentName, snapshot);
7745
7997
  const sources = new Map<string, string>();
7746
- for (const tool of this.moduleRegistry.getAllTools()) {
7998
+ for (const tool of [...this.moduleRegistry.getAllTools(), ...this.moduleRegistry.getAllUtilities()]) {
7747
7999
  sources.set(tool.name, `Module: ${tool.name.split('--')[0]}`);
7748
8000
  }
7749
8001
  // An MCPL tool's source is the server a call of it reaches, by the one
@@ -7757,8 +8009,8 @@ export class AgentFramework {
7757
8009
  }
7758
8010
 
7759
8011
  private advertisedToolsForAgent(agentName: string, snapshot?: InferenceToolSnapshot) {
7760
- return this.inspectToolPresentation(agentName, snapshot)?.advertised
7761
- ?? this.availableToolsForPresentation(agentName, snapshot);
8012
+ return this.headerTools(agentName, this.inspectToolPresentation(agentName, snapshot)?.advertised
8013
+ ?? this.availableToolsForPresentation(agentName, snapshot));
7762
8014
  }
7763
8015
 
7764
8016
  /** Compression can revisit calls to hidden tools; never apply visibility here. */
@@ -8310,8 +8562,10 @@ export class AgentFramework {
8310
8562
  * CURRENT settings — maintenance may have moved the folded floor and the
8311
8563
  * branch generation, so verdicts are computed at resume time, never reused.
8312
8564
  * Throws ResumeBlockedError (with all failing verdicts) unless `force`.
8313
- * Unavailable previews warn and pass — refusing to resume because the
8314
- * strategy cannot preview would hold hosts hostage to a diagnostic.
8565
+ * Unavailable previews pass, with a warning naming the reason unless it is
8566
+ * `no_preview_support`, a strategy with nothing to preview — refusing to
8567
+ * resume because the strategy cannot preview would hold hosts hostage to a
8568
+ * diagnostic.
8315
8569
  */
8316
8570
  async resume(opts?: { force?: boolean }): Promise<HostModeStatus> {
8317
8571
  this.refuseOperatorAfterStop('resume');
@@ -9220,7 +9474,8 @@ export class AgentFramework {
9220
9474
  }
9221
9475
 
9222
9476
  const presentation = this.inspectToolPresentation(agentName);
9223
- const tools = presentation?.advertised ?? this.availableToolsForPresentation(agentName);
9477
+ const availableTools = presentation?.available ?? this.availableToolsForPresentation(agentName);
9478
+ const tools = this.headerTools(agentName, presentation?.advertised ?? availableTools);
9224
9479
  const capture = (request: NormalizedRequest) => {
9225
9480
  if (presentation) this.presentationPreviews.set(request, presentation);
9226
9481
  return request;
@@ -9234,7 +9489,7 @@ export class AgentFramework {
9234
9489
  // writes of its own, no external RPC. Opt in explicitly.
9235
9490
  if (!opts?.injections) {
9236
9491
  return capture(await agent.buildActivationRequest(
9237
- tools, undefined, opts?.budget, presentation?.available ?? tools, { dryRun: true },
9492
+ tools, undefined, opts?.budget, availableTools, { dryRun: true },
9238
9493
  ));
9239
9494
  }
9240
9495
 
@@ -9281,7 +9536,7 @@ export class AgentFramework {
9281
9536
  const held = plan !== undefined && this.slotHeldFrom(agent);
9282
9537
  const placed = new Set(plan ? (held ? [...plan.leftAt, ...plan.rows.map((row) => row.at)] : plan.leftAt) : []);
9283
9538
  return capture(await agent.buildActivationRequest(
9284
- tools, plan ? (injections ?? []).filter((_, at) => placed.has(at)) : injections, opts?.budget, presentation?.available ?? tools, {
9539
+ tools, plan ? (injections ?? []).filter((_, at) => placed.has(at)) : injections, opts?.budget, availableTools, {
9285
9540
  dryRun: true,
9286
9541
  ...(plan && !held ? { appendedMessages: plan.rows.map(({ participant, content }) => ({ participant, content })) } : {}),
9287
9542
  },
@@ -9301,7 +9556,13 @@ export class AgentFramework {
9301
9556
  * see `AutobiographicalStrategy.previewContext` and
9302
9557
  * `Agent.compileWithInjections`.
9303
9558
  *
9304
- * Returns null when the agent's strategy has no fold plan (non-adaptive).
9559
+ * Returns null when the agent's strategy has nothing to preview: one without
9560
+ * `previewContext`, or a non-adaptive `AutobiographicalStrategy` whose select
9561
+ * fits the budget, built from the framework's own copy of context-manager.
9562
+ * Built from another copy, such a strategy throws its `previewContext
9563
+ * requires adaptiveResolution` error here instead, since the manager
9564
+ * recognises only its own copy's `NoFoldPlanToPreviewError`. Over the
9565
+ * budget, a non-adaptive strategy's preview is an infeasible one.
9305
9566
  */
9306
9567
  previewContextSettings(
9307
9568
  agentName: string,
@@ -9357,9 +9618,9 @@ export class AgentFramework {
9357
9618
  *
9358
9619
  * Never throws for preview-layer reasons: strategy without previewContext,
9359
9620
  * preview overlap, stale branch generation, the hierarchical (non-
9360
- * adaptive) path, and a context manager that refuses its queries (its
9361
- * strategy's initialization pending or failed) all come back as
9362
- * `{available: false, reason}`. Unknown agent still throws, matching the
9621
+ * adaptive) path within its budget, and a context manager that refuses its
9622
+ * queries (its strategy's initialization pending or failed) all come back
9623
+ * as `{available: false, reason}`. Unknown agent still throws, matching the
9363
9624
  * sibling accessors, and so does a patch whose values the agent refuses,
9364
9625
  * whatever its context manager's state. Whether the strategy takes live
9365
9626
  * tail and pace settings is the manager's to say, so while it refuses, a
@@ -10488,16 +10749,18 @@ export class AgentFramework {
10488
10749
  'Add or replace a wake rule (a gate.json policy) at runtime — no need to ' +
10489
10750
  'hand-edit the file. The rule is validated and hot-applied immediately. ' +
10490
10751
  'A rule with the same `name` replaces the existing one in place (add a ' +
10491
- 'position to move it). ⚠️ ORDERING IS SEMANTICS: first match wins, and a ' +
10492
- 'matched rule CONSUMES the event — a broad rule placed early makes every ' +
10493
- 'later rule unreachable for its scope, including your addressed-message ' +
10494
- '(DM/mention) wake rules. Prefer `insertBefore`/`insertAfter` to place a ' +
10495
- 'rule relative to a specific existing rule; use `position: "prepend"` only ' +
10496
- 'when the rule really must beat EVERYTHING. For "additionally wake me ' +
10497
- 'every Nth event / at most N per hour" governors, set `passthrough: true` ' +
10498
- 'so non-firing matches fall through to your normal rules instead of ' +
10499
- 'swallowing them. The result includes a before/after probe table and any ' +
10500
- 'shadow warnings — READ THEM before ending the turn. Two common shapes:\n' +
10752
+ 'position to move it). Rules are checked in order and the first one that ' +
10753
+ 'matches an event decides it, so the rules below never see that event: a ' +
10754
+ 'broad rule placed early takes events from them, your DM and mention ' +
10755
+ 'rules included. Place a rule next to a particular one with ' +
10756
+ '`insertBefore`/`insertAfter`; `position: "prepend"` puts it above all of ' +
10757
+ 'them. For a rule that only adds wakes ("also wake me every Nth event", ' +
10758
+ '"at most N an hour"), set `passthrough: true`: the events it doesn\'t ' +
10759
+ 'wake for go on to your other rules. The result shows how a few common ' +
10760
+ 'events are decided before and after the change. Its `shadowWarnings` ' +
10761
+ 'are notes on your whole rule list, not only this change, wherever an ' +
10762
+ 'earlier rule takes events a later one could get: each says whether that ' +
10763
+ 'matters and what to do about it. Two common shapes:\n' +
10501
10764
  '• Watch a FILE/workspace path: match on `mount` + `pathGlob`, e.g. ' +
10502
10765
  '`{ name: "watch-notes", match: { scope: ["workspace:modified"], mount: "project", pathGlob: "notes/*.md" }, behavior: "always" }`.\n' +
10503
10766
  '• Watch a CHANNEL: match on `source` + `channel` (and/or `tagsAny: ["chat:ambient"]`), ' +
@@ -11020,15 +11283,15 @@ export class AgentFramework {
11020
11283
  const ep = this.rewindEpisode.get(agent.name);
11021
11284
  const count = (ep?.count ?? 0) + 1;
11022
11285
  const why = cause === 'refusal'
11023
- ? `the model refused on them (content filter: ${category}). Their content is ` +
11286
+ ? `the model refused on them (content filter: ${failureShown(category)}). Their content is ` +
11024
11287
  `not reproduced here (so this note can't re-trigger the filter); the ` +
11025
11288
  `originals remain in the raw record`
11026
- : `the model API kept refusing the requests that carried them (${category}), ` +
11289
+ : `the model API kept refusing the requests that carried them (${failureShown(category)}), ` +
11027
11290
  `so that the conversation could go on. Their content is not reproduced ` +
11028
11291
  `here (so this note can't trigger the same refusal); the originals remain ` +
11029
11292
  `in the raw record`;
11030
11293
  const text =
11031
- `[refusal-rewind] The ${count} most recent turn(s) were set aside because ` +
11294
+ `[${NOTICE_TAG.refusalRewind}] The ${count} most recent turn(s) were set aside because ` +
11032
11295
  `${why}. You are clear to continue — if you ` +
11033
11296
  `were mid-task, take a different approach; otherwise carry on with whatever ` +
11034
11297
  `is now in front of you, or briefly acknowledge the gap and ask what's next.`;
@@ -13436,6 +13699,9 @@ export class AgentFramework {
13436
13699
  }
13437
13700
 
13438
13701
  private async createAgent(config: AgentConfig): Promise<Agent> {
13702
+ const sees = (metadata: unknown): boolean =>
13703
+ !(metadata as { tuneOut?: unknown } | undefined)?.tuneOut
13704
+ && !isAnotherAgentsInjection(metadata, config.name);
13439
13705
  // Create context manager for this agent
13440
13706
  const contextManager = await ContextManager.open({
13441
13707
  store: this.store,
@@ -13456,10 +13722,10 @@ export class AgentFramework {
13456
13722
  // Another agent's stored context injections (stored-injections.ts) are
13457
13723
  // that agent's context, not conversation: they stay out of this view,
13458
13724
  // and so out of its folds, as the residents share this slot.
13459
- viewFilter: (message) => !(message.metadata as { tuneOut?: unknown } | undefined)?.tuneOut
13460
- && !isAnotherAgentsInjection(message.metadata, config.name),
13725
+ viewFilter: (message) => sees(message.metadata),
13461
13726
  });
13462
13727
  this.trackManager(contextManager);
13728
+ this.storeSees.set(contextManager, sees);
13463
13729
 
13464
13730
  const agent = new Agent(config, contextManager, this.membrane);
13465
13731
  this.adoptToolResultGuard(agent);
@@ -13527,6 +13793,7 @@ export class AgentFramework {
13527
13793
  // oversized tool results / attachments under the same policy.
13528
13794
  maxMessageTokens: primaryConfig.strategy?.maxMessageTokens,
13529
13795
  });
13796
+ const sees = (metadata: unknown): boolean => !isAnotherAgentsInjection(metadata, name);
13530
13797
  const contextManager = await ContextManager.open({
13531
13798
  store: this.store,
13532
13799
  namespace: `subconscious/${primaryName}`,
@@ -13537,12 +13804,13 @@ export class AgentFramework {
13537
13804
  // residents' stored context injections (stored-injections.ts): their
13538
13805
  // context, which this agent never saw while they were placed unstored.
13539
13806
  auxiliaryMessageViews: [{}],
13540
- viewFilter: (message) => !isAnotherAgentsInjection(message.metadata, name),
13807
+ viewFilter: (message) => sees(message.metadata),
13541
13808
  strategy,
13542
13809
  membrane: this.membrane,
13543
13810
  debugLogContext: !!process.env.DEBUG_CONTEXT,
13544
13811
  });
13545
13812
  this.trackManager(contextManager);
13813
+ this.storeSees.set(contextManager, sees);
13546
13814
 
13547
13815
  // Inherit the principal's inference settings wholesale (fork-template
13548
13816
  // idiom): reasoning effort (thinking), provider params, token ceilings,
@@ -13803,6 +14071,7 @@ export class AgentFramework {
13803
14071
  if (flushedTurn) {
13804
14072
  flushedTurn.roundsRan = true;
13805
14073
  if (flushedTurn.unflushedRounds) flushedTurn.unflushedRounds--;
14074
+ this.endStatedWaitRun(agent.name);
13806
14075
  }
13807
14076
  if (flushedTurn && replySent === null && !flushedTurn.privateEnd && !flushedTurn.silent
13808
14077
  && pendingBlocks!.some((b) => b.type === 'text' && (b as { text?: string }).text?.trim())) {
@@ -13870,11 +14139,12 @@ export class AgentFramework {
13870
14139
  // with everything queued behind it — the queue keeps its order —
13871
14140
  // for the turn-end flush, where no reply follows it in this stream.
13872
14141
  //
13873
- // A message a tune-out holds from the residents is drained and
13874
- // stored like any other, but not injected: every resident's
13875
- // viewFilter leaves it out of the next compile as well, so the
13876
- // window's compiled prefix and the live request still match without
13877
- // it (#232 gap 5).
14142
+ // A message the agent's own view filter leaves out, such as one a
14143
+ // tune-out holds from the residents or another agent's stored
14144
+ // context injection, is drained and stored like any other, but not
14145
+ // injected: the next compile leaves it out as well, so the window's
14146
+ // compiled prefix and the live request still match without it
14147
+ // (#232 gap 5).
13878
14148
  //
13879
14149
  // A stream that declares it can't carry injected messages
13880
14150
  // (supportsInjectedMessages false: membrane's XML prefill path,
@@ -13907,14 +14177,11 @@ export class AgentFramework {
13907
14177
  const stored = this.writeDrainedInOrder(deferred, `tool boundary for ${agent.name}`, (msg) =>
13908
14178
  agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id)));
13909
14179
  for (const [msg, { id, content }] of stored) {
14180
+ this.noteSpokenTo(agent.name, msg.metadata);
13910
14181
  // Every other resident reads what this boundary stores; for its
13911
14182
  // own agent, the boundary weighs what it injects below.
13912
14183
  this.noteForOtherResidents(agent, msg.metadata, id);
13913
- // A message a tune-out holds from the residents is stored (the
13914
- // subconscious reads it) and left out of every resident's
13915
- // compiled view; injecting it here would put in the live turn
13916
- // what the next compile hides (#232 gap 5).
13917
- if ((msg.metadata as { tuneOut?: unknown } | undefined)?.tuneOut) continue;
14184
+ if (!this.agentSees(agent, msg.metadata)) continue;
13918
14185
  heard.push({ msg, id, content });
13919
14186
  }
13920
14187
  // Rendered in one call: a render prepares as a compile does, so
@@ -13926,10 +14193,12 @@ export class AgentFramework {
13926
14193
  const rendered = this.renderForInjection(agent, [...(clock ? [clock.id] : []), ...heard.map((h) => h.id)]);
13927
14194
  const first = clock ? 1 : 0;
13928
14195
  // A null is a message the render places before its window. Under
13929
- // the memory strategy that's one before its recent window, when
13930
- // what this boundary stored is bigger than it, or any message
13931
- // while a head window a topic change reset hasn't filled yet. The
13932
- // next compile still sends it, raw and in order, after the tool
14196
+ // context-manager 0.23.0's memory strategy that's one before its
14197
+ // recent window, when what this boundary stored is bigger than it,
14198
+ // or any message while a head window a topic change reset hasn't
14199
+ // filled yet; from 0.23.1 that strategy gives no null for a
14200
+ // message its view holds. The next compile still sends such a
14201
+ // message, raw and in order, after the tool
13933
14202
  // result, so leaving it out would change the prefix as surely as
13934
14203
  // any form, and the agent wouldn't hear it (context-manager#275's
13935
14204
  // review, R1-1). It goes in as stored, which is the form the next
@@ -13940,13 +14209,12 @@ export class AgentFramework {
13940
14209
  // cap reaches it. And a body the strategy stored in parts goes in
13941
14210
  // whole, joined as the store reads it back, where the next
13942
14211
  // compile sends it in more than one piece.
13943
- // That rests on two things. The only view filter this framework
13944
- // gives a store is the tune-out stamp (createAgent's; the other
13945
- // stores it opens have none), and a stamped message is skipped
13946
- // above, so no null here means a message the view hides. And the
13947
- // store and the render run with nothing between them, so nothing
13948
- // can put a summary in place of what this flush stored, or move
13949
- // windowed passthrough's anchor past it, before the render. A
14212
+ // That rests on two things. A message the agent's own view filter
14213
+ // leaves out is skipped above, by that filter, so no null here
14214
+ // means a message the view hides. And the store and the render
14215
+ // run with nothing between them, so nothing can put a summary in
14216
+ // place of what this flush stored, or move windowed passthrough's
14217
+ // anchor past it, before the render. A
13950
14218
  // write found here that landed at an earlier flush
13951
14219
  // (writeDrainedInOrder) is the exception: by now a summary may
13952
14220
  // stand in for it, or the anchor be past it, and then it goes in
@@ -14314,6 +14582,9 @@ export class AgentFramework {
14314
14582
  // Dispatch to all modules, tracking responses with module names
14315
14583
  const responses: ModuleProcessResponse[] = [];
14316
14584
  for (const module of this.moduleRegistry.getAllModules()) {
14585
+ // The framework's own toolsets module answers no event; every agent has
14586
+ // it now, so it isn't listed beside the modules that do.
14587
+ if (module instanceof ToolsetsModule) continue;
14317
14588
  try {
14318
14589
  const processState = this.moduleRegistry.createProcessState(module.name);
14319
14590
  const response = await module.onProcess(event, processState);
@@ -14413,6 +14684,7 @@ export class AgentFramework {
14413
14684
  // surface carries where the framework records none.
14414
14685
  const eventChannel = (event as { metadata?: { channelId?: unknown } }).metadata?.channelId;
14415
14686
  const namesChannel = typeof eventChannel === 'string' && eventChannel !== '';
14687
+ const localInput = event.type === 'external-message' || event.type === 'api:message';
14416
14688
  let added: { agent?: string; deferredId?: string; messageId?: string } | undefined;
14417
14689
  if (response.addMessages) {
14418
14690
  for (const [i, msg] of response.addMessages.entries()) {
@@ -14424,7 +14696,7 @@ export class AgentFramework {
14424
14696
  }
14425
14697
  if (surface) metadata = { ...metadata, [INBOUND_SOURCE_KEY]: surface };
14426
14698
  const placement: { agent?: string; deferredId?: string; messageId?: string } = {};
14427
- const id = this.addMessage(msg.participant, msg.content, metadata, { placement });
14699
+ const id = this.addMessage(msg.participant, localInput ? markNoticeOpenings(msg.content) : msg.content, metadata, { placement });
14428
14700
  if (response.addMessages.length === 1 && !surface && !namesChannel && metadata?.channelId === undefined) added = placement;
14429
14701
  this.emitTrace({ type: 'message:added', messageId: id, source: event.type });
14430
14702
  }
@@ -14666,6 +14938,7 @@ export class AgentFramework {
14666
14938
  missedCharacters: typeof event.metadata?.missedCharacters === 'number'
14667
14939
  ? event.metadata.missedCharacters
14668
14940
  : undefined,
14941
+ targetAgents: event.targetAgents,
14669
14942
  });
14670
14943
  if (invitation) {
14671
14944
  incomingContent.push(invitation.block);
@@ -15292,11 +15565,6 @@ export class AgentFramework {
15292
15565
  * explicit-prose agent's natural plain reply bounced with nowhere to go
15293
15566
  * (Rhys's first portal mention: a 2,320-char reply stranded).
15294
15567
  *
15295
- * Wording: option 1 teaches the `>>` destination prefix, which delivers for
15296
- * BOTH legacy (host-inferred) and explicit prose-routing agents. The
15297
- * previous "simply write your reply as normal text" was a false promise
15298
- * under explicit routing (the metadata flags it set had no consumers).
15299
- *
15300
15568
  * Human-readable names wherever the surface provided them — a mind reading
15301
15569
  * this should see WHO addressed it WHERE, not a wall of snowflake ids. Raw
15302
15570
  * ids remain only inside the tool-argument instructions, where they are the
@@ -15348,6 +15616,7 @@ export class AgentFramework {
15348
15616
  missedMessages?: number;
15349
15617
  /** Characters across those dropped messages. */
15350
15618
  missedCharacters?: number;
15619
+ targetAgents?: readonly string[];
15351
15620
  }): { block: ContentBlock; metadataPatch: Record<string, unknown> } | null {
15352
15621
  if (!this.channelRegistry) return null;
15353
15622
  if (!opts.tags?.includes('chat:addressed')) return null;
@@ -15363,8 +15632,11 @@ export class AgentFramework {
15363
15632
  // only where the name lacks one, by proseTargetFor's rule.
15364
15633
  const bareLabel = label && label !== opts.channelId ? label.replace(/^#/, '') : undefined;
15365
15634
  const hashLabel = bareLabel ? `#${bareLabel}` : undefined;
15366
- const channelLabel = hashLabel ?? `"${opts.channelId}"`;
15367
- const place = opts.guildName ? `${channelLabel} in "${opts.guildName}"` : channelLabel;
15635
+ const channelId = quotedValue(opts.channelId);
15636
+ const messageId = quotedValue(opts.messageId);
15637
+ const authorName = suppliedValue(opts.authorName);
15638
+ const channelLabel = hashLabel ? suppliedValue(hashLabel) : channelId;
15639
+ const place = opts.guildName ? `${channelLabel} in "${suppliedValue(opts.guildName)}"` : channelLabel;
15368
15640
  // The label is for reading; the prefix must be ONE token that resolves back
15369
15641
  // here, on this server (a DM label "DM: alice" quoted verbatim parses as
15370
15642
  // target "#DM:"). A registry that can name targets is authoritative: no safe
@@ -15373,7 +15645,7 @@ export class AgentFramework {
15373
15645
  const namer = this.channelRegistry as unknown as { proseTargetFor?: (ref: ChannelRef) => string | undefined };
15374
15646
  const target = typeof namer.proseTargetFor === 'function'
15375
15647
  ? namer.proseTargetFor(channel)
15376
- : [hashLabel, opts.channelId].find((t): t is string => !!t && isProseTargetToken(t));
15648
+ : [hashLabel, opts.channelId].find((t): t is string => !!t && isProseTargetToken(t) && showsAsItself(t));
15377
15649
  const inboundOnlyServer = this.channelRegistry.inboundOnlyServer(channel);
15378
15650
  // What replying does here is the registry's to say (speechOpens): a reply
15379
15651
  // opens a channel nobody decided to keep closed, and leaves one the agent
@@ -15383,24 +15655,39 @@ export class AgentFramework {
15383
15655
  const replyOpens = this.channelRegistry.speechOpens(channel);
15384
15656
  // The decline is the way to reply without opening a channel, except in a
15385
15657
  // tuned-out one, which channel_decline refuses: the tune-out ends first.
15386
- const withoutOpening =
15387
- this.channelRegistry.getDesiredState(opts.serverId, opts.channelId) === 'tuned-out'
15388
- ? ''
15389
- : " To reply without opening it, decline it first (option 3): a channel you've declined stays closed when you speak there.";
15658
+ const tunedOut = this.channelRegistry.getDesiredState(opts.serverId, opts.channelId) === 'tuned-out';
15659
+ const withoutOpening = tunedOut
15660
+ ? ''
15661
+ : " To reply without opening it, decline it first (option 3): a channel you've declined stays closed when you speak there.";
15662
+ const named = [...new Set(opts.targetAgents ?? [])].filter((name) => this.agents.has(name));
15663
+ const woken = named.length === 1 ? this.agents.get(named[0]!) : undefined;
15664
+ const mode = (woken ?? this.deliveryAgent())?.proseRouting ?? 'locus';
15665
+ const prefix = mode === 'hybrid' ? '>>>' : '>>';
15666
+ const delivered = replyOpens
15667
+ ? `it will be delivered there, which opens the channel.${withoutOpening}`
15668
+ : 'it will be delivered there, and the channel stays closed.';
15669
+ const sendTool = `check whether ${opts.serverId} exposes a send tool that can address it`;
15670
+ const plainSpeechRule = `your plain speech lands where the latest word on where it lands says: ` +
15671
+ `a [${NOTICE_TAG.routing}] notice, or channel_open's result. If that is ${this.channelShown(channel)}, ` +
15672
+ `write your reply with no prefix;`;
15390
15673
  const replyOption = inboundOnlyServer
15391
15674
  ? `1. Replying there isn't available: ${inboundOnlyServer} declared this channel inbound-only, so nothing ` +
15392
15675
  `you write is posted into it. To speak there, use ${inboundOnlyServer}'s own tools.\n`
15393
- : target
15394
- ? replyOpens
15395
- ? `1. Reply — write your reply this turn prefixed with ">>${target}"; it will be delivered there, which opens ` +
15396
- `the channel.${withoutOpening}\n`
15397
- : `1. Reply — write your reply this turn prefixed with ">>${target}"; it will be delivered there, and the ` +
15398
- `channel stays closed.\n`
15399
- : `1. Reply — no single-word target reaches this channel unambiguously, ` +
15400
- `so a ">>" prefix can't name it; check whether ${opts.serverId} exposes a send tool that can address it.` +
15401
- (replyOpens
15402
- ? ` Replying there opens the channel.${withoutOpening}\n`
15403
- : ` Replying there leaves the channel closed.\n`);
15676
+ : mode === 'disabled'
15677
+ ? `1. Replying in plain text isn't available: your plain text is never published. To speak there, ${sendTool}.\n`
15678
+ : mode === 'locus' && tunedOut
15679
+ ? `1. Reply — ${plainSpeechRule} it will be delivered there. If that is another channel, or you see none, to reply here, ` +
15680
+ `${sendTool}.\n`
15681
+ : mode === 'locus'
15682
+ ? `1. Reply — ${plainSpeechRule} ${delivered} If that is another channel, or you see none, to reply here, ` +
15683
+ `${sendTool}, or open it (option 2), whose result says where your plain speech lands.\n`
15684
+ : target
15685
+ ? `1. Reply — write your reply this turn prefixed with "${prefix}${target}"; ${delivered}\n`
15686
+ : `1. Reply — no single-word target reaches this channel unambiguously, ` +
15687
+ `so a "${prefix}" prefix can't name it; ${sendTool}.` +
15688
+ (replyOpens
15689
+ ? ` Replying there opens the channel.${withoutOpening}\n`
15690
+ : ` Replying there leaves the channel closed.\n`);
15404
15691
  const missedCount = opts.missedMessages ?? 0;
15405
15692
  const missedNote =
15406
15693
  missedCount > 0
@@ -15411,18 +15698,18 @@ export class AgentFramework {
15411
15698
  const block: ContentBlock = {
15412
15699
  type: 'text',
15413
15700
  text:
15414
- `\n[Channel invitation] ${opts.authorName} addressed you in ${place} — ` +
15701
+ `\n[${NOTICE_TAG.channelInvitation}] ${authorName} addressed you in ${place} — ` +
15415
15702
  `a channel you haven't opened. Messages there that address you, such as an @-mention, ` +
15416
15703
  `reach you whether or not you open it. ` +
15417
15704
  missedNote +
15418
15705
  `Your options:\n` +
15419
15706
  replyOption +
15420
- `2. Open the channel — call channel_open with channelId "${opts.channelId}" and serverId "${opts.serverId}"` +
15707
+ `2. Open the channel — call channel_open with channelId ${channelId} and serverId "${opts.serverId}"` +
15421
15708
  (maxBackscroll > 0
15422
- ? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId "${opts.messageId}".\n`
15709
+ ? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId ${messageId}.\n`
15423
15710
  : `.\n`) +
15424
- `3. Stay out — call channel_decline with channelId "${opts.channelId}", serverId "${opts.serverId}", ` +
15425
- `and messageId "${opts.messageId}" (optionally set acknowledge to an emoji like 👀 so ${opts.authorName} isn't left hanging).\n` +
15711
+ `3. Stay out — call channel_decline with channelId ${channelId}, serverId "${opts.serverId}", ` +
15712
+ `and messageId ${messageId} (optionally set acknowledge to an emoji like 👀 so ${authorName} isn't left hanging).\n` +
15426
15713
  `Doing nothing is also fine.`,
15427
15714
  };
15428
15715
  return {
@@ -16056,7 +16343,7 @@ export class AgentFramework {
16056
16343
  messageId: identity.messageId ?? str(origin.messageId) ?? occ.eventId,
16057
16344
  threadId: identity.threadId ?? str(origin.threadId),
16058
16345
  author: identity.author ?? { id: str(origin.authorId) ?? '', name: str(origin.authorName) ?? str(origin.authorId) ?? 'Someone' },
16059
- content: materialized ? materialized.map((block) => convertBlock(block, 'attachment on push event', occ.serverId)) : push.content,
16346
+ content: materialized ? convertBlocks(materialized, 'attachment on push event', occ.serverId) : push.content,
16060
16347
  timestamp: occ.timestamp,
16061
16348
  metadata: origin,
16062
16349
  tags: push.tags,
@@ -16077,7 +16364,7 @@ export class AgentFramework {
16077
16364
  ...occ.event.event,
16078
16365
  eventId: occ.eventId,
16079
16366
  timestamp: occ.timestamp,
16080
- ...(materialized ? { content: materialized.map((block) => convertBlock(block, 'attachment on push event', occ.serverId)) } : {}),
16367
+ ...(materialized ? { content: convertBlocks(materialized, 'attachment on push event', occ.serverId) } : {}),
16081
16368
  coalescingSubject,
16082
16369
  // §5.2: the materialized occurrence is consumed by the request being
16083
16370
  // assembled; the wake that started it is not repeated.
@@ -16203,19 +16490,13 @@ export class AgentFramework {
16203
16490
  // Every agent that can read this message must not have compiled past it.
16204
16491
  // Residents share one message slot, so a message one of them read is
16205
16492
  // history for all of them (the never-rewrite rule is about the message,
16206
- // not the agent that happened to be asked). A tune-out-diverted message
16207
- // never enters a resident's compiled view; only the subconscious reads
16208
- // it through its merged view — no subconscious, nobody reads it.
16209
- let readers: Agent[];
16210
- if ((message.metadata as { tuneOut?: unknown } | undefined)?.tuneOut) {
16211
- const subconscious = this.subconsciousAgentName ? this.agents.get(this.subconsciousAgentName) : undefined;
16212
- if (!subconscious) return true;
16213
- readers = [subconscious];
16214
- } else {
16215
- readers = this.sharedSlotAgents.has(agent)
16216
- ? [...this.sharedSlotAgents].filter((a) => this.agents.get(a.name) === a)
16217
- : [agent];
16218
- }
16493
+ // not the agent that happened to be asked). A reader is one whose view
16494
+ // filter keeps the message: a tune-out-diverted message never enters a
16495
+ // resident's compiled view, and only the subconscious reads it through
16496
+ // its merged view — no subconscious, nobody reads it.
16497
+ const readers = (this.sharedSlotAgents.has(agent)
16498
+ ? [...this.sharedSlotAgents].filter((a) => this.agents.get(a.name) === a)
16499
+ : [agent]).filter((a) => this.agentSees(a, message.metadata));
16219
16500
  const branch = cm.currentBranch().name;
16220
16501
  const ts = message.timestamp instanceof Date ? message.timestamp.getTime() : Number(message.timestamp);
16221
16502
  for (const reader of readers) {
@@ -16351,6 +16632,7 @@ export class AgentFramework {
16351
16632
  missedMessages: typeof origin.missedMessages === 'number' ? origin.missedMessages : undefined,
16352
16633
  missedCharacters:
16353
16634
  typeof origin.missedCharacters === 'number' ? origin.missedCharacters : undefined,
16635
+ targetAgents: event.targetAgents,
16354
16636
  });
16355
16637
  if (invitation) {
16356
16638
  content.push(invitation.block);
@@ -16585,7 +16867,8 @@ export class AgentFramework {
16585
16867
  // A provider wait binds its own model; with a different primary model
16586
16868
  // selected now, the turn proceeds (the wait stays recorded).
16587
16869
  const otherModel = providerCooldown.waitModel !== undefined && providerCooldown.waitModel !== agent.model;
16588
- if (!otherModel && (now < providerCooldown.until || this.extendHostProviderHold(agentName, providerCooldown))) {
16870
+ if (!otherModel && !providerCooldown.rearm
16871
+ && (now < providerCooldown.until || this.extendHostProviderHold(agentName, providerCooldown))) {
16589
16872
  for (const req of requests) this.holdRequest(providerCooldown.heldRequests, req);
16590
16873
  const recovery = this.providerAccelerationRecoveries.get(agentName);
16591
16874
  if (recovery) recovery.heldRequests = providerCooldown.heldRequests.length;
@@ -16596,6 +16879,10 @@ export class AgentFramework {
16596
16879
  // the held causes into this one current compile.
16597
16880
  clearTimeout(providerCooldown.timer);
16598
16881
  this.providerAccelerationCooldowns.delete(agentName);
16882
+ if (providerCooldown.rearm) {
16883
+ console.error(`[inference-rearm] agent=${agentName} turn queued again with a wake ` +
16884
+ `(${providerCooldown.rearm.number} of ${providerCooldown.rearm.of})`);
16885
+ }
16599
16886
  const recovery = this.providerAccelerationRecoveries.get(agentName);
16600
16887
  if (recovery) recovery.releasedAt = now;
16601
16888
  requests = [...providerCooldown.heldRequests, ...requests];
@@ -16675,7 +16962,10 @@ export class AgentFramework {
16675
16962
  // and a wake for a deferred write or a stored message is dropped
16676
16963
  // once the agent is done with that write: neither may absorb an
16677
16964
  // unrelated wake that would vanish with it.
16678
- const key = `${request.reason}|${request.addressed ? 'a' : 'n'}|${request.coalescingSubject ?? ''}|${request.readsDeferredWrite ?? ''}|${request.readsStoredMessage ?? ''}`;
16965
+ // A heartbeat's wake keeps its own key, so a later channel-less wake
16966
+ // can't absorb it: resumed together, they make a heartbeat's batch
16967
+ // (heartbeatTurn), as they would have without the quiesce.
16968
+ const key = `${request.reason}|${request.addressed ? 'a' : 'n'}|${isHeartbeatWake(request) ? 'h' : ''}|${request.coalescingSubject ?? ''}|${request.readsDeferredWrite ?? ''}|${request.readsStoredMessage ?? ''}`;
16679
16969
  const prev = newestByKey.get(key);
16680
16970
  const kept = !prev || request.timestamp >= prev.timestamp ? request : prev;
16681
16971
  // The wake it absorbs hands on its lines (WakeLine): each event's
@@ -16884,13 +17174,14 @@ export class AgentFramework {
16884
17174
  // an open channel — legacy last-inbound semantics).
16885
17175
  // A chat:system notice (RFC-001 rev 3: weather in a world, someone
16886
17176
  // joining) names no channel at all: nobody spoke there, so a turn it
16887
- // wakes alone takes the global fallback, as a heartbeat does, and the
16888
- // agent sends there explicitly if it means to.
17177
+ // wakes alone takes the global fallback, and the agent sends there
17178
+ // explicitly if it means to.
16889
17179
  // Ambient chatter must not outrank an addressed message just by being
16890
17180
  // newest (2026-07-21 Cairn lounge misroute, turn-start variant).
16891
17181
  // Non-channel wakes (heartbeats, module events, reactions — which never
16892
17182
  // carry channelId) leave both undefined → global fallback, which an
16893
- // ephemeral run never takes (takesGlobalFallback).
17183
+ // ephemeral run never takes (takesGlobalFallback), except a heartbeat's
17184
+ // turn, which takes none (heartbeatTurn below).
16894
17185
  // Track the winning REQUEST, not just its channel: channel, addressed
16895
17186
  // and counterparty must come from the same message, or a batch of
16896
17187
  // "ambient from A, then addressed from B" would report B's channel
@@ -16966,6 +17257,9 @@ export class AgentFramework {
16966
17257
  wakeLines: mergeWakeLines(requests),
16967
17258
  suppressProse: silentOnly ? trigger?.suppressProse : undefined,
16968
17259
  silentHeartbeat: silentOnly ? trigger?.silentHeartbeat : undefined,
17260
+ // A heartbeat's turn when any cause is a heartbeat's wake and none
17261
+ // names a channel, whatever the batch's order (isHeartbeatWake).
17262
+ heartbeatTurn: !channelReq && requests.some((r) => isHeartbeatWake(r)),
16969
17263
  // The channel and its server from the one request, so the turn's
16970
17264
  // channel is that message's, never one server's id with another's.
16971
17265
  channelId: channelReq?.channelId,
@@ -16992,6 +17286,8 @@ export class AgentFramework {
16992
17286
  const carry = requests.map(turnCarryOf).find((c) => c !== undefined);
16993
17287
  if (carry) start[TURN_CARRY] = carry;
16994
17288
  else delete start[TURN_CARRY];
17289
+ if (requests.every(isRearmed)) start[REARMED] = true;
17290
+ else delete start[REARMED];
16995
17291
  // A start that carries a turn on and took in wakes that don't records
16996
17292
  // the batch it was merged from (MERGED_BATCH): until it takes its
16997
17293
  // token it stands for them all.
@@ -17212,7 +17508,7 @@ export class AgentFramework {
17212
17508
  if (hold.kind === 'place') {
17213
17509
  const { place } = hold;
17214
17510
  const where = this.channelShown(place.to);
17215
- return `your ${place.tool} ${said(place.round)} ${place.delivered ? 'delivered' : 'may have posted'} to ${where}, ` +
17511
+ return `your ${suppliedValue(place.tool)} ${said(place.round)} ${place.delivered ? 'delivered' : 'may have posted'} to ${where}, ` +
17216
17512
  `where this was going${lasts(place.round, true)}`;
17217
17513
  }
17218
17514
  const why = hold.why === 'error'
@@ -17225,7 +17521,7 @@ export class AgentFramework {
17225
17521
  ? "it hadn't answered when this speech was routed"
17226
17522
  : "it hadn't answered by the end of its round";
17227
17523
  const going = target ? `, and this was going to ${this.channelShown(target)}` : '';
17228
- 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)}`;
17524
+ 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)}`;
17229
17525
  }
17230
17526
 
17231
17527
  /**
@@ -17314,8 +17610,8 @@ export class AgentFramework {
17314
17610
  const r = registry.resolveProseTarget(name, { forSpeech: true, own });
17315
17611
  return 'channelId' in r && sameChannel(r, target) ? name : undefined;
17316
17612
  };
17317
- const destination = JSON.stringify(
17318
- (target && (resent(registry?.proseTargetFor(target)) ?? resent(qualifiedChannelName(target)))) ?? '#channel',
17613
+ const destination = quotedValue(
17614
+ (target && (resent(registry?.proseTargetFor(target)) ?? resent(qualifiedChannelName(target)))) ?? NO_CHANNEL_EXAMPLE,
17319
17615
  );
17320
17616
  // Words copied from a draft that may already have been posted resend
17321
17617
  // only with the resident's confirmation; the rest resend freely.
@@ -17332,7 +17628,7 @@ export class AgentFramework {
17332
17628
  // Named: residents that share one message slot (#197) share this window.
17333
17629
  // "Not sent" only when no copied words may already have been posted.
17334
17630
  return (
17335
- `[drafts] ${agentName}: ${risky.length > 0 ? '' : 'not sent — '}` +
17631
+ `[${NOTICE_TAG.drafts}] ${agentName}: ${risky.length > 0 ? '' : 'not sent — '}` +
17336
17632
  `${many ? `${held.length} plain-speech segments` : 'a plain-speech segment'} ` +
17337
17633
  `held as draft${many ? 's' : ''} ${ids.join(', ')} — ${because ? `because ${because}` : AgentFramework.DRAFT_REASON_TEXT[reason]}. ` +
17338
17634
  `${resend.join('; ')}; drafts(action: "dismiss", draftIds: [${quoted(held)}]) sets ${many ? 'them' : 'it'} aside.`
@@ -17353,8 +17649,9 @@ export class AgentFramework {
17353
17649
  /** A destination as the agent is shown it: its label, with its name as
17354
17650
  * ChannelRegistry.nameShown gives it. */
17355
17651
  private destinationText(d: PublishDestination): string {
17356
- const label = d.label && d.label !== d.channelId ? `${d.label.startsWith('#') || d.label.startsWith('DM') ? d.label : `#${d.label}`} ` : '';
17357
- return `${label}(${this.channelRegistry?.nameShown(d) ?? d.channelId})`;
17652
+ const shown = d.label && d.label !== d.channelId ? suppliedValue(d.label) : '';
17653
+ const label = shown ? `${shown.startsWith('#') || shown.startsWith('DM') ? '' : '#'}${shown} ` : '';
17654
+ return `${label}(${this.channelRegistry?.nameShown(d) ?? suppliedValue(d.channelId)})`;
17358
17655
  }
17359
17656
 
17360
17657
  /** One line per draft: id, state, when, why, size, preview. */
@@ -17387,7 +17684,7 @@ export class AgentFramework {
17387
17684
  const risky = uncertainAttempt(draft);
17388
17685
  if (risky) {
17389
17686
  return `${draft.id}'s attempt to ${this.destinationText(risky.destination)} at ` +
17390
- `${this.draftTime(risky.at)} may already have been posted: ${risky.outcome?.reason ?? this.noOutcomeText(draft, risky)}`;
17687
+ `${this.draftTime(risky.at)} may already have been posted: ${risky.outcome?.reason !== undefined ? suppliedValue(risky.outcome.reason) : this.noOutcomeText(draft, risky)}`;
17391
17688
  }
17392
17689
  const inherited = draft.inheritedRisk!;
17393
17690
  const owner = inherited.sourceDraftId ?? inherited.draftId;
@@ -17396,7 +17693,7 @@ export class AgentFramework {
17396
17693
  ? `${owner}'s attempt to ${this.destinationText(inherited.destination)}` +
17397
17694
  (inherited.at !== undefined ? ` at ${this.draftTime(inherited.at)}` : '')
17398
17695
  : owner;
17399
- return `${draft.id} includes the words of ${inherited.draftId}${via}, and ${where} may already have been posted: ${inherited.reason}`;
17696
+ return `${draft.id} includes the words of ${inherited.draftId}${via}, and ${where} may already have been posted: ${suppliedValue(inherited.reason)}`;
17400
17697
  }
17401
17698
 
17402
17699
  /** Whether an attempt with no outcome is still out: its draft's latest,
@@ -17417,7 +17714,7 @@ export class AgentFramework {
17417
17714
  if (!delivered) return `${draft.id}: not delivered`;
17418
17715
  return `${draft.id}: delivered — confirmed at ${this.draftTime(delivered.outcome!.at)} to ` +
17419
17716
  `${this.destinationText(delivered.destination)}` +
17420
- (delivered.outcome!.messageId ? `, message ${delivered.outcome!.messageId}` : '') +
17717
+ (delivered.outcome!.messageId ? `, message ${nameToTypeBack(delivered.outcome!.messageId)}` : '') +
17421
17718
  ' (a record of that delivery, not a check that the message is still there)';
17422
17719
  }
17423
17720
 
@@ -17460,7 +17757,7 @@ export class AgentFramework {
17460
17757
  const rest = pending.slice(shown.length).map((d) => d.id);
17461
17758
  const more = rest.length > 0 ? ` · and ${rest.length} more: ${rest.join(', ')}` : '';
17462
17759
  const text =
17463
- `[drafts] ${agent.name}: ${pending.length === 1 ? 'an open draft' : `${pending.length} open drafts`} of yours ` +
17760
+ `[${NOTICE_TAG.drafts}] ${agent.name}: ${pending.length === 1 ? 'an open draft' : `${pending.length} open drafts`} of yours ` +
17464
17761
  `that no recent notice on this branch names: ${shown.join(' · ')}${more}. ` +
17465
17762
  'drafts(action: "list") shows your open drafts; resend or dismiss them by id.';
17466
17763
  let mid: string;
@@ -17702,17 +17999,17 @@ export class AgentFramework {
17702
17999
  const blocks = drafts.map((d) => {
17703
18000
  const lines = [
17704
18001
  `${d.id} — ${draftState(d)}; held ${this.draftTime(d.heldAt)} (${AgentFramework.DRAFT_REASON_TEXT[d.reason]})` +
17705
- (d.note ? `; ${d.note}` : ''),
18002
+ (d.note ? `; ${suppliedValue(d.note)}` : ''),
17706
18003
  ...(d.inheritedRisk && draftState(d) === 'unconfirmed' && !uncertainAttempt(d) ? [` ${this.riskText(d)}`] : []),
17707
18004
  ...d.attempts.map((a) => {
17708
18005
  const o = a.outcome;
17709
18006
  const status = !o
17710
18007
  ? (this.attemptIsOut(d, a) ? 'being sent right now — no outcome yet' : 'outcome never recorded — it may have been posted')
17711
18008
  : o.status === 'delivered'
17712
- ? `delivered${o.messageId ? `, message ${o.messageId}` : ''}`
18009
+ ? `delivered${o.messageId ? `, message ${nameToTypeBack(o.messageId)}` : ''}`
17713
18010
  : o.status === 'unknown'
17714
- ? `UNKNOWN — may have been posted: ${o.reason ?? 'no valid receipt'}`
17715
- : `failed, nothing posted: ${o.reason ?? 'refused'}`;
18011
+ ? `UNKNOWN — may have been posted: ${o.reason !== undefined ? suppliedValue(o.reason) : 'no valid receipt'}`
18012
+ : `failed, nothing posted: ${o.reason !== undefined ? suppliedValue(o.reason) : 'refused'}`;
17716
18013
  return ` attempt ${this.draftTime(a.at)} → ${this.destinationText(a.destination)}: ${status}` +
17717
18014
  (o?.status === 'unknown' && o.detail !== undefined ? `; connector detail: ${connectorText(o.detail)}` : '') +
17718
18015
  (a.confirmedDuplicate ? ' (resent knowing it might duplicate)' : '');
@@ -17803,16 +18100,18 @@ export class AgentFramework {
17803
18100
  // its turn a name the turn's own channel answers to means that channel.
17804
18101
  const resolved = registry.resolveProseTarget(spec, { forSpeech: true, own });
17805
18102
  if ('error' in resolved) {
17806
- const cand = resolved.candidates?.length ? ` It could mean: ${resolved.candidates.join(', ')}.` : '';
17807
- return refuse(`Destination "${spec}" did not resolve: ${resolved.error}.${cand} Nothing was sent.`);
18103
+ const cand = resolved.candidates?.length
18104
+ ? ` It could mean: ${resolved.candidates.join(', ')}.${registry.publishRouteFor(resolved.candidates) ?? ''}`
18105
+ : '';
18106
+ return refuse(`Destination "${spec}" did not resolve: ${suppliedValue(resolved.error)}.${cand} Nothing was sent.`);
17808
18107
  }
17809
18108
  named = resolved;
17810
18109
  } else {
17811
18110
  named = home!;
17812
18111
  }
17813
18112
  const target = registry.resolveDestination({ serverId: named.serverId, channelId: named.channelId });
17814
- // Channels named as the agent names them back (nameShown).
17815
- if ('error' in target) return refuse(`Destination "${spec || registry.nameShown(home!)}": ${target.error}. Nothing was sent.`);
18113
+ // Channels named as the agent names them back (quotedShown).
18114
+ if ('error' in target) return refuse(`Destination ${spec ? `"${spec}"` : registry.quotedShown(home!)}: ${suppliedValue(target.error)}. Nothing was sent.`);
17816
18115
  const destination = target.destination;
17817
18116
  if (home && !sameChannel(destination, home)) {
17818
18117
  return refuse(
@@ -17901,7 +18200,7 @@ export class AgentFramework {
17901
18200
  // it, so say what the draft reads as now rather than guess.
17902
18201
  const unrecorded = recorded ? '' : ` (recording this outcome failed; the draft reads as ${this.draftStateNow(agentName, d.id)} now)`;
17903
18202
  if (outcome.status === 'delivered') {
17904
- lines.push(`${d.id}: delivered to ${where}${outcome.messageId ? `, message ${outcome.messageId}` : ''}${unrecorded}.`);
18203
+ lines.push(`${d.id}: delivered to ${where}${outcome.messageId ? `, message ${nameToTypeBack(outcome.messageId)}` : ''}${unrecorded}.`);
17905
18204
  sendingTurn?.engaged.add(destinationKey(destination));
17906
18205
  resender?.notePublished(destinationKey(destination), readThrough);
17907
18206
  continue;
@@ -17912,9 +18211,9 @@ export class AgentFramework {
17912
18211
  // The connector's data may say what a multi-part send already posted.
17913
18212
  const detail = outcome.status === 'unknown' && outcome.detail !== undefined ? ` Connector detail: ${connectorText(outcome.detail)}` : '';
17914
18213
  lines.push((outcome.status === 'unknown'
17915
- ? `${d.id}: delivery to ${where} NOT confirmed — it may or may not have been posted: ${outcome.reason ?? 'no valid receipt'}. ` +
18214
+ ? `${d.id}: delivery to ${where} NOT confirmed — it may or may not have been posted: ${outcome.reason !== undefined ? suppliedValue(outcome.reason) : 'no valid receipt'}. ` +
17916
18215
  `Sending it again could post it twice, so a resend needs confirmDuplicate: true${unrecorded}.`
17917
- : `${d.id}: not sent to ${where} — ${outcome.reason ?? 'refused'}. Nothing was posted by this attempt${unrecorded}.` +
18216
+ : `${d.id}: not sent to ${where} — ${outcome.reason !== undefined ? suppliedValue(outcome.reason) : 'refused'}. Nothing was posted by this attempt${unrecorded}.` +
17918
18217
  (stillAtRisk
17919
18218
  ? ` The draft stays unconfirmed: ${this.riskText(after!)}.`
17920
18219
  : ' The draft stays held.')) + detail);
@@ -17940,6 +18239,47 @@ export class AgentFramework {
17940
18239
  return undefined;
17941
18240
  }
17942
18241
 
18242
+ /** Why a heartbeat's turn holds its plain speech, in a held-draft notice ("because …"). */
18243
+ private static readonly HEARTBEAT_HOLD_BECAUSE =
18244
+ 'this turn came from your heartbeat, not from a channel, so its plain speech has no destination ' +
18245
+ 'and is kept for you to send where you choose';
18246
+
18247
+ /**
18248
+ * Locus mode's delivery of plain speech to the turn's frozen locus. A
18249
+ * heartbeat's turn with no locus of its own (TurnRouting.heartbeat) holds
18250
+ * the words as a draft instead of failing the delivery: nobody was waiting
18251
+ * for them, and the agent may send them where it chooses.
18252
+ */
18253
+ private async routeLocusSpeech(
18254
+ agent: Agent,
18255
+ round: ProseRound,
18256
+ text: string,
18257
+ locus: ChannelRef | null,
18258
+ ): Promise<{ delivered: boolean; serverId: string; channelId: string; messageId?: string } | null> {
18259
+ if (locus === null && round.routing.heartbeat) {
18260
+ this.holdHeartbeatProse(agent, [text], round);
18261
+ return null;
18262
+ }
18263
+ return this.channelRegistry!.routeSpeech(agent.name, text, locus);
18264
+ }
18265
+
18266
+ /** The mark a framework wake takes from a heartbeat's turn with no locus
18267
+ * of its own (InferenceRequest.heartbeatTurn), so the turn it opens is a
18268
+ * heartbeat's too; nothing for any other turn. */
18269
+ private heartbeatTurnMark(routing: TurnRouting): { heartbeatTurn?: true } {
18270
+ return routing.heartbeat && !routing.locus ? { heartbeatTurn: true } : {};
18271
+ }
18272
+
18273
+ /** Hold a heartbeat turn's plain speech as drafts ('no-destination'), named by `round`'s notice. */
18274
+ private holdHeartbeatProse(agent: Agent, texts: string[], round: ProseRound): Draft[] {
18275
+ console.error(`[prose] ${agent.name}: heartbeat turn — ${texts.length} plain-speech segment(s) held as drafts (no destination)`);
18276
+ return this.holdProseDrafts(agent, texts, 'no-destination', {
18277
+ routing: round.routing,
18278
+ notice: round.draftsRound ? 'later' : round.notice,
18279
+ because: AgentFramework.HEARTBEAT_HOLD_BECAUSE,
18280
+ });
18281
+ }
18282
+
17943
18283
  /** Record a successful plain-prose delivery for this turn's receipt. */
17944
18284
  private recordProseDelivery(
17945
18285
  routing: TurnRouting,
@@ -18070,10 +18410,10 @@ export class AgentFramework {
18070
18410
  // it is never uncertain.
18071
18411
  const text =
18072
18412
  shown.length > 0
18073
- ? `[delivered] plain speech → ${shown.join(' · ')}${suppressedNote ? ` · ${suppressedNote}` : ''}${closedNote}`
18413
+ ? `[${NOTICE_TAG.delivered}] plain speech → ${shown.join(' · ')}${suppressedNote ? ` · ${suppressedNote}` : ''}${closedNote}`
18074
18414
  : agent.proseRouting === 'disabled'
18075
- ? `[delivered] plain speech → no channel — ${suppressedNote}`
18076
- : `[delivered] nothing${uncertain ? ' confirmed' : ''} — ${suppressedNote}`;
18415
+ ? `[${NOTICE_TAG.delivered}] plain speech → no channel — ${suppressedNote}`
18416
+ : `[${NOTICE_TAG.delivered}] nothing${uncertain ? ' confirmed' : ''} — ${suppressedNote}`;
18077
18417
  try {
18078
18418
  const mid = agent.getContextManager().addMessage(
18079
18419
  'user',
@@ -18097,7 +18437,7 @@ export class AgentFramework {
18097
18437
  'user',
18098
18438
  [{
18099
18439
  type: 'text',
18100
- text: `[speech-not-posted] Nothing you wrote before your tool calls in the rounds just above was posted. ` +
18440
+ text: `[${NOTICE_TAG.speechNotPosted}] Nothing you wrote before your tool calls in the rounds just above was posted. ` +
18101
18441
  `If any of it should go out, send it again.`,
18102
18442
  }],
18103
18443
  { system: true, kind: 'speech-not-posted' },
@@ -18110,7 +18450,7 @@ export class AgentFramework {
18110
18450
 
18111
18451
  private noteFailedTurnEnd(agent: Agent, routing: TurnRouting): void {
18112
18452
  const callsCutOff = (routing.unflushedRounds ?? 0) > 0;
18113
- if (routing.roundsRan || callsCutOff) {
18453
+ if (gotPartway(routing)) {
18114
18454
  this.exhaustedTurns?.set(agent.name, {
18115
18455
  roundsRan: routing.roundsRan === true, callsCutOff, liveSpeech: routing.liveSpeech === true,
18116
18456
  speechRouting: (routing.speechRouting ?? 0) > 0,
@@ -18144,7 +18484,7 @@ export class AgentFramework {
18144
18484
  try {
18145
18485
  this.addMessage(
18146
18486
  'user',
18147
- [{ type: 'text', text: `[closed channel] ${notes.join(' ')}${this.closedChannelNoteOff(agent.name)}` }],
18487
+ [{ type: 'text', text: `[${NOTICE_TAG.closedChannel}] ${notes.join(' ')}${this.closedChannelNoteOff(agent.name)}` }],
18148
18488
  { system: true, kind: 'delivery-receipt' } as MessageMetadata,
18149
18489
  target,
18150
18490
  );
@@ -18153,6 +18493,26 @@ export class AgentFramework {
18153
18493
  }
18154
18494
  }
18155
18495
 
18496
+ /**
18497
+ * Tell a heartbeat's turn, before it compiles, that its plain speech has
18498
+ * no destination (TurnRouting.heartbeat): the last `[routing]` notice may
18499
+ * name a channel, and this turn's words won't go there. It leaves the
18500
+ * announced baseline alone (announceLocusIfChanged).
18501
+ */
18502
+ private noticeHeartbeatTurn(agentName: string): void {
18503
+ const agent = this.agents.get(agentName);
18504
+ if (!agent) return;
18505
+ const text =
18506
+ `[${NOTICE_TAG.routing}] This turn came from your heartbeat, not from a channel, so plain speech here isn't posted: ` +
18507
+ 'it\'s held as a draft you can send with the drafts tool. An explicit send reaches a channel now.';
18508
+ try {
18509
+ const id = agent.getContextManager().addMessage('user', [{ type: 'text', text }], { system: true, kind: 'routing-notice' });
18510
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
18511
+ } catch (err) {
18512
+ console.error('noticeHeartbeatTurn: failed to record the notice:', err);
18513
+ }
18514
+ }
18515
+
18156
18516
  /**
18157
18517
  * Announce-on-change locus notice (turn-frozen routing). Appends a durable
18158
18518
  * `[routing]` window message when the turn's effective outbound locus
@@ -18177,15 +18537,15 @@ export class AgentFramework {
18177
18537
  let text: string;
18178
18538
  if (locus === null) {
18179
18539
  text =
18180
- '[routing] Your plain speech currently has no channel — it stays in ' +
18540
+ `[${NOTICE_TAG.routing}] Your plain speech currently has no channel — it stays in ` +
18181
18541
  'your archive. Use an explicit send tool to reach a channel.';
18182
18542
  } else {
18183
18543
  const shown = this.channelShown(locus);
18184
18544
  const inboundOnlyServer = this.channelRegistry?.inboundOnlyServer(locus);
18185
18545
  text = inboundOnlyServer
18186
- ? `[routing] The conversation is now in ${shown}. ${inboundOnlySpeechNote(inboundOnlyServer)} ` +
18546
+ ? `[${NOTICE_TAG.routing}] The conversation is now in ${shown}. ${inboundOnlySpeechNote(inboundOnlyServer)} ` +
18187
18547
  'Other channels need an explicit send tool.'
18188
- : `[routing] Your plain speech now lands in ${shown}. ` +
18548
+ : `[${NOTICE_TAG.routing}] Your plain speech now lands in ${shown}. ` +
18189
18549
  'Other channels need an explicit send tool.';
18190
18550
  }
18191
18551
 
@@ -18465,17 +18825,23 @@ export class AgentFramework {
18465
18825
  return { newestRowAt, lastClockAt: undefined };
18466
18826
  }
18467
18827
 
18828
+ private agentSees(agent: Agent, metadata: unknown): boolean {
18829
+ const sees = this.storeSees.get(agent.getContextManager());
18830
+ return !sees || sees(metadata);
18831
+ }
18832
+
18468
18833
  /**
18469
18834
  * Weigh a message that no tool boundary of the agent's open logical turn
18470
18835
  * will weigh: one that would re-pin the locus becomes one of the turn's
18471
- * arrivals (TurnRouting.arrivals). A message tune-out diverted
18472
- * (metadata.tuneOut) never enters a resident's compiled view, so the agent
18473
- * won't see it, and it moves nothing.
18836
+ * arrivals (TurnRouting.arrivals). A message the agent's own view leaves
18837
+ * out, such as one a tune-out diverted (metadata.tuneOut), never enters
18838
+ * its compiled view, so the agent won't see it, and it moves nothing.
18474
18839
  */
18475
18840
  private noteArrival(agentName: string, metadata: MessageMetadata | undefined, messageId: MessageId): void {
18476
18841
  const open = this.openLogical.get(agentName);
18477
18842
  if (!open) return;
18478
- if ((metadata as { tuneOut?: unknown } | undefined)?.tuneOut) return;
18843
+ const agent = this.agents.get(agentName);
18844
+ if (agent && !this.agentSees(agent, metadata)) return;
18479
18845
  if (isConversationalInjection(metadata)) (open.heard ??= new Map()).set(messageId, ++lastHeardAt);
18480
18846
  const channel = this.repinChannel(metadata, open);
18481
18847
  if (channel) open.arrivals.push({ messageId, channel });
@@ -18501,6 +18867,7 @@ export class AgentFramework {
18501
18867
  if (!this.isOrdinaryResident(agent)) return;
18502
18868
  for (const reader of this.sharedSlotAgents) {
18503
18869
  if (reader !== agent && this.agents.get(reader.name) === reader && this.isOrdinaryResident(reader)) {
18870
+ this.noteSpokenTo(reader.name, metadata);
18504
18871
  this.noteArrival(reader.name, metadata, messageId);
18505
18872
  }
18506
18873
  }
@@ -18572,9 +18939,9 @@ export class AgentFramework {
18572
18939
  const content: ContentBlock[] = [{
18573
18940
  type: 'text',
18574
18941
  text: inboundOnlyServer
18575
- ? `[routing] The conversation moved to ${shown}. ${inboundOnlySpeechNote(inboundOnlyServer)} ` +
18942
+ ? `[${NOTICE_TAG.routing}] The conversation moved to ${shown}. ${inboundOnlySpeechNote(inboundOnlyServer)} ` +
18576
18943
  'Other channels need an explicit send tool.'
18577
- : `[routing] The conversation moved to ${shown} — your plain ` +
18944
+ : `[${NOTICE_TAG.routing}] The conversation moved to ${shown} — your plain ` +
18578
18945
  'speech now lands there for the rest of this turn. Other ' +
18579
18946
  'channels need an explicit send tool.',
18580
18947
  }];
@@ -18718,7 +19085,7 @@ export class AgentFramework {
18718
19085
  }
18719
19086
  const sender = this.agentForPrimary(agentName);
18720
19087
  return [{
18721
- text: `[channels] Now open: ${shown}: a message from ${sender.first} into the closed channel opened it; ${sender.again} has ended.` +
19088
+ text: `[${NOTICE_TAG.channels}] Now open: ${shown}: a message from ${sender.first} into the closed channel opened it; ${sender.again} has ended.` +
18722
19089
  optOut(this.holdsChannelTraffic(primary, refs), this.canCloseChannels(primary, refs)),
18723
19090
  metadata: { system: true, kind: 'channel-notice', speaker: agentName } as MessageMetadata,
18724
19091
  told: `the primary, by name (${agentName} has ended)`,
@@ -18738,13 +19105,13 @@ export class AgentFramework {
18738
19105
  const tellPrimary = !!agentName && !canClose && primary !== undefined && this.agents.has(primary) &&
18739
19106
  this.canCloseChannels(primary, refs);
18740
19107
  const readerText = (told: string) => (cause === 'subscription-policy'
18741
- ? `[channels] Selected for opening by subscription policy: ${shown}. ` +
19108
+ ? `[${NOTICE_TAG.channels}] Selected for opening by subscription policy: ${shown}. ` +
18742
19109
  (plural
18743
19110
  ? "The host opens them, and if one doesn't open, tries again when its server next registers it; " +
18744
19111
  'channel_list shows whether each is open yet.'
18745
19112
  : 'The host opens it, and if that fails, tries again when its server next registers it; ' +
18746
19113
  "channel_list shows whether it's open yet.")
18747
- : `[channels] Now open: ${shown}: your own message into the closed channel opened it.`) +
19114
+ : `[${NOTICE_TAG.channels}] Now open: ${shown}: your own message into the closed channel opened it.`) +
18748
19115
  optOut(this.holdsChannelTraffic(who, refs), canClose) + told;
18749
19116
  const reader: ChannelNotice = {
18750
19117
  text: readerText(tellPrimary ? ` ${this.primaryForReader(agentName, primary)} has been told and can close ${plural ? 'them' : 'it'}.` : ''),
@@ -18761,7 +19128,7 @@ export class AgentFramework {
18761
19128
  if (tellPrimary) {
18762
19129
  const sender = this.agentForPrimary(agentName);
18763
19130
  notices.push({
18764
- 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'}.` +
19131
+ 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'}.` +
18765
19132
  optOut(this.holdsChannelTraffic(primary, refs), true),
18766
19133
  metadata: { system: true, kind: 'channel-notice', speaker: agentName } as MessageMetadata,
18767
19134
  told: `the primary, by name (${agentName} can't close ${plural ? 'them' : 'it'})`,
@@ -18955,7 +19322,7 @@ export class AgentFramework {
18955
19322
  primary !== undefined && this.agents.has(primary) &&
18956
19323
  this.canCloseChannels(primary, [{ serverId, channelId: channel.channelId }]);
18957
19324
  const sender = ended ? this.agentForPrimary(agentName!) : undefined;
18958
- const readerText = (told: string) => `[channels] Not open: ${shown}. ` +
19325
+ const readerText = (told: string) => `[${NOTICE_TAG.channels}] Not open: ${shown}. ` +
18959
19326
  (ended
18960
19327
  ? `A reply from ${sender!.first} went out there, but opening the channel failed; ${sender!.again} has ended.`
18961
19328
  : 'Your reply there went out, but opening the channel failed.') +
@@ -18982,7 +19349,7 @@ export class AgentFramework {
18982
19349
  const primaryCanOpen = !this.conversationAgentHomes.has(primary!) && this.canAgentUseTool(primary!, 'channel_open');
18983
19350
  const reader = this.agentForPrimary(who!);
18984
19351
  notices.push({
18985
- 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.` +
19352
+ 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.` +
18986
19353
  selected + ', and channel_close withdraws that.' +
18987
19354
  (primaryCanOpen && !hostRetries ? ' channel_open tries again.' : ''),
18988
19355
  metadata: { system: true, kind: 'channel-notice', speaker: who } as MessageMetadata,
@@ -19022,6 +19389,7 @@ export class AgentFramework {
19022
19389
  reason: 'prose-continuation',
19023
19390
  source: 'framework',
19024
19391
  timestamp: Date.now(),
19392
+ ...this.heartbeatTurnMark(routing),
19025
19393
  });
19026
19394
  }
19027
19395
 
@@ -19094,7 +19462,7 @@ export class AgentFramework {
19094
19462
  this.autoContinueStreaks.set(name, count);
19095
19463
  console.error(
19096
19464
  `[continue] ${name}: turn ended (${stopReason}) without skip_reply — continuation ${count} of ${config.maxConsecutive}` +
19097
- (routing.locus ? ` in ${this.channelRegistry?.nameShown(routing.locus) ?? routing.locus.channelId}` : ''),
19465
+ (routing.locus ? ` in ${this.channelRegistry?.nameShown(routing.locus) ?? suppliedValue(routing.locus.channelId)}` : ''),
19098
19466
  );
19099
19467
  // The next turn speaks where this one did (locus and hybrid modes): the
19100
19468
  // locus's server and id, as the framework's own wakes name a channel. A
@@ -19113,6 +19481,7 @@ export class AgentFramework {
19113
19481
  timestamp: Date.now(),
19114
19482
  ...channel,
19115
19483
  autoContinueRun: { stop, ...(opts.heldAfterSend ? { heldAfterSend: true } : {}) },
19484
+ ...this.heartbeatTurnMark(routing),
19116
19485
  });
19117
19486
  }
19118
19487
 
@@ -19132,11 +19501,12 @@ export class AgentFramework {
19132
19501
  * starts (startAgentStream), with the run's count as it stands and the
19133
19502
  * agent's cap. False when it couldn't be stored.
19134
19503
  */
19135
- private writeAutoContinueNotice(agent: Agent, mark: AutoContinueRun): boolean {
19504
+ private writeAutoContinueNotice(agent: Agent, mark: AutoContinueRun, heartbeat = false): boolean {
19136
19505
  const count = this.autoContinueStreaks.get(agent.name) ?? 1;
19137
19506
  const max = agent.autoContinue?.maxConsecutive ?? count;
19138
19507
  const text = autoContinueNotice({
19139
19508
  agentName: agent.name, stop: mark.stop, last: count >= max, heldAfterSend: mark.heldAfterSend,
19509
+ heartbeat,
19140
19510
  });
19141
19511
  const before = this.storedCountOf(agent);
19142
19512
  let id: MessageId;
@@ -19350,7 +19720,7 @@ export class AgentFramework {
19350
19720
  try {
19351
19721
  if (target) this.addMessage(
19352
19722
  'user',
19353
- [{ type: 'text', text: `[prose-routing] ${reason}, so nothing was sent. drafts(action: "list") shows your open drafts.` }],
19723
+ [{ type: 'text', text: `[${NOTICE_TAG.proseRouting}] ${reason}, so nothing was sent. drafts(action: "list") shows your open drafts.` }],
19354
19724
  { system: true, kind: 'prose-bounce' },
19355
19725
  target,
19356
19726
  );
@@ -19373,7 +19743,7 @@ export class AgentFramework {
19373
19743
  } else if (draftState(draft) === 'unconfirmed') {
19374
19744
  why = this.riskText(draft);
19375
19745
  next = 'sending that draft again could post it twice. To send it anyway, use ' +
19376
- `drafts(action: "resend", draftIds: ["${draft.id}"], destination: "#channel", confirmDuplicate: true)`;
19746
+ `drafts(action: "resend", draftIds: ["${draft.id}"], destination: ${quotedValue(NO_CHANNEL_EXAMPLE)}, confirmDuplicate: true)`;
19377
19747
  } else {
19378
19748
  return { draft };
19379
19749
  }
@@ -19395,7 +19765,7 @@ export class AgentFramework {
19395
19765
  ? ` Your message is held as draft ${held.id}, with {{unsent}} written out; since it includes ${draft.id}'s words, it ` +
19396
19766
  `can be resent only with confirmDuplicate: true${afterResult}.`
19397
19767
  : ' Your message could not be held as a draft; its words remain in your history.';
19398
- const text = `[prose-routing] {{unsent}} is draft ${draft.id}, ${why.startsWith('which') ? why : `and ${why}`}. ` +
19768
+ const text = `[${NOTICE_TAG.proseRouting}] {{unsent}} is draft ${draft.id}, ${why.startsWith('which') ? why : `and ${why}`}. ` +
19399
19769
  `Nothing was sent; ${next}.${kept}`;
19400
19770
  const target = this.noticeTargetFor(agent.name, 'refused-{{unsent}}');
19401
19771
  try {
@@ -19460,7 +19830,7 @@ export class AgentFramework {
19460
19830
  } catch (err) {
19461
19831
  console.error(`[drafts] ${agent.name}: outcome of ${unsent.id} not recorded:`, err);
19462
19832
  const text =
19463
- `[prose-routing] {{unsent}} was draft ${unsent.id}; its send came back ${outcome.status}, but recording that ` +
19833
+ `[${NOTICE_TAG.proseRouting}] {{unsent}} was draft ${unsent.id}; its send came back ${outcome.status}, but recording that ` +
19464
19834
  `failed, so the draft reads as ${this.draftStateNow(agent.name, unsent.id)} now. drafts(action: "read") shows it.`;
19465
19835
  const target = this.noticeTargetFor(agent.name, 'unrecorded-outcome');
19466
19836
  try {
@@ -19556,13 +19926,13 @@ export class AgentFramework {
19556
19926
  // not bounced, so its words are not re-held as a fresh draft that
19557
19927
  // invites a duplicate.
19558
19928
  if (outcome.status === 'failed') {
19559
- this.bounceProse(agent, parsed.body, `delivery to ${this.channelRegistry?.nameShown(resolved) ?? resolved.channelId} failed: ${outcome.reason ?? 'refused'}`, undefined, '>>>', round, { substituted: unsent.draft });
19929
+ 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 });
19560
19930
  }
19561
19931
  }
19562
19932
  } catch (err) {
19563
19933
  delete routing.pin;
19564
19934
  routing.hybridHold = 'failed';
19565
- 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' });
19935
+ 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' });
19566
19936
  }
19567
19937
  return;
19568
19938
  }
@@ -19581,6 +19951,10 @@ export class AgentFramework {
19581
19951
  this.holdProseDrafts(agent, [envelope], 'no-destination', round.draftsRound ? { ...round, notice: 'later' } : round);
19582
19952
  return;
19583
19953
  }
19954
+ if (!sticky && locus === null && routing.heartbeat) {
19955
+ this.holdHeartbeatProse(agent, [envelope], round);
19956
+ return;
19957
+ }
19584
19958
  const target = sticky ?? locus;
19585
19959
  try {
19586
19960
  const outcome = await this.channelRegistry!.routeSpeech(agent.name, envelope, target);
@@ -19613,7 +19987,7 @@ export class AgentFramework {
19613
19987
  }
19614
19988
  };
19615
19989
  if (this.draftsSending.has(`${agent.name}\u0000${draft.id}`)) {
19616
- tell(`[prose-routing] {{unsent}} is draft ${draft.id}, which is being sent right now. It was not set aside, ` +
19990
+ tell(`[${NOTICE_TAG.proseRouting}] {{unsent}} is draft ${draft.id}, which is being sent right now. It was not set aside, ` +
19617
19991
  'since that can\'t call back words already on their way; wait for that result.', 'in-flight-dismiss');
19618
19992
  return;
19619
19993
  }
@@ -19628,12 +20002,12 @@ export class AgentFramework {
19628
20002
  const now = this.draftStateNow(agent.name, draft.id);
19629
20003
  if (now === 'dismissed') this.emitTrace({ type: 'prose:drafts-dismissed', agentName: agent.name, draftIds: [draft.id] });
19630
20004
  const named = this.proseDrafts.latestBounce(agent.name)?.id === draft.id;
19631
- tell(`[prose-routing] Recording setting aside draft ${draft.id}, which {{unsent}} named, failed ` +
20005
+ tell(`[${NOTICE_TAG.proseRouting}] Recording setting aside draft ${draft.id}, which {{unsent}} named, failed ` +
19632
20006
  `(${err instanceof Error ? err.message : String(err)}): it reads as ${now} now, and {{unsent}} ` +
19633
20007
  `${named ? 'still names it' : 'names nothing'}.${now === 'dismissed' && risk ? ` Note that ${risk}.` : ''}`, 'failed-dismiss');
19634
20008
  return;
19635
20009
  }
19636
- if (risk) tell(`[prose-routing] Draft ${draft.id} is set aside. Note that ${risk}.`, 'at-risk-dismiss');
20010
+ if (risk) tell(`[${NOTICE_TAG.proseRouting}] Draft ${draft.id} is set aside. Note that ${risk}.`, 'at-risk-dismiss');
19637
20011
  }
19638
20012
 
19639
20013
  private async deliverProseEnvelope(
@@ -19705,12 +20079,12 @@ export class AgentFramework {
19705
20079
  { ...round, notice: 'later' },
19706
20080
  );
19707
20081
  if (held.length === 0 && words.text.trim().length > 0) {
19708
- this.bounceProse(agent, rawText, 'no destination prefix, and this turn has no destination now', undefined, '>>', round);
20082
+ this.bounceProse(agent, rawText, 'no destination prefix, and this turn has no destination now', undefined, '>>', round, { unprefixed: true });
19709
20083
  }
19710
20084
  }
19711
20085
  return;
19712
20086
  }
19713
- this.bounceProse(agent, rawText, 'no destination prefix, and this turn has no destination now', undefined, '>>', round);
20087
+ this.bounceProse(agent, rawText, 'no destination prefix, and this turn has no destination now', undefined, '>>', round, { unprefixed: true });
19714
20088
  return;
19715
20089
  }
19716
20090
  targetChannel = sticky;
@@ -19742,10 +20116,10 @@ export class AgentFramework {
19742
20116
  } else if (outcome.status === 'failed') {
19743
20117
  // Unknown deliveries are marker-only: offering a fresh draft would
19744
20118
  // invite a duplicate. Keep the authored body for the re-bounce rule.
19745
- this.bounceProse(agent, envelopeBody, `delivery to ${this.channelRegistry?.nameShown(targetChannel) ?? targetChannel.channelId} failed: ${outcome.reason ?? 'refused'}`, undefined, '>>', round, { substituted: unsent.draft });
20119
+ 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 });
19746
20120
  }
19747
20121
  } catch (err) {
19748
- 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' });
20122
+ 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' });
19749
20123
  }
19750
20124
  if (!delivered && this.channelRegistry!.inboundOnlyServer(targetChannel) !== undefined) {
19751
20125
  // The channel is inbound-only by now: its server declared it after the
@@ -19837,7 +20211,7 @@ export class AgentFramework {
19837
20211
  round: ProseRound,
19838
20212
  /** After a send: the draft it substituted for `{{unsent}}`
19839
20213
  * (unsentWords), and whether its outcome is unknown (it threw). */
19840
- after: { substituted?: Draft; outcome?: 'unknown' } = {},
20214
+ after: { substituted?: Draft; outcome?: 'unknown'; unprefixed?: boolean } = {},
19841
20215
  ): void {
19842
20216
  const name = agent.name;
19843
20217
  round.routing.lastEnvelopeBounced = true;
@@ -19846,7 +20220,7 @@ export class AgentFramework {
19846
20220
  // out. Like an unknown outcome, that is marker-only: holding them as a
19847
20221
  // draft would invite a duplicate post.
19848
20222
  const notice =
19849
- `[prose-routing] Your text (${text.length} chars) may not have been delivered — ${reason}. ` +
20223
+ `[${NOTICE_TAG.proseRouting}] Your text (${text.length} chars) may not have been delivered — ${suppliedValue(reason)}. ` +
19850
20224
  "It isn't held as a draft, since a resend could post it twice; the words remain in your history.";
19851
20225
  const target = this.noticeTargetFor(name, 'unknown-send');
19852
20226
  try {
@@ -19875,13 +20249,21 @@ export class AgentFramework {
19875
20249
  { routing: round.routing, note: reason, notice: 'later' },
19876
20250
  )[0];
19877
20251
  }
19878
- const streak = (this.proseBounceStreaks.get(name) ?? 0) + 1;
19879
- this.proseBounceStreaks.set(name, streak);
19880
- const cand = candidates?.length ? ` Known channels it could mean: ${candidates.join(', ')}.` : '';
20252
+ // A heartbeat's turn came from no channel: its bounce is noticed, and
20253
+ // waits for the agent's next turn, rather than starting one of its own,
20254
+ // so it neither wakes the agent nor counts toward the bounce-wake cap.
20255
+ // Only words with no destination: a `>>` or `>>>` target that fails was
20256
+ // meant for someone, and its bounce wakes the agent as any does.
20257
+ const quiet = round.routing.heartbeat === true && after.unprefixed === true;
20258
+ const streak = (this.proseBounceStreaks.get(name) ?? 0) + (quiet ? 0 : 1);
20259
+ if (!quiet) this.proseBounceStreaks.set(name, streak);
20260
+ const cand = candidates?.length
20261
+ ? ` Known channels it could mean: ${candidates.map(suppliedValue).join(', ')}.${this.channelRegistry?.publishRouteFor(candidates) ?? ''}`
20262
+ : '';
19881
20263
  // The examples name the first candidate that is one token, as a target
19882
- // takes it, so they show the form to write, such as a server-qualified
19883
- // `a:general`; with none, a generic `#channel`.
19884
- const example = candidates?.find(isProseTargetToken) ?? '#channel';
20264
+ // takes it, and shows as itself, so they show the form to write, such as a server-qualified
20265
+ // `a:general`; with none, the generic `#<channel>`.
20266
+ const example = candidates?.find((c) => isProseTargetToken(c) && showsAsItself(c)) ?? NO_CHANNEL_EXAMPLE;
19885
20267
  // A bare {{unsent}} whose send failed re-bounces the draft it sent, which
19886
20268
  // a later bounce may have displaced as the latest; and {{unsent}} refuses
19887
20269
  // a draft that may already have been posted. Then only the drafts tool
@@ -19892,11 +20274,11 @@ export class AgentFramework {
19892
20274
  ? 'It could not be held as a draft; the words remain in your history.'
19893
20275
  : byToken
19894
20276
  ? `It is held as draft ${draft.id}; nothing is lost. To deliver it unchanged, reply with a destination plus the ` +
19895
- `token {{unsent}}, e.g. "${prefix}${example} {{unsent}}" or "${prefix}@person {{unsent}}", or use ` +
19896
- `drafts(action: "resend", draftIds: ["${draft.id}"], destination: "${example}"). ` +
20277
+ `token {{unsent}}, e.g. "${prefix}${example} {{unsent}}" or "${prefix}${NO_PERSON_EXAMPLE} {{unsent}}", or use ` +
20278
+ `drafts(action: "resend", draftIds: ["${draft.id}"], destination: ${quotedValue(example)}). ` +
19897
20279
  `"${prefix}skip_reply {{unsent}}" sets it aside.`
19898
20280
  : `It is held as draft ${draft.id}; nothing is lost. To deliver it unchanged, use ` +
19899
- `drafts(action: "resend", draftIds: ["${draft.id}"], destination: "${example}"` +
20281
+ `drafts(action: "resend", draftIds: ["${draft.id}"], destination: ${quotedValue(example)}` +
19900
20282
  `${inherited || ownRisk ? ', confirmDuplicate: true' : ''}); ` +
19901
20283
  `drafts(action: "dismiss", draftIds: ["${draft.id}"]) sets it aside.`;
19902
20284
  const risky = inherited && draft
@@ -19912,7 +20294,7 @@ export class AgentFramework {
19912
20294
  // wasn't delivered.
19913
20295
  const delivered = after.outcome === 'unknown' ? 'may not have been delivered' : 'was not delivered';
19914
20296
  const notice =
19915
- `[prose-routing] Your text (${text.length} chars) ${delivered} — ${reason}.${cand} ${kept}${risky}` +
20297
+ `[${NOTICE_TAG.proseRouting}] Your text (${text.length} chars) ${delivered} — ${suppliedValue(reason)}.${cand} ${kept}${risky}` +
19916
20298
  (prefix === '>>' && this.canAgentUseTool(agent.name, 'prose_help') ? ' The prose_help tool shows the full syntax.' : '');
19917
20299
  const target = this.noticeTargetFor(name, 'prose-bounce');
19918
20300
  if (target) {
@@ -19939,9 +20321,9 @@ export class AgentFramework {
19939
20321
  const capped = streak > AgentFramework.PROSE_BOUNCE_WAKE_CAP;
19940
20322
  console.error(
19941
20323
  `[prose] ${name}: BOUNCED ${text.length} chars — ${reason}` +
19942
- (capped ? ' (bounce-wake cap reached; notice appended without wake)' : ''),
20324
+ (quiet ? ' (a heartbeat turn: notice appended without wake)' : capped ? ' (bounce-wake cap reached; notice appended without wake)' : ''),
19943
20325
  );
19944
- if (!capped && target) {
20326
+ if (!capped && !quiet && target) {
19945
20327
  // Wake so the resend can happen immediately — an unsent DM reply must
19946
20328
  // not sit invisible until the next unrelated event.
19947
20329
  this.pendingRequests.push({
@@ -19949,6 +20331,7 @@ export class AgentFramework {
19949
20331
  reason: 'prose-bounce',
19950
20332
  source: 'framework',
19951
20333
  timestamp: Date.now(),
20334
+ ...this.heartbeatTurnMark(round.routing),
19952
20335
  });
19953
20336
  }
19954
20337
  }
@@ -20159,6 +20542,7 @@ export class AgentFramework {
20159
20542
  stopReason: 'exhausted', speech: '', error: error instanceof Error ? error.message : String(error),
20160
20543
  }, this.openLogical.get(agent.name));
20161
20544
  }
20545
+ this.endStatedWaitRun(agent.name);
20162
20546
  // The agent's window at the event gate ends here too, while this start
20163
20547
  // still holds the turn token, as at the provider-wait hold above. A
20164
20548
  // start that resumes a turn (a budget restart, a tool-result-guard
@@ -20661,13 +21045,16 @@ export class AgentFramework {
20661
21045
  // without the mark, so a retry doesn't write it again. A row that can't
20662
21046
  // be stored gives back the run's count, and withdraws a turn the
20663
21047
  // continuation alone caused: it would wake to nothing it can read.
21048
+ // A framework wake that continues a heartbeat's turn (heartbeatTurn) is
21049
+ // one too, and isn't told again what the turn that started the run was.
21050
+ const continuesHeartbeat = trigger?.heartbeatTurn === true && trigger.source === 'framework';
20664
21051
  if (attempt === 0 && !resumesTurn && trigger?.autoContinueRun) {
20665
21052
  const mark = trigger.autoContinueRun;
20666
21053
  const { autoContinueRun: _mark, ...unmarked } = trigger;
20667
21054
  trigger = unmarked;
20668
21055
  // Another reader's turn may have started since the scheduler checked.
20669
21056
  const busy = this.sharedSlotBusy(agent);
20670
- if (busy || !this.writeAutoContinueNotice(agent, mark)) {
21057
+ if (busy || !this.writeAutoContinueNotice(agent, mark, trigger.heartbeatTurn === true)) {
20671
21058
  this.giveBackAutoContinue(agent.name, busy ? "another resident's turn is alive in the shared slot" : 'its row could not be stored');
20672
21059
  if (mark.sole) {
20673
21060
  console.error(`[continue] ${agent.name}: turn not started (the continuation was its only cause)`);
@@ -20799,9 +21186,10 @@ export class AgentFramework {
20799
21186
 
20800
21187
  if (opensTurn) {
20801
21188
  // The turn's trigger channel (item-3 redux): the channel of a
20802
- // channel/DM-triggered turn, with its server, and none for a heartbeat
20803
- // / no-trigger turn, which falls back to the global default rather
20804
- // than inheriting a previous turn's channel. A wake whose channel,
21189
+ // channel/DM-triggered turn, with its server, and none for a
21190
+ // no-trigger turn, which doesn't inherit a previous turn's channel: a
21191
+ // heartbeat's takes no locus at all (TurnRouting.heartbeat), and any
21192
+ // other falls back to the global default. A wake whose channel,
20805
21193
  // given without its server, names no one registered channel (a host's
20806
21194
  // id that two servers' channels answer to, or that none does) has none
20807
21195
  // either, and takes no fallback: it was for a channel, and the latest
@@ -20816,6 +21204,10 @@ export class AgentFramework {
20816
21204
  const unresolvedTrigger = !!trigger?.channelId && routing.triggerChannel === undefined;
20817
21205
  if (unresolvedTrigger) routing.noChannel = true;
20818
21206
  else delete routing.noChannel;
21207
+ // A heartbeat's turn has no channel, and takes no fallback: its plain
21208
+ // speech is held as a draft (TurnRouting.heartbeat).
21209
+ if (isHeartbeatWake(trigger)) routing.heartbeat = true;
21210
+ else delete routing.heartbeat;
20819
21211
  // Resolve the turn's outbound locus now: the home channel for forks,
20820
21212
  // else the trigger channel, else the global default, which an
20821
21213
  // ephemeral run never takes (takesGlobalFallback). Ambient mid-turn
@@ -20823,8 +21215,8 @@ export class AgentFramework {
20823
21215
  // chatter and reactions hijacked it before, 2026-07-21); only an
20824
21216
  // addressed message, a follow-up in a channel the turn engaged
20825
21217
  // (repinTurnLocus) or the agent's own channel_open moves it, between
20826
- // rounds. The eager snapshot protects heartbeat turns from a moving
20827
- // defaultPublishChannel. Explicit mode uses a turn-scoped `>>` target
21218
+ // rounds. The eager snapshot protects a no-trigger turn that takes the
21219
+ // fallback (a timer's) from a moving defaultPublishChannel. Explicit mode uses a turn-scoped `>>` target
20828
21220
  // and disabled mode has no prose target, so neither resolves or
20829
21221
  // announces a locus. If the effective locus differs from the last one
20830
21222
  // announced, a durable `[routing]` notice goes into the window BEFORE
@@ -20838,7 +21230,15 @@ export class AgentFramework {
20838
21230
  routing.locus = locus;
20839
21231
  noteTurnChannel(routing, locus);
20840
21232
  }
20841
- if (attempt === 0) this.announceLocusIfChanged(agent.name, locus);
21233
+ // A heartbeat's turn speaks nowhere by design. It's told so up front,
21234
+ // once per heartbeat (a continuation of its run isn't told again),
21235
+ // without moving the announced baseline, so the next channel turn
21236
+ // doesn't announce its channel again.
21237
+ if (attempt === 0 && routing.heartbeat) {
21238
+ if (!continuesHeartbeat) this.noticeHeartbeatTurn(agent.name);
21239
+ } else if (attempt === 0) {
21240
+ this.announceLocusIfChanged(agent.name, locus);
21241
+ }
20842
21242
  }
20843
21243
  } else {
20844
21244
  // Every other start of the turn keeps its locus: a resume, or a start
@@ -20905,7 +21305,9 @@ export class AgentFramework {
20905
21305
  const presentation = this.inspectToolPresentation(agent.name, requestSnapshot);
20906
21306
  const compressionTools = presentation?.available
20907
21307
  ?? structuredClone(this.availableToolsForPresentation(agent.name, requestSnapshot));
20908
- const tools = presentation?.advertised ?? compressionTools;
21308
+ // The tool list: what the agent's presentation advertises, less the
21309
+ // host's own toolsets (the built-in one, servers it has as tabs).
21310
+ const tools = this.headerTools(agent.name, presentation?.advertised ?? compressionTools);
20909
21311
 
20910
21312
  // Gather context from modules (pull-based) and MCPL hooks (push-based)
20911
21313
  // Both produce ContextInjection[] that get merged before inference.
@@ -21202,7 +21604,8 @@ export class AgentFramework {
21202
21604
  return false;
21203
21605
  }
21204
21606
 
21205
- const action: ErrorAction = statedWait === 'fail' ? { retry: false } : this.errorPolicy.onInferenceError(err, agent.name, attempt);
21607
+ const action: ErrorAction = statedWait === 'fail' || (isRearmed(trigger) && !gotPartway(routing))
21608
+ ? { retry: false } : this.errorPolicy.onInferenceError(err, agent.name, attempt);
21206
21609
  if (action.retry) {
21207
21610
  // A policy's delay can be a provider's retry-after: wait all of it.
21208
21611
  await this.waitForRetry(action.delayMs, checkWindow, agent);
@@ -21262,6 +21665,7 @@ export class AgentFramework {
21262
21665
  speech: '',
21263
21666
  ...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
21264
21667
  }, routing);
21668
+ if (isRearmed(trigger) && isTransientFailure(err)) this.exhaustingRearms.add(agent.name);
21265
21669
  this.emitTrace({
21266
21670
  type: 'inference:exhausted',
21267
21671
  agentName: agent.name,
@@ -21281,6 +21685,7 @@ export class AgentFramework {
21281
21685
  if (action.emit) {
21282
21686
  this.pushEvent(action.emit);
21283
21687
  }
21688
+ this.rearmExhaustedTurn(agent, err, trigger, ownsProviderGate, routing);
21284
21689
  }
21285
21690
  // No handoff on either failure branch: the terminal branch deleted the
21286
21691
  // token above; the retry branch's recursive startAgentStream replaced
@@ -21572,9 +21977,9 @@ export class AgentFramework {
21572
21977
  }
21573
21978
  };
21574
21979
  };
21575
- const enqueueSpeech = (text: string, locus: ChannelRef | null, onPublished: SpeechPublished): void => {
21980
+ const enqueueSpeech = (text: string, locus: ChannelRef | null, onPublished: SpeechPublished, round?: ProseRound): void => {
21576
21981
  chainSpeech(routingSpeech(async () => {
21577
- const outcome = await this.channelRegistry!.routeSpeech(agent.name, text, locus);
21982
+ const outcome = await this.routeLocusSpeech(agent, round ?? { routing: streamRouting, notice: 'later' }, text, locus);
21578
21983
  this.recordProseDelivery(streamRouting, outcome);
21579
21984
  if (outcome?.delivered) {
21580
21985
  streamRouting.liveSpeech = true;
@@ -22117,11 +22522,11 @@ export class AgentFramework {
22117
22522
  `[routing] ${agent.name}: mid-turn round [${toolNames.join(', ')}] -> prose NOT routed (same_round_think_text_policy=private)`,
22118
22523
  );
22119
22524
  } else {
22120
- console.error(
22121
- `[routing] ${agent.name}: mid-turn round [${toolNames.join(', ')}] -> routing ${roundSegments.length} prose segment(s) live -> ${roundLocus ?? '(default)'}`,
22122
- );
22525
+ console.error(roundLocus === null && streamRouting.heartbeat
22526
+ ? `[routing] ${agent.name}: mid-turn round [${toolNames.join(', ')}] -> ${roundSegments.length} prose segment(s) held as drafts (a heartbeat turn)`
22527
+ : `[routing] ${agent.name}: mid-turn round [${toolNames.join(', ')}] -> routing ${roundSegments.length} prose segment(s) live -> ${roundLocus ?? '(default)'}`);
22123
22528
  for (const seg of roundSegments) {
22124
- enqueueSpeech(seg, roundLocus, roundPublished);
22529
+ enqueueSpeech(seg, roundLocus, roundPublished, roundOpts);
22125
22530
  }
22126
22531
  }
22127
22532
  };
@@ -22441,7 +22846,7 @@ export class AgentFramework {
22441
22846
  // self-seed. Exact raw output remains in the provider ledger.
22442
22847
  agent.getContextManager().addMessage('user', [{
22443
22848
  type: 'text',
22444
- text: '[tool-boundary] No tool was called.',
22849
+ text: `[${NOTICE_TAG.toolBoundary}] No tool was called.`,
22445
22850
  }], {
22446
22851
  system: true,
22447
22852
  kind: 'tool-wrapper-prose-contained',
@@ -22476,7 +22881,7 @@ export class AgentFramework {
22476
22881
  } else if (cut?.unnamed) {
22477
22882
  agent.getContextManager().addMessage('user', [{
22478
22883
  type: 'text',
22479
- text: '[tool-boundary] Your response was cut off at the output limit inside a tool-call block, before any call in it ' +
22884
+ text: `[${NOTICE_TAG.toolBoundary}] Your response was cut off at the output limit inside a tool-call block, before any call in it ` +
22480
22885
  'was named, so nothing ran. To make a call, send less in one response.',
22481
22886
  }], { system: true, kind: 'cut-off-tool-block', ...this.silentTurnRowMetadata(agent.name) });
22482
22887
  }
@@ -22771,7 +23176,7 @@ export class AgentFramework {
22771
23176
  );
22772
23177
  for (const segment of speechSegments) {
22773
23178
  try {
22774
- const outcome = await this.channelRegistry.routeSpeech(agent.name, segment, locus);
23179
+ const outcome = await this.routeLocusSpeech(agent, textOnlyHold, segment, locus);
22775
23180
  this.recordProseDelivery(streamRouting, outcome);
22776
23181
  if (outcome?.delivered) endPublished(outcome, segment, outcome.messageId);
22777
23182
  } catch (err) {
@@ -22890,7 +23295,7 @@ export class AgentFramework {
22890
23295
  // Deliver sequentially (await each) so the segments land in order.
22891
23296
  for (const seg of segments) {
22892
23297
  try {
22893
- const outcome = await this.channelRegistry.routeSpeech(agent.name, seg, locus);
23298
+ const outcome = await this.routeLocusSpeech(agent, trailingHold, seg, locus);
22894
23299
  this.recordProseDelivery(streamRouting, outcome);
22895
23300
  if (outcome?.delivered) endPublished(outcome, seg, outcome.messageId);
22896
23301
  } catch (err) {
@@ -23043,7 +23448,8 @@ export class AgentFramework {
23043
23448
  break;
23044
23449
  }
23045
23450
 
23046
- const action: ErrorAction = statedWait === 'fail' ? { retry: false } : this.errorPolicy.onInferenceError(err, agent.name, attempt);
23451
+ const action: ErrorAction = statedWait === 'fail' || (isRearmed(trigger) && !gotPartway(streamRouting))
23452
+ ? { retry: false } : this.errorPolicy.onInferenceError(err, agent.name, attempt);
23047
23453
  if (action.retry) {
23048
23454
  // A stop made during the wait reaches no stream: the check after
23049
23455
  // the wait ends the turn, as the guard's check after its wait
@@ -23100,6 +23506,7 @@ export class AgentFramework {
23100
23506
  speech: '',
23101
23507
  ...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
23102
23508
  }, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting : undefined, streamRouting);
23509
+ if (isRearmed(trigger) && isTransientFailure(err)) this.exhaustingRearms.add(agent.name);
23103
23510
  this.emitTrace({
23104
23511
  type: 'inference:exhausted',
23105
23512
  agentName: agent.name,
@@ -23117,6 +23524,7 @@ export class AgentFramework {
23117
23524
  if (action.emit) {
23118
23525
  this.pushEvent(action.emit);
23119
23526
  }
23527
+ this.rearmExhaustedTurn(agent, err, trigger, ownsProviderGate, streamRouting);
23120
23528
  }
23121
23529
  break;
23122
23530
  }
@@ -23191,6 +23599,7 @@ export class AgentFramework {
23191
23599
  agentName: agent.name,
23192
23600
  error: terminal,
23193
23601
  });
23602
+ this.rearmExhaustedTurn(agent, new Error(terminal), trigger, ownsProviderGate, streamRouting);
23194
23603
  // Postmortem 2026-05-28 P2 #7: persist the abort to the
23195
23604
  // inference log so future investigations can attribute the
23196
23605
  // terminal cause without relying on live in-memory reducer
@@ -23481,12 +23890,14 @@ export class AgentFramework {
23481
23890
  speech: '',
23482
23891
  ...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
23483
23892
  }, streamRouting);
23893
+ if (isRearmed(trigger) && isTransientFailure(err)) this.exhaustingRearms.add(agent.name);
23484
23894
  this.emitTrace({
23485
23895
  type: 'inference:exhausted',
23486
23896
  agentName: agent.name,
23487
23897
  error: err.message,
23488
23898
  ...this.classifyInferenceError(err),
23489
23899
  });
23900
+ this.rearmExhaustedTurn(agent, err, trigger, ownsProviderGate, streamRouting);
23490
23901
  // Postmortem 2026-05-28 P2 #7: catch-path failures (stream itself
23491
23902
  // threw) were previously only visible via in-memory trace listeners.
23492
23903
  // Persist to inference log for forensic attribution.
@@ -23710,8 +24121,7 @@ export class AgentFramework {
23710
24121
  * exists only through the private closure handed to ModuleRegistry
23711
24122
  * (ctx.callTool → executeToolCallFrom with {kind:'module'}), which is exempt
23712
24123
  * from agent allowlists. Registered agents' allowedTools is enforced at
23713
- * dispatch, including namespaced utility names; utils only grants the
23714
- * list/describe/run slot. Genuinely unidentified public callers (including
24124
+ * dispatch, including namespaced utility names. Genuinely unidentified public callers (including
23715
24125
  * unknown legacy names) remain unrestricted. Framework-created identities
23716
24126
  * are refused while unregistered, even if their configured list allows it.
23717
24127
  */
@@ -23930,6 +24340,8 @@ export class AgentFramework {
23930
24340
  this.emitTrace({ type: 'tool:failed', module: 'framework', tool: call.name, callId: call.id, error: traced });
23931
24341
  return failure;
23932
24342
  };
24343
+ // utils was removed: said whatever the allowlist names (dispatchToolCall).
24344
+ if (call.name === 'utils') return failed({ success: false, isError: true, error: this.utilsRemoved(origin.kind === 'agent' ? origin.agentName : call.callerAgentName) });
23933
24345
  if (origin.kind === 'agent') {
23934
24346
  const failure = this.agentToolPermissionFailure(origin.agentName, call, false);
23935
24347
  if (failure) return failed(failure);
@@ -23949,6 +24361,14 @@ export class AgentFramework {
23949
24361
  return this.runCodeExecution(call.callerAgentName ?? '__ephemeral__', call);
23950
24362
  }
23951
24363
 
24364
+ // toolsets--act on a host toolset's tool (the built-in one, a server's
24365
+ // tab) goes where a programmatic call of that tool goes, under its name.
24366
+ if (call.name === META_TOOL_NAMES.act && call.callerAgentName !== undefined) {
24367
+ const host = this.hostPresentation?.resolveAct(call.callerAgentName, call.input) ?? null;
24368
+ if (host?.kind === 'refused') return failed({ success: false, error: host.error, isError: true });
24369
+ if (host?.kind === 'call') return this.executeToolCallFrom({ ...call, name: host.name, input: host.args }, origin);
24370
+ }
24371
+
23952
24372
  // RFC-014: toolsets--act, resolved to the dynamic tool it calls, goes
23953
24373
  // where a programmatic call of that tool goes, with its instance.
23954
24374
  if (call.name === META_TOOL_NAMES.act && this.toolsetHub && this.mcplServerRegistry) {
@@ -23993,22 +24413,13 @@ export class AgentFramework {
23993
24413
  return this.draftsToolResult(origin.agentName, call.input);
23994
24414
  }
23995
24415
 
23996
- // The utils meta-tool, for callers that await a tool's result. A call
23997
- // through executeToolCall (the puppet, a host) reaches what its agent's
23998
- // allowedTools permits, as the model's does, and an unidentified caller
23999
- // is unrestricted, as executeToolCall's doc says; a module, through
24000
- // callTool, is trusted and reaches every utility.
24001
24416
  // The heartbeat's tools, for callers that await a tool's result (an
24002
- // in-process call, a puppet, a module's callTool). A module is trusted,
24003
- // as it is for utils; an agent is held to what dispatch holds it to.
24417
+ // in-process call, a puppet, a module's callTool). A module is trusted;
24418
+ // an agent is held to what dispatch holds it to.
24004
24419
  if (this.heartbeat?.ownsTool(call.name)) {
24005
24420
  const refusal = origin.kind === 'module' ? null : this.heartbeatRefusal(call.callerAgentName ?? '__ephemeral__', call);
24006
24421
  return refusal ? { success: false, error: refusal, isError: true } : this.runHeartbeatTool(call);
24007
24422
  }
24008
- if (call.name === 'utils') {
24009
- return this.handleUtilsToolCall(call.callerAgentName ?? '__ephemeral__', call, { inProcess: true, trustedModule: origin.kind === 'module' });
24010
- }
24011
-
24012
24423
  // Module tools. The caller uses the result in process, so it gets the
24013
24424
  // module's own result, as the module returned it, less a refusal it
24014
24425
  // claims, which is the dispatcher's to say.
@@ -24052,6 +24463,23 @@ export class AgentFramework {
24052
24463
  return { ok: true, call: inner, serverId: target.serverId, prefix, ...(target.instance !== undefined ? { instance: target.instance } : {}) };
24053
24464
  }
24054
24465
 
24466
+ /** A toolsets--act call a host toolset resolved (HostPresentation.resolveAct):
24467
+ * its refusal answered, or the tool's own call dispatched in its place,
24468
+ * with the act's id, so its result is the act's. */
24469
+ private dispatchHostAct(agentName: string, call: ToolCall, host: HostActTarget, turnId: number | undefined): void {
24470
+ if (host.kind === 'refused') {
24471
+ this.emitTrace({ type: 'tool:failed', module: TOOLSETS_MODULE, tool: call.name, callId: call.id, error: host.error });
24472
+ this.pushEvent({
24473
+ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: TOOLSETS_MODULE,
24474
+ result: this.refusedResult(agentName, call.id, host.error),
24475
+ });
24476
+ return;
24477
+ }
24478
+ const inner: ToolCall = { ...call, name: host.name, input: host.args };
24479
+ this.toolLifecycleEmitter?.retarget(agentName, call.id, inner);
24480
+ this.dispatchToolCall(agentName, inner, turnId);
24481
+ }
24482
+
24055
24483
  /**
24056
24484
  * RFC-014 §8.2: a toolsets--act call from the agent (the model's, or a
24057
24485
  * script's) goes down the path a direct call of the server's tool takes
@@ -24887,17 +25315,17 @@ export class AgentFramework {
24887
25315
  readCap: number, written: SpillWrite): string {
24888
25316
  if (written && written.error === undefined) {
24889
25317
  const canRaise = bound === undefined || cutStoredToolResultToFit(content, bound, shown, bound).head.length > headLength;
24890
- return `\n\n[truncated — showing ${headLength} of ${content.length} chars; full content: workspace file ${written.path}`
25318
+ return `\n\n[${NOTICE_TAG.truncated} — showing ${headLength} of ${content.length} chars; full content: workspace file ${written.path}`
24891
25319
  + `. ${this.spillPageAdvice(written.path, readCap, canRaise)}]`;
24892
25320
  }
24893
25321
  if (written) {
24894
25322
  // A workspace was there and the write FAILED — say that, loudly and
24895
25323
  // distinctly. Telling the agent "no writable workspace" would teach
24896
25324
  // them their residence lacks a capability it actually has.
24897
- return `\n\n[truncated — showing ${headLength} of ${content.length} chars; spill to workspace file ${written.path} FAILED`
25325
+ return `\n\n[${NOTICE_TAG.truncated} — showing ${headLength} of ${content.length} chars; spill to workspace file ${written.path} FAILED`
24898
25326
  + ` (${written.error}); content over the cap was not retained]`;
24899
25327
  }
24900
- return '\n\n[truncated — original was ' + content.length + ' chars; ' + this.noFileMountText() + ', full content not retained]';
25328
+ return `\n\n[${NOTICE_TAG.truncated} — original was ${content.length} chars; ${this.noFileMountText()}, full content not retained]`;
24901
25329
  }
24902
25330
 
24903
25331
  /**
@@ -25800,6 +26228,17 @@ export class AgentFramework {
25800
26228
  * script left running, which no call watches, has none.
25801
26229
  */
25802
26230
  private dispatchToolCall(agentName: string, call: ToolCall, turnId?: number): void {
26231
+ // utils was removed; its utilities are in the built-in toolset. Said
26232
+ // whatever the allowlist names, so an old call learns where they went.
26233
+ if (call.name === 'utils') {
26234
+ const removed = this.utilsRemoved(agentName);
26235
+ this.emitTrace({ type: 'tool:failed', module: 'framework', tool: call.name, callId: call.id, error: removed });
26236
+ this.pushEvent({
26237
+ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'framework',
26238
+ result: this.refusedResult(agentName, call.id, removed),
26239
+ });
26240
+ return;
26241
+ }
25803
26242
  const refusal = this.agentToolPermissionFailure(agentName, call) ?? this.markedInputFailure(agentName, call);
25804
26243
  if (refusal) {
25805
26244
  this.pushEvent({
@@ -25821,6 +26260,17 @@ export class AgentFramework {
25821
26260
  }
25822
26261
 
25823
26262
 
26263
+ // toolsets--act on a host toolset's tool (the built-in one, a server's
26264
+ // tab): the call of that tool, under its own name, down the path a
26265
+ // direct call of it takes, permissions included.
26266
+ if (enrichedCall.name === META_TOOL_NAMES.act) {
26267
+ const host = this.hostPresentation?.resolveAct(agentName, enrichedCall.input) ?? null;
26268
+ if (host) {
26269
+ this.dispatchHostAct(agentName, call, host, turnId);
26270
+ return;
26271
+ }
26272
+ }
26273
+
25824
26274
  // RFC-014: toolsets--act calls a server's dynamic tool, down the same
25825
26275
  // path as a direct call of it (dispatchToolsetAct).
25826
26276
  if (enrichedCall.name === META_TOOL_NAMES.act && this.toolsetHub && this.mcplServerRegistry) {
@@ -25847,12 +26297,6 @@ export class AgentFramework {
25847
26297
  return;
25848
26298
  }
25849
26299
 
25850
- // Route the agent's typed, allowlisted hot-settings surface.
25851
- if (enrichedCall.name === 'utils') {
25852
- this.dispatchUtilsToolCall(agentName, enrichedCall);
25853
- return;
25854
- }
25855
-
25856
26300
  if (enrichedCall.name === 'agent_settings') {
25857
26301
  this.dispatchAgentSettingsToolCall(agentName, enrichedCall);
25858
26302
  return;
@@ -25892,7 +26336,7 @@ export class AgentFramework {
25892
26336
  void this.tuneOutCoordinator
25893
26337
  .handleSubconsciousTool(enrichedCall.name, enrichedCall.input as Record<string, unknown>, this.callOwnChannels(agentName, enrichedCall))
25894
26338
  // A rejection is answered with a failed result, as a framework tool's
25895
- // dispatch failure is (dispatchUtilsToolCall), never left unhandled.
26339
+ // dispatch failure is (dispatchAgentSettingsToolCall), never left unhandled.
25896
26340
  .catch((err): ToolResult => ({ success: false, error: thrownText(err), isError: true }))
25897
26341
  .then((result) => {
25898
26342
  // Through pushEvent, like every other tool's result, so one that
@@ -25964,10 +26408,18 @@ export class AgentFramework {
25964
26408
  }
25965
26409
  const wake = readSkipReplyWake(enrichedCall.input);
25966
26410
  if (wake.kind === 'wake' && this.eventGate) {
26411
+ const armedAt = Date.now();
25967
26412
  const { inMs, armed } = this.eventGate.armSelfWake(agentName, wake.seconds, 'skip_reply');
26413
+ // The heartbeat's tick or reminder due before the timer: if it wakes
26414
+ // the agent, its turn cancels the timer, so the confirmation names
26415
+ // it and the time the self-wake would have left.
26416
+ const earlierWake = armed ? this.heartbeatWakeBefore(agentName, armedAt + inMs) : undefined;
25968
26417
  console.error(armed
25969
26418
  ? `[self-wake] agent=${agentName} armed in ${Math.round(inMs / 1000)}s (skip_reply)`
26419
+ + (earlierWake ? `; due sooner, in ~${earlierWake.inSeconds}s: ${logDetail(earlierWake.what)}` : '')
25970
26420
  : `[self-wake] agent=${agentName} not armed: the framework has stopped (skip_reply)`);
26421
+ this.dispatchChannelToolCall(agentName, enrichedCall, earlierWake);
26422
+ return;
25971
26423
  }
25972
26424
  }
25973
26425
  this.dispatchChannelToolCall(agentName, enrichedCall);
@@ -26184,7 +26636,7 @@ export class AgentFramework {
26184
26636
  // messages flush at the next turn's START (before its compile — riding
26185
26637
  // their own wake), at the agent's next tool boundary (where they are
26186
26638
  // ALSO injected into the live stream — hear-while-acting — all but a
26187
- // message a tune-out holds from the residents), or in driveStream's
26639
+ // message the agent's own view leaves out), or in driveStream's
26188
26640
  // finally when the turn ends.
26189
26641
  // Quiesce (issue #122): the window exists to run compression/refold
26190
26642
  // against a FROZEN context — module events and api message.send must not
@@ -26372,18 +26824,20 @@ export class AgentFramework {
26372
26824
  }
26373
26825
 
26374
26826
  /** Whether `entry`'s own stored entry is the newest in `agent`'s window,
26375
- * clock rows and stored context injections after it aside: a channel
26376
- * message by its server, channel and message id, a push event by its
26377
- * server and event id, and never one a tune-out holds. Past
26827
+ * clock rows, stored context injections and rows the agent's own view
26828
+ * leaves out after it aside: a channel message by its server, channel and
26829
+ * message id, a push event by its server and event id, and never one the
26830
+ * agent's view leaves out, such as one a tune-out holds. Past
26378
26831
  * NEWEST_SCAN_ROWS such rows, it answers no. */
26379
26832
  private isNewestIn(agent: Agent, entry: GateEntryRef): boolean {
26380
26833
  // A clock row the framework wrote after it (a turn start's, an event's),
26381
26834
  // or a stored context injection (any agent's, a start's), isn't a
26382
26835
  // message a reply could be meant for, so it doesn't count.
26383
26836
  const newest = agent.getContextManager().queryMessages({ limit: AgentFramework.NEWEST_SCAN_ROWS, reverse: true }).messages
26384
- .find((m) => (m.metadata as { kind?: unknown } | undefined)?.kind !== CLOCK_ROW_KIND && !storedInjectionMarkOf(m.metadata));
26837
+ .find((m) => (m.metadata as { kind?: unknown } | undefined)?.kind !== CLOCK_ROW_KIND && !storedInjectionMarkOf(m.metadata)
26838
+ && this.agentSees(agent, m.metadata));
26385
26839
  const md = newest?.metadata as Record<string, unknown> | undefined;
26386
- if (!md || 'tuneOut' in md || entry.serverId === undefined || md.serverId !== entry.serverId) return false;
26840
+ if (!md || entry.serverId === undefined || md.serverId !== entry.serverId) return false;
26387
26841
  if (entry.eventType === 'mcpl:channel-incoming') {
26388
26842
  return entry.messageId !== undefined && md.messageId === entry.messageId && md.channelId === entry.channelId;
26389
26843
  }
@@ -26403,11 +26857,18 @@ export class AgentFramework {
26403
26857
  : this.agents.values().next().value;
26404
26858
  }
26405
26859
 
26860
+ private noteSpokenTo(agentName: string, metadata: MessageMetadata | undefined): void {
26861
+ const agent = this.agents.get(agentName);
26862
+ if ((agent && !this.agentSees(agent, metadata)) || isOwnScheduleWrite(metadata) || !isConversationalInjection(metadata)) return;
26863
+ if (this.turnRearms?.delete(agentName)) console.error(`[inference-rearm] agent=${agentName} re-send count started again: a message was said to it`);
26864
+ }
26865
+
26406
26866
  /** The notes addMessage takes for a message it stored: an arrival for the
26407
26867
  * agent when none of its turns is alive or the message was assembled for
26408
26868
  * the start now beginning (no tool boundary will weigh it), and one for
26409
26869
  * every other resident's open turn. */
26410
26870
  private noteStored(agent: Agent, metadata: MessageMetadata | undefined, stored: MessageId, bypassDeferralFor?: string): void {
26871
+ this.noteSpokenTo(agent.name, metadata);
26411
26872
  if (!this.activeTurnTokens.has(agent.name) || bypassDeferralFor === agent.name) {
26412
26873
  this.noteArrival(agent.name, metadata, stored);
26413
26874
  }
@@ -26864,6 +27325,7 @@ export class AgentFramework {
26864
27325
  agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id)));
26865
27326
  for (const [msg, { id }] of stored) {
26866
27327
  this.emitTrace({ type: 'message:added', messageId: id, source: `deferred-flush:${occasion}` });
27328
+ this.noteSpokenTo(agent.name, msg.metadata);
26867
27329
  this.noteArrival(agent.name, msg.metadata, id);
26868
27330
  this.noteForOtherResidents(agent, msg.metadata, id);
26869
27331
  }
@@ -27367,18 +27829,18 @@ export class AgentFramework {
27367
27829
  const outcome = failure.outcome === 'failed' || failure.outcome === 'unknown' ? failure.outcome : undefined;
27368
27830
  const detail = typeof failure.detail === 'string' ? failure.detail : undefined;
27369
27831
  const size = textLen !== undefined ? ` (${textLen} chars)` : '';
27370
- const why = reason !== undefined ? ` (${reason})` : '';
27832
+ const why = reason !== undefined ? ` (${suppliedValue(reason)})` : '';
27371
27833
  // A notice queued by an earlier build names no server: its channel is
27372
27834
  // the one registered channel its id means, or shown by its id alone.
27373
27835
  const named = this.channelNamed(failure.channelId, typeof failure.serverId === 'string' ? failure.serverId : undefined);
27374
- const where = named ? this.channelForNotice(named) : failure.channelId;
27836
+ const where = named ? this.channelForNotice(named) : suppliedValue(failure.channelId);
27375
27837
  const text = outcome === 'unknown'
27376
- ? `[discord-send-failed] Delivery of a reply by ${speaker}${size} to ${where} was not confirmed${why}. ` +
27838
+ ? `[${NOTICE_TAG.discordSendFailed}] Delivery of a reply by ${speaker}${size} to ${where} was not confirmed${why}. ` +
27377
27839
  `It may or may not have been posted, so sending it again could post it twice. ${speaker} has ended.`
27378
- : `[discord-send-failed] A reply by ${speaker}${size} could not be delivered to ${where}${why}. ` +
27840
+ : `[${NOTICE_TAG.discordSendFailed}] A reply by ${speaker}${size} could not be delivered to ${where}${why}. ` +
27379
27841
  `Nothing was posted. ${speaker} has ended.`;
27380
27842
  return {
27381
- content: [{ type: 'text', text: text + (detail !== undefined ? ` Connector detail: ${detail}` : '') }],
27843
+ content: [{ type: 'text', text: text + (detail !== undefined ? ` Connector detail: ${suppliedValue(detail)}` : '') }],
27382
27844
  metadata: {
27383
27845
  system: true, kind: 'discord-send-failed', channelId: failure.channelId, speaker,
27384
27846
  // The channel's server, as the marker it passes on carries it.
@@ -27407,17 +27869,16 @@ export class AgentFramework {
27407
27869
  * channel no string picks alone, its id and its server.
27408
27870
  */
27409
27871
  private channelShown(channel: ChannelRef): string {
27410
- const name = this.channelRegistry?.nameShown(channel) ?? channel.channelId;
27872
+ const name = this.channelRegistry?.nameShown(channel) ?? suppliedValue(channel.channelId);
27411
27873
  const label = this.channelRegistry?.getDescriptor(channel)?.label;
27412
- return label && label !== channel.channelId
27413
- ? `${label.startsWith('#') ? label : `#${label}`} (${name})`
27414
- : name;
27874
+ const shown = label && label !== channel.channelId ? suppliedValue(label) : undefined;
27875
+ return shown ? `${shown.startsWith('#') ? '' : '#'}${shown} (${name})` : name;
27415
27876
  }
27416
27877
  /** A channel named in a notice with the label it was given there: the
27417
27878
  * label, with its name as ChannelRegistry.nameShown gives it. */
27418
27879
  private labelledChannel(channel: ChannelRef & { label?: string }): string {
27419
- const name = this.channelRegistry?.nameShown(channel) ?? channel.channelId;
27420
- return channel.label && channel.label !== channel.channelId ? `${channel.label} (${name})` : name;
27880
+ const name = this.channelRegistry?.nameShown(channel) ?? suppliedValue(channel.channelId);
27881
+ return channel.label && channel.label !== channel.channelId ? `${suppliedValue(channel.label)} (${name})` : name;
27421
27882
  }
27422
27883
 
27423
27884
 
@@ -27836,6 +28297,10 @@ export class AgentFramework {
27836
28297
  if (name && this.exhaustionRewinds.get(name)) {
27837
28298
  this.exhaustionRewinds.set(name, 0);
27838
28299
  }
28300
+ if (name) this.turnRearms?.delete(name);
28301
+ }
28302
+ if (event.type === 'inference:completed' || event.type === 'inference:exhausted' || event.type === 'inference:aborted') {
28303
+ this.endStatedWaitRun(event.agentName as string);
27839
28304
  }
27840
28305
 
27841
28306
  const traceEvent = {
@@ -28166,7 +28631,7 @@ export class AgentFramework {
28166
28631
  streak,
28167
28632
  // The refused agent's own view: a subagent's refusal names no channel.
28168
28633
  // Named as the agent is shown it, so a shared id names its server.
28169
- locus: ((channel) => (channel ? this.channelRegistry?.nameShown(channel) ?? channel.channelId : null))(
28634
+ locus: ((channel) => (channel ? this.channelRegistry?.nameShown(channel) ?? suppliedValue(channel.channelId) : null))(
28170
28635
  this.channelRegistry?.incomingFor(agentName)?.channel),
28171
28636
  tokens: tokens ?? null,
28172
28637
  });
@@ -28250,6 +28715,7 @@ export class AgentFramework {
28250
28715
  ): void {
28251
28716
  const ended = this.exhaustedTurns?.get(agentName);
28252
28717
  this.exhaustedTurns?.delete(agentName);
28718
+ const rearmed = this.exhaustingRearms?.delete(agentName) === true;
28253
28719
  // The reason is the failure's own message, and a provider can echo the
28254
28720
  // whole rejected request in it: one household's [inference-failed]
28255
28721
  // marker carried ~1.7 MB of its own request back into its context. Every
@@ -28305,7 +28771,7 @@ export class AgentFramework {
28305
28771
 
28306
28772
  // (2) Agent-facing chronicle marker (no inference triggered → no loop).
28307
28773
  const agent = this.agents.get(agentName);
28308
- if (agent && process.env.SUPPRESS_INFERENCE_FAILED_MARKER !== '1') {
28774
+ if (agent && !(rearmed && !ended) && process.env.SUPPRESS_INFERENCE_FAILED_MARKER !== '1') {
28309
28775
  const cause = [errorType, httpStatus !== undefined ? `HTTP ${httpStatus}` : undefined]
28310
28776
  .filter((part): part is string => part !== undefined)
28311
28777
  .join(', ');
@@ -28316,15 +28782,15 @@ export class AgentFramework {
28316
28782
  type: 'text',
28317
28783
  text:
28318
28784
  (ended
28319
- ? `[inference-failed] Your previous turn did not complete: it ` +
28320
- `failed${cause ? ` (${cause})` : ''}.` +
28785
+ ? `[${NOTICE_TAG.inferenceFailed}] Your previous turn did not complete: it ` +
28786
+ `failed${cause ? ` (${suppliedValue(cause)})` : ''}.` +
28321
28787
  `${ended.roundsRan ? ' Its earlier rounds ran, and their tool calls ran as their results show.' : ''}` +
28322
28788
  `${ended.callsCutOff ? ` ${CALLS_CUT_OFF}` : ''}` +
28323
28789
  `${ended.liveSpeech ? ` ${LIVE_SPEECH_UNRECEIPTED}` : ''}` +
28324
28790
  `${ended.speechRouting ? ` ${SPEECH_STILL_ROUTING}` : ''} Reason: `
28325
- : `[inference-failed] Your previous turn did not complete: the model ` +
28326
- `call failed${cause ? ` (${cause})` : ''} and produced no response, so nothing was sent. Reason: `) +
28327
- `${markerReason}. If this recurs with the same cause, change approach ` +
28791
+ : `[${NOTICE_TAG.inferenceFailed}] Your previous turn did not complete: the model ` +
28792
+ `call failed${cause ? ` (${suppliedValue(cause)})` : ''} and produced no response, so nothing was sent. Reason: `) +
28793
+ `${failureShown(markerReason)}. If this recurs with the same cause, change approach ` +
28328
28794
  `rather than retrying identically (e.g. drop an oversized attachment ` +
28329
28795
  `or an unsupported setting).`,
28330
28796
  }],
@@ -28633,13 +29099,14 @@ export class AgentFramework {
28633
29099
  // channel hijacks it. An opening start resolves the turn's locus from
28634
29100
  // it (the trigger channel then), and the channel context names it, so
28635
29101
  // a later start of the turn is told where its unprefixed speech lands
28636
- // unless a hybrid `>>>` destination takes it. Empty for a heartbeat /
28637
- // no-trigger turn until its locus is resolved, so that resolution
28638
- // takes the global fallback (none for an ephemeral run); the turn then
28639
- // keeps that channel.
29102
+ // unless a hybrid `>>>` destination takes it. Empty for a no-trigger
29103
+ // turn until its locus is resolved, so that resolution takes the
29104
+ // global fallback (none for an ephemeral run), and the turn then keeps
29105
+ // that channel; null for a heartbeat's turn, which takes none
29106
+ // (TurnRouting.heartbeat).
28640
29107
  activeChannelResolver: (agentName) => {
28641
29108
  const routing = this.turnRouting.get(agentName);
28642
- return routing?.locus ?? routing?.triggerChannel ?? (routing?.noChannel ? null : undefined);
29109
+ return routing?.locus ?? routing?.triggerChannel ?? (routing?.noChannel || routing?.heartbeat ? null : undefined);
28643
29110
  },
28644
29111
  // An ephemeral run (a subagent) never takes the process-global
28645
29112
  // fallback: its prose is its caller's result, and the latest inbound
@@ -28694,13 +29161,13 @@ export class AgentFramework {
28694
29161
  ? (outcome === 'unknown'
28695
29162
  // A request went out and no valid receipt came back: it may
28696
29163
  // have been posted (in part or whole). Never claim it wasn't.
28697
- ? `[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.`
28698
- : `[discord-send-failed] Your previous reply (${textLen} chars) could not be delivered to ${where} (${reason}). Nothing was posted; it is saved in your archive.`)
28699
- : `[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.`;
29164
+ ? `[${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.`
29165
+ : `[${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.`)
29166
+ : `[${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.`;
28700
29167
  // A registered speaker's notice is addressed to it (noticeTargetFor).
28701
29168
  this.addMessage(
28702
29169
  'user',
28703
- [{ type: 'text', text: text + (detail !== undefined ? ` Connector detail: ${detail}` : '') }],
29170
+ [{ type: 'text', text: text + (detail !== undefined ? ` Connector detail: ${suppliedValue(detail)}` : '') }],
28704
29171
  metadata,
28705
29172
  { forAgent: conversationId },
28706
29173
  );
@@ -28824,6 +29291,10 @@ export class AgentFramework {
28824
29291
  return (this.mcpRestartClaims ??= { ids: new Map(), prefixes: new Map() });
28825
29292
  }
28826
29293
 
29294
+ private closedTabConfigs(): Map<string, McplServerConfig> {
29295
+ return (this.closedTabConfigMap ??= new Map());
29296
+ }
29297
+
28827
29298
  private legacyConnectAborts(): Map<string, AbortController> {
28828
29299
  return (this.legacyConnectAbortMap ??= new Map());
28829
29300
  }
@@ -29436,14 +29907,61 @@ export class AgentFramework {
29436
29907
  });
29437
29908
  }
29438
29909
 
29910
+ /** RFC-014: who held the name `toolsets` at startup (a module given to
29911
+ * create(); a server's toolPrefix of it is refused), so a server that
29912
+ * leaves toolHints out has none for the life of this framework; null when
29913
+ * the name was free. */
29914
+ private toolsetsNameHeldAtStart: string | null = null;
29915
+
29916
+ /** At startup: note who holds the name the meta-tools need, and say which
29917
+ * servers that leaves without hints. */
29918
+ private reserveToolsetsName(modules: readonly Module[], servers: readonly McplServerConfig[]): void {
29919
+ const module = modules.find((m) => m.name === TOOLSETS_MODULE);
29920
+ this.toolsetsNameHeldAtStart = module ? `module "${TOOLSETS_MODULE}"` : null;
29921
+ if (!this.toolsetsNameHeldAtStart) return;
29922
+ // Those that would have had hints: MCPL servers that leave the setting out.
29923
+ const defaulted = servers
29924
+ .filter((s) => (s.toolHints === undefined || s.toolHints === null) && isLegacyFamily(s))
29925
+ .map((s) => s.id);
29926
+ if (defaulted.length) {
29927
+ console.error(
29928
+ `[mcpl] tool hints (RFC-014) are off for ${defaulted.join(', ')}, which leave toolHints out: the name "${TOOLSETS_MODULE}" ` +
29929
+ `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.`,
29930
+ );
29931
+ }
29932
+ }
29933
+
29934
+ /**
29935
+ * RFC-014: whether a server has tool hints. Only an MCPL (legacy-family)
29936
+ * server can: a modern MCP server never has them. `true` and `false` are
29937
+ * as configured. Left out (or null) they're on, wherever the name
29938
+ * `toolsets` the meta-tools need is free: no other module holds it and no
29939
+ * server's prefix is it, now or at startup. So a configuration that loaded
29940
+ * when hints were opt-in still loads. A name held at startup stays held
29941
+ * for the framework's life.
29942
+ */
29943
+ private hintsFor(config: McplServerConfig): boolean {
29944
+ if (config.toolHints === false || !isLegacyFamily(config)) return false;
29945
+ if (config.toolHints === true) return true;
29946
+ if (this.toolsetsNameHeldAtStart !== null) return false;
29947
+ if ((config.toolPrefix ?? `mcpl--${config.id}`) === TOOLSETS_MODULE) return false;
29948
+ const module = this.moduleRegistry.getAllModules().find((m) => m.name === TOOLSETS_MODULE);
29949
+ if (module && !(module instanceof ToolsetsModule)) return false;
29950
+ // A restart moving its server to the prefix holds the name as a claim.
29951
+ if (this.restartClaims().prefixes.has(TOOLSETS_MODULE)) return false;
29952
+ const owner = this.mcplPrefixMap.get(TOOLSETS_MODULE);
29953
+ return owner === undefined || owner === config.id;
29954
+ }
29955
+
29439
29956
  /**
29440
29957
  * The host capabilities offered to one server: the host's own, plus
29441
- * RFC-014's `toolHints` where its configuration asks for them. Without it,
29442
- * the very object every server is offered.
29958
+ * RFC-014's `toolHints` unless its configuration turns them off
29959
+ * (`toolHints: false`). With them off, the very object every server is
29960
+ * offered.
29443
29961
  */
29444
- private mcplHostCapabilitiesFor(config: McplServerConfig): McplHostCapabilities {
29962
+ private mcplHostCapabilitiesFor(config: McplServerConfig, hints = this.hintsFor(config)): McplHostCapabilities {
29445
29963
  const base = this.mcplHostCapabilities!;
29446
- return config.toolHints === true ? { ...base, toolHints: true } : base;
29964
+ return hints ? { ...base, toolHints: true } : base;
29447
29965
  }
29448
29966
 
29449
29967
  /** RFC-014: every toolHints server's presence, made on first use. */
@@ -29460,20 +29978,170 @@ export class AgentFramework {
29460
29978
  if (this.mcplToolServers?.get(name) !== serverId) return undefined;
29461
29979
  return this.mcplTools.some((t) => t.name === name) ? 'static' : 'dynamic';
29462
29980
  },
29981
+ hostOpen: (agentName) => this.hostPresentation?.openMarkers(agentName) ?? [],
29463
29982
  }));
29464
29983
  }
29465
29984
 
29466
- /** RFC-014: the `toolsets` module (toolsets--open, --act, --look,
29467
- * --close), registered once, by the first server configured with
29468
- * toolHints. Its tools don't change after that. */
29985
+ /**
29986
+ * The host's own toolsets (host-presentation.ts): the built-in "settings"
29987
+ * toolset and each MCPL server's tab. State is kept beside the store, in
29988
+ * `config/toolsets.json`; with no store path, in memory only.
29989
+ */
29990
+ private makeHostPresentation(storePath: string | undefined): HostPresentation {
29991
+ return new HostPresentation({
29992
+ // As the agent is shown them: its description overrides applied.
29993
+ available: (agentName) => (this.agents.has(agentName) ? this.compressionToolsForAgent(agentName) : this.getAllTools()),
29994
+ placedInBuiltin: (name) => this.placedInBuiltin(name),
29995
+ hidden: (agentName) => {
29996
+ if (!this.agents.has(agentName)) return new Set<string>();
29997
+ const snapshot = this.inspectToolPresentation(agentName);
29998
+ return new Set(snapshot?.entries.filter((e) => !e.visible).map((e) => e.name) ?? []);
29999
+ },
30000
+ serverOf: (name) => this.resolveMcplTool(name)?.[0] ?? null,
30001
+ servers: () => {
30002
+ const out = new Map<string, { id: string; configured: 'full' | 'tab' | 'closed-tab'; connected: boolean }>();
30003
+ for (const [id, config] of this.mcplServerConfigs) {
30004
+ out.set(id, { id, configured: config.presentation ?? 'full', connected: !!this.mcplServerRegistry?.getServer(id) || this.modernServers().has(id) });
30005
+ }
30006
+ for (const [id, config] of this.closedTabConfigs()) {
30007
+ if (!out.has(id)) out.set(id, { id, configured: config.presentation ?? 'full', connected: false });
30008
+ }
30009
+ return [...out.values()];
30010
+ },
30011
+ canUseMeta: (agentName) => this.hostToolsetsFor(agentName),
30012
+ disconnect: async (serverId) => {
30013
+ const config = this.mcplServerConfigs.get(serverId);
30014
+ if (!config) throw new Error(`no MCPL server "${serverId}" is connected here`);
30015
+ this.refuseOperatorAfterStop('disconnect MCPL server');
30016
+ // As a transient close: its checkpoint trees and RFC-014 state stay
30017
+ // for the connect its next open makes.
30018
+ await this.trackOperatorOperation('mcpl:disconnect', () => this.runDisconnectMcplServer(serverId, { keepToolsets: true, keepCheckpoints: true }));
30019
+ this.closedTabConfigs().set(serverId, config);
30020
+ },
30021
+ reconnect: async (serverId) => {
30022
+ const config = this.closedTabConfigs().get(serverId);
30023
+ if (!config) throw new Error(`no MCPL server "${serverId}" is kept as a closed tab here`);
30024
+ this.refuseOperatorAfterStop('connect MCPL server');
30025
+ await this.trackOperatorOperation('mcpl:connect', () => this.runConnectMcplServer(config, undefined, { reopen: true }));
30026
+ },
30027
+ log: (line) => console.error(line),
30028
+ isUtility: (name) => this.isModuleUtility(name),
30029
+ residents: () => this.heartbeatTargets(),
30030
+ }, storePath ? join(storePath, 'config', 'toolsets.json') : null);
30031
+ }
30032
+
30033
+ /** Whether a name is a module utility (and not a tool of its module, which takes the name). */
30034
+ private isModuleUtility(name: string): boolean {
30035
+ const sep = name.indexOf('--');
30036
+ if (sep <= 0) return false;
30037
+ const module = this.moduleRegistry.getModule(name.slice(0, sep));
30038
+ if (!module) return false;
30039
+ const bare = name.slice(sep + 2);
30040
+ try {
30041
+ return !module.getTools().some((t) => t.name === bare) && (module.getUtilities?.()?.some((t) => t.name === bare) ?? false);
30042
+ } catch {
30043
+ return false;
30044
+ }
30045
+ }
30046
+
30047
+ /** Whether an agent has the host's own toolsets: the meta-tools registered, and
30048
+ * usable by it (the subconscious keeps its own small surface). */
30049
+ /**
30050
+ * What a call of the removed `utils` is answered: where utilities went, as
30051
+ * this agent can reach them. One that can't use the built-in toolset is
30052
+ * told to call a utility by its own name and which ones it may call.
30053
+ */
30054
+ private utilsRemoved(agentName: string | undefined): string {
30055
+ if (agentName === undefined || this.hostToolsetsFor(agentName)) return UTILS_REMOVED;
30056
+ const agent = this.agents.get(agentName);
30057
+ const why = this.moduleRegistry.getAllModules().find((m) => m.name === TOOLSETS_MODULE) instanceof ToolsetsModule
30058
+ ? `its allowedTools doesn't permit ${META_TOOL_NAMES.open} and ${META_TOOL_NAMES.act}`
30059
+ : `this host's module named "${TOOLSETS_MODULE}" isn't the framework's`;
30060
+ const mine = this.getAllTools().filter((t) => this.isModuleUtility(t.name) && (agent?.canUseTool(t.name) ?? true)).map((t) => t.name);
30061
+ return `utils was removed: module utilities are in the host's built-in toolset now, which this agent can't use (${why}). ` +
30062
+ `Call a utility by its own name instead${mine.length ? `; this agent may call ${mine.join(', ')}` : '; this agent may call none'}. Nothing was run.`;
30063
+ }
30064
+
30065
+ private hostToolsetsFor(agentName: string): boolean {
30066
+ if (agentName === this.subconsciousAgentName) return false;
30067
+ const module = this.moduleRegistry.getAllModules().find((m) => m.name === TOOLSETS_MODULE);
30068
+ if (!(module instanceof ToolsetsModule)) return false;
30069
+ const agent = this.agents.get(agentName);
30070
+ if (!agent) return true;
30071
+ return agent.canUseTool(META_TOOL_NAMES.open) && agent.canUseTool(META_TOOL_NAMES.act);
30072
+ }
30073
+
30074
+ /**
30075
+ * Whether a host tool belongs to the built-in toolset: the framework's own
30076
+ * rarely-used ones (BUILTIN_TOOLSET_TOOLS), a module's that it places there
30077
+ * (Module.toolPlacement), and every module utility.
30078
+ */
30079
+ private placedInBuiltin(name: string): boolean {
30080
+ if (BUILTIN_TOOLSET_TOOLS.has(name)) return true;
30081
+ const sep = name.indexOf('--');
30082
+ if (sep <= 0) return false;
30083
+ const module = this.moduleRegistry.getModule(name.slice(0, sep));
30084
+ if (!module) return false;
30085
+ const bare = name.slice(sep + 2);
30086
+ // A module whose listing throws keeps its tools where they are.
30087
+ try {
30088
+ if (module.getTools().some((t) => t.name === bare)) return module.toolPlacement?.(bare) === 'builtin';
30089
+ return module.getUtilities?.()?.some((t) => t.name === bare) ?? false;
30090
+ } catch {
30091
+ return false;
30092
+ }
30093
+ }
30094
+
30095
+ /**
30096
+ * The tool list an agent's requests carry, from the tools it may call: its
30097
+ * host presentation applied (the built-in toolset's tools, the servers it
30098
+ * has as tabs and its hidden tools left out; a utility never in it), as
30099
+ * every request of a registered agent has it (with its own tool
30100
+ * presentation, hidden tools and descriptions, applied first). For a stream that isn't
30101
+ * registered yet, such as a fork whose orientation is written before its
30102
+ * run starts, the host says whether it will be able to call
30103
+ * `toolsets--open` and `toolsets--act` (`canUseMeta`); left out, the agent
30104
+ * registered under that name is asked. Presentation it chose itself (open
30105
+ * toolsets, tabs) is read under that name, so a new name starts from the
30106
+ * configured presentation.
30107
+ */
30108
+ presentedTools(agentName: string, tools: import('./types/index.js').ToolDefinition[], canUseMeta?: boolean): import('./types/index.js').ToolDefinition[] {
30109
+ // A registered agent's own presentation first, as its requests have it
30110
+ // before the host's: the tools it hid left out, its descriptions applied.
30111
+ const snapshot = this.agents.has(agentName) ? this.inspectToolPresentation(agentName) : undefined;
30112
+ if (snapshot) {
30113
+ const hidden = new Set(snapshot.entries.filter((e) => !e.visible).map((e) => e.name));
30114
+ const shown = new Map(snapshot.advertised.map((t) => [t.name, t]));
30115
+ tools = tools.filter((t) => !hidden.has(t.name)).map((t) => shown.get(t.name) ?? t);
30116
+ }
30117
+ if (!this.hostPresentation) return tools;
30118
+ if (canUseMeta === undefined) return this.hostPresentation.header(agentName, tools);
30119
+ // As hostToolsetsFor decides for a registered agent: the subconscious and
30120
+ // a host whose module named toolsets isn't the framework's present nothing.
30121
+ const meta = canUseMeta && agentName !== this.subconsciousAgentName
30122
+ && this.moduleRegistry.getAllModules().find((m) => m.name === TOOLSETS_MODULE) instanceof ToolsetsModule;
30123
+ return this.hostPresentation.header(agentName, tools, meta);
30124
+ }
30125
+
30126
+ /** The host presentation of a server's tools, for the tool list: the agent's
30127
+ * view, less the built-in toolset and the servers it has as tabs. */
30128
+ private headerTools(agentName: string, tools: import('./types/index.js').ToolDefinition[]): import('./types/index.js').ToolDefinition[] {
30129
+ return this.hostPresentation ? this.hostPresentation.header(agentName, tools) : tools;
30130
+ }
30131
+
30132
+ /** The `toolsets` module (toolsets--open, --act, --look, --close): the
30133
+ * meta-tools of the host's own toolsets and of RFC-014 servers' toolsets.
30134
+ * Registered once, at create(), unless a module given to create() holds
30135
+ * the name; a server with toolHints: true then can't have hints, and its
30136
+ * connect is refused here. Its tools don't change after that. */
29469
30137
  private async ensureToolsetsModule(): Promise<void> {
29470
30138
  const hub = this.toolsets();
29471
30139
  const existing = this.moduleRegistry.getAllModules().find((m) => m.name === TOOLSETS_MODULE);
29472
30140
  if (existing instanceof ToolsetsModule) return;
29473
30141
  if (existing) {
29474
- throw new Error(`A module named "${TOOLSETS_MODULE}" is registered, and a server configured with toolHints needs that name for its meta-tools`);
30142
+ 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`);
29475
30143
  }
29476
- await this.addModule(new ToolsetsModule(hub, (call) => this.changeTaker(call)));
30144
+ await this.addModule(new ToolsetsModule(hub, (call) => this.changeTaker(call), this.hostPresentation));
29477
30145
  }
29478
30146
 
29479
30147
  /** RFC-014 §5.3: after a (re)connect whose policy has settled, read what
@@ -29509,7 +30177,7 @@ export class AgentFramework {
29509
30177
  try {
29510
30178
  this.addMessage(
29511
30179
  'user',
29512
- [{ type: 'text', text: `MCPL server "${serverId}", tool changes:\n${text}` }],
30180
+ [{ type: 'text', text: `[${NOTICE_TAG.toolsets}] MCPL server "${serverId}", tool changes:\n${text}` }],
29513
30181
  { system: true, kind: 'mcpl-toolsets', serverId, ...(opts.invite ? { invite: true } : {}) } as MessageMetadata,
29514
30182
  );
29515
30183
  return true;
@@ -29677,23 +30345,27 @@ export class AgentFramework {
29677
30345
  let registeredEpoch = 0;
29678
30346
  const abort = new AbortController();
29679
30347
  this.legacyConnectAborts().set(config.id, abort);
30348
+ // RFC-014: decided once for this connect, so the meta-tools, the offer
30349
+ // and the read tap agree though the state hintsFor reads can change
30350
+ // across the awaits below.
30351
+ const hints = this.hintsFor(config);
29680
30352
  try {
29681
- // RFC-014: a toolHints server's meta-tools exist before it connects.
29682
- if (config.toolHints === true) await this.ensureToolsetsModule();
30353
+ // RFC-014: a hinted server's meta-tools exist before it connects.
30354
+ if (hints) await this.ensureToolsetsModule();
29683
30355
  // Its policy exchange is in flight from its registration until the
29684
30356
  // exchange settles: an opener that runs meanwhile (a resume, startup's
29685
30357
  // staged open) passes the connection by, so nothing opens its planes
29686
30358
  // before its grant exists.
29687
30359
  connection = await this.mcplServerRegistry.addServer(
29688
30360
  connectionConfig,
29689
- this.mcplHostCapabilitiesFor(config),
30361
+ this.mcplHostCapabilitiesFor(config, hints),
29690
30362
  abort.signal,
29691
30363
  (registering) => {
29692
30364
  registeredEpoch = registering.transportEpoch;
29693
30365
  this.mcplPolicyExchanges.set(registering, registeredEpoch);
29694
30366
  // RFC-014 §6.1: hints apply as the connection reads them, from its
29695
30367
  // first message, so the tap is on before any I/O can deliver one.
29696
- if (config.toolHints === true) this.toolsets().controllerFor(config).attach(registering);
30368
+ if (hints) this.toolsets().controllerFor(config).attach(registering);
29697
30369
  },
29698
30370
  );
29699
30371
  } catch (error) {
@@ -30018,7 +30690,7 @@ export class AgentFramework {
30018
30690
  // instances; toolHints withdrawn makes every tool static. Its impact is
30019
30691
  // surface-changed, applied, with no grant consequence (§17.6).
30020
30692
  const declarations = toolsetDeclarationsOf(fetched);
30021
- const toolsetsChanged = config.toolHints === true
30693
+ const toolsetsChanged = this.hintsFor(config)
30022
30694
  && JSON.stringify(declarations) !== JSON.stringify(connection.toolsetDeclarations);
30023
30695
  connection.toolsetDeclarations = declarations;
30024
30696
  if (toolsetsChanged) this.toolsetHub?.get(connection.id)?.manifestChanged(declarations);
@@ -30333,6 +31005,7 @@ export class AgentFramework {
30333
31005
  private async runConnectMcplServer(
30334
31006
  config: import('./mcpl/types.js').McplServerConfig,
30335
31007
  claim?: McpRestartClaim,
31008
+ opts: { reopen?: boolean } = {},
30336
31009
  ): Promise<void> {
30337
31010
  assertServerConfigBooleans(config);
30338
31011
  // Lazily bring up the MCPL subsystem — a framework that started with zero
@@ -30359,8 +31032,30 @@ export class AgentFramework {
30359
31032
  // registers. A server already registered was refused just above, so the
30360
31033
  // read never reaches a registered server's trees.
30361
31034
  this.checkpointManager?.loadHostStates(config.id);
31035
+ // An operator's connect ends a closed tab as it begins, as a restart
31036
+ // does: one that fails once its configuration is taken leaves an
31037
+ // ordinary server that's down, under that configuration; one refused
31038
+ // before (its transport, the awareness ledger) changes nothing, and the
31039
+ // closed tab is put back. Whether it was one is the kept configuration's
31040
+ // to say. An agent's open (reopen) ends it only once connected, so one
31041
+ // that fails leaves the closed tab to open again.
31042
+ const keptClosedTab = opts.reopen ? undefined : this.closedTabConfigs().get(config.id);
31043
+ const closedWas = keptClosedTab ? this.hostPresentation?.closedState(config.id) : undefined;
31044
+ if (keptClosedTab) {
31045
+ this.closedTabConfigs().delete(config.id);
31046
+ this.hostPresentation?.noteConnected(config.id, true);
31047
+ }
30362
31048
  const oldToolNames = new Set(this.mcplTools.map(t => t.name));
30363
- const connection = await this.connectMcplServerInternal(config, false, claim);
31049
+ let connection: Awaited<ReturnType<AgentFramework['connectMcplServerInternal']>>;
31050
+ try {
31051
+ connection = await this.connectMcplServerInternal(config, false, claim);
31052
+ } catch (error) {
31053
+ if (keptClosedTab && !this.mcplServerConfigs.has(config.id) && !this.closedTabConfigs().has(config.id)) {
31054
+ this.closedTabConfigs().set(config.id, keptClosedTab);
31055
+ this.hostPresentation?.restoreClosed(config.id, closedWas);
31056
+ }
31057
+ throw error;
31058
+ }
30364
31059
  // A stop() that began during the connect's setup or its tool listing
30365
31060
  // ends the connect: it doesn't wait on a server for a framework that is
30366
31061
  // stopping, and the teardown closes what the connect set up.
@@ -30381,6 +31076,9 @@ export class AgentFramework {
30381
31076
  if (this.mcpServerOps?.get(config.id)?.kind === 'disconnect' || !AgentFramework.mcpConnectionServes(connection)) {
30382
31077
  throw new Error(`MCP server "${config.id}" was not connected: it was disconnected or lost while its tools were being listed`);
30383
31078
  }
31079
+ // Connected, however it was asked for: not a closed tab any more.
31080
+ const wasClosedTab = this.closedTabConfigs().delete(config.id);
31081
+ this.hostPresentation?.noteConnected(config.id, wasClosedTab);
30384
31082
  this.emitMcplToolDiff(oldToolNames, config.id);
30385
31083
  }
30386
31084
 
@@ -30407,10 +31105,20 @@ export class AgentFramework {
30407
31105
  // even one that finishes before the restart's connect begins.
30408
31106
  const claim = this.restartClaims().ids.get(id);
30409
31107
  if (claim) claim.endedBy = `MCP server "${id}" was disconnected, which ends the restart of "${claim.restarting}"`;
30410
- return this.trackOperatorOperation('mcpl:disconnect', () => this.runDisconnectMcplServer(id));
31108
+ // A closed tab removed by its operator: there's nothing to connect it
31109
+ // again with, and what its disconnect kept for that connect (checkpoint
31110
+ // trees, RFC-014 state) goes, as any disconnect removes it.
31111
+ if (this.closedTabConfigs().delete(id) && !this.mcplServerConfigs.has(id)) {
31112
+ this.checkpointManager?.removeServer(id);
31113
+ this.toolsetHub?.forget(id);
31114
+ this.hostPresentation?.noteRemoved(id);
31115
+ return;
31116
+ }
31117
+ await this.trackOperatorOperation('mcpl:disconnect', () => this.runDisconnectMcplServer(id));
31118
+ this.hostPresentation?.noteRemoved(id);
30411
31119
  }
30412
31120
 
30413
- private async runDisconnectMcplServer(id: string, opts: { keepToolsets?: boolean } = {}): Promise<void> {
31121
+ private async runDisconnectMcplServer(id: string, opts: { keepToolsets?: boolean; keepCheckpoints?: boolean } = {}): Promise<void> {
30414
31122
  if (!this.mcplServerRegistry) {
30415
31123
  throw new Error('MCPL subsystem is not initialized');
30416
31124
  }
@@ -30462,7 +31170,9 @@ export class AgentFramework {
30462
31170
  // already transiently closed (reconnect pending) emits no second 'close'
30463
31171
  // from close(), and the transient path deliberately preserves checkpoints.
30464
31172
  this.featureSetManager?.removeServer(id);
30465
- this.checkpointManager?.removeServer(id);
31173
+ // A closed tab (host-presentation.ts) keeps its checkpoint trees for
31174
+ // the connect its next open makes, as a transient close does.
31175
+ if (!opts.keepCheckpoints) this.checkpointManager?.removeServer(id);
30466
31176
  if (!opts.keepToolsets) this.toolsetHub?.forget(id);
30467
31177
 
30468
31178
  // Only this server's own claims, as the prefix map holds them rather
@@ -30474,6 +31184,7 @@ export class AgentFramework {
30474
31184
  }
30475
31185
  this.mcplServerConfigs.delete(id);
30476
31186
  referenceRegistry.forgetServerAddress(id, this);
31187
+ this.eventGate?.sourcesChanged();
30477
31188
 
30478
31189
  await this.refreshMcplTools();
30479
31190
  this.emitMcplToolDiff(oldToolNames, id);
@@ -30533,7 +31244,8 @@ export class AgentFramework {
30533
31244
  // A stop(), or a disconnect of the id it moves the server to, that
30534
31245
  // begins after these checks isn't held off: it ends the restart, as it
30535
31246
  // would end a connect.
30536
- const config = newConfig ?? this.mcplServerConfigs.get(id);
31247
+ // A closed tab restarts from the configuration it's kept with.
31248
+ const config = newConfig ?? this.mcplServerConfigs.get(id) ?? this.closedTabConfigs().get(id);
30537
31249
  if (!config) {
30538
31250
  throw new Error(`MCPL server "${id}" is not configured`);
30539
31251
  }
@@ -30573,7 +31285,15 @@ export class AgentFramework {
30573
31285
  this.restartClaims().ids.set(config.id, claim);
30574
31286
  this.restartClaims().prefixes.set(prefix, claim);
30575
31287
  try {
30576
- await this.runDisconnectMcplServer(id, { keepToolsets: config.id === id && config.toolHints === true });
31288
+ await this.runDisconnectMcplServer(id, { keepToolsets: config.id === id && this.hintsFor(config) });
31289
+ // A restart ends a closed tab as it begins: the operator connects the
31290
+ // server now, and one whose connect fails is left an ordinary server
31291
+ // that's down. Moved to another id, the old id's tab is no more.
31292
+ if (this.closedTabConfigs().has(id)) {
31293
+ if (config.id === id) this.hostPresentation?.noteConnected(id, true);
31294
+ else this.hostPresentation?.noteRemoved(id);
31295
+ this.closedTabConfigs().delete(id);
31296
+ }
30577
31297
  try {
30578
31298
  await this.runConnectMcplServer(config, claim);
30579
31299
  } catch (error) {
@@ -30686,6 +31406,14 @@ export class AgentFramework {
30686
31406
  return prefixesMeetError(config.id, prefix, otherId, other).message;
30687
31407
  }
30688
31408
  }
31409
+ // A closed tab keeps its prefix (and the prefixes it would meet) for the
31410
+ // connect its next open makes.
31411
+ for (const [otherId, other] of this.closedTabConfigs()) {
31412
+ if (otherId === config.id || otherId === leaving || this.mcplServerConfigs.has(otherId)) continue;
31413
+ const otherPrefix = other.toolPrefix ?? `mcpl--${otherId}`;
31414
+ if (otherPrefix === prefix) return `MCPL server "${config.id}" toolPrefix "${prefix}" collides with server "${otherId}" (a closed tab)`;
31415
+ if (prefixesMeet(prefix, otherPrefix)) return prefixesMeetError(config.id, prefix, otherId, otherPrefix).message;
31416
+ }
30689
31417
  for (const [other, holder] of this.restartClaims().prefixes) {
30690
31418
  if (holder !== claim && other !== prefix && prefixesMeet(prefix, other)) {
30691
31419
  return `MCPL server "${config.id}" toolPrefix "${prefix}" meets the toolPrefix "${other}" the restart of ` +
@@ -30719,6 +31447,9 @@ export class AgentFramework {
30719
31447
  listMcplServers(): Array<{
30720
31448
  id: string;
30721
31449
  connected: boolean;
31450
+ /** Kept disconnected as a closed tab (host-presentation.ts) until an
31451
+ * agent's toolsets--open, an operator's connect or a restart connects it. */
31452
+ closedTab: boolean;
30722
31453
  retrying: boolean;
30723
31454
  /** Which engine serves it: `legacy` (MCP 2024-11-05 + MCPL) or `modern`
30724
31455
  * (MCP 2026-07-28), as its configuration selects; null when the
@@ -30776,7 +31507,7 @@ export class AgentFramework {
30776
31507
  url?: string;
30777
31508
  }> {
30778
31509
  const result: Array<{
30779
- id: string; connected: boolean; retrying: boolean;
31510
+ id: string; connected: boolean; closedTab: boolean; retrying: boolean;
30780
31511
  family: 'legacy' | 'modern' | null; protocolVersion: string | null;
30781
31512
  transport: 'stdio' | 'websocket' | 'http' | null; refused: string | null;
30782
31513
  toolPrefix: string; toolCount: number; policyEstablished: boolean | null;
@@ -30791,7 +31522,10 @@ export class AgentFramework {
30791
31522
  };
30792
31523
  command?: string; url?: string;
30793
31524
  }> = [];
30794
- for (const [id, config] of this.mcplServerConfigs) {
31525
+ // Closed tabs too: configured, kept disconnected until an agent opens one.
31526
+ const configured = [...this.mcplServerConfigs];
31527
+ for (const [id, config] of this.closedTabConfigs()) if (!this.mcplServerConfigs.has(id)) configured.push([id, config]);
31528
+ for (const [id, config] of configured) {
30795
31529
  const prefix = config.toolPrefix ?? `mcpl--${id}`;
30796
31530
  const modern = this.modernServers().get(id) ?? null;
30797
31531
  const connection = modern ? null : this.mcplServerRegistry?.getServer(id) ?? null;
@@ -30814,6 +31548,7 @@ export class AgentFramework {
30814
31548
  result.push({
30815
31549
  id,
30816
31550
  connected,
31551
+ closedTab: this.closedTabConfigs().has(id),
30817
31552
  retrying: !connected && (modern?.willReconnect ?? connection?.willReconnect ?? false),
30818
31553
  family: binding?.family ?? null,
30819
31554
  protocolVersion: modern?.protocolVersion ?? connection?.protocolVersion ?? null,
@@ -31884,7 +32619,7 @@ export class AgentFramework {
31884
32619
  });
31885
32620
 
31886
32621
  if (newTools.length > 0) {
31887
- const toolList = newTools.map(t => t.name).join(', ');
32622
+ const toolList = newTools.map(t => suppliedValue(t.name)).join(', ');
31888
32623
  this.pushEvent({
31889
32624
  type: 'external-message',
31890
32625
  source: `mcpl:${serverId}`,
@@ -32488,7 +33223,7 @@ export class AgentFramework {
32488
33223
  /**
32489
33224
  * Dispatch a synthesized channel tool call.
32490
33225
  */
32491
- private dispatchChannelToolCall(agentName: string, call: ToolCall): void {
33226
+ private dispatchChannelToolCall(agentName: string, call: ToolCall, earlierWake?: SkipReplyEarlierWake): void {
32492
33227
  // Conversation forks act only on their home channel. channel_publish and
32493
33228
  // channel_close default a missing channelId to home and reject foreign
32494
33229
  // ones; channel_open is rejected outright — opening channels mutates
@@ -32515,7 +33250,7 @@ export class AgentFramework {
32515
33250
 
32516
33251
  // The home quoted for a sentence (quotedShown), as the agent names it
32517
33252
  // back, whose id-and-server form carries its own quotes.
32518
- const homeQuoted = this.channelRegistry?.quotedShown(home) ?? JSON.stringify(home.channelId);
33253
+ const homeQuoted = this.channelRegistry?.quotedShown(home) ?? quotedValue(home.channelId);
32519
33254
  // A home its server no longer registers (a channels/changed update
32520
33255
  // removed it, or this host removed its server; a dropped connection
32521
33256
  // keeps the entry) takes nothing, and the fork has nowhere else to
@@ -32598,7 +33333,9 @@ export class AgentFramework {
32598
33333
  const readThrough = publisher ? this.publishReadThrough(publisher, call.id) : null;
32599
33334
  const published: { outcome?: PublishOutcome } = {};
32600
33335
 
32601
- this.channelRegistry!.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName, own: callingTurn ? this.ownChannels(callingTurn) : [] }, published)
33336
+ this.channelRegistry!.handleChannelToolCall(call.name, call.input, {
33337
+ kind: 'agent', agentName, own: callingTurn ? this.ownChannels(callingTurn) : [], ...(earlierWake ? { earlierWake } : {}),
33338
+ }, published)
32602
33339
  .then((result) => {
32603
33340
  const destination = published.outcome?.status === 'delivered' ? published.outcome.destination : undefined;
32604
33341
  if (publisher && destination) publisher.notePublished(destinationKey(destination), readThrough);
@@ -32647,13 +33384,15 @@ export class AgentFramework {
32647
33384
  noteTurnChannel(callingTurn, opened);
32648
33385
  this.lastAnnouncedLocus.set(agentName, opened);
32649
33386
  const inboundOnlyServer = this.channelRegistry?.inboundOnlyServer(opened);
33387
+ // The routing word comes first, ahead of any backscroll, so a
33388
+ // result cut to its head for being long keeps it.
32650
33389
  result = {
32651
33390
  ...result,
32652
33391
  data: {
32653
- ...(result.data as Record<string, unknown> | undefined),
32654
33392
  routing: inboundOnlyServer
32655
33393
  ? `${inboundOnlySpeechNote(inboundOnlyServer)} Other channels need an explicit send tool.`
32656
33394
  : 'Your plain speech now lands in this channel. Other channels need an explicit send tool.',
33395
+ ...(result.data as Record<string, unknown> | undefined),
32657
33396
  },
32658
33397
  };
32659
33398
  console.error(
@@ -33005,7 +33744,7 @@ export class AgentFramework {
33005
33744
  // answered for it.
33006
33745
  const now = this.channelRegistry!.getTuneOutState(channel.serverId, channelId);
33007
33746
  const error = now?.params.epochId === tuneOut.params.epochId
33008
- ? alreadyTunedOutText(shownNow(), now.params,
33747
+ ? alreadyTunedOutText(this.channelRegistry!.quotedShown(channel), now.params,
33009
33748
  openError ?? 'Its transport is open now, so its messages reach the subconscious.')
33010
33749
  : TUNE_OUT_REPLACED;
33011
33750
  this.pushEvent({
@@ -33145,6 +33884,8 @@ export class AgentFramework {
33145
33884
  } else {
33146
33885
  console.error(`[sleep] ${agentName}: no prose target this turn — sleep announcement not posted (explicit mode never guesses)`);
33147
33886
  }
33887
+ } else if (!callingTurn.locus && callingTurn.heartbeat) {
33888
+ console.error(`[sleep] ${agentName}: a heartbeat turn has no channel — sleep announcement not posted`);
33148
33889
  } else {
33149
33890
  const locus = callingTurn.locus ?? null;
33150
33891
  registry.routeSpeech(agentName, announcement(locus), locus).then(noted).catch((err) => {
@@ -33337,7 +34078,7 @@ export class AgentFramework {
33337
34078
  if (Date.now() - startedAt > BARRIER_MS) {
33338
34079
  throw new Error(
33339
34080
  `save_recent_image: ${open.length} sibling tool call(s) in this batch still pending after ` +
33340
- `${Math.round(BARRIER_MS / 1000)}s (${open.map((p) => `${p.name} (call ${p.id})`).join(', ')}) — their results ` +
34081
+ `${Math.round(BARRIER_MS / 1000)}s (${open.map((p) => `${suppliedValue(p.name)} (call ${p.id})`).join(', ')}) — their results ` +
33341
34082
  'may carry newer images than anything in your context, so no index can be trusted yet. Nothing written. ' +
33342
34083
  'Save again once they have finished.',
33343
34084
  );
@@ -33413,7 +34154,7 @@ export class AgentFramework {
33413
34154
  const ledger = this.toolImageLedgerFor(agentName);
33414
34155
 
33415
34156
  const describeTool = (c: { toolName: string; toolCallId: string }): string =>
33416
- `${c.toolName} (call ${c.toolCallId})`;
34157
+ `${suppliedValue(c.toolName)} (call ${c.toolCallId})`;
33417
34158
  const unavailable = (status: 'evicted' | 'unknown'): string =>
33418
34159
  status === 'evicted'
33419
34160
  ? 'its bytes were evicted from the bounded retention budget'
@@ -33634,9 +34375,10 @@ export class AgentFramework {
33634
34375
  for (let i = messages.length - 1; i >= 0 && collecting(); i--) {
33635
34376
  const content = messages[i]?.content;
33636
34377
  if (!Array.isArray(content)) continue;
33637
- // Another agent's stored context injection isn't in this agent's
33638
- // view (stored-injections.ts), so its images aren't either.
33639
- if (isAnotherAgentsInjection(messages[i]?.metadata, agentName)) continue;
34378
+ // A row the agent's own view leaves out, such as another agent's
34379
+ // stored context injection (stored-injections.ts), isn't in its
34380
+ // window, so its images aren't either.
34381
+ if (!this.agentSees(agent, messages[i]?.metadata)) continue;
33640
34382
  const messagesBack = total - (start + i);
33641
34383
  for (let b = content.length - 1; b >= 0 && collecting(); b--) {
33642
34384
  const block = content[b] as {
@@ -33721,7 +34463,8 @@ export class AgentFramework {
33721
34463
  // error says that whether the entry changed isn't known
33722
34464
  // (writeBinary); the images before it are saved, and the
33723
34465
  // failure says so, as a returned one does.
33724
- failed(error);
34466
+ console.error(`[save_recent_image] writing "${destination}" failed:`, error);
34467
+ failed(thrownWords(error));
33725
34468
  return;
33726
34469
  }
33727
34470
  const inChronicle = (result.data as { inChronicle?: boolean } | undefined)?.inChronicle === true;
@@ -33758,116 +34501,6 @@ export class AgentFramework {
33758
34501
  })();
33759
34502
  }
33760
34503
 
33761
- /** The `utils` meta-tool: list/describe/run over module utilities
33762
- * (Module.getUtilities). Shared by the model-facing dispatcher, which a
33763
- * running code_execution script's calls take too, and executeToolCallFrom,
33764
- * which the puppet and a host reach through executeToolCall and a module
33765
- * through callTool. */
33766
- private async handleUtilsToolCall(agentName: string, call: ToolCall, options: { inProcess?: boolean; trustedModule?: boolean } = {}): Promise<ToolResult> {
33767
- const input = (call.input ?? {}) as { action?: unknown; name?: unknown; args?: unknown };
33768
- const allUtilities = this.moduleRegistry.getAllUtilities();
33769
- const utilities = allUtilities.filter(def => options.trustedModule || this.canAgentUseTool(agentName, def.name));
33770
- const counts = `${registeredUtilities(allUtilities.length)}, ${utilities.length} permitted`;
33771
- const available = (): string => utilities.length
33772
- ? `Available: ${utilities.map((u) => u.name).join(', ')} (${counts})`
33773
- : allUtilities.length
33774
- ? `${counts}; none permitted by this agent's allowedTools. Add each required module--utility name to allowedTools.`
33775
- : `${counts} (none registered)`;
33776
- switch (input.action) {
33777
- case 'list':
33778
- if (allUtilities.length > 0 && utilities.length === 0) {
33779
- return { success: false, isError: true, error: available() };
33780
- }
33781
- return {
33782
- success: true,
33783
- data: utilities.map((u) => ({ name: u.name, description: utilityOneLiner(u.description) })),
33784
- };
33785
- case 'describe': {
33786
- const def = allUtilities.find((u) => u.name === input.name);
33787
- if (!def) {
33788
- return { success: false, isError: true, error: `No utility "${String(input.name)}". ${available()}` };
33789
- }
33790
- if (!options.trustedModule) {
33791
- const refusal = this.agentToolPermissionFailure(agentName, { ...call, name: def.name }, false);
33792
- if (refusal) return refusal;
33793
- }
33794
- return { success: true, data: def };
33795
- }
33796
- case 'run': {
33797
- const def = allUtilities.find((u) => u.name === input.name);
33798
- if (!def) {
33799
- return { success: false, isError: true, error: `No utility "${String(input.name)}", so nothing was done. ${available()}` };
33800
- }
33801
- if (!options.trustedModule) {
33802
- // The allowed outer utils call owns its lifecycle and terminal trace;
33803
- // refusing the utility does not create or refuse a separate call.
33804
- const refusal = this.agentToolPermissionFailure(agentName, { ...call, name: def.name }, false);
33805
- if (refusal) return refusal;
33806
- }
33807
- const args = input.args ?? {};
33808
- const problems = toolArgsProblems(args, def.inputSchema, { values: true, field: 'args' });
33809
- if (problems.length > 0) {
33810
- // Teach by bounce: the error carries the schema, so the retry needs
33811
- // no describe round-trip.
33812
- return {
33813
- success: false,
33814
- isError: true,
33815
- error: `Nothing was done: ${def.name} ${problems.join('; ')}. Schema for ${def.name}: ${JSON.stringify(def.inputSchema)}`,
33816
- };
33817
- }
33818
- // Same dispatch as a first-class tool call — the module cannot tell
33819
- // which surface the call came through. The registry checks the call
33820
- // against the utility resolved here, even where one of the module's
33821
- // tools shares its name. An in-process caller gets the module's own
33822
- // result; the model's path takes the result's stored form.
33823
- //
33824
- // The result goes where the utils call's does. On the model's path it
33825
- // is stored or handed to a script as that call's result, so it may be
33826
- // as long as that call's may (resultMaxCharsFor). An in-process caller
33827
- // gets it back, and the utils call carries what that caller may take,
33828
- // if anything does: the puppet's carries the agent's cap, since the
33829
- // puppet stores the pair under it.
33830
- const resultMaxChars = options.inProcess ? call.resultMaxChars : this.resultMaxCharsFor(agentName, call.id);
33831
- const utilityCall: ToolCall = {
33832
- id: call.id, name: def.name, input: args, callerAgentName: agentName,
33833
- ...(call.callerTurnId !== undefined ? { callerTurnId: call.callerTurnId } : {}),
33834
- ...(resultMaxChars !== undefined ? { resultMaxChars } : {}),
33835
- };
33836
- return options.inProcess
33837
- ? this.moduleRegistry.handleToolCall(utilityCall, { definition: def })
33838
- : this.moduleRegistry.storableToolCall(utilityCall, { definition: def });
33839
- }
33840
- default:
33841
- return { success: false, isError: true, error: 'action must be "list", "describe", or "run"' };
33842
- }
33843
- }
33844
-
33845
- private dispatchUtilsToolCall(agentName: string, call: ToolCall): void {
33846
- this.emitTrace({ type: 'tool:started', module: 'framework', tool: 'utils', callId: call.id, input: call.input });
33847
- const startedAt = Date.now();
33848
- void this.handleUtilsToolCall(agentName, call)
33849
- .catch((err): ToolResult => ({
33850
- success: false,
33851
- error: err instanceof Error ? err.message : String(err),
33852
- isError: true,
33853
- }))
33854
- .then((result) => {
33855
- if (result.success) {
33856
- this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'utils', callId: call.id, durationMs: Date.now() - startedAt });
33857
- } else {
33858
- this.emitTrace({ type: 'tool:failed', module: 'framework', tool: 'utils', callId: call.id, error: result.error ?? 'unknown error' });
33859
- }
33860
- this.pushEvent({
33861
- type: 'tool-result',
33862
- callId: call.id,
33863
- toolName: call.name,
33864
- agentName,
33865
- moduleName: 'framework',
33866
- result,
33867
- });
33868
- });
33869
- }
33870
-
33871
34504
  private readAgentSettings(agentName: string, touched: ReadonlyMap<AgentSettingsExtension, readonly string[]>): SettingsReading {
33872
34505
  const s = this.getAgentRuntimeSettings(agentName);
33873
34506
  const saved = this.agents.get(agentName)?.getRuntimeSettingsOverrides() ?? {};
@@ -34203,3 +34836,10 @@ export class AgentFramework {
34203
34836
  };
34204
34837
  }
34205
34838
  }
34839
+
34840
+ function thrownWords(error: unknown): string {
34841
+ if (error instanceof Refusal) return error.message;
34842
+ const code = errnoCode(error);
34843
+ 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.");
34844
+ return `${systemWords(code, error).replace(/\.$/, '')}. ${on}`;
34845
+ }