@animalabs/agent-framework 0.33.0 → 0.34.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 (176) hide show
  1. package/README.md +136 -8
  2. package/dist/src/agent.d.ts +25 -4
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +68 -13
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/append-all.d.ts +5 -0
  7. package/dist/src/append-all.d.ts.map +1 -0
  8. package/dist/src/append-all.js +8 -0
  9. package/dist/src/append-all.js.map +1 -0
  10. package/dist/src/context-options.d.ts +9 -6
  11. package/dist/src/context-options.d.ts.map +1 -1
  12. package/dist/src/context-options.js +13 -8
  13. package/dist/src/context-options.js.map +1 -1
  14. package/dist/src/cut-off-round.js +4 -4
  15. package/dist/src/dendrite/registry.d.ts.map +1 -1
  16. package/dist/src/dendrite/registry.js +4 -2
  17. package/dist/src/dendrite/registry.js.map +1 -1
  18. package/dist/src/failure-text.d.ts +36 -0
  19. package/dist/src/failure-text.d.ts.map +1 -0
  20. package/dist/src/failure-text.js +89 -0
  21. package/dist/src/failure-text.js.map +1 -0
  22. package/dist/src/framework.d.ts +94 -30
  23. package/dist/src/framework.d.ts.map +1 -1
  24. package/dist/src/framework.js +987 -284
  25. package/dist/src/framework.js.map +1 -1
  26. package/dist/src/gate/event-gate.d.ts +40 -6
  27. package/dist/src/gate/event-gate.d.ts.map +1 -1
  28. package/dist/src/gate/event-gate.js +66 -25
  29. package/dist/src/gate/event-gate.js.map +1 -1
  30. package/dist/src/gate/reply-judge.d.ts +4 -0
  31. package/dist/src/gate/reply-judge.d.ts.map +1 -1
  32. package/dist/src/gate/reply-judge.js +12 -3
  33. package/dist/src/gate/reply-judge.js.map +1 -1
  34. package/dist/src/gate/suppression-review.js +2 -2
  35. package/dist/src/gate/suppression-review.js.map +1 -1
  36. package/dist/src/gate/types.d.ts +7 -0
  37. package/dist/src/gate/types.d.ts.map +1 -1
  38. package/dist/src/heartbeat/heartbeat.js +1 -1
  39. package/dist/src/heartbeat/heartbeat.js.map +1 -1
  40. package/dist/src/index.d.ts +1 -0
  41. package/dist/src/index.d.ts.map +1 -1
  42. package/dist/src/index.js +1 -0
  43. package/dist/src/index.js.map +1 -1
  44. package/dist/src/inference-bounds.d.ts +2 -1
  45. package/dist/src/inference-bounds.d.ts.map +1 -1
  46. package/dist/src/inference-bounds.js +9 -6
  47. package/dist/src/inference-bounds.js.map +1 -1
  48. package/dist/src/mcpl/channel-registry.d.ts +57 -13
  49. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  50. package/dist/src/mcpl/channel-registry.js +136 -24
  51. package/dist/src/mcpl/channel-registry.js.map +1 -1
  52. package/dist/src/mcpl/modern-connection.d.ts +109 -15
  53. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  54. package/dist/src/mcpl/modern-connection.js +593 -86
  55. package/dist/src/mcpl/modern-connection.js.map +1 -1
  56. package/dist/src/mcpl/protocol-family.d.ts +3 -1
  57. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  58. package/dist/src/mcpl/protocol-family.js +15 -9
  59. package/dist/src/mcpl/protocol-family.js.map +1 -1
  60. package/dist/src/mcpl/server-connection.d.ts +37 -18
  61. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  62. package/dist/src/mcpl/server-connection.js +31 -16
  63. package/dist/src/mcpl/server-connection.js.map +1 -1
  64. package/dist/src/mcpl/tool-presence.d.ts.map +1 -1
  65. package/dist/src/mcpl/tool-presence.js +10 -6
  66. package/dist/src/mcpl/tool-presence.js.map +1 -1
  67. package/dist/src/mcpl/toolset-host.d.ts.map +1 -1
  68. package/dist/src/mcpl/toolset-host.js +7 -6
  69. package/dist/src/mcpl/toolset-host.js.map +1 -1
  70. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  71. package/dist/src/mcpl/toolsets-module.js +2 -1
  72. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  73. package/dist/src/mcpl/types.d.ts +25 -4
  74. package/dist/src/mcpl/types.d.ts.map +1 -1
  75. package/dist/src/mcpl/types.js.map +1 -1
  76. package/dist/src/modules/discord/index.js +1 -1
  77. package/dist/src/modules/discord/index.js.map +1 -1
  78. package/dist/src/modules/health/index.js +2 -2
  79. package/dist/src/modules/health/index.js.map +1 -1
  80. package/dist/src/modules/history/semantic.js +3 -3
  81. package/dist/src/modules/history/semantic.js.map +1 -1
  82. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  83. package/dist/src/modules/workspace/index.js +4 -3
  84. package/dist/src/modules/workspace/index.js.map +1 -1
  85. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  86. package/dist/src/modules/workspace/observe.js +2 -1
  87. package/dist/src/modules/workspace/observe.js.map +1 -1
  88. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  89. package/dist/src/modules/workspace/reconcile.js +3 -2
  90. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  91. package/dist/src/notice-tags.d.ts +10 -8
  92. package/dist/src/notice-tags.d.ts.map +1 -1
  93. package/dist/src/notice-tags.js +10 -8
  94. package/dist/src/notice-tags.js.map +1 -1
  95. package/dist/src/presentation.d.ts +2 -1
  96. package/dist/src/presentation.d.ts.map +1 -1
  97. package/dist/src/presentation.js +4 -2
  98. package/dist/src/presentation.js.map +1 -1
  99. package/dist/src/reasoning-loss.d.ts +5 -0
  100. package/dist/src/reasoning-loss.d.ts.map +1 -0
  101. package/dist/src/reasoning-loss.js +8 -0
  102. package/dist/src/reasoning-loss.js.map +1 -0
  103. package/dist/src/recovery/offline-branch.d.ts.map +1 -1
  104. package/dist/src/recovery/offline-branch.js +5 -4
  105. package/dist/src/recovery/offline-branch.js.map +1 -1
  106. package/dist/src/recovery/recover-cli.js +2 -1
  107. package/dist/src/recovery/recover-cli.js.map +1 -1
  108. package/dist/src/sent-rows.js +1 -1
  109. package/dist/src/supplied-text.d.ts +1 -1
  110. package/dist/src/supplied-text.js +3 -3
  111. package/dist/src/supplied-text.js.map +1 -1
  112. package/dist/src/thinking-drops.d.ts +2 -0
  113. package/dist/src/thinking-drops.d.ts.map +1 -1
  114. package/dist/src/thinking-drops.js.map +1 -1
  115. package/dist/src/tool-presentation.d.ts.map +1 -1
  116. package/dist/src/tool-presentation.js +38 -9
  117. package/dist/src/tool-presentation.js.map +1 -1
  118. package/dist/src/tune-out/coordinator.d.ts +13 -4
  119. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  120. package/dist/src/tune-out/coordinator.js +33 -16
  121. package/dist/src/tune-out/coordinator.js.map +1 -1
  122. package/dist/src/tune-out/tools.d.ts.map +1 -1
  123. package/dist/src/tune-out/tools.js +2 -1
  124. package/dist/src/tune-out/tools.js.map +1 -1
  125. package/dist/src/types/agent.d.ts +39 -5
  126. package/dist/src/types/agent.d.ts.map +1 -1
  127. package/dist/src/types/framework.d.ts +43 -8
  128. package/dist/src/types/framework.d.ts.map +1 -1
  129. package/dist/src/types/inference-log.d.ts +1 -0
  130. package/dist/src/types/inference-log.d.ts.map +1 -1
  131. package/dist/src/types/trace.d.ts +64 -4
  132. package/dist/src/types/trace.d.ts.map +1 -1
  133. package/package.json +3 -3
  134. package/src/agent.ts +71 -14
  135. package/src/append-all.ts +6 -0
  136. package/src/context-options.ts +18 -8
  137. package/src/cut-off-round.ts +4 -4
  138. package/src/dendrite/registry.ts +4 -2
  139. package/src/failure-text.ts +88 -0
  140. package/src/framework.ts +1018 -293
  141. package/src/gate/event-gate.ts +90 -26
  142. package/src/gate/reply-judge.ts +12 -3
  143. package/src/gate/suppression-review.ts +2 -2
  144. package/src/gate/types.ts +7 -0
  145. package/src/heartbeat/heartbeat.ts +1 -1
  146. package/src/index.ts +1 -0
  147. package/src/inference-bounds.ts +11 -6
  148. package/src/mcpl/channel-registry.ts +160 -31
  149. package/src/mcpl/modern-connection.ts +629 -91
  150. package/src/mcpl/protocol-family.ts +15 -9
  151. package/src/mcpl/server-connection.ts +28 -15
  152. package/src/mcpl/tool-presence.ts +10 -5
  153. package/src/mcpl/toolset-host.ts +7 -6
  154. package/src/mcpl/toolsets-module.ts +2 -1
  155. package/src/mcpl/types.ts +25 -4
  156. package/src/modules/discord/index.ts +1 -1
  157. package/src/modules/health/index.ts +2 -2
  158. package/src/modules/history/semantic.ts +3 -3
  159. package/src/modules/workspace/index.ts +4 -3
  160. package/src/modules/workspace/observe.ts +2 -1
  161. package/src/modules/workspace/reconcile.ts +3 -2
  162. package/src/notice-tags.ts +11 -8
  163. package/src/presentation.ts +4 -2
  164. package/src/reasoning-loss.ts +7 -0
  165. package/src/recovery/offline-branch.ts +5 -4
  166. package/src/recovery/recover-cli.ts +2 -1
  167. package/src/sent-rows.ts +1 -1
  168. package/src/supplied-text.ts +3 -3
  169. package/src/thinking-drops.ts +2 -0
  170. package/src/tool-presentation.ts +34 -9
  171. package/src/tune-out/coordinator.ts +32 -15
  172. package/src/tune-out/tools.ts +2 -1
  173. package/src/types/agent.ts +36 -5
  174. package/src/types/framework.ts +44 -8
  175. package/src/types/inference-log.ts +1 -0
  176. package/src/types/trace.ts +66 -4
@@ -43,6 +43,7 @@ import { expandCoreTags, isMarkerTagged } from './tags.js';
43
43
  import { coalesceRejectionReason, EmptyContentError, validateCoalescedContent } from './push-coalescer.js';
44
44
  import { isVisiblyEmptyConversion } from './visible-content.js';
45
45
  import { logDetail, logValue } from './log-value.js';
46
+ import { jsonKind } from '../jsonrpc-envelope.js';
46
47
  import { RefusalLines } from './refusal-lines.js';
47
48
  import { CapabilityGrant } from './capability-grant.js';
48
49
  import type { InboundSource } from './inbound-source.js';
@@ -1093,6 +1094,9 @@ interface ChannelRegistryOptions {
1093
1094
  outcome?: 'failed' | 'unknown';
1094
1095
  /** Bounded display of the connector's structured error data, when present. */
1095
1096
  detail?: string;
1097
+ /** Set when the turn had no channel and the reason names the channels
1098
+ * its batch came from. */
1099
+ byDesign?: true;
1096
1100
  }) => void;
1097
1101
  /**
1098
1102
  * Announces either a durable subscription-policy admission (before its
@@ -1135,15 +1139,29 @@ interface ChannelRegistryOptions {
1135
1139
  * message across ALL channels (markers aside) and misroutes a reply to
1136
1140
  * whichever channel last spoke under concurrency (item-3 redux). The
1137
1141
  * framework keeps both on the live turn's routing state and exposes them
1138
- * here. For a heartbeat / no-trigger turn it resolves to undefined until the
1139
- * turn's locus is resolved, so that
1140
- * resolution takes the global fallback, which the turn then keeps.
1141
- * Consulted AFTER `homeChannelResolver` (a fork's home always wins), BEFORE
1142
- * `defaultPublishChannel`. Null where the turn deliberately has no channel,
1143
- * as one woken for a channel id two servers register, given without its
1144
- * server: that takes no fallback either.
1142
+ * here. For a no-trigger turn it resolves to undefined until the turn's
1143
+ * locus is resolved, so that resolution takes the global fallback, which the
1144
+ * turn then keeps. Consulted AFTER `homeChannelResolver` (a fork's home
1145
+ * always wins), BEFORE the global fallback. Null where the turn deliberately
1146
+ * has no channel, as a heartbeat's turn, or one woken for a channel id two
1147
+ * servers register, given without its server: that takes no fallback
1148
+ * either.
1145
1149
  */
1146
1150
  activeChannelResolver?: (agentName: string) => ChannelRef | null | undefined;
1151
+ /**
1152
+ * FrameworkConfig.unpromptedChannel: where a turn that takes it
1153
+ * (takesUnpromptedChannel) and has no home and no channel of its own is
1154
+ * placed, within the process-global fallback, after an all-except hold's
1155
+ * channel (sharedFallback). A name, as entriesNamed reads one.
1156
+ */
1157
+ unpromptedChannel?: string;
1158
+ /**
1159
+ * Whether the conversation's current turn takes `unpromptedChannel` where it
1160
+ * has no channel of its own: the framework answers true only for a turn
1161
+ * the agent's own timer started, as FrameworkConfig.unpromptedChannel
1162
+ * says. Absent, every turn does.
1163
+ */
1164
+ takesUnpromptedChannel?: (conversationId: string) => boolean;
1147
1165
  /**
1148
1166
  * Whether the agent's current turn has no channel of its own — a
1149
1167
  * heartbeat's, or one woken for a channel that names none — so it replies
@@ -1282,6 +1300,9 @@ export class ChannelRegistry {
1282
1300
  private channellessTurn?: (agentName: string) => boolean;
1283
1301
  private takesGlobalFallback?: (conversationId: string) => boolean;
1284
1302
  private serverConnecting?: (serverId: string) => boolean;
1303
+ private unpromptedChannel?: string;
1304
+ private unpromptedChannelWarned = false;
1305
+ private takesUnpromptedChannel?: (conversationId: string) => boolean;
1285
1306
  private store?: JsStore;
1286
1307
 
1287
1308
  /**
@@ -1483,6 +1504,11 @@ export class ChannelRegistry {
1483
1504
  return new ChannelLifecycleError(`this host isn't permitted to open or close the "${serverId}" server's channels`, 'not-permitted');
1484
1505
  }
1485
1506
 
1507
+ private publishingServer(serverId: string): McplServerConnection | undefined {
1508
+ const server = this.serverRegistry.getServer(serverId);
1509
+ return server && CapabilityGrant.of(server).has('channels.publish') ? server : undefined;
1510
+ }
1511
+
1486
1512
  /**
1487
1513
  * Why a send to this server's channels is refused before anything goes
1488
1514
  * out, as one clause with what works from there, for a caller that found
@@ -1677,6 +1703,18 @@ export class ChannelRegistry {
1677
1703
  this.handleCoalescedIncoming = options?.handleCoalescedIncoming;
1678
1704
  this.acceptInbound = options?.acceptInbound;
1679
1705
  this.channelTools = CHANNEL_TOOL_DEFINITIONS.map((tool) => {
1706
+ if (tool.name === 'channel_publish') {
1707
+ if (options?.unpromptedChannel === undefined || options.eventGatePresent !== true) return tool;
1708
+ return {
1709
+ ...tool,
1710
+ description: tool.description
1711
+ .replace(
1712
+ 'otherwise, while a focus holds, the channel it keeps.',
1713
+ 'otherwise, while a focus holds, the channel it keeps; otherwise, on a turn your own skip_reply wake or the end of your sleep started (or a turn continuing one, when your plain speech isn\'t routed), the channel this host names for such turns.',
1714
+ )
1715
+ .replace('A turn with no channel of its own, such as', 'A turn with no channel by design, such as'),
1716
+ };
1717
+ }
1680
1718
  if (tool.name !== 'skip_reply' || options?.eventGatePresent === true) return tool;
1681
1719
  const schema = tool.inputSchema as {
1682
1720
  type: 'object';
@@ -1709,6 +1747,8 @@ export class ChannelRegistry {
1709
1747
  this.activeChannelResolver = options?.activeChannelResolver;
1710
1748
  this.channellessTurn = options?.channellessTurn;
1711
1749
  this.takesGlobalFallback = options?.takesGlobalFallback;
1750
+ this.unpromptedChannel = options?.unpromptedChannel;
1751
+ this.takesUnpromptedChannel = options?.takesUnpromptedChannel;
1712
1752
  this.serverConnecting = options?.serverConnecting;
1713
1753
  this.store = options?.store;
1714
1754
  this.initializeLifecycleStore();
@@ -2509,6 +2549,28 @@ export class ChannelRegistry {
2509
2549
  return this.channelTools;
2510
2550
  }
2511
2551
 
2552
+ /**
2553
+ * The refusal of a channel tool's call that gives a parameter its schema
2554
+ * declares a string as something else, before anything changes; absent and
2555
+ * `null` pass, as each tool reads them. A number written past 2^53 has
2556
+ * usually lost its last digits by the time the call is parsed.
2557
+ */
2558
+ nonStringRefusal(toolName: string, input: unknown): ToolResult | undefined {
2559
+ if (input === null || typeof input !== 'object') return undefined;
2560
+ const properties = this.channelTools.find((tool) => tool.name === toolName)?.inputSchema.properties ?? {};
2561
+ for (const [field, value] of Object.entries(input)) {
2562
+ if ((properties[field] as { type?: unknown } | undefined)?.type !== 'string') continue;
2563
+ if (value === undefined || value === null || typeof value === 'string') continue;
2564
+ return {
2565
+ success: false,
2566
+ error: `Nothing changed: ${toolName}'s ${field} must be a string, and this call gave ${jsonKind(value)}. Give it in quotes`
2567
+ + (typeof value === 'number' ? ': a number can lose its last digits as the call is read.' : '.'),
2568
+ isError: true,
2569
+ };
2570
+ }
2571
+ return undefined;
2572
+ }
2573
+
2512
2574
  /**
2513
2575
  * Handle a call to one of the synthesized channel tools.
2514
2576
  *
@@ -2529,6 +2591,8 @@ export class ChannelRegistry {
2529
2591
  * attempted, so the caller learns where it was delivered. */
2530
2592
  seen?: { outcome?: PublishOutcome },
2531
2593
  ): Promise<ToolResult> {
2594
+ const refused = this.nonStringRefusal(toolName, input);
2595
+ if (refused) return refused;
2532
2596
  switch (toolName) {
2533
2597
  case 'channel_list':
2534
2598
  return this.handleToolList();
@@ -2627,7 +2691,9 @@ export class ChannelRegistry {
2627
2691
  // declared it inbound-only, since speech isn't posted there): a conversation
2628
2692
  // fork's home channel, else this turn's channel (single trunk agent — item-3
2629
2693
  // redux: its locus, else its triggering channel), else the global default
2630
- // (a no-trigger turn's, but not a heartbeat's, whose turn takes none).
2694
+ // (a no-trigger turn's, but not a heartbeat's, whose turn takes none), with
2695
+ // the configured unprompted channel in it where the turn takes it: the
2696
+ // channel channel_publish naming none publishes to, in every prose mode.
2631
2697
  // Without this, a fork
2632
2698
  // was advertised the global locus but published somewhere else; and a trunk
2633
2699
  // agent was told the wrong channel under concurrency.
@@ -2638,9 +2704,11 @@ export class ChannelRegistry {
2638
2704
  // An ephemeral run takes no global fallback (takesGlobalFallback): not the
2639
2705
  // all-except hold's channel, not the latest inbound message's channel as
2640
2706
  // its outgoing one, nor that message as the one it is replying to.
2641
- const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.sharedFallback();
2707
+ const fallback = (): ChannelRef | null => agentName !== undefined
2708
+ ? this.globalFallbackFor(agentName, () => this.configuredUnprompted(agentName))
2709
+ : this.sharedFallback();
2642
2710
  // A turn that deliberately has no channel (null) takes no fallback.
2643
- const outgoing = home ?? (active !== undefined ? active : fallback);
2711
+ const outgoing = home ?? (active !== undefined ? active : fallback());
2644
2712
 
2645
2713
  const context: ChannelContext = {};
2646
2714
 
@@ -6022,7 +6090,8 @@ export class ChannelRegistry {
6022
6090
  }
6023
6091
 
6024
6092
  /** Resolve the outbound locus (fork HOME → this-turn's TRIGGERING channel →
6025
- * process-global default), with its server. Public so a multi-segment
6093
+ * process-global default, with the configured unprompted channel in it
6094
+ * where the turn takes it), with its server. Public so a multi-segment
6026
6095
  * caller can snapshot it ONCE and pin every segment to it via
6027
6096
  * routeSpeech's locus. */
6028
6097
  resolveLocus(conversationId: string): ChannelRef | null {
@@ -6030,7 +6099,33 @@ export class ChannelRegistry {
6030
6099
  if (home) return home;
6031
6100
  // A turn that deliberately has no channel (null) takes no fallback.
6032
6101
  const active = this.activeChannelResolver?.(conversationId);
6033
- return active !== undefined ? active : this.globalFallbackFor(conversationId);
6102
+ return active !== undefined
6103
+ ? active
6104
+ : this.globalFallbackFor(conversationId, () => this.configuredUnprompted(conversationId));
6105
+ }
6106
+
6107
+ private configuredUnprompted(conversationId: string | undefined): ChannelRef | null {
6108
+ if (!this.unpromptedChannel) return null;
6109
+ if (conversationId !== undefined && this.takesUnpromptedChannel?.(conversationId) === false) return null;
6110
+ const named = this.entriesNamed(this.unpromptedChannel);
6111
+ if (named.length === 1 && !isInboundOnly(named[0]!)) {
6112
+ this.unpromptedChannelWarned = false;
6113
+ return refOf(named[0]!);
6114
+ }
6115
+ if (!this.unpromptedChannelWarned) {
6116
+ this.unpromptedChannelWarned = true;
6117
+ const why = named.length === 0
6118
+ ? 'names no registered channel'
6119
+ : named.length === 1
6120
+ ? 'names a channel its server declared inbound-only'
6121
+ : `names ${named.length} registered channels (name one with its server, as "serverId:channelId")`;
6122
+ console.error(
6123
+ `[routing] unpromptedChannel ${logValue(this.unpromptedChannel)} ${why}, so a turn the agent's own timer starts `
6124
+ + 'takes the fallback it takes without the setting until the setting names a channel it can speak in. '
6125
+ + 'Not logged again until a turn has used it.',
6126
+ );
6127
+ }
6128
+ return null;
6034
6129
  }
6035
6130
 
6036
6131
  /**
@@ -6039,19 +6134,21 @@ export class ChannelRegistry {
6039
6134
  * result, so neither the all-except hold's channel nor the latest inbound
6040
6135
  * message's is ever its destination.
6041
6136
  */
6042
- private globalFallbackFor(conversationId: string): ChannelRef | null {
6043
- return this.takesGlobalFallback?.(conversationId) === false ? null : this.sharedFallback();
6137
+ private globalFallbackFor(conversationId: string, unprompted?: () => ChannelRef | null): ChannelRef | null {
6138
+ return this.takesGlobalFallback?.(conversationId) === false ? null : this.sharedFallback(unprompted);
6044
6139
  }
6045
6140
 
6046
6141
  /**
6047
6142
  * What every conversation that takes the global fallback falls back to when
6048
6143
  * neither a home channel nor its own delivery names one: the all-except
6049
- * hold's channel while one is open, else the latest delivered inbound
6050
- * channel under the legacy locus rule, else none — the prose stays private
6051
- * (architecture channel §3 carve-out, 2026-10-06).
6144
+ * hold's channel while one is open, else, for a turn's locus, the
6145
+ * configured unprompted channel where the turn takes it (resolveLocus),
6146
+ * else the latest delivered inbound channel under the legacy locus rule,
6147
+ * else none — the prose stays private (architecture channel §3 carve-out,
6148
+ * 2026-10-06).
6052
6149
  */
6053
- private sharedFallback(): ChannelRef | null {
6054
- return this.focusChannelResolver?.() ?? this.fallbackLocus();
6150
+ private sharedFallback(unprompted?: () => ChannelRef | null): ChannelRef | null {
6151
+ return this.focusChannelResolver?.() ?? unprompted?.() ?? this.fallbackLocus();
6055
6152
  }
6056
6153
 
6057
6154
  private fallbackLocus(): ChannelRef | null {
@@ -6204,12 +6301,12 @@ export class ChannelRegistry {
6204
6301
  if (isInboundOnly(entry)) {
6205
6302
  return { status: 'failed', destination, reason: inboundOnlyReason(entry), at: at() };
6206
6303
  }
6207
- const server = this.serverRegistry.getServer(entry.serverId);
6304
+ const server = this.publishingServer(entry.serverId);
6208
6305
  // §14.1: channels/publish requires channels.publish in the grant. The
6209
6306
  // host not sending is the enforcement, here and at the last check, and
6210
6307
  // the server's state words the refusal (sendRefusal).
6211
6308
  const ungranted = (): PublishOutcome => ({ status: 'failed', destination, reason: this.sendRefusal(entry.serverId), at: at() });
6212
- if (!server || !CapabilityGrant.of(server).has('channels.publish')) return ungranted();
6309
+ if (!server) return ungranted();
6213
6310
 
6214
6311
  const prevented = (err: unknown): PublishOutcome => ({
6215
6312
  status: 'failed', destination, reason: lifecycleClause(err) + refusalRemedy(err), at: at(),
@@ -6372,12 +6469,15 @@ export class ChannelRegistry {
6372
6469
  locus: ChannelRef | null,
6373
6470
  /** `heldOnFailure`: the caller holds a `failed` delivery's words as a
6374
6471
  * draft with its own notice, so no failure marker is written for it.
6375
- * An `unknown` outcome still gets its marker. */
6376
- opts?: { heldOnFailure?: boolean },
6472
+ * An `unknown` outcome still gets its marker. `cameFrom`: with no
6473
+ * locus, the channels the turn's batch came from, each with how the
6474
+ * agent is shown it; the failure's reason names them, and how to reply
6475
+ * in those registered that take speech. */
6476
+ opts?: { heldOnFailure?: boolean; cameFrom?: ReadonlyArray<{ channel: ChannelRef; shown: string }> },
6377
6477
  ): Promise<PublishOutcome> {
6378
6478
  // `logged` is what the log line and the trace say, where it isn't the
6379
- // agent's reason (the two no-locus reasons below).
6380
- const fail = (channel: ChannelRef | null, reason: string, outcome: PublishOutcome, logged = reason): PublishOutcome => {
6479
+ // agent's reason (the three no-locus reasons below).
6480
+ const fail = (channel: ChannelRef | null, reason: string, outcome: PublishOutcome, logged = reason, byDesign = false): PublishOutcome => {
6381
6481
  const status = outcome.status === 'unknown' ? 'unknown' : 'failed';
6382
6482
  const channelId = channel?.channelId ?? null;
6383
6483
  const detail = outcome.detail !== undefined ? connectorText(outcome.detail) : undefined;
@@ -6397,17 +6497,19 @@ export class ChannelRegistry {
6397
6497
  this.onRouteFailure?.({
6398
6498
  conversationId, channelId, ...(channel ? { serverId: channel.serverId } : {}),
6399
6499
  reason, textLen: text.length, outcome: status, ...(detail !== undefined ? { detail } : {}),
6500
+ ...(byDesign ? { byDesign: true as const } : {}),
6400
6501
  });
6401
6502
  }
6402
6503
  return outcome;
6403
6504
  };
6404
6505
 
6405
- // These two reasons are the only ones whose log line and trace keep the
6506
+ // These three reasons are the only ones whose log line and trace keep the
6406
6507
  // operator's words rather than the agent's: connectome-host's
6407
6508
  // connectome-doctor reads the last [routeSpeech] line for "no locus". Keep
6408
- // "no locus" in both logged texts. Each names no tool: whether one the
6509
+ // "no locus" in each logged text. Each names no tool: whether one the
6409
6510
  // agent may call could post the words isn't known here, and the notice
6410
- // calls it a routing or configuration situation.
6511
+ // calls it a routing or configuration situation, or for a turn whose
6512
+ // batch named the channels it came from a routing one (byDesign).
6411
6513
  // Runtime backstop for JS callers the compiler can't see: an omitted
6412
6514
  // locus is a routing bug at the call site, never something to paper over
6413
6515
  // with a live re-resolution.
@@ -6427,6 +6529,32 @@ export class ChannelRegistry {
6427
6529
  // global inbound ever seen) — the agent was told its prose stays in
6428
6530
  // the archive; honor that.
6429
6531
  // The locus froze at the turn's start, so this says what the turn had.
6532
+ const from = opts?.cameFrom ?? [];
6533
+ if (from.length > 0) {
6534
+ const list = (names: readonly string[]) =>
6535
+ names.length === 1 ? names[0] : `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`;
6536
+ const closed: string[] = [];
6537
+ const open: Array<{ channel: ChannelRef; shown: string }> = [];
6538
+ for (const came of from) {
6539
+ const entry = this.entryAt(came.channel);
6540
+ if (!entry) closed.push(`${came.shown} isn't registered with this host now, so this host has no channel there to send to`);
6541
+ else if (isInboundOnly(entry)) closed.push(`${came.shown} is ${inboundOnlyReason(entry)}`);
6542
+ else if (!this.publishingServer(entry.serverId)) {
6543
+ const reach = this.serverReach(entry.serverId);
6544
+ closed.push(reach === 'connecting' || reach === 'down'
6545
+ ? `${came.shown} can't take a send: ${unreachableClause(entry.serverId, reach)}, so to reply there, send your reply to that channel explicitly once it's back`
6546
+ : `${came.shown} can't take a send: ${this.sendRefusal(entry.serverId)}`);
6547
+ } else open.push(came);
6548
+ }
6549
+ const reply = open.length === 0 ? ''
6550
+ : open.length === from.length
6551
+ ? `; to reply ${from.length === 1 ? 'there' : 'in one of them'}, send your reply to that channel explicitly`
6552
+ : `; to reply in ${open.length === 1 ? open[0].shown : `one of ${list(open.map((c) => c.shown))}`}, send your reply to that channel explicitly`;
6553
+ const reason = `this turn came from ${list(from.map((c) => c.shown))}, and no channel was chosen for its reply` +
6554
+ `${reply}${closed.map((c) => `; ${c}`).join('')}`;
6555
+ return fail(null, reason, { status: 'failed', reason, at: Date.now() },
6556
+ "turn has no locus (no channel was chosen for its batch's reply)", true);
6557
+ }
6430
6558
  const reason = 'this turn came from no channel, and it had no home or recent channel to reply in; if it should have ' +
6431
6559
  'had one, ask whoever runs this host';
6432
6560
  return fail(null, reason, { status: 'failed', reason, at: Date.now() },
@@ -6461,8 +6589,9 @@ export class ChannelRegistry {
6461
6589
  conversationId: string,
6462
6590
  text: string,
6463
6591
  locus: ChannelRef | null,
6592
+ opts?: { cameFrom?: ReadonlyArray<{ channel: ChannelRef; shown: string }> },
6464
6593
  ): Promise<{ delivered: boolean; serverId: string; channelId: string; messageId?: string } | null> {
6465
- const outcome = await this.deliverSpeech(conversationId, text, locus);
6594
+ const outcome = await this.deliverSpeech(conversationId, text, locus, opts);
6466
6595
  if (outcome.status !== 'delivered') return null;
6467
6596
  return {
6468
6597
  delivered: true,
@@ -6621,8 +6750,8 @@ export class ChannelRegistry {
6621
6750
 
6622
6751
  // The same refusal, in the same words, as plain speech's (sendRefusal),
6623
6752
  // whose server state is the reason's: nothing awaits between the two.
6624
- const server = this.serverRegistry.getServer(entry.serverId);
6625
- if (!server || !CapabilityGrant.of(server).has('channels.publish')) {
6753
+ const server = this.publishingServer(entry.serverId);
6754
+ if (!server) {
6626
6755
  if (seen) {
6627
6756
  const reach = this.serverReach(entry.serverId);
6628
6757
  seen.refusal = reach === 'up' ? 'not-permitted' : `server-${reach}`;