@animalabs/agent-framework 0.29.0 → 0.30.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 (104) hide show
  1. package/dist/src/admission/admit.d.ts +90 -0
  2. package/dist/src/admission/admit.d.ts.map +1 -0
  3. package/dist/src/admission/admit.js +43 -0
  4. package/dist/src/admission/admit.js.map +1 -0
  5. package/dist/src/agent.d.ts +21 -0
  6. package/dist/src/agent.d.ts.map +1 -1
  7. package/dist/src/agent.js +43 -6
  8. package/dist/src/agent.js.map +1 -1
  9. package/dist/src/author-presentation.d.ts +50 -0
  10. package/dist/src/author-presentation.d.ts.map +1 -0
  11. package/dist/src/author-presentation.js +58 -0
  12. package/dist/src/author-presentation.js.map +1 -0
  13. package/dist/src/dendrite/index.d.ts +6 -0
  14. package/dist/src/dendrite/index.d.ts.map +1 -0
  15. package/dist/src/dendrite/index.js +3 -0
  16. package/dist/src/dendrite/index.js.map +1 -0
  17. package/dist/src/dendrite/presets.d.ts +60 -0
  18. package/dist/src/dendrite/presets.d.ts.map +1 -0
  19. package/dist/src/dendrite/presets.js +117 -0
  20. package/dist/src/dendrite/presets.js.map +1 -0
  21. package/dist/src/dendrite/registry.d.ts +260 -0
  22. package/dist/src/dendrite/registry.d.ts.map +1 -0
  23. package/dist/src/dendrite/registry.js +742 -0
  24. package/dist/src/dendrite/registry.js.map +1 -0
  25. package/dist/src/dendrite/types.d.ts +392 -0
  26. package/dist/src/dendrite/types.d.ts.map +1 -0
  27. package/dist/src/dendrite/types.js +24 -0
  28. package/dist/src/dendrite/types.js.map +1 -0
  29. package/dist/src/framework.d.ts +684 -33
  30. package/dist/src/framework.d.ts.map +1 -1
  31. package/dist/src/framework.js +2998 -350
  32. package/dist/src/framework.js.map +1 -1
  33. package/dist/src/gate/event-gate.d.ts +12 -4
  34. package/dist/src/gate/event-gate.d.ts.map +1 -1
  35. package/dist/src/gate/event-gate.js +25 -5
  36. package/dist/src/gate/event-gate.js.map +1 -1
  37. package/dist/src/host-presentation.d.ts +4 -0
  38. package/dist/src/host-presentation.d.ts.map +1 -1
  39. package/dist/src/host-presentation.js +7 -3
  40. package/dist/src/host-presentation.js.map +1 -1
  41. package/dist/src/index.d.ts +5 -2
  42. package/dist/src/index.d.ts.map +1 -1
  43. package/dist/src/index.js +6 -1
  44. package/dist/src/index.js.map +1 -1
  45. package/dist/src/mcpl/channel-registry.d.ts +84 -9
  46. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  47. package/dist/src/mcpl/channel-registry.js +136 -58
  48. package/dist/src/mcpl/channel-registry.js.map +1 -1
  49. package/dist/src/mcpl/push-coalescer.d.ts +9 -0
  50. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  51. package/dist/src/mcpl/push-coalescer.js +21 -0
  52. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  53. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  54. package/dist/src/mcpl/toolsets-module.js +7 -0
  55. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  56. package/dist/src/notice-tags.d.ts +2 -0
  57. package/dist/src/notice-tags.d.ts.map +1 -1
  58. package/dist/src/notice-tags.js +2 -0
  59. package/dist/src/notice-tags.js.map +1 -1
  60. package/dist/src/silent-heartbeat.d.ts +4 -2
  61. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  62. package/dist/src/silent-heartbeat.js +7 -4
  63. package/dist/src/silent-heartbeat.js.map +1 -1
  64. package/dist/src/stored-injections.d.ts +4 -0
  65. package/dist/src/stored-injections.d.ts.map +1 -1
  66. package/dist/src/stored-injections.js +6 -0
  67. package/dist/src/stored-injections.js.map +1 -1
  68. package/dist/src/tune-out/coordinator.d.ts +379 -9
  69. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  70. package/dist/src/tune-out/coordinator.js +1080 -97
  71. package/dist/src/tune-out/coordinator.js.map +1 -1
  72. package/dist/src/tune-out/tools.d.ts +101 -5
  73. package/dist/src/tune-out/tools.d.ts.map +1 -1
  74. package/dist/src/tune-out/tools.js +55 -18
  75. package/dist/src/tune-out/tools.js.map +1 -1
  76. package/dist/src/types/agent.d.ts +25 -0
  77. package/dist/src/types/agent.d.ts.map +1 -1
  78. package/dist/src/types/framework.d.ts +99 -0
  79. package/dist/src/types/framework.d.ts.map +1 -1
  80. package/dist/src/types/trace.d.ts +136 -0
  81. package/dist/src/types/trace.d.ts.map +1 -1
  82. package/package.json +3 -3
  83. package/src/admission/admit.ts +113 -0
  84. package/src/agent.ts +48 -6
  85. package/src/author-presentation.ts +81 -0
  86. package/src/dendrite/index.ts +32 -0
  87. package/src/dendrite/presets.ts +149 -0
  88. package/src/dendrite/registry.ts +808 -0
  89. package/src/dendrite/types.ts +397 -0
  90. package/src/framework.ts +3239 -369
  91. package/src/gate/event-gate.ts +31 -5
  92. package/src/host-presentation.ts +10 -3
  93. package/src/index.ts +15 -2
  94. package/src/mcpl/channel-registry.ts +170 -60
  95. package/src/mcpl/push-coalescer.ts +19 -0
  96. package/src/mcpl/toolsets-module.ts +7 -0
  97. package/src/notice-tags.ts +2 -0
  98. package/src/silent-heartbeat.ts +7 -4
  99. package/src/stored-injections.ts +7 -0
  100. package/src/tune-out/coordinator.ts +1229 -98
  101. package/src/tune-out/tools.ts +152 -11
  102. package/src/types/agent.ts +28 -0
  103. package/src/types/framework.ts +102 -0
  104. package/src/types/trace.ts +150 -0
@@ -339,11 +339,11 @@ const CHANNEL_TOOL_DEFINITIONS = [
339
339
  },
340
340
  {
341
341
  name: 'channel_publish',
342
- description: "Publish a message to a channel. If channelId is omitted, publishes to your conversation's channel if you are bound to one; otherwise to the channel of the most recent incoming message this host received on any channel, whether or not you've been shown it. Reactions, reaction removals, deletions and edits don't set it, though an edit of a message you hadn't been shown can count as that message. A message pushed to you rather than posted on a channel never sets it. A subagent has neither default, so it must name a channel.",
342
+ description: "Publish a message to a channel. If channelId is omitted, publishes where your plain speech goes this turn: your conversation's channel if you are bound to one; otherwise the channel of the message that woke this turn, or one you opened during it; otherwise, while a focus holds, the channel it keeps. Where none of those names a channel, nothing is posted, unless this host is set to use the channel of the latest incoming message instead. A turn with no channel of its own, such as a heartbeat's, has no default, so it names one. A subagent has no default, so it must name a channel.",
343
343
  inputSchema: {
344
344
  type: 'object',
345
345
  properties: {
346
- channelId: { type: 'string', description: "ID of the channel to publish to (defaults to your conversation's channel if you are bound to one; otherwise the channel of the most recent incoming message this host received on any channel, whether or not you've been shown it, other than a reaction, reaction removal, deletion or edit, though an edit of a message you hadn't been shown can count; a message pushed to you rather than posted on a channel never sets it; required of a subagent)" },
346
+ channelId: { type: 'string', description: "ID of the channel to publish to (defaults to where your plain speech goes this turn, as the tool's description says; required of a subagent)" },
347
347
  content: { type: 'string', description: 'Text content to publish' },
348
348
  text: { type: 'string', description: 'Alias for content' },
349
349
  },
@@ -677,6 +677,9 @@ export class ChannelRegistry {
677
677
  emitTraceFn;
678
678
  sendTypingFn;
679
679
  shouldTriggerInference;
680
+ latestInboundLocus;
681
+ onToolClose;
682
+ focusChannelResolver;
680
683
  onRouteFailure;
681
684
  onChannelAutoOpened;
682
685
  homeChannelResolver;
@@ -1047,6 +1050,9 @@ export class ChannelRegistry {
1047
1050
  this.emitTraceFn = emitTraceFn;
1048
1051
  this.sendTypingFn = options?.sendTypingFn;
1049
1052
  this.shouldTriggerInference = options?.shouldTriggerInference;
1053
+ this.latestInboundLocus = options?.latestInboundLocus === true;
1054
+ this.onToolClose = options?.onToolClose;
1055
+ this.focusChannelResolver = options?.focusChannelResolver;
1050
1056
  this.onRouteFailure = options?.onRouteFailure;
1051
1057
  this.onChannelAutoOpened = options?.onChannelAutoOpened;
1052
1058
  this.homeChannelResolver = options?.homeChannelResolver;
@@ -1255,9 +1261,6 @@ export class ChannelRegistry {
1255
1261
  // The batch came on the transport open now: a close after this point
1256
1262
  // (transportClosed) leaves its traffic nothing to confirm.
1257
1263
  const closesAtArrival = this.transportCloses.get(serverId) ?? 0;
1258
- // And came from a server registered now: one removed since (removeServer)
1259
- // gives no message the agent replies to (markAccepted).
1260
- const removalsAtArrival = this.serverRemovals.get(serverId) ?? 0;
1261
1264
  for (const message of params.messages) {
1262
1265
  if (!message || typeof message.channelId !== 'string' || !message.channelId
1263
1266
  || typeof message.messageId !== 'string' || !message.messageId) {
@@ -1360,28 +1363,19 @@ export class ChannelRegistry {
1360
1363
  rejectEmpty();
1361
1364
  continue;
1362
1365
  }
1363
- // Track default publish channel (most recent ACCEPTED incoming) —
1364
- // deliberately after §14.5 validation: a rejected message from an
1365
- // unregistered channel must not retarget outbound speech (the locus is
1366
- // exactly the authority a self-attested channel would be stealing).
1367
- // A coalesced item is "accepted" only once the coalescer admits it, so
1368
- // for those this runs after the hook (below).
1366
+ // A server sending channels/incoming is evidence that the transport
1367
+ // is open. This repairs transient status only; durable desired state
1368
+ // still changes exclusively through lifecycle operations. Nothing else
1369
+ // is decided here: whether this message is delivered, wakes anyone or
1370
+ // counts as the latest inbound is the framework's admission decision,
1371
+ // taken once the hold is known (see `noteDelivered`; issue #232 root
1372
+ // cause A). A coalesced item is "accepted" only once the coalescer
1373
+ // admits it, so for those this runs after the hook (below).
1369
1374
  const incomingEntry = this.channels.get(incomingKey);
1370
- const markAccepted = (firstView = false) => {
1371
- // Non-conversational markers (a reaction added or removed, an edit, a
1372
- // deletion, and a chat:system notice such as a join or the weather)
1373
- // are ABOUT a message or the place, not one the agent replies to: they
1374
- // must not retarget the default publish locus or become
1375
- // `context.incoming`'s reply anchor. An edit the coalescer delivered
1376
- // as the agent's first view counts as the message it replaced.
1377
- // A message the coalescer admits after its server was removed sets no
1378
- // default: the removal cleared that server's message, and a server
1379
- // that's gone mustn't displace a live one's default.
1380
- if (!isMarkerTagged(message.tags, firstView) && (this.serverRemovals.get(serverId) ?? 0) === removalsAtArrival) {
1381
- this.defaultPublishChannel = { serverId, channelId: message.channelId };
1382
- this.defaultPublishMessageId = message.messageId;
1383
- this.defaultPublishThreadId = message.threadId;
1384
- }
1375
+ // `firstView` is the coalescer's mark that an edit was the agent's first
1376
+ // view of its message; the registry no longer records the publish locus
1377
+ // here (admission does, through noteDelivered, with the same marker rule).
1378
+ const markAccepted = (_firstView = false) => {
1385
1379
  // Where the host directs this server's lifecycle, traffic shows the
1386
1380
  // transport open only for a channel still decided open or tuned out,
1387
1381
  // and, but for one exception, only for a confirmed target: after a
@@ -1419,7 +1413,7 @@ export class ChannelRegistry {
1419
1413
  };
1420
1414
  if (!coalesced)
1421
1415
  markAccepted();
1422
- // Determine whether to trigger inference
1416
+ // The server-level veto only; the wake gate runs at admission.
1423
1417
  let triggerInference = true;
1424
1418
  if (this.shouldTriggerInference) {
1425
1419
  // The text the agent's context gets, as the push lane's filter reads
@@ -1701,15 +1695,58 @@ export class ChannelRegistry {
1701
1695
  // ==========================================================================
1702
1696
  // Accessors
1703
1697
  // ==========================================================================
1698
+ /**
1699
+ * The most recent DELIVERED inbound message (channel, message, thread).
1700
+ * Recorded by the framework after admission, so a held message never
1701
+ * becomes "the latest inbound" (issue #232 gap 2), and never for a marker
1702
+ * (a reaction, reaction removal, edit or deletion, apart from an edit the
1703
+ * coalescer delivered as the agent's first view: isMarkerTagged). Read as
1704
+ * the `incoming` reference in the channel context and, under
1705
+ * `speech.locus: 'latest-inbound'`, as the fallback speech locus. The
1706
+ * channel is on the server the message came from. A message whose server
1707
+ * no longer registers its channel sets nothing, as one the coalescer admits
1708
+ * after its server was removed: the removal cleared that server's message,
1709
+ * and a server that's gone mustn't displace a live one's anchor. That's
1710
+ * read at admission, where main's guard compared the server's removal
1711
+ * count at arrival and at acceptance (markAccepted): a message that
1712
+ * arrived before its server's removal and is admitted once the server
1713
+ * registers the channel again sets the anchor here, as a message delivered
1714
+ * on a live channel, where main's would set nothing.
1715
+ */
1716
+ noteDelivered(channel, messageId, threadId, tags, firstView = false) {
1717
+ // A marker is ABOUT a message, not one the agent replies to: it leaves
1718
+ // the anchor where it was (#157), and so does a chat:system notice (a
1719
+ // join, the weather), which is about the place (isMarkerTagged). An edit
1720
+ // the coalescer delivered as the agent's first view of its message counts
1721
+ // as that message.
1722
+ if (isMarkerTagged(tags, firstView))
1723
+ return;
1724
+ if (!this.entryAt(channel))
1725
+ return;
1726
+ this.defaultPublishChannel = { serverId: channel.serverId, channelId: channel.channelId };
1727
+ this.defaultPublishMessageId = messageId;
1728
+ this.defaultPublishThreadId = threadId;
1729
+ }
1704
1730
  /**
1705
1731
  * Get the default publish channel ID: the channel of the most recent
1706
- * incoming message that isn't a reaction, reaction removal, edit or
1707
- * deletion marker, apart from an edit the coalescer delivers on acceptance
1708
- * as the agent's first view.
1732
+ * delivered incoming message that isn't a reaction, reaction removal, edit
1733
+ * or deletion marker, apart from an edit the coalescer delivers on
1734
+ * acceptance as the agent's first view.
1709
1735
  */
1710
1736
  getDefaultPublishChannel() {
1711
1737
  return this.defaultPublishChannel;
1712
1738
  }
1739
+ /**
1740
+ * Public form of the tool-input channel resolution (resolveToolChannelEntry):
1741
+ * optional server, the calling turn's own channels (`own`), ambiguity is an
1742
+ * error.
1743
+ */
1744
+ resolveChannel(channelId, serverId, own) {
1745
+ const resolved = this.resolveToolChannelEntry(channelId, serverId, own);
1746
+ if (!resolved.entry)
1747
+ return { error: resolved.error ?? `Channel not found: ${channelId}` };
1748
+ return { serverId: resolved.entry.serverId, channelId: resolved.entry.descriptor.id };
1749
+ }
1713
1750
  /**
1714
1751
  * RFC-006 §3.2: is `channelId` registered BY `serverId` through a server
1715
1752
  * declaration (channels/register, channels/changed) or an authorized open?
@@ -1807,7 +1844,7 @@ export class ChannelRegistry {
1807
1844
  // ==========================================================================
1808
1845
  /**
1809
1846
  * The message the agent is replying to, with its channel: the most recent
1810
- * inbound message that isn't a reaction, reaction removal, edit or deletion
1847
+ * delivered inbound message (noteDelivered) that isn't a reaction, reaction removal, edit or deletion
1811
1848
  * marker, apart from an edit the coalescer delivers on acceptance as the
1812
1849
  * agent's first view. Left process-global: per-channel inbound tracking
1813
1850
  * (the right messageId for a fork's own channel) is a separate concern from
@@ -1824,10 +1861,14 @@ export class ChannelRegistry {
1824
1861
  if (agentName !== undefined && this.activeChannelResolver?.(agentName) === null && !this.homeChannelResolver?.(agentName)) {
1825
1862
  return undefined;
1826
1863
  }
1827
- const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.defaultPublishChannel;
1828
- if (!fallback || !this.defaultPublishMessageId)
1864
+ // The latest delivered inbound itself, not the speech fallback
1865
+ // (globalFallbackFor), which under `speech.locus: 'delivery'` is the
1866
+ // all-except hold's excepted channel while one is open, and none
1867
+ // otherwise.
1868
+ const anchor = agentName !== undefined && this.takesGlobalFallback?.(agentName) === false ? null : this.defaultPublishChannel;
1869
+ if (!anchor || !this.defaultPublishMessageId)
1829
1870
  return undefined;
1830
- return { channel: fallback, messageId: this.defaultPublishMessageId, threadId: this.defaultPublishThreadId };
1871
+ return { channel: anchor, messageId: this.defaultPublishMessageId, threadId: this.defaultPublishThreadId };
1831
1872
  }
1832
1873
  /**
1833
1874
  * The channel context one server is told for an agent's inference
@@ -1852,14 +1893,17 @@ export class ChannelRegistry {
1852
1893
  // '' is a name an ephemeral run can have, and resolveLocus reads it too.
1853
1894
  const home = agentName !== undefined ? this.homeChannelResolver?.(agentName) : undefined;
1854
1895
  const active = agentName !== undefined ? this.activeChannelResolver?.(agentName) : undefined;
1855
- // An ephemeral run takes no global fallback (takesGlobalFallback): neither
1856
- // the latest inbound message's channel as its outgoing one, nor that
1857
- // message as the one it is replying to.
1858
- const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.defaultPublishChannel;
1896
+ // An ephemeral run takes no global fallback (takesGlobalFallback): not the
1897
+ // all-except hold's channel, not the latest inbound message's channel as
1898
+ // its outgoing one, nor that message as the one it is replying to.
1899
+ const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.sharedFallback();
1859
1900
  // A turn that deliberately has no channel (null) takes no fallback.
1860
1901
  const outgoing = home ?? (active !== undefined ? active : fallback);
1861
1902
  const context = {};
1862
- // Incoming: the message the agent is replying to (incomingFor).
1903
+ // Incoming: the message the agent is replying to (incomingFor). The
1904
+ // anchor is independent of the speech rule: under `speech.locus:
1905
+ // 'delivery'` the latest inbound is no fallback for where prose lands,
1906
+ // but it is still the message the agent is replying to.
1863
1907
  const incoming = this.incomingFor(agentName);
1864
1908
  if (incoming && incoming.channel.serverId === serverId) {
1865
1909
  context.incoming = {
@@ -3279,8 +3323,8 @@ export class ChannelRegistry {
3279
3323
  /**
3280
3324
  * How a tuned-out channel's tune-out ends, for the texts that tell an agent
3281
3325
  * channel_open doesn't change it: a tune_out cancel, by the name that
3282
- * picks the channel (nameFor), or, where no name picks it alone, its own
3283
- * end, since tune_out takes a name.
3326
+ * picks the channel (nameFor), or, where no name picks it alone, by its id
3327
+ * and server, since tune_out takes a serverId.
3284
3328
  * For channel_close, which refuses a tuned-out channel too, it adds that
3285
3329
  * the close comes after that.
3286
3330
  */
@@ -3292,12 +3336,15 @@ export class ChannelRegistry {
3292
3336
  }
3293
3337
  tuneOutEndText(entry, tool) {
3294
3338
  const close = tool === 'channel_close';
3295
- return this.nameFor(entry) !== undefined
3296
- ? `Call tune_out with channelId ${quotedValue(this.nameFor(entry))} and mode "cancel" for it to resume normal ` +
3297
- `attention; cancelling delivers the diverted backlog${close ? ', and channel_close closes it after that.' : '.'}`
3298
- : 'No channelId names it alone, so tune_out can\'t cancel it: its tune-out ends by itself when its duration ' +
3299
- 'runs out, or at the first wake past its wake budget, and its backlog is delivered then.' +
3300
- (close ? ' Once it has ended, channel_close can close it.' : '');
3339
+ // A name is written as a literal tune_out's channelId takes back
3340
+ // (quotedValue, main's #852); one no name picks alone, by its id and
3341
+ // server, since this branch's tune_out takes a serverId.
3342
+ const name = this.nameFor(entry);
3343
+ const names = name !== undefined
3344
+ ? `channelId ${quotedValue(name)}`
3345
+ : `channelId ${quotedValue(entry.descriptor.id)}, serverId ${JSON.stringify(entry.serverId)}`;
3346
+ return `Call tune_out with ${names} and mode "cancel" for it to resume normal ` +
3347
+ `attention; cancelling delivers the diverted backlog${close ? ', and channel_close closes it after that.' : '.'}`;
3301
3348
  }
3302
3349
  /**
3303
3350
  * A channel's name as nameShown gives it, quoted for a sentence: the name
@@ -4328,13 +4375,14 @@ export class ChannelRegistry {
4328
4375
  // Closed already, perhaps only by default: the agent's close is still
4329
4376
  // its decision, and is recorded as one (setDesiredState), so a later
4330
4377
  // registration's lift of a default leaves the channel closed.
4378
+ const ended = this.onToolClose?.(entry.serverId, input.channelId);
4331
4379
  try {
4332
4380
  this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
4333
4381
  }
4334
4382
  catch (err) {
4335
4383
  return {
4336
4384
  success: false,
4337
- error: `Channel ${shown} is already closed, but recording the decision to close it failed: ${err.message}`,
4385
+ error: `Channel ${shown} is already closed, but recording the decision to close it failed: ${err.message}${ended ? ` ${ended}` : ''}`,
4338
4386
  isError: true,
4339
4387
  };
4340
4388
  }
@@ -4374,12 +4422,18 @@ export class ChannelRegistry {
4374
4422
  const refusal = this.tunedOutRefusal('channel_close', entry);
4375
4423
  return isModuleOrigin ? { ...refusal, isError: false } : refusal;
4376
4424
  }
4377
- // A decision the log refused changed nothing, and the answer says so.
4425
+ // A hold around this channel — the all-except scope it is the excepted
4426
+ // channel of — ends through the cancel flow: backlog released, end
4427
+ // recorded, never by overwriting state (#232 gap 9).
4428
+ const ended = this.onToolClose?.(entry.serverId, input.channelId);
4429
+ // A decision the log refused changed nothing for the channel, and the
4430
+ // answer says so, and what the hook ended all the same.
4378
4431
  try {
4379
4432
  this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
4380
4433
  }
4381
4434
  catch (err) {
4382
- return { success: false, error: this.lifecycleFailureText('close', entry, unrecordedDecision(err)), isError: true };
4435
+ const text = this.lifecycleFailureText('close', entry, unrecordedDecision(err));
4436
+ return { success: false, error: ended ? `${text} ${ended}` : text, isError: true };
4383
4437
  }
4384
4438
  // This channel's typing, not every channel with its id (stopTyping's
4385
4439
  // fan-out of a bare name).
@@ -4843,9 +4897,27 @@ export class ChannelRegistry {
4843
4897
  const active = this.activeChannelResolver?.(conversationId);
4844
4898
  return active !== undefined ? active : this.globalFallbackFor(conversationId);
4845
4899
  }
4846
- /** The process-global fallback channel for this conversation, or null when it takes none (takesGlobalFallback). */
4900
+ /**
4901
+ * The process-global fallback for this conversation, or null when it takes
4902
+ * none (takesGlobalFallback): an ephemeral run's prose is its caller's
4903
+ * result, so neither the all-except hold's channel nor the latest inbound
4904
+ * message's is ever its destination.
4905
+ */
4847
4906
  globalFallbackFor(conversationId) {
4848
- return this.takesGlobalFallback?.(conversationId) === false ? null : this.defaultPublishChannel;
4907
+ return this.takesGlobalFallback?.(conversationId) === false ? null : this.sharedFallback();
4908
+ }
4909
+ /**
4910
+ * What every conversation that takes the global fallback falls back to when
4911
+ * neither a home channel nor its own delivery names one: the all-except
4912
+ * hold's channel while one is open, else the latest delivered inbound
4913
+ * channel under the legacy locus rule, else none — the prose stays private
4914
+ * (architecture channel §3 carve-out, 2026-10-06).
4915
+ */
4916
+ sharedFallback() {
4917
+ return this.focusChannelResolver?.() ?? this.fallbackLocus();
4918
+ }
4919
+ fallbackLocus() {
4920
+ return this.latestInboundLocus ? this.defaultPublishChannel : null;
4849
4921
  }
4850
4922
  /**
4851
4923
  * Resolve a publish target to exactly one registered channel: `serverId`
@@ -5263,17 +5335,20 @@ export class ChannelRegistry {
5263
5335
  if (input.channelId === '' && tool === 'channel_publish') {
5264
5336
  return { success: false, error: `Nothing was posted: channelId is empty. ${findIds}`, isError: true };
5265
5337
  }
5266
- // Resolve channelId: default to the channel of the most recent incoming
5267
- // message that isn't a reaction, reaction removal, edit or deletion
5268
- // marker, apart from an edit the coalescer delivers on acceptance as the
5269
- // agent's first view. An ephemeral run never takes that default
5338
+ // Resolve channelId: a call made inside the agent's turn (inTurn) has
5339
+ // the turn's default applied already, where its plain speech goes, and
5340
+ // takes none here. Any other defaults to what the caller falls back to:
5341
+ // the all-except hold's channel, else the latest delivered inbound
5342
+ // channel under the legacy locus rule (a marker — reaction, edit,
5343
+ // deletion — is never that channel), which an ephemeral run never takes
5270
5344
  // (takesGlobalFallback), so it names one.
5271
5345
  // A channel named by a string means what it means to every channel tool
5272
5346
  // (entriesNamed): one channel, by its id or its server-qualified name,
5273
5347
  // and a name two servers' channels answer to is refused with them as
5274
5348
  // candidates, never sent to the first.
5275
5349
  const caller = origin?.kind === 'agent' ? origin.agentName : undefined;
5276
- const fallback = caller !== undefined ? this.globalFallbackFor(caller) : this.defaultPublishChannel;
5350
+ const inTurn = origin?.kind === 'agent' && origin.inTurn === true;
5351
+ const fallback = caller === undefined ? this.sharedFallback() : inTurn ? null : this.globalFallbackFor(caller);
5277
5352
  if (!(input.channelId ?? fallback)) {
5278
5353
  return {
5279
5354
  success: false,
@@ -5282,7 +5357,10 @@ export class ChannelRegistry {
5282
5357
  : caller !== undefined && this.takesGlobalFallback?.(caller) === false
5283
5358
  ? 'Nothing was posted: name the channel with channelId. A subagent has no default channel, since its plain '
5284
5359
  + 'speech is returned to whoever called it, so channel_publish sends only to a channel it names.'
5285
- : `Nothing was posted: channel_publish needs a \`channelId\`, and there's no channel to default to yet. ${findIds}`,
5360
+ : caller !== undefined && inTurn && this.activeChannelResolver?.(caller) === null
5361
+ ? `Nothing was posted: name the channel with channelId. This turn has no channel of its own, so its plain speech `
5362
+ + `goes to none and channel_publish sends only to a channel it names. ${findIds}`
5363
+ : `Nothing was posted: channel_publish needs a \`channelId\`, and there's no channel to default to yet. ${findIds}`,
5286
5364
  isError: true,
5287
5365
  };
5288
5366
  }