@animalabs/agent-framework 0.21.0 → 0.22.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 (271) hide show
  1. package/README.md +43 -0
  2. package/dist/src/agent.d.ts +17 -4
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +102 -42
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/code-execution/py-runner.d.ts +22 -1
  7. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  8. package/dist/src/code-execution/py-runner.js +64 -9
  9. package/dist/src/code-execution/py-runner.js.map +1 -1
  10. package/dist/src/code-execution/runtime-py.d.ts +4 -0
  11. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  12. package/dist/src/code-execution/runtime-py.js +215 -38
  13. package/dist/src/code-execution/runtime-py.js.map +1 -1
  14. package/dist/src/framework.d.ts +250 -39
  15. package/dist/src/framework.d.ts.map +1 -1
  16. package/dist/src/framework.js +1602 -371
  17. package/dist/src/framework.js.map +1 -1
  18. package/dist/src/gate/event-gate.d.ts +21 -0
  19. package/dist/src/gate/event-gate.d.ts.map +1 -1
  20. package/dist/src/gate/event-gate.js +40 -7
  21. package/dist/src/gate/event-gate.js.map +1 -1
  22. package/dist/src/index.d.ts +5 -0
  23. package/dist/src/index.d.ts.map +1 -1
  24. package/dist/src/index.js +4 -0
  25. package/dist/src/index.js.map +1 -1
  26. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  27. package/dist/src/mcpl/channel-registry.js +26 -3
  28. package/dist/src/mcpl/channel-registry.js.map +1 -1
  29. package/dist/src/mcpl/conversation-router.d.ts +28 -1
  30. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  31. package/dist/src/mcpl/conversation-router.js +17 -0
  32. package/dist/src/mcpl/conversation-router.js.map +1 -1
  33. package/dist/src/mcpl/feature-set-manager.d.ts +11 -0
  34. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  35. package/dist/src/mcpl/feature-set-manager.js +31 -7
  36. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  37. package/dist/src/mcpl/index.d.ts +2 -0
  38. package/dist/src/mcpl/index.d.ts.map +1 -1
  39. package/dist/src/mcpl/index.js +1 -0
  40. package/dist/src/mcpl/index.js.map +1 -1
  41. package/dist/src/mcpl/prose-stream-router.d.ts +27 -6
  42. package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
  43. package/dist/src/mcpl/prose-stream-router.js +85 -22
  44. package/dist/src/mcpl/prose-stream-router.js.map +1 -1
  45. package/dist/src/mcpl/push-coalescer.d.ts +32 -4
  46. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  47. package/dist/src/mcpl/push-coalescer.js +46 -13
  48. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  49. package/dist/src/mcpl/push-handler.d.ts +5 -3
  50. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  51. package/dist/src/mcpl/push-handler.js +41 -11
  52. package/dist/src/mcpl/push-handler.js.map +1 -1
  53. package/dist/src/mcpl/server-connection.d.ts +4 -0
  54. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  55. package/dist/src/mcpl/server-connection.js +45 -8
  56. package/dist/src/mcpl/server-connection.js.map +1 -1
  57. package/dist/src/mcpl/server-registry.d.ts +5 -2
  58. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  59. package/dist/src/mcpl/server-registry.js +25 -7
  60. package/dist/src/mcpl/server-registry.js.map +1 -1
  61. package/dist/src/mcpl/tool-lifecycle.d.ts +52 -2
  62. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
  63. package/dist/src/mcpl/tool-lifecycle.js +47 -1
  64. package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
  65. package/dist/src/mcpl/tool-pattern-check.d.ts +51 -0
  66. package/dist/src/mcpl/tool-pattern-check.d.ts.map +1 -0
  67. package/dist/src/mcpl/tool-pattern-check.js +79 -0
  68. package/dist/src/mcpl/tool-pattern-check.js.map +1 -0
  69. package/dist/src/mcpl/types.d.ts +15 -5
  70. package/dist/src/mcpl/types.d.ts.map +1 -1
  71. package/dist/src/mcpl/types.js.map +1 -1
  72. package/dist/src/mcpl/visible-content.d.ts +39 -0
  73. package/dist/src/mcpl/visible-content.d.ts.map +1 -0
  74. package/dist/src/mcpl/visible-content.js +54 -0
  75. package/dist/src/mcpl/visible-content.js.map +1 -0
  76. package/dist/src/module-registry.d.ts +81 -3
  77. package/dist/src/module-registry.d.ts.map +1 -1
  78. package/dist/src/module-registry.js +257 -16
  79. package/dist/src/module-registry.js.map +1 -1
  80. package/dist/src/modules/workspace/branch-intent.d.ts +72 -0
  81. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -0
  82. package/dist/src/modules/workspace/branch-intent.js +83 -0
  83. package/dist/src/modules/workspace/branch-intent.js.map +1 -0
  84. package/dist/src/modules/workspace/disk-agreement.d.ts +167 -0
  85. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -0
  86. package/dist/src/modules/workspace/disk-agreement.js +259 -0
  87. package/dist/src/modules/workspace/disk-agreement.js.map +1 -0
  88. package/dist/src/modules/workspace/effects.d.ts +84 -0
  89. package/dist/src/modules/workspace/effects.d.ts.map +1 -0
  90. package/dist/src/modules/workspace/effects.js +368 -0
  91. package/dist/src/modules/workspace/effects.js.map +1 -0
  92. package/dist/src/modules/workspace/index.d.ts +155 -17
  93. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  94. package/dist/src/modules/workspace/index.js +1059 -424
  95. package/dist/src/modules/workspace/index.js.map +1 -1
  96. package/dist/src/modules/workspace/observe.d.ts +278 -0
  97. package/dist/src/modules/workspace/observe.d.ts.map +1 -0
  98. package/dist/src/modules/workspace/observe.js +805 -0
  99. package/dist/src/modules/workspace/observe.js.map +1 -0
  100. package/dist/src/modules/workspace/reconcile.d.ts +224 -0
  101. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -0
  102. package/dist/src/modules/workspace/reconcile.js +901 -0
  103. package/dist/src/modules/workspace/reconcile.js.map +1 -0
  104. package/dist/src/modules/workspace/sync.d.ts +6 -59
  105. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  106. package/dist/src/modules/workspace/sync.js +6 -257
  107. package/dist/src/modules/workspace/sync.js.map +1 -1
  108. package/dist/src/modules/workspace/types.d.ts +77 -10
  109. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  110. package/dist/src/modules/workspace/types.js.map +1 -1
  111. package/dist/src/record-journal.d.ts +127 -0
  112. package/dist/src/record-journal.d.ts.map +1 -0
  113. package/dist/src/record-journal.js +202 -0
  114. package/dist/src/record-journal.js.map +1 -0
  115. package/dist/src/shutdown.d.ts +13 -0
  116. package/dist/src/shutdown.d.ts.map +1 -0
  117. package/dist/src/shutdown.js +46 -0
  118. package/dist/src/shutdown.js.map +1 -0
  119. package/dist/src/silent-heartbeat.d.ts +74 -0
  120. package/dist/src/silent-heartbeat.d.ts.map +1 -0
  121. package/dist/src/silent-heartbeat.js +77 -0
  122. package/dist/src/silent-heartbeat.js.map +1 -0
  123. package/dist/src/tool-presentation.d.ts +50 -0
  124. package/dist/src/tool-presentation.d.ts.map +1 -0
  125. package/dist/src/tool-presentation.js +235 -0
  126. package/dist/src/tool-presentation.js.map +1 -0
  127. package/dist/src/tool-result-guard.d.ts +125 -6
  128. package/dist/src/tool-result-guard.d.ts.map +1 -1
  129. package/dist/src/tool-result-guard.js +310 -35
  130. package/dist/src/tool-result-guard.js.map +1 -1
  131. package/dist/src/tool-result-history.d.ts +13 -0
  132. package/dist/src/tool-result-history.d.ts.map +1 -1
  133. package/dist/src/tool-result-history.js +21 -1
  134. package/dist/src/tool-result-history.js.map +1 -1
  135. package/dist/src/types/agent.d.ts +18 -2
  136. package/dist/src/types/agent.d.ts.map +1 -1
  137. package/dist/src/types/events.d.ts +4 -0
  138. package/dist/src/types/events.d.ts.map +1 -1
  139. package/dist/src/types/framework.d.ts +12 -0
  140. package/dist/src/types/framework.d.ts.map +1 -1
  141. package/dist/src/types/module.d.ts +14 -0
  142. package/dist/src/types/module.d.ts.map +1 -1
  143. package/dist/src/types/trace.d.ts +73 -0
  144. package/dist/src/types/trace.d.ts.map +1 -1
  145. package/dist/test/batched-wake-locus.test.d.ts +2 -0
  146. package/dist/test/batched-wake-locus.test.d.ts.map +1 -0
  147. package/dist/test/batched-wake-locus.test.js +166 -0
  148. package/dist/test/batched-wake-locus.test.js.map +1 -0
  149. package/dist/test/code-execution.test.js +386 -2
  150. package/dist/test/code-execution.test.js.map +1 -1
  151. package/dist/test/component-defaults.test.d.ts +2 -0
  152. package/dist/test/component-defaults.test.d.ts.map +1 -0
  153. package/dist/test/component-defaults.test.js +54 -0
  154. package/dist/test/component-defaults.test.js.map +1 -0
  155. package/dist/test/conversation-routing.test.js +57 -1
  156. package/dist/test/conversation-routing.test.js.map +1 -1
  157. package/dist/test/ephemeral-settle.test.js +3 -0
  158. package/dist/test/ephemeral-settle.test.js.map +1 -1
  159. package/dist/test/event-gate.test.js +118 -0
  160. package/dist/test/event-gate.test.js.map +1 -1
  161. package/dist/test/explicit-prose-routing.test.js +977 -0
  162. package/dist/test/explicit-prose-routing.test.js.map +1 -1
  163. package/dist/test/helpers/pytc-stop-rows.d.ts +11 -0
  164. package/dist/test/helpers/pytc-stop-rows.d.ts.map +1 -0
  165. package/dist/test/helpers/pytc-stop-rows.js +143 -0
  166. package/dist/test/helpers/pytc-stop-rows.js.map +1 -0
  167. package/dist/test/mcpl-coalescing.test.js +19 -6
  168. package/dist/test/mcpl-coalescing.test.js.map +1 -1
  169. package/dist/test/mcpl-config-diagnostics.test.d.ts +2 -0
  170. package/dist/test/mcpl-config-diagnostics.test.d.ts.map +1 -0
  171. package/dist/test/mcpl-config-diagnostics.test.js +449 -0
  172. package/dist/test/mcpl-config-diagnostics.test.js.map +1 -0
  173. package/dist/test/mcpl-empty-content.test.d.ts +2 -0
  174. package/dist/test/mcpl-empty-content.test.d.ts.map +1 -0
  175. package/dist/test/mcpl-empty-content.test.js +368 -0
  176. package/dist/test/mcpl-empty-content.test.js.map +1 -0
  177. package/dist/test/mcpl-tool-lifecycle-emitter.test.js +106 -1
  178. package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -1
  179. package/dist/test/mcpl-tool-lifecycle-script.test.d.ts +2 -0
  180. package/dist/test/mcpl-tool-lifecycle-script.test.d.ts.map +1 -0
  181. package/dist/test/mcpl-tool-lifecycle-script.test.js +288 -0
  182. package/dist/test/mcpl-tool-lifecycle-script.test.js.map +1 -0
  183. package/dist/test/prose-stream-router.test.js +41 -0
  184. package/dist/test/prose-stream-router.test.js.map +1 -1
  185. package/dist/test/record-journal.test.d.ts +6 -0
  186. package/dist/test/record-journal.test.d.ts.map +1 -0
  187. package/dist/test/record-journal.test.js +269 -0
  188. package/dist/test/record-journal.test.js.map +1 -0
  189. package/dist/test/save-recent-image.test.js +102 -0
  190. package/dist/test/save-recent-image.test.js.map +1 -1
  191. package/dist/test/shutdown-errors.test.d.ts +2 -0
  192. package/dist/test/shutdown-errors.test.d.ts.map +1 -0
  193. package/dist/test/shutdown-errors.test.js +662 -0
  194. package/dist/test/shutdown-errors.test.js.map +1 -0
  195. package/dist/test/silent-heartbeat-rows.test.d.ts +2 -0
  196. package/dist/test/silent-heartbeat-rows.test.d.ts.map +1 -0
  197. package/dist/test/silent-heartbeat-rows.test.js +451 -0
  198. package/dist/test/silent-heartbeat-rows.test.js.map +1 -0
  199. package/dist/test/silent-heartbeat.test.js +49 -38
  200. package/dist/test/silent-heartbeat.test.js.map +1 -1
  201. package/dist/test/stop-with-work-in-flight.test.d.ts +23 -0
  202. package/dist/test/stop-with-work-in-flight.test.d.ts.map +1 -0
  203. package/dist/test/stop-with-work-in-flight.test.js +672 -0
  204. package/dist/test/stop-with-work-in-flight.test.js.map +1 -0
  205. package/dist/test/tool-batch-hook.test.d.ts +8 -0
  206. package/dist/test/tool-batch-hook.test.d.ts.map +1 -0
  207. package/dist/test/tool-batch-hook.test.js +275 -0
  208. package/dist/test/tool-batch-hook.test.js.map +1 -0
  209. package/dist/test/tool-presentation.test.d.ts +2 -0
  210. package/dist/test/tool-presentation.test.d.ts.map +1 -0
  211. package/dist/test/tool-presentation.test.js +268 -0
  212. package/dist/test/tool-presentation.test.js.map +1 -0
  213. package/dist/test/tool-result-guard.test.js +2330 -18
  214. package/dist/test/tool-result-guard.test.js.map +1 -1
  215. package/dist/test/tool-result-history.test.js +2 -1
  216. package/dist/test/tool-result-history.test.js.map +1 -1
  217. package/dist/test/turn-trigger-provenance.test.js +35 -0
  218. package/dist/test/turn-trigger-provenance.test.js.map +1 -1
  219. package/dist/test/unconvertible-tool-result.test.d.ts +2 -0
  220. package/dist/test/unconvertible-tool-result.test.d.ts.map +1 -0
  221. package/dist/test/unconvertible-tool-result.test.js +358 -0
  222. package/dist/test/unconvertible-tool-result.test.js.map +1 -0
  223. package/dist/test/workspace-materialize-freshness.test.d.ts +16 -0
  224. package/dist/test/workspace-materialize-freshness.test.d.ts.map +1 -0
  225. package/dist/test/workspace-materialize-freshness.test.js +252 -0
  226. package/dist/test/workspace-materialize-freshness.test.js.map +1 -0
  227. package/dist/test/workspace-three-way.test.d.ts +12 -0
  228. package/dist/test/workspace-three-way.test.d.ts.map +1 -0
  229. package/dist/test/workspace-three-way.test.js +3079 -0
  230. package/dist/test/workspace-three-way.test.js.map +1 -0
  231. package/dist/tsconfig.tsbuildinfo +1 -1
  232. package/package.json +3 -3
  233. package/src/agent.ts +120 -44
  234. package/src/code-execution/py-runner.ts +66 -9
  235. package/src/code-execution/runtime-py.ts +215 -38
  236. package/src/framework.ts +1661 -363
  237. package/src/gate/event-gate.ts +59 -7
  238. package/src/index.ts +11 -0
  239. package/src/mcpl/channel-registry.ts +24 -3
  240. package/src/mcpl/conversation-router.ts +34 -1
  241. package/src/mcpl/feature-set-manager.ts +36 -11
  242. package/src/mcpl/index.ts +2 -0
  243. package/src/mcpl/prose-stream-router.ts +79 -23
  244. package/src/mcpl/push-coalescer.ts +62 -13
  245. package/src/mcpl/push-handler.ts +41 -12
  246. package/src/mcpl/server-connection.ts +41 -10
  247. package/src/mcpl/server-registry.ts +24 -7
  248. package/src/mcpl/tool-lifecycle.ts +77 -2
  249. package/src/mcpl/tool-pattern-check.ts +106 -0
  250. package/src/mcpl/types.ts +15 -5
  251. package/src/mcpl/visible-content.ts +57 -0
  252. package/src/module-registry.ts +252 -16
  253. package/src/modules/workspace/branch-intent.ts +123 -0
  254. package/src/modules/workspace/disk-agreement.ts +325 -0
  255. package/src/modules/workspace/effects.ts +362 -0
  256. package/src/modules/workspace/index.ts +1058 -446
  257. package/src/modules/workspace/observe.ts +839 -0
  258. package/src/modules/workspace/reconcile.ts +1136 -0
  259. package/src/modules/workspace/sync.ts +6 -336
  260. package/src/modules/workspace/types.ts +75 -10
  261. package/src/record-journal.ts +246 -0
  262. package/src/shutdown.ts +49 -0
  263. package/src/silent-heartbeat.ts +141 -0
  264. package/src/tool-presentation.ts +188 -0
  265. package/src/tool-result-guard.ts +335 -33
  266. package/src/tool-result-history.ts +25 -1
  267. package/src/types/agent.ts +18 -2
  268. package/src/types/events.ts +4 -0
  269. package/src/types/framework.ts +12 -0
  270. package/src/types/module.ts +15 -0
  271. package/src/types/trace.ts +80 -0
@@ -18,6 +18,7 @@
18
18
  * bookkeeping and is unit-tested without a framework.
19
19
  */
20
20
  import type { McplContentBlock, PushEventResult } from './types.js';
21
+ import { isVisiblyEmptyContent } from './visible-content.js';
21
22
 
22
23
  export const PUSH_COALESCING_SUPPORT = {
23
24
  pushEvents: true, channelsIncoming: true, deferred: true, channelScopedPush: true,
@@ -36,6 +37,15 @@ export class CoalesceError extends Error {
36
37
  }
37
38
  }
38
39
 
40
+ /** Content with nothing model-visible (see visible-content.ts): -32602. */
41
+ export class EmptyContentError extends CoalesceError {
42
+ readonly reason = 'empty-content';
43
+ constructor(field = 'payload.content') {
44
+ super(field, 'content has no visible content: send at least one non-text block or non-whitespace text');
45
+ this.name = 'EmptyContentError';
46
+ }
47
+ }
48
+
39
49
  /** Where an unread occurrence currently lives. */
40
50
  export interface CoalescingPlacement {
41
51
  agent: string;
@@ -100,6 +110,18 @@ interface Rendering<E> {
100
110
  batch: DeferredBatch<E>;
101
111
  cancelled: boolean;
102
112
  done: Promise<void>;
113
+ /** Set once the render (or its fallback) was delivered as content. */
114
+ materialized?: boolean;
115
+ }
116
+
117
+ /** What an assembly did, per subject, for the turn being assembled. */
118
+ export interface AssemblyResult {
119
+ /** Subjects whose render this assembly waited on to completion. */
120
+ settled: Set<string>;
121
+ /** Of those, the subjects whose render or fallback was delivered as content.
122
+ * A settled subject that is not here gave the turn nothing to read: empty
123
+ * or blank render (§5.2), cancelled, or revoked at response. */
124
+ materialized: Set<string>;
103
125
  }
104
126
 
105
127
  interface SubjectState<E> {
@@ -207,8 +229,20 @@ function mergeIdentity(prior: CoalescedOccurrence['identity'], next: CoalescedOc
207
229
  return out;
208
230
  }
209
231
 
210
- /** Validate wire content before it is stored, rendered or converted. */
211
- export function validateCoalescedContent(content: unknown, maxBytes = 1024 * 1024): asserts content is McplContentBlock[] {
232
+ /**
233
+ * Validate wire content before it is stored, rendered or converted.
234
+ *
235
+ * Visibly-empty content is refused unless `allowEmpty`: an admitted
236
+ * occurrence with nothing to show would wake the model with no visible cause.
237
+ * Empty is legitimate only where the RFC gives it a meaning — a retraction
238
+ * with nothing to announce (§6) and a render result that says nothing
239
+ * happened (§5.2) — and for the silent-heartbeat marker.
240
+ */
241
+ export function validateCoalescedContent(
242
+ content: unknown,
243
+ maxBytes = 1024 * 1024,
244
+ options: { allowEmpty?: boolean } = {},
245
+ ): asserts content is McplContentBlock[] {
212
246
  if (!Array.isArray(content)) throw new CoalesceError('payload.content', 'content must be an array');
213
247
  for (const b of content as Array<Record<string, unknown>>) {
214
248
  if (!b || typeof b !== 'object' || Array.isArray(b)) throw new CoalesceError('payload.content', 'invalid content block');
@@ -219,6 +253,7 @@ export function validateCoalescedContent(content: unknown, maxBytes = 1024 * 102
219
253
  throw new CoalesceError('payload.content', 'invalid content block');
220
254
  }
221
255
  if (Buffer.byteLength(JSON.stringify(content)) > maxBytes) throw new CoalesceError('payload.content', 'content exceeds host byte limit');
256
+ if (!options.allowEmpty && isVisiblyEmptyContent(content)) throw new EmptyContentError();
222
257
  }
223
258
 
224
259
  /** Validate the `coalesce` member of either lane (§13). */
@@ -486,7 +521,9 @@ export class PushCoalescer<E = unknown> {
486
521
  this.host.cancelWake(subject);
487
522
  state.consumedEventId = undefined;
488
523
  if (state.history === 'none') return 'retracted';
489
- if (!occurrence.content.length) return 'consumed';
524
+ // An empty notice — or one with only blank text, which would reach the
525
+ // model as an empty message — is pure withdrawal: append nothing.
526
+ if (isVisiblyEmptyContent(occurrence.content)) return 'consumed';
490
527
  // The notice is an ordinary occurrence: it rides the normal delivery path
491
528
  // (tags → gate policy → wake) and is consumed like any message — in the
492
529
  // context that read a version, never a new one (§3.2).
@@ -563,20 +600,29 @@ export class PushCoalescer<E = unknown> {
563
600
  * Render and materialize every pending batch whose audience includes
564
601
  * `agentName`. Called by the host at a turn's assembly boundary, before the
565
602
  * compile. Bounded by the host's render timeout; a failed or late render
566
- * materializes the admitted fallback (§5.3).
603
+ * materializes the admitted fallback (§5.3). Reports which subjects
604
+ * settled and which of them produced content, so the host can tell a turn
605
+ * whose only cause rendered nothing.
567
606
  */
568
- async assemble(agentName: string): Promise<void> {
569
- if (this.suspended) return;
607
+ async assemble(agentName: string): Promise<AssemblyResult> {
608
+ const result: AssemblyResult = { settled: new Set(), materialized: new Set() };
609
+ if (this.suspended) return result;
570
610
  const work: Promise<void>[] = [];
571
611
  for (const subject of [...this.subjects.keys()]) {
572
612
  // The handle is wrapped: a bare promise returned through `locked` would
573
613
  // be flattened by `then`, holding the lock until the render settled —
574
614
  // and the settlement itself needs the lock.
575
615
  const started = await this.locked(() => this.freeze(subject, agentName));
576
- if (started) work.push(started.done);
616
+ if (started) {
617
+ work.push(started.done.then(() => {
618
+ result.settled.add(subject);
619
+ if (started.rendering.materialized) result.materialized.add(subject);
620
+ }));
621
+ }
577
622
  }
578
623
  await Promise.all(work);
579
624
  this.persist();
625
+ return result;
580
626
  }
581
627
 
582
628
  /**
@@ -585,13 +631,13 @@ export class PushCoalescer<E = unknown> {
585
631
  * the RPC. Returns the render's completion (shared by every assembly that
586
632
  * waits on this batch, vector 25) or undefined when nothing was started.
587
633
  */
588
- private async freeze(subject: string, agentName: string): Promise<{ done: Promise<void> } | undefined> {
634
+ private async freeze(subject: string, agentName: string): Promise<{ done: Promise<void>; rendering: Rendering<E> } | undefined> {
589
635
  const state = this.subjects.get(subject);
590
636
  if (!state || this.suspended) return undefined;
591
637
  if (state.rendering) {
592
638
  // Another assembly froze this batch; share its outcome (vector 25).
593
639
  const rendering = state.rendering;
594
- return (await this.host.audience(rendering.batch.latest)).includes(agentName) && state.rendering === rendering ? { done: rendering.done } : undefined;
640
+ return (await this.host.audience(rendering.batch.latest)).includes(agentName) && state.rendering === rendering ? { done: rendering.done, rendering } : undefined;
595
641
  }
596
642
  const batch = state.batch;
597
643
  if (!batch) return undefined;
@@ -634,7 +680,7 @@ export class PushCoalescer<E = unknown> {
634
680
  return undefined;
635
681
  }
636
682
  rendering.done = this.render(subject, state, rendering, agentName);
637
- return { done: rendering.done };
683
+ return { done: rendering.done, rendering };
638
684
  }
639
685
 
640
686
  private async render(subject: string, state: SubjectState<E>, rendering: Rendering<E>, assemblingFor: string): Promise<void> {
@@ -654,7 +700,8 @@ export class PushCoalescer<E = unknown> {
654
700
  try {
655
701
  const result = await this.host.render(occurrence, params);
656
702
  if (rendering.cancelled) { this.host.audit({ kind: 'late-render', subject, eventId: occurrence.eventId, discarded: true }); }
657
- else validateCoalescedContent(result?.content, this.options.maxContentBytes);
703
+ // §5.2: an empty result is legitimate ("nothing happened").
704
+ else validateCoalescedContent(result?.content, this.options.maxContentBytes, { allowEmpty: true });
658
705
  if (rendering.cancelled) throw new CancelledRender();
659
706
  content = result.content;
660
707
  timestamp = typeof result.timestamp === 'string' ? result.timestamp : new Date().toISOString();
@@ -673,9 +720,11 @@ export class PushCoalescer<E = unknown> {
673
720
  if (rendering.cancelled || state.rendering !== rendering || this.suspended) return;
674
721
  // Rule 4: authority is re-checked at response.
675
722
  if (!this.host.authorized(occurrence)) { this.host.audit({ kind: 'revoked', subject, eventId: occurrence.eventId }); return; }
676
- this.host.audit({ kind: 'rendered', subject, eventId: occurrence.eventId, source, empty: content.length === 0 });
677
- if (!content.length) return; // §5.2: nothing happened
723
+ const empty = isVisiblyEmptyContent(content);
724
+ this.host.audit({ kind: 'rendered', subject, eventId: occurrence.eventId, source, empty });
725
+ if (empty) return; // §5.2: nothing happened (blank text included)
678
726
  const placement = await this.host.deliver({ ...occurrence, timestamp, content }, content, assemblingFor);
727
+ rendering.materialized = true;
679
728
  if (placement?.deferredId) {
680
729
  // Landed in another agent's deferred queue (its turn is alive): still
681
730
  // unread there, so it stays replaceable and withdrawable.
@@ -18,7 +18,8 @@ import type {
18
18
  import type { FeatureSetManager } from './feature-set-manager.js';
19
19
  import { McplFeatureSetError } from './feature-set-manager.js';
20
20
  import { expandCoreTags } from './tags.js';
21
- import { validateCoalescedContent } from './push-coalescer.js';
21
+ import { EmptyContentError, validateCoalescedContent } from './push-coalescer.js';
22
+ import { isSilentHeartbeatMarker, isVisiblyEmptyContent } from './visible-content.js';
22
23
 
23
24
  // ============================================================================
24
25
  // McplPushEvent (the ProcessEvent shape pushed to the queue)
@@ -188,9 +189,9 @@ export class PushHandler {
188
189
  * Handle a push/event message from an MCPL server.
189
190
  *
190
191
  * 1. Validate feature set
191
- * 2. Deduplicate by eventId
192
- * 3. Optionally check shouldTriggerInference callback
193
- * 4. Convert content blocks
192
+ * 2. Convert content blocks; reject visibly-empty content
193
+ * 3. Deduplicate by eventId
194
+ * 4. Optionally check shouldTriggerInference callback
194
195
  * 5. Push event to queue
195
196
  * 6. Emit trace
196
197
  * 7. Respond with accepted + inferenceId
@@ -229,30 +230,46 @@ export class PushHandler {
229
230
  return;
230
231
  }
231
232
 
232
- // 2. Deduplicate by eventId. A coalesced occurrence is deduplicated by the
233
- // coalescer's receipts instead (RFC-006 §3.1: a retry within the window
234
- // gets its original result, which this set could not return).
235
233
  const coalesced = params.coalesce !== undefined && !!this.handleCoalesced;
234
+ // Empty content is meaningful only for a coalescing retraction (pure
235
+ // withdrawal, RFC-006 §6 — the coalescer appends nothing for it) and for
236
+ // the silent-heartbeat marker. Anything else that shows the model nothing
237
+ // would wake it with no visible cause.
238
+ const emptyAllowed = (coalesced && params.coalesce?.retract === true)
239
+ || isSilentHeartbeatMarker({ serverId, featureSet: params.featureSet, origin: params.origin, content: params.payload?.content });
236
240
  if (coalesced) {
237
241
  // RFC-006 §13: malformed content is a -32602, checked before conversion.
238
242
  try {
239
- validateCoalescedContent(params.payload?.content);
243
+ validateCoalescedContent(params.payload?.content, undefined, { allowEmpty: emptyAllowed });
240
244
  } catch (error) {
241
245
  const err = error as Error & { code?: number; field?: string };
246
+ if (err instanceof EmptyContentError) this.traceEmptyRejection(serverId, params);
242
247
  if (responder?.respondError) responder.respondError(err.code ?? -32602, err.message, { field: err.field });
243
- else responder?.respond({ accepted: false, reason: err.message });
248
+ else responder?.respond({ accepted: false, reason: err instanceof EmptyContentError ? err.reason : err.message });
244
249
  return;
245
250
  }
246
251
  }
252
+
253
+ // 2. Convert content blocks, and refuse content with nothing visible in
254
+ // it — before dedup, so a refused eventId is not burned for a retry.
255
+ const content: ContentBlock[] = params.payload.content.map(convertBlock);
256
+ if (!emptyAllowed && isVisiblyEmptyContent(content)) {
257
+ const err = new EmptyContentError();
258
+ this.traceEmptyRejection(serverId, params);
259
+ if (responder?.respondError) responder.respondError(err.code, err.message, { field: err.field });
260
+ else responder?.respond({ accepted: false, reason: err.reason });
261
+ return;
262
+ }
263
+
264
+ // 3. Deduplicate by eventId. A coalesced occurrence is deduplicated by the
265
+ // coalescer's receipts instead (RFC-006 §3.1: a retry within the window
266
+ // gets its original result, which this set could not return).
247
267
  if (!coalesced && this.dedup.checkAndAdd(params.eventId)) {
248
268
  console.error(`[push-event-rejected] server=${serverId} eventId=${params.eventId} reason=duplicate`);
249
269
  responder?.respond({ accepted: false, reason: 'duplicate' });
250
270
  return;
251
271
  }
252
272
 
253
- // 3. Convert content blocks
254
- const content: ContentBlock[] = params.payload.content.map(convertBlock);
255
-
256
273
  // 4. Check shouldTriggerInference callback
257
274
  let triggerInference = true;
258
275
  if (this.shouldTriggerInference) {
@@ -316,4 +333,16 @@ export class PushHandler {
316
333
  // 8. Respond
317
334
  responder?.respond({ accepted: true, inferenceId });
318
335
  }
336
+
337
+ /** Loud, like every other push rejection: the producer's wake went nowhere. */
338
+ private traceEmptyRejection(serverId: string, params: PushEventParams): void {
339
+ console.error(`[push-event-rejected] server=${serverId} eventId=${params.eventId} reason=empty-content`);
340
+ this.emitTraceFn({
341
+ type: 'mcpl:push-event-rejected',
342
+ serverId,
343
+ eventId: params.eventId,
344
+ featureSet: params.featureSet,
345
+ reason: 'empty-content',
346
+ });
347
+ }
319
348
  }
@@ -209,6 +209,9 @@ export class McplServerConnection extends EventEmitter {
209
209
  * observable even though its original promise has already been rejected. */
210
210
  private orphanedRequests = new Map<string | number, { method: string; renderParams?: Record<string, unknown> }>();
211
211
  private closed = false;
212
+ private closePromise: Promise<void> | null = null;
213
+ private closeIncomplete = false;
214
+ private reconnectPromise: Promise<void> | null = null;
212
215
 
213
216
  /** Per-request timeout in ms (0 disables). See McplServerConfig.requestTimeoutMs. */
214
217
  private requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS;
@@ -837,10 +840,10 @@ export class McplServerConnection extends EventEmitter {
837
840
  this.reconnectTimer = null;
838
841
  }
839
842
 
840
- if (this.closed) {
841
- return;
842
- }
843
+ if (this.closePromise) return this.closePromise;
844
+ if (this.closed && !this.closeIncomplete && !this.reconnectPromise) return;
843
845
  this.closed = true;
846
+ this.closeIncomplete = true;
844
847
  this.resetPolicyForTransportBoundary();
845
848
 
846
849
  // Reject all pending requests
@@ -854,11 +857,20 @@ export class McplServerConnection extends EventEmitter {
854
857
  // Tear down the transport (kills the child / closes the socket). The
855
858
  // transport's own 'close' event is short-circuited by `this.closed` in
856
859
  // setupLifecycle, so this is the single source of the connection 'close'.
857
- if (this.transport) {
858
- await this.transport.close();
859
- }
860
-
861
- this.emit('close');
860
+ const attempt = Promise.resolve().then(async () => {
861
+ // An already opened handshake owns a transport too. It hands that
862
+ // transport back without admission once reconnect has been disabled.
863
+ // The registry bounds this wait; retry reuses this real close attempt.
864
+ if (this.reconnectPromise) await this.reconnectPromise;
865
+ if (this.transport) await this.transport.close();
866
+ this.closeIncomplete = false;
867
+ this.emit('close');
868
+ });
869
+ this.closePromise = attempt;
870
+ void attempt.catch(() => {
871
+ if (this.closePromise === attempt) this.closePromise = null;
872
+ });
873
+ return attempt;
862
874
  }
863
875
 
864
876
  /**
@@ -912,8 +924,19 @@ export class McplServerConnection extends EventEmitter {
912
924
  * approach this builds no throwaway connection instance, so there are no
913
925
  * leaked listeners on a dead object.
914
926
  */
915
- private async attemptReconnect(): Promise<void> {
916
- if (!this.reconnectEnabled || !this.config || !this.hostCapabilities) return;
927
+ private attemptReconnect(): Promise<void> {
928
+ if (this.reconnectPromise) return this.reconnectPromise;
929
+ const attempt = this.reconnectOnce();
930
+ this.reconnectPromise = attempt;
931
+ const clear = () => { if (this.reconnectPromise === attempt) this.reconnectPromise = null; };
932
+ void attempt.then(clear, clear);
933
+ return attempt;
934
+ }
935
+
936
+ private async reconnectOnce(): Promise<void> {
937
+ // A queued recovery timer may outlive an earlier successful handshake.
938
+ // Recheck disconnection before opening or replacing any owned transport.
939
+ if (!this.closed || !this.reconnectEnabled || !this.config || !this.hostCapabilities) return;
917
940
 
918
941
  // Ordinal of this attempt: 0 was the initial connect, so the Nth retry
919
942
  // reports attempt N (reconnectAttempts counts failures so far).
@@ -925,6 +948,14 @@ export class McplServerConnection extends EventEmitter {
925
948
  this.hostCapabilities,
926
949
  );
927
950
 
951
+ if (!this.reconnectEnabled) {
952
+ // The prior transport was disconnected before this retry began. Keep
953
+ // the fresh, unadmitted transport owned by close(), including on a
954
+ // failed transport cleanup, instead of reviving a stopped connection.
955
+ this.transport = transport;
956
+ return;
957
+ }
958
+
928
959
  // Close the data plane before the new transport can emit. The reconnect
929
960
  // lifecycle event bypasses buffering; its framework listener installs the
930
961
  // awareness barrier synchronously, then releases control traffic needed
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type { McplServerConfig, McplHostCapabilities } from './types.js';
8
8
  import { McplServerConnection } from './server-connection.js';
9
+ import { DEFAULT_SHUTDOWN_TIMEOUT_MS, validateShutdownTimeout, waitForShutdown } from '../shutdown.js';
9
10
 
10
11
  /**
11
12
  * Capability keys that can be queried via `getServersWithCapability`.
@@ -25,6 +26,7 @@ export type McplCapabilityQuery =
25
26
  */
26
27
  export class McplServerRegistry {
27
28
  private servers = new Map<string, McplServerConnection>();
29
+ private closing = new Map<McplServerConnection, Promise<void>>();
28
30
 
29
31
  /**
30
32
  * Connect to an MCPL server and register it.
@@ -70,8 +72,7 @@ export class McplServerRegistry {
70
72
  if (!connection) {
71
73
  return;
72
74
  }
73
- this.servers.delete(id);
74
- await connection.close();
75
+ await this.closeServer(id, connection);
75
76
  }
76
77
 
77
78
  /**
@@ -135,11 +136,27 @@ export class McplServerRegistry {
135
136
  }
136
137
 
137
138
  /**
138
- * Close all server connections and clear the registry.
139
+ * Close all server connections, retaining failed or pending attempts.
140
+ * An explicit timeout bounds the caller's wait without cancelling cleanup.
139
141
  */
140
- async closeAll(): Promise<void> {
141
- const connections = Array.from(this.servers.values());
142
- this.servers.clear();
143
- await Promise.all(connections.map((c) => c.close()));
142
+ async closeAll(timeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS): Promise<void> {
143
+ validateShutdownTimeout(timeoutMs);
144
+ await waitForShutdown(Array.from(this.servers, ([id, connection]) => ({
145
+ label: `mcpl:${id}`, promise: this.closeServer(id, connection),
146
+ })), timeoutMs);
147
+ }
148
+
149
+ private closeServer(id: string, connection: McplServerConnection): Promise<void> {
150
+ const existing = this.closing.get(connection);
151
+ if (existing) return existing;
152
+ const attempt = Promise.resolve().then(() => connection.close()).then(() => {
153
+ if (this.servers.get(id) === connection) this.servers.delete(id);
154
+ this.closing.delete(connection);
155
+ }, error => {
156
+ this.closing.delete(connection);
157
+ throw error;
158
+ });
159
+ this.closing.set(connection, attempt);
160
+ return attempt;
144
161
  }
145
162
  }
@@ -50,8 +50,20 @@ export interface ToolLifecycleParams {
50
50
  inputWithheld?: boolean;
51
51
  isError?: boolean;
52
52
  durationMs?: number;
53
+ /**
54
+ * MCP's extension slot. Carries only SCRIPT_PARENT_META_KEY, on calls a
55
+ * code_execution script made: RFC-007 has no field for a call's origin,
56
+ * and the `mcpl/` prefix is reserved for keys MCPL defines (RFC-008 §3).
57
+ */
58
+ _meta?: Record<string, unknown>;
53
59
  }
54
60
 
61
+ /**
62
+ * `_meta` key naming the `toolCallId` of the code_execution call whose script
63
+ * made this call. Host-specific: not defined by RFC-007.
64
+ */
65
+ export const SCRIPT_PARENT_META_KEY = 'agent-framework/parentToolCallId';
66
+
55
67
  export interface ToolObserveMatch {
56
68
  tool?: string;
57
69
  serverTool?: string;
@@ -78,7 +90,8 @@ export interface ToolObserveRule {
78
90
  * RFC-007 §6.2 grammar.
79
91
  */
80
92
  export interface ToolLifecycleNarrowing {
81
- /** Patterns over the model-facing tool name (`computer--*`). */
93
+ /** Patterns over the model-facing tool name (`computer--*`): for an MCPL
94
+ * tool `<toolPrefix>--<tool>`, toolPrefix defaulting to `mcpl--<serverId>`. */
82
95
  tools?: string[];
83
96
  /** The tool's effective class must intersect this set. `'default'` is
84
97
  * DEFAULT_INPUT_CLASSES (computer, shell, files, web, media, body). */
@@ -242,6 +255,8 @@ export interface ToolCallDescriptor {
242
255
  serverId?: string;
243
256
  serverTool?: string;
244
257
  input: unknown;
258
+ /** Script-inner calls: the code_execution call's `toolCallId`, when it had one. */
259
+ parentToolCallId?: string;
245
260
  }
246
261
 
247
262
  function anyGlob(patterns: readonly string[], subject: string): boolean {
@@ -466,6 +481,7 @@ function baseParams(call: ToolCallDescriptor, phase: ToolPhase): ToolLifecyclePa
466
481
  class: [...call.class],
467
482
  ...(call.serverId !== undefined ? { serverId: call.serverId, serverTool: call.serverTool } : {}),
468
483
  phase,
484
+ ...(call.parentToolCallId !== undefined ? { _meta: { [SCRIPT_PARENT_META_KEY]: call.parentToolCallId } } : {}),
469
485
  };
470
486
  }
471
487
 
@@ -537,10 +553,23 @@ export interface ToolLifecycleHost {
537
553
  describe(tool: string): { class: ToolClass[]; serverId?: string; serverTool?: string };
538
554
  }
539
555
 
556
+ /**
557
+ * What a call made from inside a code_execution script inherits from the
558
+ * code_execution call that ran the script (scriptOrigin()).
559
+ */
560
+ export interface ScriptCallOrigin {
561
+ /** The inference whose output requested the code_execution call. */
562
+ inferenceId: string;
563
+ /** That call's `toolCallId`. Absent when nothing observed it, so it has none. */
564
+ parentToolCallId?: string;
565
+ }
566
+
540
567
  interface TrackedCall {
541
568
  agentName: string;
542
569
  modelCallId: string;
543
570
  call: ToolCallDescriptor;
571
+ /** Made by a code_execution script, not by the model (registerScriptCall). */
572
+ scriptInner: boolean;
544
573
  /** Connection id → transport epoch the opening went to. */
545
574
  openedTo: Map<string, number>;
546
575
  /** `started` has been sent (open() ran and the call was not refused). */
@@ -569,6 +598,9 @@ export class ToolLifecycleEmitter {
569
598
  private readonly recentIds = new Set<string>();
570
599
  private readonly recentOrder: string[] = [];
571
600
  private mintSeq = 0;
601
+ /** The call between register() and open(), kept even when nothing observes,
602
+ * so a code_execution dispatch can learn its inference (scriptOrigin()). */
603
+ private dispatching: { agentName: string; callId: string; inferenceId: string } | null = null;
572
604
 
573
605
  constructor(host: ToolLifecycleHost, now: () => number = Date.now) {
574
606
  this.host = host;
@@ -608,6 +640,44 @@ export class ToolLifecycleEmitter {
608
640
  * host may still refuse it (refuse()) before it executes.
609
641
  */
610
642
  register(agentName: string, inferenceId: string, call: { id: string; name: string; input: unknown }): void {
643
+ this.dispatching = { agentName, callId: call.id, inferenceId };
644
+ this.track(agentName, inferenceId, call, false);
645
+ }
646
+
647
+ /**
648
+ * A call a code_execution script made, about to go through the same
649
+ * dispatch as a model call (then refuse() / open() / onResult() as for
650
+ * one). Its class, provider, grant narrowing and filter matching are the
651
+ * INNER tool's own; its inference is the one that issued the
652
+ * code_execution call. Unlike a model call it is not aborted when its
653
+ * stream ends: the tool keeps running after the turn or the script is
654
+ * gone, so its terminal is the result's, or `failed` when the host gives
655
+ * up waiting for one.
656
+ */
657
+ registerScriptCall(agentName: string, origin: ScriptCallOrigin, call: { id: string; name: string; input: unknown }): void {
658
+ this.track(agentName, origin.inferenceId, call, true, origin.parentToolCallId);
659
+ }
660
+
661
+ /**
662
+ * The origin a code_execution call passes to the calls its script makes.
663
+ * Valid only while that call is being dispatched (between register() and
664
+ * open()), which is where the framework asks.
665
+ */
666
+ scriptOrigin(agentName: string, callId: string): ScriptCallOrigin | undefined {
667
+ const tracked = this.calls.get(callKey(agentName, callId));
668
+ if (tracked) return { inferenceId: tracked.call.inferenceId, parentToolCallId: tracked.call.toolCallId };
669
+ const d = this.dispatching;
670
+ if (d && d.agentName === agentName && d.callId === callId) return { inferenceId: d.inferenceId };
671
+ return undefined;
672
+ }
673
+
674
+ private track(
675
+ agentName: string,
676
+ inferenceId: string,
677
+ call: { id: string; name: string; input: unknown },
678
+ scriptInner: boolean,
679
+ parentToolCallId?: string,
680
+ ): void {
611
681
  if (!this.anyObserver()) return;
612
682
  const described = this.host.describe(call.name);
613
683
  this.calls.set(callKey(agentName, call.id), {
@@ -623,7 +693,9 @@ export class ToolLifecycleEmitter {
623
693
  ? { serverId: described.serverId, serverTool: described.serverTool }
624
694
  : {}),
625
695
  input: call.input,
696
+ ...(parentToolCallId !== undefined ? { parentToolCallId } : {}),
626
697
  },
698
+ scriptInner,
627
699
  openedTo: new Map(),
628
700
  opened: false,
629
701
  // Taken before dispatch, so durationMs includes work a synchronous
@@ -652,6 +724,7 @@ export class ToolLifecycleEmitter {
652
724
  * run synchronously inside dispatch, before this.
653
725
  */
654
726
  open(agentName: string, callId: string): void {
727
+ this.dispatching = null;
655
728
  const key = callKey(agentName, callId);
656
729
  const tracked = this.calls.get(key);
657
730
  if (!tracked || tracked.opened) return;
@@ -725,12 +798,14 @@ export class ToolLifecycleEmitter {
725
798
  * A stream ended: its calls still open were cancelled before a result.
726
799
  * Scoped to the inference ids that stream minted, so a successor stream
727
800
  * for the same agent (a budget restart overlapping this one's teardown)
728
- * keeps its live calls. A result arriving later finds nothing.
801
+ * keeps its live calls. A result arriving later finds nothing. Calls a
802
+ * script made are left open: they end with their own result.
729
803
  */
730
804
  abortOpen(agentName: string, inferenceIds: Iterable<string>): void {
731
805
  if (this.calls.size === 0) return;
732
806
  const ids = new Set(inferenceIds);
733
807
  for (const tracked of [...this.calls.values()]) {
808
+ if (tracked.scriptInner) continue;
734
809
  if (tracked.agentName === agentName && ids.has(tracked.call.inferenceId)) {
735
810
  if (tracked.opened) this.terminate(tracked, 'aborted');
736
811
  else this.calls.delete(callKey(tracked.agentName, tracked.modelCallId));
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Zero-match diagnostics for operator tool-name patterns.
3
+ *
4
+ * `toolClassOverrides` keys and `toolLifecycle.{observe,inputs}.tools` are
5
+ * RFC-007 §6.2 patterns over the MODEL-FACING tool name. For an MCPL tool
6
+ * that is `<toolPrefix>--<tool>`, and `toolPrefix` defaults to
7
+ * `mcpl--<serverId>`. A pattern written against the bare server id
8
+ * (`search--*` for server `search` with no toolPrefix) is valid config that
9
+ * matches nothing, and nothing else reports it: the override classes no tool
10
+ * and the narrowing admits no call. This module decides, for one pattern and
11
+ * the current tool listing, whether that is the case and what was probably
12
+ * meant.
13
+ */
14
+
15
+ import { globMatch } from './tool-glob.js';
16
+
17
+ export interface PatternServer {
18
+ id: string;
19
+ /** Effective tool prefix: config `toolPrefix ?? mcpl--<id>`. */
20
+ prefix: string;
21
+ /** True when this server's tools are in the current listing. */
22
+ listed: boolean;
23
+ }
24
+
25
+ export type ToolPatternVerdict =
26
+ | { kind: 'matched' }
27
+ /** Could still match tools of servers whose listing is not in (yet). */
28
+ | { kind: 'pending'; servers: string[] }
29
+ | { kind: 'unmatched'; suggestion?: string; hint: string };
30
+
31
+ /**
32
+ * Could `pattern` match some name under `<prefix>--`? Decided on the literal
33
+ * text before the first `*`; anything after it is not consulted, so this
34
+ * errs toward "could" (which only defers a warning, never invents one).
35
+ */
36
+ export function couldMatchUnderPrefix(pattern: string, prefix: string): boolean {
37
+ const head = `${prefix}--`;
38
+ const star = pattern.indexOf('*');
39
+ if (star === -1) return pattern.startsWith(head);
40
+ const literal = pattern.slice(0, star);
41
+ return literal.startsWith(head) || head.startsWith(literal);
42
+ }
43
+
44
+ /**
45
+ * Check one pattern against the model-facing tool names offered right now.
46
+ *
47
+ * `pending` while a server whose tools the pattern could name has not
48
+ * listed them (not connected yet, reconnecting, or removed): its listing is
49
+ * the only evidence, and an early verdict would be a false alarm.
50
+ */
51
+ export function checkToolPattern(
52
+ pattern: string,
53
+ toolNames: Iterable<string>,
54
+ servers: readonly PatternServer[],
55
+ ): ToolPatternVerdict {
56
+ const names = [...toolNames];
57
+ for (const name of names) {
58
+ if (globMatch(pattern, name)) return { kind: 'matched' };
59
+ }
60
+ const pending = servers.filter((s) => !s.listed && couldMatchUnderPrefix(pattern, s.prefix));
61
+ if (pending.length > 0) return { kind: 'pending', servers: [...new Set(pending.map((s) => s.id))] };
62
+
63
+ // Written against the wrong namespace for a known server: the bare id
64
+ // (`<id>--…`) where the effective prefix differs, or the default form
65
+ // (`mcpl--<id>--…`) for a server that set its own toolPrefix. Never when
66
+ // the namespace as written is real — another server's toolPrefix or a
67
+ // module's tools — since the pattern then names that, not a mistake.
68
+ const inRealNamespace = (ns: string) =>
69
+ servers.some((o) => o.prefix === ns) || names.some((n) => n.startsWith(`${ns}--`));
70
+ for (const s of servers) {
71
+ let rest: string | undefined;
72
+ if (s.prefix !== s.id && pattern.startsWith(`${s.id}--`) && !inRealNamespace(s.id)) {
73
+ rest = pattern.slice(s.id.length + 2);
74
+ } else if (
75
+ s.prefix !== `mcpl--${s.id}` && pattern.startsWith(`mcpl--${s.id}--`) && !inRealNamespace(`mcpl--${s.id}`)
76
+ ) {
77
+ rest = pattern.slice(s.id.length + 8);
78
+ }
79
+ if (rest === undefined) continue;
80
+ const why = s.prefix === `mcpl--${s.id}` ? ' (no toolPrefix set; the default is "mcpl--<serverId>")' : '';
81
+ return {
82
+ kind: 'unmatched',
83
+ suggestion: `${s.prefix}--${rest}`,
84
+ hint: `server "${s.id}" names its tools "${s.prefix}--<tool>"${why}`,
85
+ };
86
+ }
87
+
88
+ if (pattern.includes('--') && !servers.some((s) => couldMatchUnderPrefix(pattern, s.prefix))) {
89
+ return { kind: 'unmatched', hint: 'no connected MCPL server has a toolPrefix it could match' };
90
+ }
91
+ return { kind: 'unmatched', hint: 'no tool the framework offers has a matching name' };
92
+ }
93
+
94
+ /** The operator-facing line for an `unmatched` verdict. */
95
+ export function describeUnmatchedPattern(
96
+ where: string,
97
+ pattern: string,
98
+ verdict: Extract<ToolPatternVerdict, { kind: 'unmatched' }>,
99
+ ): string {
100
+ const meant = verdict.suggestion ? ` — did you mean ${JSON.stringify(verdict.suggestion)}?` : '';
101
+ return (
102
+ `[mcpl] ${where} pattern ${JSON.stringify(pattern)} matches no tool${meant} ` +
103
+ `(${verdict.hint}). Patterns match the full model-facing tool name, ` +
104
+ `"<toolPrefix>--<tool>" for MCPL tools.`
105
+ );
106
+ }