@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
@@ -2156,6 +2156,8 @@ export class EventGate {
2156
2156
  ) => unknown;
2157
2157
  private requestInferenceFn: (agentName: string, reason: string, source: string, provenance?: WakeProvenance, after?: WritePlacement) => void;
2158
2158
  private getAgentNamesFn: () => string[];
2159
+ /** Delivery-time re-check: is this channel held from the recipients now? */
2160
+ private isHeldFn: (serverId: string | undefined, channelId: string | undefined) => boolean;
2159
2161
  private resolveRouteChannelFn: (info: GateEventInfo) => string | undefined;
2160
2162
  private channelShownFn?: (channel: { serverId: string; channelId: string }) => string;
2161
2163
  private isNewestEntryFn?: (entry: GateEntryRef) => boolean;
@@ -2180,6 +2182,13 @@ export class EventGate {
2180
2182
  * message is answered. */
2181
2183
  requestInference: (agentName: string, reason: string, source: string, provenance?: WakeProvenance, after?: WritePlacement) => void;
2182
2184
  getAgentNames: () => string[];
2185
+ /**
2186
+ * Whether a channel is held from the recipients (a tune-out epoch in
2187
+ * force). A held event never enters a batch — admission runs before the
2188
+ * gate — but a hold can begin after an event was batched; at delivery
2189
+ * the batch is re-checked and held events are dropped (#232 H3).
2190
+ */
2191
+ isHeld?: (serverId: string | undefined, channelId: string | undefined) => boolean;
2183
2192
  /** Optional clock — defaults to Date.now. Tests inject for deterministic time. */
2184
2193
  now?: () => number;
2185
2194
  /** Per-event timeout (ms) for the optional gate.js script. Default 50. */
@@ -2228,6 +2237,7 @@ export class EventGate {
2228
2237
  this.addMessageFn = opts.addMessage;
2229
2238
  this.requestInferenceFn = opts.requestInference;
2230
2239
  this.getAgentNamesFn = opts.getAgentNames;
2240
+ this.isHeldFn = opts.isHeld ?? (() => false);
2231
2241
  this.resolveRouteChannelFn = opts.resolveRouteChannel ?? defaultRouteChannel;
2232
2242
  this.channelShownFn = opts.channelShown;
2233
2243
  this.isNewestEntryFn = opts.isNewestEntry;
@@ -3687,7 +3697,23 @@ export class EventGate {
3687
3697
  // Event delivery
3688
3698
  // =========================================================================
3689
3699
 
3690
- private deliverEvents(events: PendingEvent[]): void {
3700
+ private deliverEvents(batched: PendingEvent[]): void {
3701
+ // Delivery-time re-check: a channel tuned out after its events were
3702
+ // batched is held now, and "already shown above at arrival" would be
3703
+ // untrue of them. They are dropped; the hold's own reader has them.
3704
+ const heldAt = (e: PendingEvent): string | undefined => e.routeChannelId ?? e.channelId;
3705
+ const events = batched.filter((e) => {
3706
+ const channel = heldAt(e);
3707
+ return !(channel && this.isHeldFn(e.serverId, channel));
3708
+ });
3709
+ if (events.length < batched.length) {
3710
+ this.emitTrace({
3711
+ type: 'gate:held-dropped',
3712
+ dropped: batched.length - events.length,
3713
+ channels: [...new Set(batched.filter((e) => !events.includes(e)).map(heldAt))],
3714
+ timestamp: this.now(),
3715
+ });
3716
+ }
3691
3717
  if (events.length === 0) return;
3692
3718
 
3693
3719
  const policyNames = [...new Set(events.map(e => e.policyName))];
@@ -4269,10 +4295,9 @@ export class EventGate {
4269
4295
  }
4270
4296
 
4271
4297
  /**
4272
- * `agentName` is gone, as a conversation fork's end or an ephemeral run's
4273
- * removes one: its pending self-wake is cancelled, rather than wake a name
4274
- * that no longer exists. It holds no sleep: neither can set one (the
4275
- * framework refuses their `sleep`).
4298
+ * `agentName` is gone, as an agent's end removes one: its pending
4299
+ * self-wake is cancelled, and a sleep it set ends, rather than go on
4300
+ * quieting every agent and then wake a name that no longer exists.
4276
4301
  */
4277
4302
  agentGone(agentName: string): void {
4278
4303
  const selfWake = this.selfWakeTimers.get(agentName);
@@ -4280,6 +4305,7 @@ export class EventGate {
4280
4305
  clearTimeout(selfWake);
4281
4306
  this.selfWakeTimers.delete(agentName);
4282
4307
  }
4308
+ if (this.sleepAgent === agentName) this.clearSleep();
4283
4309
  }
4284
4310
 
4285
4311
  // =========================================================================
@@ -72,6 +72,8 @@ export interface HostPresentationDeps {
72
72
  isUtility(toolName: string): boolean;
73
73
  /** The resident agents of this host (no forks, ephemeral runs or the subconscious). */
74
74
  residents(): string[];
75
+ /** The agent whose presentation this one is shown: a derived agent that shows its parent's tool list, its parent; anyone else, itself. */
76
+ ownerOf?(agentName: string): string;
75
77
  }
76
78
 
77
79
  interface AgentState {
@@ -198,10 +200,15 @@ export class HostPresentation {
198
200
  return server?.configured === 'closed-tab' && !server.connected;
199
201
  }
200
202
 
203
+ /** The agent whose presentation `agentName` is shown (HostPresentationDeps.ownerOf). */
204
+ ownerOf(agentName: string): string {
205
+ return this.deps.ownerOf?.(agentName) ?? agentName;
206
+ }
207
+
201
208
  /** How a server is presented to an agent now. */
202
209
  presentation(agentName: string, serverId: string): ServerPresentation {
203
210
  if (this.isClosedTab(serverId)) return 'closed-tab';
204
- const chosen = this.doc.agents[agentName]?.servers[serverId];
211
+ const chosen = this.doc.agents[this.ownerOf(agentName)]?.servers[serverId];
205
212
  if (chosen) return chosen;
206
213
  const configured = this.server(serverId)?.configured ?? 'full';
207
214
  return configured === 'closed-tab' ? 'tab' : configured;
@@ -218,7 +225,7 @@ export class HostPresentation {
218
225
  }
219
226
 
220
227
  isOpen(agentName: string, key: string): boolean {
221
- return this.doc.agents[agentName]?.open.includes(key) ?? false;
228
+ return this.doc.agents[this.ownerOf(agentName)]?.open.includes(key) ?? false;
222
229
  }
223
230
 
224
231
  private setOpen(agentName: string, key: string, open: boolean): void {
@@ -578,7 +585,7 @@ export class HostPresentation {
578
585
  openMarkers(agentName: string): Array<{ marker: string; reminder: () => string; dueWhenAbsent: true }> {
579
586
  if (!this.active(agentName)) return [];
580
587
  const out: Array<{ marker: string; reminder: () => string; dueWhenAbsent: true }> = [];
581
- for (const key of this.doc.agents[agentName]?.open ?? []) {
588
+ for (const key of this.doc.agents[this.ownerOf(agentName)]?.open ?? []) {
582
589
  if (key === BUILTIN_TOOLSET) {
583
590
  out.push({
584
591
  marker: `${this.builtinLabel} open`,
package/src/index.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  export * from './types/index.js';
3
3
 
4
4
  // Core classes
5
- export { AgentFramework, BudgetPreflightError, DefaultErrorPolicy, ProviderWaitNotSleptError, QuiesceBlockedError, ResumeBlockedError } from './framework.js';
6
- export type { RuntimeSettingsPreview, HostModeStatus } from './framework.js';
5
+ export { AgentFramework, AgentStoppedError, BudgetPreflightError, DefaultErrorPolicy, ProviderWaitNotSleptError, QuiesceBlockedError, ResumeBlockedError } from './framework.js';
6
+ export type { RuntimeSettingsPreview, HostModeStatus, DeriveAgentOptions } from './framework.js';
7
7
  export { Agent } from './agent.js';
8
8
  export { storedInjectionMarkOf, type StoredInjectionMark } from './stored-injections.js';
9
9
  export type { StartStreamResult } from './agent.js';
@@ -15,6 +15,9 @@ export { CLOCK_ROW_KIND, resolveClockConfig } from './clock-rows.js';
15
15
  export type { ClockConfig, ClockStamp, ResolvedClockConfig } from './clock-rows.js';
16
16
  export { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK, REFUSAL_REACTION_BASELINE } from './refusal-reactions.js';
17
17
 
18
+ // Dendrite: agent registry, lifecycle and presets
19
+ export * from './dendrite/index.js';
20
+
18
21
  // Built-in modules
19
22
  export * from './modules/index.js';
20
23
 
@@ -196,6 +199,16 @@ export type {
196
199
  } from './recovery/offline-branch.js';
197
200
 
198
201
  export * from "./tool-presentation.js";
202
+
203
+ // Tune-out: the subconscious configuration and the default reader-fork framing.
204
+ export { presentAuthor, authorLine, authorNamed, ANONYMOUS_PARTICIPANT } from './author-presentation.js';
205
+ export {
206
+ defaultReaderFraming,
207
+ type SubconsciousConfig,
208
+ type PersistentReaderConfig,
209
+ type ForkReaderConfig,
210
+ type ReaderFramingContext,
211
+ } from './tune-out/tools.js';
199
212
  export {
200
213
  SILENT_HEARTBEAT_SEPARATOR,
201
214
  silentHeartbeatOf,
@@ -566,11 +566,11 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
566
566
  },
567
567
  {
568
568
  name: 'channel_publish',
569
- 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.",
569
+ 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.",
570
570
  inputSchema: {
571
571
  type: 'object' as const,
572
572
  properties: {
573
- 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)" },
573
+ 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)" },
574
574
  content: { type: 'string', description: 'Text content to publish' },
575
575
  text: { type: 'string', description: 'Alias for content' },
576
576
  },
@@ -992,8 +992,35 @@ interface ChannelRegistryOptions {
992
992
  ) => Promise<ChannelIncomingMessageResult & { firstView?: true }>;
993
993
  /** Chronicle store used for durable desired channel lifecycle state. */
994
994
  store?: JsStore;
995
- /** Callback to determine whether an incoming message should trigger inference. */
995
+ /**
996
+ * The server-level veto on waking for an incoming message (a per-server
997
+ * `McplServerConfig.shouldTriggerInference`). The wake gate is NOT wired
998
+ * here: it runs in the framework's admission stage, after the hold
999
+ * decision, so a held message never forms a gate batch (issue #232 gap 3).
1000
+ */
996
1001
  shouldTriggerInference?: (content: string, metadata: Record<string, unknown>) => boolean;
1002
+ /**
1003
+ * Whether the most recent delivered inbound message is a fallback speech
1004
+ * locus for turns that have none of their own (`speech.locus:
1005
+ * 'latest-inbound'`). Off by default: incoming traffic never moves the
1006
+ * locus (architecture channel, 2026-10-06).
1007
+ */
1008
+ latestInboundLocus?: boolean;
1009
+ /**
1010
+ * A channel is being closed by tool: an all-except hold whose exception
1011
+ * this is ends through the cancel flow (release the backlog, record the
1012
+ * end) before the lifecycle state flips to closed. A
1013
+ * tuned-out channel's close is refused before this (its cancel ends it).
1014
+ * Returns what it ended, in a sentence the close's answer adds when the
1015
+ * close itself isn't recorded.
1016
+ */
1017
+ onToolClose?: (serverId: string, channelId: string) => string | undefined;
1018
+ /**
1019
+ * The channel unprefixed speech lands in while an all-except hold is
1020
+ * active (its exception), else null. Consulted after a fork's home and
1021
+ * the turn's own delivery, before the legacy fallback.
1022
+ */
1023
+ focusChannelResolver?: () => ChannelRef | null;
997
1024
  /**
998
1025
  * Called when a text-only turn's speech was not delivered to its
999
1026
  * conversational locus (`failed`), or may not have been (`unknown`), for
@@ -1043,7 +1070,7 @@ interface ChannelRegistryOptions {
1043
1070
  /**
1044
1071
  * Resolve a conversation fork's HOME channel from its agent name. Conversation
1045
1072
  * forks are spawned bound to a single channel (the framework tracks this in
1046
- * `conversationAgentHomes` / `ConversationRouter.channelForAgent`); their
1073
+ * the agent registry's `homeChannel` / `ConversationRouter.channelForAgent`); their
1047
1074
  * plain-text speech must route THERE. The process-global `defaultPublishChannel`
1048
1075
  * tracks only the most recent inbound message across ALL channels (reaction,
1049
1076
  * reaction-removal, edit and deletion markers aside, apart from an edit the
@@ -1120,6 +1147,10 @@ export type ChannelToolOrigin =
1120
1147
  * wake due before it, when there is one, which its confirmation names
1121
1148
  * (skipReplyNote). */
1122
1149
  earlierWake?: SkipReplyEarlierWake;
1150
+ /** Made inside one of the agent's turns (the framework's callingTurnOf):
1151
+ * a channel_publish naming no channel arrives with that turn's default
1152
+ * already applied, so it takes no other. */
1153
+ inTurn?: boolean;
1123
1154
  };
1124
1155
 
1125
1156
  /** The machine decision sources a module-origin channel_close may record.
@@ -1154,6 +1185,9 @@ export class ChannelRegistry {
1154
1185
  op?: 'start' | 'stop',
1155
1186
  ) => void;
1156
1187
  private shouldTriggerInference?: (content: string, metadata: Record<string, unknown>) => boolean;
1188
+ private readonly latestInboundLocus: boolean;
1189
+ private onToolClose?: (serverId: string, channelId: string) => string | undefined;
1190
+ private focusChannelResolver?: () => ChannelRef | null;
1157
1191
  private onRouteFailure?: ChannelRegistryOptions['onRouteFailure'];
1158
1192
  private onChannelAutoOpened?: (info: {
1159
1193
  conversationId?: string;
@@ -1582,6 +1616,9 @@ export class ChannelRegistry {
1582
1616
  this.emitTraceFn = emitTraceFn;
1583
1617
  this.sendTypingFn = options?.sendTypingFn;
1584
1618
  this.shouldTriggerInference = options?.shouldTriggerInference;
1619
+ this.latestInboundLocus = options?.latestInboundLocus === true;
1620
+ this.onToolClose = options?.onToolClose;
1621
+ this.focusChannelResolver = options?.focusChannelResolver;
1585
1622
  this.onRouteFailure = options?.onRouteFailure;
1586
1623
  this.onChannelAutoOpened = options?.onChannelAutoOpened;
1587
1624
  this.homeChannelResolver = options?.homeChannelResolver;
@@ -1823,9 +1860,6 @@ export class ChannelRegistry {
1823
1860
  // The batch came on the transport open now: a close after this point
1824
1861
  // (transportClosed) leaves its traffic nothing to confirm.
1825
1862
  const closesAtArrival = this.transportCloses.get(serverId) ?? 0;
1826
- // And came from a server registered now: one removed since (removeServer)
1827
- // gives no message the agent replies to (markAccepted).
1828
- const removalsAtArrival = this.serverRemovals.get(serverId) ?? 0;
1829
1863
 
1830
1864
  for (const message of params.messages) {
1831
1865
  if (!message || typeof message.channelId !== 'string' || !message.channelId
@@ -1931,28 +1965,19 @@ export class ChannelRegistry {
1931
1965
  continue;
1932
1966
  }
1933
1967
 
1934
- // Track default publish channel (most recent ACCEPTED incoming) —
1935
- // deliberately after §14.5 validation: a rejected message from an
1936
- // unregistered channel must not retarget outbound speech (the locus is
1937
- // exactly the authority a self-attested channel would be stealing).
1938
- // A coalesced item is "accepted" only once the coalescer admits it, so
1939
- // for those this runs after the hook (below).
1968
+ // A server sending channels/incoming is evidence that the transport
1969
+ // is open. This repairs transient status only; durable desired state
1970
+ // still changes exclusively through lifecycle operations. Nothing else
1971
+ // is decided here: whether this message is delivered, wakes anyone or
1972
+ // counts as the latest inbound is the framework's admission decision,
1973
+ // taken once the hold is known (see `noteDelivered`; issue #232 root
1974
+ // cause A). A coalesced item is "accepted" only once the coalescer
1975
+ // admits it, so for those this runs after the hook (below).
1940
1976
  const incomingEntry = this.channels.get(incomingKey)!;
1941
- const markAccepted = (firstView = false) => {
1942
- // Non-conversational markers (a reaction added or removed, an edit, a
1943
- // deletion, and a chat:system notice such as a join or the weather)
1944
- // are ABOUT a message or the place, not one the agent replies to: they
1945
- // must not retarget the default publish locus or become
1946
- // `context.incoming`'s reply anchor. An edit the coalescer delivered
1947
- // as the agent's first view counts as the message it replaced.
1948
- // A message the coalescer admits after its server was removed sets no
1949
- // default: the removal cleared that server's message, and a server
1950
- // that's gone mustn't displace a live one's default.
1951
- if (!isMarkerTagged(message.tags, firstView) && (this.serverRemovals.get(serverId) ?? 0) === removalsAtArrival) {
1952
- this.defaultPublishChannel = { serverId, channelId: message.channelId };
1953
- this.defaultPublishMessageId = message.messageId;
1954
- this.defaultPublishThreadId = message.threadId;
1955
- }
1977
+ // `firstView` is the coalescer's mark that an edit was the agent's first
1978
+ // view of its message; the registry no longer records the publish locus
1979
+ // here (admission does, through noteDelivered, with the same marker rule).
1980
+ const markAccepted = (_firstView = false) => {
1956
1981
  // Where the host directs this server's lifecycle, traffic shows the
1957
1982
  // transport open only for a channel still decided open or tuned out,
1958
1983
  // and, but for one exception, only for a confirmed target: after a
@@ -1987,7 +2012,7 @@ export class ChannelRegistry {
1987
2012
  };
1988
2013
  if (!coalesced) markAccepted();
1989
2014
 
1990
- // Determine whether to trigger inference
2015
+ // The server-level veto only; the wake gate runs at admission.
1991
2016
  let triggerInference = true;
1992
2017
  if (this.shouldTriggerInference) {
1993
2018
  // The text the agent's context gets, as the push lane's filter reads
@@ -2289,16 +2314,58 @@ export class ChannelRegistry {
2289
2314
  // Accessors
2290
2315
  // ==========================================================================
2291
2316
 
2317
+ /**
2318
+ * The most recent DELIVERED inbound message (channel, message, thread).
2319
+ * Recorded by the framework after admission, so a held message never
2320
+ * becomes "the latest inbound" (issue #232 gap 2), and never for a marker
2321
+ * (a reaction, reaction removal, edit or deletion, apart from an edit the
2322
+ * coalescer delivered as the agent's first view: isMarkerTagged). Read as
2323
+ * the `incoming` reference in the channel context and, under
2324
+ * `speech.locus: 'latest-inbound'`, as the fallback speech locus. The
2325
+ * channel is on the server the message came from. A message whose server
2326
+ * no longer registers its channel sets nothing, as one the coalescer admits
2327
+ * after its server was removed: the removal cleared that server's message,
2328
+ * and a server that's gone mustn't displace a live one's anchor. That's
2329
+ * read at admission, where main's guard compared the server's removal
2330
+ * count at arrival and at acceptance (markAccepted): a message that
2331
+ * arrived before its server's removal and is admitted once the server
2332
+ * registers the channel again sets the anchor here, as a message delivered
2333
+ * on a live channel, where main's would set nothing.
2334
+ */
2335
+ noteDelivered(channel: ChannelRef, messageId: string, threadId?: string, tags?: unknown, firstView = false): void {
2336
+ // A marker is ABOUT a message, not one the agent replies to: it leaves
2337
+ // the anchor where it was (#157), and so does a chat:system notice (a
2338
+ // join, the weather), which is about the place (isMarkerTagged). An edit
2339
+ // the coalescer delivered as the agent's first view of its message counts
2340
+ // as that message.
2341
+ if (isMarkerTagged(tags, firstView)) return;
2342
+ if (!this.entryAt(channel)) return;
2343
+ this.defaultPublishChannel = { serverId: channel.serverId, channelId: channel.channelId };
2344
+ this.defaultPublishMessageId = messageId;
2345
+ this.defaultPublishThreadId = threadId;
2346
+ }
2347
+
2292
2348
  /**
2293
2349
  * Get the default publish channel ID: the channel of the most recent
2294
- * incoming message that isn't a reaction, reaction removal, edit or
2295
- * deletion marker, apart from an edit the coalescer delivers on acceptance
2296
- * as the agent's first view.
2350
+ * delivered incoming message that isn't a reaction, reaction removal, edit
2351
+ * or deletion marker, apart from an edit the coalescer delivers on
2352
+ * acceptance as the agent's first view.
2297
2353
  */
2298
2354
  getDefaultPublishChannel(): ChannelRef | null {
2299
2355
  return this.defaultPublishChannel;
2300
2356
  }
2301
2357
 
2358
+ /**
2359
+ * Public form of the tool-input channel resolution (resolveToolChannelEntry):
2360
+ * optional server, the calling turn's own channels (`own`), ambiguity is an
2361
+ * error.
2362
+ */
2363
+ resolveChannel(channelId: string, serverId?: string, own?: readonly ChannelRef[]): ChannelRef | { error: string } {
2364
+ const resolved = this.resolveToolChannelEntry(channelId, serverId, own);
2365
+ if (!resolved.entry) return { error: resolved.error ?? `Channel not found: ${channelId}` };
2366
+ return { serverId: resolved.entry.serverId, channelId: resolved.entry.descriptor.id };
2367
+ }
2368
+
2302
2369
  /**
2303
2370
  * RFC-006 §3.2: is `channelId` registered BY `serverId` through a server
2304
2371
  * declaration (channels/register, channels/changed) or an authorized open?
@@ -2439,7 +2506,7 @@ export class ChannelRegistry {
2439
2506
 
2440
2507
  /**
2441
2508
  * The message the agent is replying to, with its channel: the most recent
2442
- * inbound message that isn't a reaction, reaction removal, edit or deletion
2509
+ * delivered inbound message (noteDelivered) that isn't a reaction, reaction removal, edit or deletion
2443
2510
  * marker, apart from an edit the coalescer delivers on acceptance as the
2444
2511
  * agent's first view. Left process-global: per-channel inbound tracking
2445
2512
  * (the right messageId for a fork's own channel) is a separate concern from
@@ -2456,9 +2523,13 @@ export class ChannelRegistry {
2456
2523
  if (agentName !== undefined && this.activeChannelResolver?.(agentName) === null && !this.homeChannelResolver?.(agentName)) {
2457
2524
  return undefined;
2458
2525
  }
2459
- const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.defaultPublishChannel;
2460
- if (!fallback || !this.defaultPublishMessageId) return undefined;
2461
- return { channel: fallback, messageId: this.defaultPublishMessageId, threadId: this.defaultPublishThreadId };
2526
+ // The latest delivered inbound itself, not the speech fallback
2527
+ // (globalFallbackFor), which under `speech.locus: 'delivery'` is the
2528
+ // all-except hold's excepted channel while one is open, and none
2529
+ // otherwise.
2530
+ const anchor = agentName !== undefined && this.takesGlobalFallback?.(agentName) === false ? null : this.defaultPublishChannel;
2531
+ if (!anchor || !this.defaultPublishMessageId) return undefined;
2532
+ return { channel: anchor, messageId: this.defaultPublishMessageId, threadId: this.defaultPublishThreadId };
2462
2533
  }
2463
2534
 
2464
2535
  /**
@@ -2484,16 +2555,19 @@ export class ChannelRegistry {
2484
2555
  // '' is a name an ephemeral run can have, and resolveLocus reads it too.
2485
2556
  const home = agentName !== undefined ? this.homeChannelResolver?.(agentName) : undefined;
2486
2557
  const active = agentName !== undefined ? this.activeChannelResolver?.(agentName) : undefined;
2487
- // An ephemeral run takes no global fallback (takesGlobalFallback): neither
2488
- // the latest inbound message's channel as its outgoing one, nor that
2489
- // message as the one it is replying to.
2490
- const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.defaultPublishChannel;
2558
+ // An ephemeral run takes no global fallback (takesGlobalFallback): not the
2559
+ // all-except hold's channel, not the latest inbound message's channel as
2560
+ // its outgoing one, nor that message as the one it is replying to.
2561
+ const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.sharedFallback();
2491
2562
  // A turn that deliberately has no channel (null) takes no fallback.
2492
2563
  const outgoing = home ?? (active !== undefined ? active : fallback);
2493
2564
 
2494
2565
  const context: ChannelContext = {};
2495
2566
 
2496
- // Incoming: the message the agent is replying to (incomingFor).
2567
+ // Incoming: the message the agent is replying to (incomingFor). The
2568
+ // anchor is independent of the speech rule: under `speech.locus:
2569
+ // 'delivery'` the latest inbound is no fallback for where prose lands,
2570
+ // but it is still the message the agent is replying to.
2497
2571
  const incoming = this.incomingFor(agentName);
2498
2572
  if (incoming && incoming.channel.serverId === serverId) {
2499
2573
  context.incoming = {
@@ -3994,8 +4068,8 @@ export class ChannelRegistry {
3994
4068
  /**
3995
4069
  * How a tuned-out channel's tune-out ends, for the texts that tell an agent
3996
4070
  * channel_open doesn't change it: a tune_out cancel, by the name that
3997
- * picks the channel (nameFor), or, where no name picks it alone, its own
3998
- * end, since tune_out takes a name.
4071
+ * picks the channel (nameFor), or, where no name picks it alone, by its id
4072
+ * and server, since tune_out takes a serverId.
3999
4073
  * For channel_close, which refuses a tuned-out channel too, it adds that
4000
4074
  * the close comes after that.
4001
4075
  */
@@ -4008,12 +4082,15 @@ export class ChannelRegistry {
4008
4082
 
4009
4083
  private tuneOutEndText(entry: ChannelEntry, tool?: string): string {
4010
4084
  const close = tool === 'channel_close';
4011
- return this.nameFor(entry) !== undefined
4012
- ? `Call tune_out with channelId ${quotedValue(this.nameFor(entry)!)} and mode "cancel" for it to resume normal ` +
4013
- `attention; cancelling delivers the diverted backlog${close ? ', and channel_close closes it after that.' : '.'}`
4014
- : 'No channelId names it alone, so tune_out can\'t cancel it: its tune-out ends by itself when its duration ' +
4015
- 'runs out, or at the first wake past its wake budget, and its backlog is delivered then.' +
4016
- (close ? ' Once it has ended, channel_close can close it.' : '');
4085
+ // A name is written as a literal tune_out's channelId takes back
4086
+ // (quotedValue, main's #852); one no name picks alone, by its id and
4087
+ // server, since this branch's tune_out takes a serverId.
4088
+ const name = this.nameFor(entry);
4089
+ const names = name !== undefined
4090
+ ? `channelId ${quotedValue(name)}`
4091
+ : `channelId ${quotedValue(entry.descriptor.id)}, serverId ${JSON.stringify(entry.serverId)}`;
4092
+ return `Call tune_out with ${names} and mode "cancel" for it to resume normal ` +
4093
+ `attention; cancelling delivers the diverted backlog${close ? ', and channel_close closes it after that.' : '.'}`;
4017
4094
  }
4018
4095
 
4019
4096
  /**
@@ -5156,12 +5233,13 @@ export class ChannelRegistry {
5156
5233
  // Closed already, perhaps only by default: the agent's close is still
5157
5234
  // its decision, and is recorded as one (setDesiredState), so a later
5158
5235
  // registration's lift of a default leaves the channel closed.
5236
+ const ended = this.onToolClose?.(entry.serverId, input.channelId);
5159
5237
  try {
5160
5238
  this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
5161
5239
  } catch (err) {
5162
5240
  return {
5163
5241
  success: false,
5164
- error: `Channel ${shown} is already closed, but recording the decision to close it failed: ${(err as Error).message}`,
5242
+ error: `Channel ${shown} is already closed, but recording the decision to close it failed: ${(err as Error).message}${ended ? ` ${ended}` : ''}`,
5165
5243
  isError: true,
5166
5244
  };
5167
5245
  }
@@ -5204,12 +5282,18 @@ export class ChannelRegistry {
5204
5282
  const refusal = this.tunedOutRefusal('channel_close', entry);
5205
5283
  return isModuleOrigin ? { ...refusal, isError: false } : refusal;
5206
5284
  }
5285
+ // A hold around this channel — the all-except scope it is the excepted
5286
+ // channel of — ends through the cancel flow: backlog released, end
5287
+ // recorded, never by overwriting state (#232 gap 9).
5288
+ const ended = this.onToolClose?.(entry.serverId, input.channelId);
5207
5289
 
5208
- // A decision the log refused changed nothing, and the answer says so.
5290
+ // A decision the log refused changed nothing for the channel, and the
5291
+ // answer says so, and what the hook ended all the same.
5209
5292
  try {
5210
5293
  this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
5211
5294
  } catch (err) {
5212
- return { success: false, error: this.lifecycleFailureText('close', entry, unrecordedDecision(err)), isError: true };
5295
+ const text = this.lifecycleFailureText('close', entry, unrecordedDecision(err));
5296
+ return { success: false, error: ended ? `${text} ${ended}` : text, isError: true };
5213
5297
  }
5214
5298
  // This channel's typing, not every channel with its id (stopTyping's
5215
5299
  // fan-out of a bare name).
@@ -5686,9 +5770,29 @@ export class ChannelRegistry {
5686
5770
  return active !== undefined ? active : this.globalFallbackFor(conversationId);
5687
5771
  }
5688
5772
 
5689
- /** The process-global fallback channel for this conversation, or null when it takes none (takesGlobalFallback). */
5773
+ /**
5774
+ * The process-global fallback for this conversation, or null when it takes
5775
+ * none (takesGlobalFallback): an ephemeral run's prose is its caller's
5776
+ * result, so neither the all-except hold's channel nor the latest inbound
5777
+ * message's is ever its destination.
5778
+ */
5690
5779
  private globalFallbackFor(conversationId: string): ChannelRef | null {
5691
- return this.takesGlobalFallback?.(conversationId) === false ? null : this.defaultPublishChannel;
5780
+ return this.takesGlobalFallback?.(conversationId) === false ? null : this.sharedFallback();
5781
+ }
5782
+
5783
+ /**
5784
+ * What every conversation that takes the global fallback falls back to when
5785
+ * neither a home channel nor its own delivery names one: the all-except
5786
+ * hold's channel while one is open, else the latest delivered inbound
5787
+ * channel under the legacy locus rule, else none — the prose stays private
5788
+ * (architecture channel §3 carve-out, 2026-10-06).
5789
+ */
5790
+ private sharedFallback(): ChannelRef | null {
5791
+ return this.focusChannelResolver?.() ?? this.fallbackLocus();
5792
+ }
5793
+
5794
+ private fallbackLocus(): ChannelRef | null {
5795
+ return this.latestInboundLocus ? this.defaultPublishChannel : null;
5692
5796
  }
5693
5797
 
5694
5798
  /**
@@ -6140,17 +6244,20 @@ export class ChannelRegistry {
6140
6244
  if (input.channelId === '' && tool === 'channel_publish') {
6141
6245
  return { success: false, error: `Nothing was posted: channelId is empty. ${findIds}`, isError: true };
6142
6246
  }
6143
- // Resolve channelId: default to the channel of the most recent incoming
6144
- // message that isn't a reaction, reaction removal, edit or deletion
6145
- // marker, apart from an edit the coalescer delivers on acceptance as the
6146
- // agent's first view. An ephemeral run never takes that default
6247
+ // Resolve channelId: a call made inside the agent's turn (inTurn) has
6248
+ // the turn's default applied already, where its plain speech goes, and
6249
+ // takes none here. Any other defaults to what the caller falls back to:
6250
+ // the all-except hold's channel, else the latest delivered inbound
6251
+ // channel under the legacy locus rule (a marker — reaction, edit,
6252
+ // deletion — is never that channel), which an ephemeral run never takes
6147
6253
  // (takesGlobalFallback), so it names one.
6148
6254
  // A channel named by a string means what it means to every channel tool
6149
6255
  // (entriesNamed): one channel, by its id or its server-qualified name,
6150
6256
  // and a name two servers' channels answer to is refused with them as
6151
6257
  // candidates, never sent to the first.
6152
6258
  const caller = origin?.kind === 'agent' ? origin.agentName : undefined;
6153
- const fallback = caller !== undefined ? this.globalFallbackFor(caller) : this.defaultPublishChannel;
6259
+ const inTurn = origin?.kind === 'agent' && origin.inTurn === true;
6260
+ const fallback = caller === undefined ? this.sharedFallback() : inTurn ? null : this.globalFallbackFor(caller);
6154
6261
  if (!(input.channelId ?? fallback)) {
6155
6262
  return {
6156
6263
  success: false,
@@ -6159,7 +6266,10 @@ export class ChannelRegistry {
6159
6266
  : caller !== undefined && this.takesGlobalFallback?.(caller) === false
6160
6267
  ? 'Nothing was posted: name the channel with channelId. A subagent has no default channel, since its plain '
6161
6268
  + 'speech is returned to whoever called it, so channel_publish sends only to a channel it names.'
6162
- : `Nothing was posted: channel_publish needs a \`channelId\`, and there's no channel to default to yet. ${findIds}`,
6269
+ : caller !== undefined && inTurn && this.activeChannelResolver?.(caller) === null
6270
+ ? `Nothing was posted: name the channel with channelId. This turn has no channel of its own, so its plain speech `
6271
+ + `goes to none and channel_publish sends only to a channel it names. ${findIds}`
6272
+ : `Nothing was posted: channel_publish needs a \`channelId\`, and there's no channel to default to yet. ${findIds}`,
6163
6273
  isError: true,
6164
6274
  };
6165
6275
  }
@@ -1000,6 +1000,25 @@ export class PushCoalescer<E = unknown> {
1000
1000
  return result;
1001
1001
  }
1002
1002
 
1003
+ /**
1004
+ * Render and materialize one subject's pending batch for `agentName` now,
1005
+ * outside a turn's assembly: a batch whose wake a hold keeps back, stored
1006
+ * as it renders (held, as a plain push under the hold is), so whoever reads
1007
+ * held traffic sees it and the turn after the hold doesn't take it again.
1008
+ * A batch that opened while another render ran is rendered once that
1009
+ * render settles. Resolves whether it materialized.
1010
+ */
1011
+ async materializeNow(agentName: string, subject: string): Promise<boolean> {
1012
+ for (;;) {
1013
+ if (this.suspended) return false;
1014
+ const started = await this.locked(() => this.freeze(subject, agentName));
1015
+ if (!started || started === 'declined') return false;
1016
+ await started.done;
1017
+ this.persist();
1018
+ if (!started.joined) return started.rendering.materialized === true;
1019
+ }
1020
+ }
1021
+
1003
1022
  /**
1004
1023
  * Under the lock: decide whether `subject`'s batch renders for `agentName`,
1005
1024
  * freeze it, make the render-start durable, re-check authority, and start
@@ -315,6 +315,13 @@ export class ToolsetsModule implements Module {
315
315
  const args = isObj(call.input) ? call.input : {};
316
316
  try {
317
317
  checkSelectors(args);
318
+ // An agent shown another's tool list can't switch it: what it opened
319
+ // or closed would change its owner's list, or nothing it is shown.
320
+ const caller = call.callerAgentName;
321
+ const owner = caller && this.host ? this.host.ownerOf(caller) : caller;
322
+ if ((call.name === 'open' || call.name === 'close') && caller && owner !== caller) {
323
+ fail(`${caller} is shown ${owner}'s tools and can't ${call.name} a toolset; it reaches the toolsets ${owner} has open. Nothing changed.`);
324
+ }
318
325
  const hosted = await this.hostCall(call.name, args, call.callerAgentName);
319
326
  if (hosted) return hosted;
320
327
  const take = this.takerFor(call);
@@ -33,6 +33,8 @@ export const NOTICE_TAG = {
33
33
  tuneOutWake: 'Tune-out wake',
34
34
  tuneOutCadence: 'Tune-out cadence',
35
35
  tuneOutCancelled: 'Tune-out cancelled',
36
+ focus: 'Focus',
37
+ agentLifecycle: 'Agent lifecycle',
36
38
  standingDispositions: 'Standing dispositions',
37
39
  channelInvitation: 'Channel invitation',
38
40
  system: 'system',
@@ -190,14 +190,17 @@ export interface RequestOnlyRowIndex {
190
190
  /** Index the agent's own rows that follow request-only turns: the rows of its
191
191
  * silent ticks, and the rows of each reply to a `[Continue]`. The two are
192
192
  * independent, so a row can be both. Only stamped rows are keyed, so an
193
- * agent's history walk costs a metadata read per row. */
194
- export function indexRequestOnlyRows(stored: readonly StoredRowLike[], agentName: string): RequestOnlyRowIndex {
193
+ * agent's history walk costs a metadata read per row. `own` is the agent's
194
+ * name, or every participant that is its own voice, as a derived agent's
195
+ * inherited rows are. */
196
+ export function indexRequestOnlyRows(stored: readonly StoredRowLike[], own: string | ReadonlySet<string>): RequestOnlyRowIndex {
197
+ const ownNames = typeof own === 'string' ? new Set([own]) : own;
195
198
  const ticks = new Map<string, { tick: string; text: string }>();
196
199
  const prompts = new Map<string, RequestOnlyPrompt>();
197
200
  for (const row of stored) {
198
- if (row.participant !== agentName) continue;
201
+ if (!ownNames.has(row.participant)) continue;
199
202
  const stamp = silentHeartbeatOf(row.metadata);
200
- const ownTick = stamp !== undefined && stamp.agentName === agentName;
203
+ const ownTick = stamp !== undefined && stamp.agentName === row.participant;
201
204
  const prompt = promptedByOf(row.metadata);
202
205
  if (!ownTick && !prompt) continue;
203
206
  const keys = identityKeys(row.content);