@animalabs/agent-framework 0.19.0 → 0.20.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 (193) hide show
  1. package/README.md +29 -0
  2. package/dist/src/agent.d.ts +22 -0
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +128 -10
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/types.d.ts +1 -1
  7. package/dist/src/api/types.d.ts.map +1 -1
  8. package/dist/src/code-execution/py-runner.d.ts +10 -1
  9. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  10. package/dist/src/code-execution/py-runner.js +27 -2
  11. package/dist/src/code-execution/py-runner.js.map +1 -1
  12. package/dist/src/code-execution/tool-definition.d.ts +21 -0
  13. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  14. package/dist/src/code-execution/tool-definition.js +34 -0
  15. package/dist/src/code-execution/tool-definition.js.map +1 -1
  16. package/dist/src/framework.d.ts +153 -2
  17. package/dist/src/framework.d.ts.map +1 -1
  18. package/dist/src/framework.js +1169 -76
  19. package/dist/src/framework.js.map +1 -1
  20. package/dist/src/index.d.ts +2 -0
  21. package/dist/src/index.d.ts.map +1 -1
  22. package/dist/src/index.js +4 -0
  23. package/dist/src/index.js.map +1 -1
  24. package/dist/src/mcpl/capability-grant.d.ts +1 -1
  25. package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
  26. package/dist/src/mcpl/capability-grant.js +49 -5
  27. package/dist/src/mcpl/capability-grant.js.map +1 -1
  28. package/dist/src/mcpl/channel-registry.d.ts +59 -2
  29. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  30. package/dist/src/mcpl/channel-registry.js +106 -6
  31. package/dist/src/mcpl/channel-registry.js.map +1 -1
  32. package/dist/src/mcpl/index.d.ts +3 -0
  33. package/dist/src/mcpl/index.d.ts.map +1 -1
  34. package/dist/src/mcpl/index.js +3 -0
  35. package/dist/src/mcpl/index.js.map +1 -1
  36. package/dist/src/mcpl/push-coalescer.d.ts +318 -0
  37. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -0
  38. package/dist/src/mcpl/push-coalescer.js +709 -0
  39. package/dist/src/mcpl/push-coalescer.js.map +1 -0
  40. package/dist/src/mcpl/push-handler.d.ts +7 -2
  41. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  42. package/dist/src/mcpl/push-handler.js +43 -4
  43. package/dist/src/mcpl/push-handler.js.map +1 -1
  44. package/dist/src/mcpl/server-connection.d.ts +18 -0
  45. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  46. package/dist/src/mcpl/server-connection.js +44 -1
  47. package/dist/src/mcpl/server-connection.js.map +1 -1
  48. package/dist/src/mcpl/tool-classes.d.ts +71 -0
  49. package/dist/src/mcpl/tool-classes.d.ts.map +1 -0
  50. package/dist/src/mcpl/tool-classes.js +170 -0
  51. package/dist/src/mcpl/tool-classes.js.map +1 -0
  52. package/dist/src/mcpl/tool-glob.d.ts +16 -0
  53. package/dist/src/mcpl/tool-glob.d.ts.map +1 -0
  54. package/dist/src/mcpl/tool-glob.js +41 -0
  55. package/dist/src/mcpl/tool-glob.js.map +1 -0
  56. package/dist/src/mcpl/tool-lifecycle.d.ts +253 -0
  57. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -0
  58. package/dist/src/mcpl/tool-lifecycle.js +592 -0
  59. package/dist/src/mcpl/tool-lifecycle.js.map +1 -0
  60. package/dist/src/mcpl/transport.d.ts +12 -0
  61. package/dist/src/mcpl/transport.d.ts.map +1 -1
  62. package/dist/src/mcpl/transport.js +33 -1
  63. package/dist/src/mcpl/transport.js.map +1 -1
  64. package/dist/src/mcpl/types.d.ts +66 -1
  65. package/dist/src/mcpl/types.d.ts.map +1 -1
  66. package/dist/src/mcpl/types.js +6 -0
  67. package/dist/src/mcpl/types.js.map +1 -1
  68. package/dist/src/modules/discord/index.js +1 -1
  69. package/dist/src/modules/discord/index.js.map +1 -1
  70. package/dist/src/modules/history/index.d.ts +85 -0
  71. package/dist/src/modules/history/index.d.ts.map +1 -1
  72. package/dist/src/modules/history/index.js +1019 -24
  73. package/dist/src/modules/history/index.js.map +1 -1
  74. package/dist/src/modules/history/semantic.d.ts +202 -0
  75. package/dist/src/modules/history/semantic.d.ts.map +1 -0
  76. package/dist/src/modules/history/semantic.js +418 -0
  77. package/dist/src/modules/history/semantic.js.map +1 -0
  78. package/dist/src/modules/index.d.ts +2 -1
  79. package/dist/src/modules/index.d.ts.map +1 -1
  80. package/dist/src/modules/index.js.map +1 -1
  81. package/dist/src/tool-result-guard.d.ts +82 -0
  82. package/dist/src/tool-result-guard.d.ts.map +1 -0
  83. package/dist/src/tool-result-guard.js +306 -0
  84. package/dist/src/tool-result-guard.js.map +1 -0
  85. package/dist/src/types/agent.d.ts +14 -0
  86. package/dist/src/types/agent.d.ts.map +1 -1
  87. package/dist/src/types/framework.d.ts +21 -0
  88. package/dist/src/types/framework.d.ts.map +1 -1
  89. package/dist/src/types/trace.d.ts +17 -0
  90. package/dist/src/types/trace.d.ts.map +1 -1
  91. package/dist/test/channel-registry-routing.test.js +59 -0
  92. package/dist/test/channel-registry-routing.test.js.map +1 -1
  93. package/dist/test/code-execution.test.js +139 -0
  94. package/dist/test/code-execution.test.js.map +1 -1
  95. package/dist/test/helpers/coalescing-fixture.d.ts +72 -0
  96. package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -0
  97. package/dist/test/helpers/coalescing-fixture.js +145 -0
  98. package/dist/test/helpers/coalescing-fixture.js.map +1 -0
  99. package/dist/test/helpers/coalescing-kill-child.d.ts +2 -0
  100. package/dist/test/helpers/coalescing-kill-child.d.ts.map +1 -0
  101. package/dist/test/helpers/coalescing-kill-child.js +24 -0
  102. package/dist/test/helpers/coalescing-kill-child.js.map +1 -0
  103. package/dist/test/history-module-realstore.test.d.ts +2 -0
  104. package/dist/test/history-module-realstore.test.d.ts.map +1 -0
  105. package/dist/test/history-module-realstore.test.js +363 -0
  106. package/dist/test/history-module-realstore.test.js.map +1 -0
  107. package/dist/test/history-module-semantic.test.d.ts +2 -0
  108. package/dist/test/history-module-semantic.test.d.ts.map +1 -0
  109. package/dist/test/history-module-semantic.test.js +213 -0
  110. package/dist/test/history-module-semantic.test.js.map +1 -0
  111. package/dist/test/history-module-ux.test.d.ts +2 -0
  112. package/dist/test/history-module-ux.test.d.ts.map +1 -0
  113. package/dist/test/history-module-ux.test.js +304 -0
  114. package/dist/test/history-module-ux.test.js.map +1 -0
  115. package/dist/test/history-module.test.js +25 -1
  116. package/dist/test/history-module.test.js.map +1 -1
  117. package/dist/test/history-semantic-review-findings.test.d.ts +2 -0
  118. package/dist/test/history-semantic-review-findings.test.d.ts.map +1 -0
  119. package/dist/test/history-semantic-review-findings.test.js +338 -0
  120. package/dist/test/history-semantic-review-findings.test.js.map +1 -0
  121. package/dist/test/history-semantic-sync-converges.test.d.ts +2 -0
  122. package/dist/test/history-semantic-sync-converges.test.d.ts.map +1 -0
  123. package/dist/test/history-semantic-sync-converges.test.js +109 -0
  124. package/dist/test/history-semantic-sync-converges.test.js.map +1 -0
  125. package/dist/test/mcpl-capability-grant.test.js +3 -1
  126. package/dist/test/mcpl-capability-grant.test.js.map +1 -1
  127. package/dist/test/mcpl-child-env.test.d.ts +2 -0
  128. package/dist/test/mcpl-child-env.test.d.ts.map +1 -0
  129. package/dist/test/mcpl-child-env.test.js +69 -0
  130. package/dist/test/mcpl-child-env.test.js.map +1 -0
  131. package/dist/test/mcpl-coalescing.test.d.ts +2 -0
  132. package/dist/test/mcpl-coalescing.test.d.ts.map +1 -0
  133. package/dist/test/mcpl-coalescing.test.js +1050 -0
  134. package/dist/test/mcpl-coalescing.test.js.map +1 -0
  135. package/dist/test/mcpl-list-status.test.js +4 -0
  136. package/dist/test/mcpl-list-status.test.js.map +1 -1
  137. package/dist/test/mcpl-tool-class-listing.test.d.ts +2 -0
  138. package/dist/test/mcpl-tool-class-listing.test.d.ts.map +1 -0
  139. package/dist/test/mcpl-tool-class-listing.test.js +147 -0
  140. package/dist/test/mcpl-tool-class-listing.test.js.map +1 -0
  141. package/dist/test/mcpl-tool-classes.test.d.ts +7 -0
  142. package/dist/test/mcpl-tool-classes.test.d.ts.map +1 -0
  143. package/dist/test/mcpl-tool-classes.test.js +80 -0
  144. package/dist/test/mcpl-tool-classes.test.js.map +1 -0
  145. package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts +2 -0
  146. package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts.map +1 -0
  147. package/dist/test/mcpl-tool-lifecycle-e2e.test.js +237 -0
  148. package/dist/test/mcpl-tool-lifecycle-e2e.test.js.map +1 -0
  149. package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts +11 -0
  150. package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts.map +1 -0
  151. package/dist/test/mcpl-tool-lifecycle-emitter.test.js +307 -0
  152. package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -0
  153. package/dist/test/mcpl-tool-lifecycle.test.d.ts +11 -0
  154. package/dist/test/mcpl-tool-lifecycle.test.d.ts.map +1 -0
  155. package/dist/test/mcpl-tool-lifecycle.test.js +270 -0
  156. package/dist/test/mcpl-tool-lifecycle.test.js.map +1 -0
  157. package/dist/test/silent-heartbeat.test.d.ts +2 -0
  158. package/dist/test/silent-heartbeat.test.d.ts.map +1 -0
  159. package/dist/test/silent-heartbeat.test.js +154 -0
  160. package/dist/test/silent-heartbeat.test.js.map +1 -0
  161. package/dist/test/tool-result-guard.test.d.ts +2 -0
  162. package/dist/test/tool-result-guard.test.d.ts.map +1 -0
  163. package/dist/test/tool-result-guard.test.js +1081 -0
  164. package/dist/test/tool-result-guard.test.js.map +1 -0
  165. package/dist/test/trunk-channel-routing.test.js +67 -0
  166. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  167. package/dist/tsconfig.tsbuildinfo +1 -1
  168. package/package.json +2 -2
  169. package/src/agent.ts +128 -10
  170. package/src/api/types.ts +2 -0
  171. package/src/code-execution/py-runner.ts +34 -2
  172. package/src/code-execution/tool-definition.ts +46 -0
  173. package/src/framework.ts +1201 -80
  174. package/src/index.ts +13 -0
  175. package/src/mcpl/capability-grant.ts +54 -6
  176. package/src/mcpl/channel-registry.ts +115 -7
  177. package/src/mcpl/index.ts +29 -0
  178. package/src/mcpl/push-coalescer.ts +821 -0
  179. package/src/mcpl/push-handler.ts +40 -4
  180. package/src/mcpl/server-connection.ts +48 -2
  181. package/src/mcpl/tool-classes.ts +188 -0
  182. package/src/mcpl/tool-glob.ts +36 -0
  183. package/src/mcpl/tool-lifecycle.ts +730 -0
  184. package/src/mcpl/transport.ts +35 -1
  185. package/src/mcpl/types.ts +69 -1
  186. package/src/modules/discord/index.ts +1 -1
  187. package/src/modules/history/index.ts +1089 -24
  188. package/src/modules/history/semantic.ts +473 -0
  189. package/src/modules/index.ts +2 -1
  190. package/src/tool-result-guard.ts +318 -0
  191. package/src/types/agent.ts +15 -0
  192. package/src/types/framework.ts +23 -0
  193. package/src/types/trace.ts +17 -0
package/src/index.ts CHANGED
@@ -93,6 +93,19 @@ export type { McplServerConfig } from './mcpl/index.js';
93
93
  export { McplServerConnection } from './mcpl/index.js';
94
94
  export type { McplHostCapabilities } from './mcpl/index.js';
95
95
 
96
+ // MCPL tool lifecycle (RFC-007) and tool classes (RFC-008): the types a host
97
+ // needs to write McplServerConfig.toolLifecycle policy and class tables, and
98
+ // the portable pattern matcher both use.
99
+ export { TOOL_CLASSES, DEFAULT_INPUT_CLASSES, globMatch } from './mcpl/index.js';
100
+ export type {
101
+ ToolClass,
102
+ ToolClassSource,
103
+ ToolLifecycleConfig,
104
+ ToolLifecycleNarrowing,
105
+ ToolLifecycleParams,
106
+ ToolObserveRule,
107
+ } from './mcpl/index.js';
108
+
96
109
  // Per-channel conversation routing
97
110
  export { ConversationRouter, DEFAULT_CLOSURE_PROMPT } from './mcpl/index.js';
98
111
  export type {
@@ -48,6 +48,9 @@ const VOCABULARY: CapNode = {
48
48
  modelInfo: null,
49
49
  inferenceRequest: { streaming: null },
50
50
  inferenceLifecycle: null,
51
+ // RFC-007: metadata observation and argument observation are separate
52
+ // leaves, separately granted (observation never bundles content).
53
+ toolLifecycle: { observe: null, inputs: null },
51
54
  contextHooks: {
52
55
  beforeInference: {
53
56
  observe: null,
@@ -217,8 +220,36 @@ export function capabilityPatternMatches(pattern: string, path: string): boolean
217
220
  // The grant
218
221
  // ============================================================================
219
222
 
220
- /** §13.4: denied unless explicitly enabled by host config. */
221
- const DENY_BY_DEFAULT: readonly string[] = ['contextHooks.beforeInference.inject.system'];
223
+ /** §13.4: denied unless explicitly enabled by host config. RFC-007 adds both
224
+ * tool-lifecycle leaves: observing other servers' tool use (and, above all,
225
+ * their arguments) is an operator decision, never a default. */
226
+ const DENY_BY_DEFAULT: readonly string[] = [
227
+ 'contextHooks.beforeInference.inject.system',
228
+ 'toolLifecycle.observe',
229
+ 'toolLifecycle.inputs',
230
+ ];
231
+
232
+ /**
233
+ * RFC-007: stating a `toolLifecycle.observe` / `.inputs` policy block in the
234
+ * server's config is itself the explicit grant of that path — the operator
235
+ * who wrote a narrowing meant to grant what it narrows.
236
+ */
237
+ function configGrants(config: Pick<McplServerConfig, 'toolLifecycle'>, path: string): boolean {
238
+ const key = path === 'toolLifecycle.observe' ? 'observe' : path === 'toolLifecycle.inputs' ? 'inputs' : null;
239
+ if (!key) return false;
240
+ const block = (config.toolLifecycle as Record<string, unknown> | undefined)?.[key];
241
+ if (block === undefined) return false;
242
+ // Only a policy OBJECT is a grant. `observe: false` (or null, or a string)
243
+ // must never read as "granted with no narrowing".
244
+ if (!isPlainObject(block)) {
245
+ console.error(
246
+ `[mcpl] toolLifecycle.${key} is ${JSON.stringify(block)}, not a policy object — ` +
247
+ `not granted by this block (RFC-007 §4.1)`,
248
+ );
249
+ return false;
250
+ }
251
+ return true;
252
+ }
222
253
 
223
254
  export class CapabilityGrant {
224
255
  /** Exact granted paths (already expanded — no patterns stored). */
@@ -286,7 +317,7 @@ export class CapabilityGrant {
286
317
  */
287
318
  export function computeGrant(
288
319
  capabilities: McplCapabilities | null,
289
- config: Pick<McplServerConfig, 'enabledCapabilities'>,
320
+ config: Pick<McplServerConfig, 'enabledCapabilities' | 'toolLifecycle'>,
290
321
  opts?: {
291
322
  /** §5.1: `tools` is sourced from the OUTER standard MCP
292
323
  * `capabilities.tools` — pass its presence here. The experimental
@@ -304,16 +335,33 @@ export function computeGrant(
304
335
  !!config.enabledCapabilities?.some((pat) => capabilityPatternMatches(pat, path));
305
336
 
306
337
  for (const path of advertised) {
307
- if (DENY_BY_DEFAULT.includes(path) && !explicitlyEnabled(path)) {
338
+ if (DENY_BY_DEFAULT.includes(path) && !explicitlyEnabled(path) && !configGrants(config, path)) {
308
339
  denied.push(path);
309
340
  console.error(
310
- `[mcpl] "${path}" advertised but DENIED BY DEFAULT (SPEC §13.4) — ` +
311
- `grant it explicitly via enabledCapabilities if intended`,
341
+ `[mcpl] "${path}" advertised but DENIED BY DEFAULT (${path.startsWith('toolLifecycle.') ? 'RFC-007' : 'SPEC §13.4'}) — ` +
342
+ `grant it explicitly via enabledCapabilities${path.startsWith('toolLifecycle.') ? ' or a toolLifecycle policy block' : ''} if intended`,
312
343
  );
313
344
  continue;
314
345
  }
315
346
  granted.add(path);
316
347
  }
317
348
 
349
+ // RFC-007 §4.1/§4.3 diagnostics — loud, because each is a grant that
350
+ // silently delivers less than the operator probably meant.
351
+ if (granted.has('toolLifecycle.inputs')) {
352
+ if (!granted.has('toolLifecycle.observe')) {
353
+ console.error('[mcpl] toolLifecycle.inputs granted without toolLifecycle.observe — delivers nothing (RFC-007 §4.1)');
354
+ }
355
+ const n = config.toolLifecycle?.inputs;
356
+ const hasTerm = isPlainObject(n) && ((Array.isArray(n.tools) && n.tools.length > 0)
357
+ || n.classes === 'default' || (Array.isArray(n.classes) && n.classes.length > 0));
358
+ if (!hasTerm) {
359
+ console.error(
360
+ '[mcpl] toolLifecycle.inputs granted with no tools/classes narrowing — treated as narrowed to NO tools; ' +
361
+ 'inputs is never unconditional (RFC-007 §4.3)',
362
+ );
363
+ }
364
+ }
365
+
318
366
  return new CapabilityGrant(granted, denied.sort());
319
367
  }
@@ -26,6 +26,7 @@ import type {
26
26
  ChannelsIncomingParams,
27
27
  ChannelsIncomingResult,
28
28
  ChannelIncomingMessageResult,
29
+ ChannelIncomingMessage,
29
30
  ChannelsPublishParams,
30
31
  ChannelsOpenResult,
31
32
  ChannelHistoryRequest,
@@ -36,6 +37,7 @@ import type { McplServerRegistry } from './server-registry.js';
36
37
  import type { FeatureSetManager } from './feature-set-manager.js';
37
38
  import type { ToolDefinition, ToolResult, ProcessEvent } from '../types/index.js';
38
39
  import { expandCoreTags } from './tags.js';
40
+ import { validateCoalescedContent } from './push-coalescer.js';
39
41
  import { CapabilityGrant } from './capability-grant.js';
40
42
 
41
43
  // ============================================================================
@@ -452,6 +454,17 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
452
454
  // ============================================================================
453
455
 
454
456
  interface ChannelRegistryOptions {
457
+ /**
458
+ * RFC-006: an admitted `channels/incoming` message carrying `coalesce`, with
459
+ * the event the ordinary path would have queued. The handler decides
460
+ * replace / append / withdraw and returns the per-message result. Throws a
461
+ * `CoalesceError` for a malformed or unauthorized occurrence.
462
+ */
463
+ handleCoalescedIncoming?: (
464
+ serverId: string,
465
+ message: ChannelIncomingMessage,
466
+ event: McplChannelIncomingEvent,
467
+ ) => Promise<ChannelIncomingMessageResult>;
455
468
  /** Chronicle store used for durable desired channel lifecycle state. */
456
469
  store?: JsStore;
457
470
  /** Callback to determine whether an incoming message should trigger inference. */
@@ -643,6 +656,7 @@ export class ChannelRegistry {
643
656
  /** One-time migration inputs from the retired recipe auto-open policy. */
644
657
  private legacyPolicies = new Map<string, 'auto' | 'manual' | string[]>();
645
658
  private migratedLegacyPolicies = new Set<string>();
659
+ private handleCoalescedIncoming?: ChannelRegistryOptions['handleCoalescedIncoming'];
646
660
 
647
661
  constructor(
648
662
  serverRegistry: McplServerRegistry,
@@ -658,6 +672,7 @@ export class ChannelRegistry {
658
672
  ) => void;
659
673
  },
660
674
  ) {
675
+ this.handleCoalescedIncoming = options?.handleCoalescedIncoming;
661
676
  this.serverRegistry = serverRegistry;
662
677
  this.featureSetManager = featureSetManager;
663
678
  this.pushEventFn = pushEventFn;
@@ -838,14 +853,23 @@ export class ChannelRegistry {
838
853
  * Converts each message's content, pushes McplChannelIncomingEvent to the
839
854
  * queue, and responds with per-message results.
840
855
  */
841
- handleIncoming(
856
+ async handleIncoming(
842
857
  serverId: string,
843
858
  params: ChannelsIncomingParams,
844
859
  responder?: Responder,
845
- ): void {
860
+ ): Promise<void> {
846
861
  const results: ChannelIncomingMessageResult[] = [];
847
862
 
848
863
  for (const message of params.messages) {
864
+ if (!message || typeof message.channelId !== 'string' || !message.channelId
865
+ || typeof message.messageId !== 'string' || !message.messageId) {
866
+ results.push({
867
+ messageId: typeof message?.messageId === 'string' ? message.messageId : '',
868
+ accepted: false,
869
+ reason: message?.coalesce !== undefined ? 'coalesce_invalid' : 'invalid channel/message identity',
870
+ });
871
+ continue;
872
+ }
849
873
  // §14.5 FIRST, before ANY semantic processing: admission against the
850
874
  // actually-registered channel precedes tag expansion and content
851
875
  // conversion — decoding an unregistered sender's payload (including
@@ -890,6 +914,17 @@ export class ChannelRegistry {
890
914
 
891
915
  // ACCEPTED from here down: semantic processing only for admitted
892
916
  // messages. §16.3 core-tag closure, then content conversion.
917
+ const coalesced = message.coalesce !== undefined && !!this.handleCoalescedIncoming;
918
+ if (coalesced) {
919
+ // RFC-006 §13: malformed content on a coalesced item is that item's
920
+ // failure, not the batch's — check the shape before converting.
921
+ try {
922
+ validateCoalescedContent(message.content);
923
+ } catch (error) {
924
+ results.push({ messageId: message.messageId, accepted: false, reason: 'coalesce_invalid' });
925
+ continue;
926
+ }
927
+ }
893
928
  if (message.tags) message.tags = expandCoreTags(message.tags);
894
929
  const convertedContent: ContentBlock[] = message.content.map(convertBlock);
895
930
 
@@ -897,16 +932,19 @@ export class ChannelRegistry {
897
932
  // deliberately after §14.5 validation: a rejected message from an
898
933
  // unregistered channel must not retarget outbound speech (the locus is
899
934
  // exactly the authority a self-attested channel would be stealing).
900
- this.defaultPublishChannel = message.channelId;
901
- this.defaultPublishMessageId = message.messageId;
902
- this.defaultPublishThreadId = message.threadId;
903
- {
935
+ // A coalesced item is "accepted" only once the coalescer admits it, so
936
+ // for those this runs after the hook (below).
937
+ const markAccepted = () => {
938
+ this.defaultPublishChannel = message.channelId;
939
+ this.defaultPublishMessageId = message.messageId;
940
+ this.defaultPublishThreadId = message.threadId;
904
941
  // A server sending channels/incoming is authoritative evidence that
905
942
  // the transport is actually open. This repairs transient status only;
906
943
  // durable desired state still changes exclusively through lifecycle
907
944
  // operations.
908
945
  this.channels.get(incomingKey)!.open = true;
909
- }
946
+ };
947
+ if (!coalesced) markAccepted();
910
948
 
911
949
  // Determine whether to trigger inference
912
950
  let triggerInference = true;
@@ -945,6 +983,26 @@ export class ChannelRegistry {
945
983
  triggerInference,
946
984
  };
947
985
 
986
+ if (coalesced) {
987
+ // RFC-006 §14.3: a coalesced message is admitted like any other and
988
+ // then handed, with the event the ordinary path would have queued, to
989
+ // the coalescer, which replaces, appends or withdraws. Malformed
990
+ // `coalesce` is a per-message failure; siblings are unaffected.
991
+ try {
992
+ const result = await this.handleCoalescedIncoming!(serverId, message, event);
993
+ if (result.accepted) markAccepted();
994
+ results.push(result);
995
+ } catch (error) {
996
+ const err = error as Error & { code?: number };
997
+ results.push({
998
+ messageId: message.messageId,
999
+ accepted: false,
1000
+ reason: err.code === -32602 ? 'coalesce_invalid' : err.message,
1001
+ });
1002
+ }
1003
+ continue;
1004
+ }
1005
+
948
1006
  // Push to the processing queue
949
1007
  // Cast through unknown because McplChannelIncomingEvent matches the
950
1008
  // CustomEvent `${string}:${string}` type pattern but lacks an index signature.
@@ -1155,6 +1213,18 @@ export class ChannelRegistry {
1155
1213
  * Get the descriptor for a channel by its channelId (first match across
1156
1214
  * servers). Used by the conversation router for DM classification.
1157
1215
  */
1216
+ /**
1217
+ * RFC-006 §3.2: is `channelId` registered BY `serverId` through a server
1218
+ * declaration (channels/register, channels/changed) or an authorized open?
1219
+ * A placeholder minted from a push's `origin` (ensureChannelRegistered)
1220
+ * does not count: a channel id appearing in an untrusted field is not a
1221
+ * registration.
1222
+ */
1223
+ isDeclaredChannel(serverId: string, channelId: string): boolean {
1224
+ const entry = this.channels.get(`${serverId}:${channelId}`);
1225
+ return !!entry && !(entry.descriptor.metadata as { lazyRegistered?: boolean } | undefined)?.lazyRegistered;
1226
+ }
1227
+
1158
1228
  getDescriptor(channelId: string): ChannelDescriptor | undefined {
1159
1229
  return this.findChannelEntry(channelId)?.descriptor;
1160
1230
  }
@@ -2375,6 +2445,44 @@ export class ChannelRegistry {
2375
2445
  return { error: `no channel matches "${trimmed}"`, ...(near.length ? { candidates: near } : {}) };
2376
2446
  }
2377
2447
 
2448
+ /**
2449
+ * The `>>` target to show an agent for a channel: one whitespace-free token
2450
+ * that `resolveProseTarget()` maps back to this same channel. The prefix
2451
+ * grammar takes the target as the first non-whitespace run, so a label with
2452
+ * a space can't be quoted verbatim: `>>#DM: alice` parses as target `#DM:`
2453
+ * plus body `alice …`, and `>>#fable (antra's server)` delivers
2454
+ * `(antra's server)` as text. Tried in order: `@name` for a DM, `#label`,
2455
+ * `#name` (label without its server suffix), the descriptor id.
2456
+ *
2457
+ * Undefined when there is no safe token: the channel isn't registered (on
2458
+ * `serverId`, when given), no candidate is whitespace-free and resolves back,
2459
+ * or the id is registered by more than one server. A resolved target names a
2460
+ * channel by id alone, and ids are unique only within a connection, so a
2461
+ * shared id could route the reply through the wrong server.
2462
+ */
2463
+ proseTargetFor(channelId: string, serverId?: string): string | undefined {
2464
+ const sameId = [...this.channels.values()].filter((e) => e.descriptor.id === channelId);
2465
+ const entry = serverId ? sameId.find((e) => e.serverId === serverId) : sameId[0];
2466
+ if (!entry || sameId.length > 1) return undefined;
2467
+ const d = entry.descriptor;
2468
+ const label = d.label ?? '';
2469
+ const meta = d.metadata as { channelType?: string; recipientName?: string } | undefined;
2470
+ const isDm = meta?.channelType === 'dm' || label.toLowerCase().startsWith('dm: ') || d.id.includes(':dm:');
2471
+ const dmName = meta?.recipientName ?? (label.toLowerCase().startsWith('dm: ') ? label.slice(4) : undefined);
2472
+ const bare = label.replace(/^#/, '');
2473
+ const candidates = [
2474
+ ...(isDm && dmName ? [`@${dmName}`] : []),
2475
+ ...(bare ? [`#${bare}`, `#${bare.replace(/\s*\([^)]*\)\s*$/, '')}`] : []),
2476
+ d.id,
2477
+ ];
2478
+ for (const c of candidates) {
2479
+ if (/\s/.test(c) || c === '#') continue;
2480
+ const r = this.resolveProseTarget(c);
2481
+ if ('channelId' in r && r.channelId === d.id) return c;
2482
+ }
2483
+ return undefined;
2484
+ }
2485
+
2378
2486
  /**
2379
2487
  * Open a channel because something was DELIVERED into it (explicit send
2380
2488
  * tool or routed speech). Sending into a closed channel is not a thing:
package/src/mcpl/index.ts CHANGED
@@ -118,6 +118,35 @@ export { ScopeManager, type ElevationHandler } from './scope-manager.js';
118
118
  // Hook orchestration (beforeInference/afterInference fan-out)
119
119
  export { HookOrchestrator } from './hook-orchestrator.js';
120
120
 
121
+ export {
122
+ ToolLifecycleEmitter,
123
+ parseToolObserveParams,
124
+ selectFields,
125
+ boundInput,
126
+ openingFor,
127
+ DEFAULT_MAX_INPUT_BYTES,
128
+ TOOL_OBSERVE_LIMITS,
129
+ type ToolPhase,
130
+ type ToolLifecycleParams,
131
+ type ToolObserveRule,
132
+ type ToolObserveMatch,
133
+ type ToolLifecycleConfig,
134
+ type ToolLifecycleNarrowing,
135
+ } from './tool-lifecycle.js';
136
+ export {
137
+ TOOL_CLASSES,
138
+ TOOL_CLASS_META_KEY,
139
+ DEFAULT_INPUT_CLASSES,
140
+ BUILTIN_TOOL_CLASSES,
141
+ parseDeclaredClasses,
142
+ resolveToolClass,
143
+ isToolClass,
144
+ type ToolClass,
145
+ type ToolClassSource,
146
+ type EffectiveToolClass,
147
+ } from './tool-classes.js';
148
+ export { globMatch } from './tool-glob.js';
149
+
121
150
  // Push events (Section 9)
122
151
  export { PushHandler } from './push-handler.js';
123
152
  export type { McplPushEvent } from './push-handler.js';