@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
@@ -0,0 +1,730 @@
1
+ /**
2
+ * Tool lifecycle (MCPL RFC-007): `tools/lifecycle` notifications about the
3
+ * agent's tool calls, and the `tools/observe` filter a server sets on them.
4
+ *
5
+ * Authority is the grant plus host narrowing; a server's filter is interest
6
+ * and only ever narrows. Per call and connection the host decides, in order
7
+ * (RFC-007 §6.4):
8
+ *
9
+ * 1. reported at all? grant `toolLifecycle.observe` + its narrowing
10
+ * 2. reported to this server? filter: first matching rule and its `report`
11
+ * (no filter → yes)
12
+ * 3. arguments requested? that rule's `input` (no filter → NO)
13
+ * 4. arguments allowed? grant `toolLifecycle.inputs` + its narrowing,
14
+ * and never for `comms` or unclassed tools
15
+ * 5. which fields, how large? the rule's field selection, then the bound
16
+ *
17
+ * Tool RESULTS are never carried — no field for them exists here.
18
+ *
19
+ * Delivery is best-effort like `inference/lifecycle` (SPEC §10.5): one
20
+ * terminal is attempted per opened call, to exactly the connections the
21
+ * opening went to, unless their grant or narrowing no longer covers the call
22
+ * (RFC-007 §4.5). A filter change never suppresses a terminal (§6.5).
23
+ */
24
+
25
+ import { CapabilityGrant } from './capability-grant.js';
26
+ import { globMatch } from './tool-glob.js';
27
+ import { DEFAULT_INPUT_CLASSES, isToolClass, type ToolClass } from './tool-classes.js';
28
+
29
+ export const TOOL_LIFECYCLE_OBSERVE = 'toolLifecycle.observe';
30
+ export const TOOL_LIFECYCLE_INPUTS = 'toolLifecycle.inputs';
31
+
32
+ // ============================================================================
33
+ // Wire types
34
+ // ============================================================================
35
+
36
+ export type ToolPhase = 'pending' | 'started' | 'completed' | 'failed' | 'aborted';
37
+
38
+ /** `tools/lifecycle` params (Host → Server, Notification). RFC-007 §3. */
39
+ export interface ToolLifecycleParams {
40
+ toolCallId: string;
41
+ inferenceId: string;
42
+ conversationId: string;
43
+ tool: string;
44
+ class: ToolClass[];
45
+ serverId?: string;
46
+ serverTool?: string;
47
+ phase: ToolPhase;
48
+ input?: Record<string, unknown>;
49
+ inputAltered?: boolean;
50
+ inputWithheld?: boolean;
51
+ isError?: boolean;
52
+ durationMs?: number;
53
+ }
54
+
55
+ export interface ToolObserveMatch {
56
+ tool?: string;
57
+ serverTool?: string;
58
+ serverId?: string;
59
+ conversationId?: string;
60
+ class?: ToolClass;
61
+ }
62
+
63
+ /** One normalized `tools/observe` rule (RFC-007 §6.1). */
64
+ export interface ToolObserveRule {
65
+ match: ToolObserveMatch;
66
+ report: boolean;
67
+ /** false = metadata only; true = all arguments; string[] = field paths. */
68
+ input: boolean | string[];
69
+ }
70
+
71
+ // ============================================================================
72
+ // Host policy (config)
73
+ // ============================================================================
74
+
75
+ /**
76
+ * A narrowing attached to one `toolLifecycle.*` grant entry (RFC-007 §4.3).
77
+ * A call is inside it when it satisfies EVERY key stated. Patterns use the
78
+ * RFC-007 §6.2 grammar.
79
+ */
80
+ export interface ToolLifecycleNarrowing {
81
+ /** Patterns over the model-facing tool name (`computer--*`). */
82
+ tools?: string[];
83
+ /** The tool's effective class must intersect this set. `'default'` is
84
+ * DEFAULT_INPUT_CLASSES (computer, shell, files, web, media, body). */
85
+ classes?: ToolClass[] | 'default';
86
+ /** Patterns over `conversationId` (the agent name in this host). */
87
+ conversations?: string[];
88
+ }
89
+
90
+ /**
91
+ * Per-server tool-lifecycle policy (`McplServerConfig.toolLifecycle`).
92
+ *
93
+ * Both capability paths are DENIED BY DEFAULT. Stating `observe` (even as
94
+ * `{}`) or `inputs` here is the operator's explicit grant of that path, as
95
+ * is naming it in `enabledCapabilities`. `inputs` is never unconditional:
96
+ * without at least one `tools` or `classes` term it delivers no arguments
97
+ * (RFC-007 §4.3).
98
+ */
99
+ export interface ToolLifecycleConfig {
100
+ observe?: ToolLifecycleNarrowing;
101
+ inputs?: ToolLifecycleNarrowing;
102
+ /** Serialized-size bound for `input` per notification. Default 16 KiB. */
103
+ maxInputBytes?: number;
104
+ }
105
+
106
+ export const DEFAULT_MAX_INPUT_BYTES = 16 * 1024;
107
+
108
+ /** Accepted sizes for a `tools/observe` request (RFC-007 §6.6 asks hosts to
109
+ * accept at least 64 rules, 64 paths per rule, 256-character strings). */
110
+ export const TOOL_OBSERVE_LIMITS = {
111
+ rules: 256,
112
+ pathsPerRule: 256,
113
+ stringLength: 256,
114
+ } as const;
115
+
116
+ // ============================================================================
117
+ // tools/observe parsing (RFC-007 §6.1, §6.6)
118
+ // ============================================================================
119
+
120
+ export type ToolObserveParseResult =
121
+ | { ok: true; rules: ToolObserveRule[] | null }
122
+ | { ok: false; message: string; data?: Record<string, unknown> };
123
+
124
+ const MATCH_KEYS = new Set(['tool', 'serverTool', 'serverId', 'conversationId', 'class']);
125
+ const RULE_KEYS = new Set(['match', 'report', 'input']);
126
+
127
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
128
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
129
+ }
130
+
131
+ /**
132
+ * Validate `tools/observe` params. `rules` absent or null clears the filter
133
+ * (`{ok, rules: null}`); `[]` is a valid filter that reports nothing. Unknown
134
+ * members are REJECTED, not ignored: ignoring an unknown `match` member would
135
+ * make a rule match more than its author wrote.
136
+ */
137
+ export function parseToolObserveParams(params: unknown): ToolObserveParseResult {
138
+ if (params === undefined || params === null) return { ok: true, rules: null };
139
+ if (!isPlainObject(params)) return { ok: false, message: 'tools/observe params must be an object' };
140
+ // Unknown members are rejected here too (MCP's own `_meta` excepted): a
141
+ // misspelled `rules` must not read as "no rules" and silently clear a
142
+ // restrictive filter.
143
+ for (const key of Object.keys(params)) {
144
+ if (key !== 'rules' && key !== '_meta') {
145
+ return { ok: false, message: `tools/observe: unknown params member "${key}"` };
146
+ }
147
+ }
148
+ const raw = params.rules;
149
+ if (raw === undefined || raw === null) return { ok: true, rules: null };
150
+ if (!Array.isArray(raw)) return { ok: false, message: 'tools/observe: rules must be an array or null' };
151
+ if (raw.length > TOOL_OBSERVE_LIMITS.rules) {
152
+ return { ok: false, message: `tools/observe: more than ${TOOL_OBSERVE_LIMITS.rules} rules`, data: { limit: 'rules' } };
153
+ }
154
+
155
+ const rules: ToolObserveRule[] = [];
156
+ for (let i = 0; i < raw.length; i++) {
157
+ const at = `rules[${i}]`;
158
+ const rule = raw[i];
159
+ if (!isPlainObject(rule)) return { ok: false, message: `tools/observe: ${at} must be an object` };
160
+ for (const key of Object.keys(rule)) {
161
+ if (!RULE_KEYS.has(key)) return { ok: false, message: `tools/observe: ${at} has unknown member "${key}"` };
162
+ }
163
+ if (!isPlainObject(rule.match)) return { ok: false, message: `tools/observe: ${at}.match must be an object` };
164
+
165
+ const match: ToolObserveMatch = {};
166
+ for (const [key, value] of Object.entries(rule.match)) {
167
+ if (!MATCH_KEYS.has(key)) {
168
+ return { ok: false, message: `tools/observe: ${at}.match has unknown member "${key}"` };
169
+ }
170
+ if (key === 'class') {
171
+ if (!isToolClass(value)) return { ok: false, message: `tools/observe: ${at}.match.class is not a ToolClass` };
172
+ match.class = value;
173
+ continue;
174
+ }
175
+ if (typeof value !== 'string') return { ok: false, message: `tools/observe: ${at}.match.${key} must be a string` };
176
+ if (value.length > TOOL_OBSERVE_LIMITS.stringLength) {
177
+ return { ok: false, message: `tools/observe: ${at}.match.${key} is too long`, data: { limit: 'stringLength' } };
178
+ }
179
+ (match as Record<string, string>)[key] = value;
180
+ }
181
+
182
+ if (rule.report !== undefined && typeof rule.report !== 'boolean') {
183
+ return { ok: false, message: `tools/observe: ${at}.report must be a boolean` };
184
+ }
185
+
186
+ let input: boolean | string[] = false;
187
+ if (rule.input !== undefined) {
188
+ if (typeof rule.input === 'boolean') {
189
+ input = rule.input;
190
+ } else if (Array.isArray(rule.input)) {
191
+ if (rule.input.length > TOOL_OBSERVE_LIMITS.pathsPerRule) {
192
+ return { ok: false, message: `tools/observe: ${at}.input has too many paths`, data: { limit: 'pathsPerRule' } };
193
+ }
194
+ const paths: string[] = [];
195
+ for (const path of rule.input) {
196
+ if (typeof path !== 'string' || path.length === 0) {
197
+ return { ok: false, message: `tools/observe: ${at}.input must hold non-empty strings` };
198
+ }
199
+ if (path.length > TOOL_OBSERVE_LIMITS.stringLength) {
200
+ return { ok: false, message: `tools/observe: ${at}.input path is too long`, data: { limit: 'stringLength' } };
201
+ }
202
+ paths.push(path);
203
+ }
204
+ input = paths;
205
+ } else {
206
+ return { ok: false, message: `tools/observe: ${at}.input must be a boolean or an array of field paths` };
207
+ }
208
+ }
209
+
210
+ rules.push({ match, report: rule.report !== false, input });
211
+ }
212
+ return { ok: true, rules };
213
+ }
214
+
215
+ // ============================================================================
216
+ // Matching
217
+ // ============================================================================
218
+
219
+ /** What the host knows about one call, shared by every connection. */
220
+ export interface ToolCallDescriptor {
221
+ /** Host-unique on every connection (RFC-007 §3). */
222
+ toolCallId: string;
223
+ inferenceId: string;
224
+ conversationId: string;
225
+ tool: string;
226
+ class: ToolClass[];
227
+ serverId?: string;
228
+ serverTool?: string;
229
+ input: unknown;
230
+ }
231
+
232
+ function anyGlob(patterns: readonly string[], subject: string): boolean {
233
+ for (const p of patterns) if (globMatch(p, subject)) return true;
234
+ return false;
235
+ }
236
+
237
+ const isStringArray = (v: unknown): v is string[] => Array.isArray(v) && v.every((x) => typeof x === 'string');
238
+
239
+ /**
240
+ * Every stated key must hold. An unclassed tool never satisfies `classes`.
241
+ * Policy comes from operator config (JSON recipes), so a key of the wrong
242
+ * shape admits NOTHING rather than being read as absent — fail closed.
243
+ */
244
+ function narrowingAdmits(n: ToolLifecycleNarrowing, call: ToolCallDescriptor): boolean {
245
+ if (n.tools !== undefined && (!isStringArray(n.tools) || !anyGlob(n.tools, call.tool))) return false;
246
+ if (n.classes !== undefined) {
247
+ const classes = n.classes === 'default' ? DEFAULT_INPUT_CLASSES : n.classes;
248
+ if (!Array.isArray(classes) || !call.class.some((c) => classes.includes(c))) return false;
249
+ }
250
+ if (n.conversations !== undefined
251
+ && (!isStringArray(n.conversations) || !anyGlob(n.conversations, call.conversationId))) {
252
+ return false;
253
+ }
254
+ return true;
255
+ }
256
+
257
+ /**
258
+ * `observe` narrowing: absent means no narrowing (the grant is enough). A
259
+ * value that is not a narrowing object (`false`, `null`, a string) admits
260
+ * nothing: a setting that looks like "off" must never read as "everything".
261
+ */
262
+ export function observeAdmits(config: ToolLifecycleConfig | undefined, call: ToolCallDescriptor): boolean {
263
+ const n = config?.observe as unknown;
264
+ if (n === undefined) return true;
265
+ if (!isPlainObject(n)) return false;
266
+ return narrowingAdmits(n as ToolLifecycleNarrowing, call);
267
+ }
268
+
269
+ /**
270
+ * Does `inputs` narrowing say this call has no tool/class term at all? Such
271
+ * an entry is unconditional and therefore treated as narrowed to NO tools
272
+ * (RFC-007 §4.3 — the widest form is not producible by omission).
273
+ */
274
+ export function inputsNarrowingIsUnconditional(n: ToolLifecycleNarrowing | undefined): boolean {
275
+ if (!isPlainObject(n)) return true;
276
+ const hasTools = Array.isArray(n.tools) && n.tools.length > 0;
277
+ const hasClasses = n.classes === 'default' || (Array.isArray(n.classes) && n.classes.length > 0);
278
+ return !hasTools && !hasClasses;
279
+ }
280
+
281
+ /** Class exclusions that no narrowing overrides (RFC-007 §4.3, §5.4). */
282
+ export function inputsExcludedByClass(call: ToolCallDescriptor): boolean {
283
+ return call.class.length === 0 || call.class.includes('comms');
284
+ }
285
+
286
+ export function inputsAdmit(config: ToolLifecycleConfig | undefined, call: ToolCallDescriptor): boolean {
287
+ const n = config?.inputs;
288
+ if (inputsNarrowingIsUnconditional(n)) return false;
289
+ if (inputsExcludedByClass(call)) return false;
290
+ return narrowingAdmits(n!, call);
291
+ }
292
+
293
+ export function ruleMatches(match: ToolObserveMatch, call: ToolCallDescriptor): boolean {
294
+ if (match.tool !== undefined && !globMatch(match.tool, call.tool)) return false;
295
+ if (match.serverTool !== undefined && (call.serverTool === undefined || !globMatch(match.serverTool, call.serverTool))) {
296
+ return false;
297
+ }
298
+ if (match.serverId !== undefined && (call.serverId === undefined || !globMatch(match.serverId, call.serverId))) {
299
+ return false;
300
+ }
301
+ if (match.conversationId !== undefined && !globMatch(match.conversationId, call.conversationId)) return false;
302
+ if (match.class !== undefined && !call.class.includes(match.class)) return false;
303
+ return true;
304
+ }
305
+
306
+ // ============================================================================
307
+ // Arguments: field selection and bounding (RFC-007 §5.2, §6.3)
308
+ // ============================================================================
309
+
310
+ const hasOwn = (o: object, k: string): boolean => Object.prototype.hasOwnProperty.call(o, k);
311
+
312
+ function cloneJson<T>(value: T): T {
313
+ return JSON.parse(JSON.stringify(value)) as T;
314
+ }
315
+
316
+ /**
317
+ * Select field paths from an arguments object. `.` descends into nested
318
+ * objects; a path ending on an object or array selects it whole; a path that
319
+ * passes through a non-object or names a missing member selects nothing.
320
+ * Selected members keep their place in the structure. Only OWN properties
321
+ * are read and the output is built from null-prototype objects, so a path
322
+ * like `__proto__.x` can neither read nor write a prototype.
323
+ */
324
+ export function selectFields(input: Record<string, unknown>, paths: readonly string[]): Record<string, unknown> {
325
+ const out: Record<string, unknown> = Object.create(null);
326
+ for (const path of paths) {
327
+ const segs = path.split('.');
328
+ let src: unknown = input;
329
+ let found = true;
330
+ for (const seg of segs) {
331
+ if (!isPlainObject(src) || !hasOwn(src, seg)) {
332
+ found = false;
333
+ break;
334
+ }
335
+ src = src[seg];
336
+ }
337
+ if (!found || src === undefined) continue;
338
+ let dst = out;
339
+ for (let i = 0; i < segs.length - 1; i++) {
340
+ const seg = segs[i];
341
+ const next = hasOwn(dst, seg) ? dst[seg] : undefined;
342
+ if (!isPlainObject(next)) {
343
+ const fresh: Record<string, unknown> = Object.create(null);
344
+ dst[seg] = fresh;
345
+ dst = fresh;
346
+ } else {
347
+ dst = next;
348
+ }
349
+ }
350
+ dst[segs[segs.length - 1]] = cloneJson(src);
351
+ }
352
+ // Round-trip to ordinary objects: the wire form is JSON either way, and
353
+ // callers comparing with deepEqual expect Object prototypes.
354
+ return JSON.parse(JSON.stringify(out)) as Record<string, unknown>;
355
+ }
356
+
357
+ const byteLength = (value: unknown): number => Buffer.byteLength(JSON.stringify(value), 'utf8');
358
+
359
+ /** Walk an object tree and collect every string leaf with its holder. */
360
+ function collectStrings(root: unknown): Array<{ holder: Record<string, unknown> | unknown[]; key: string | number; length: number }> {
361
+ const out: Array<{ holder: Record<string, unknown> | unknown[]; key: string | number; length: number }> = [];
362
+ const visit = (node: unknown): void => {
363
+ if (Array.isArray(node)) {
364
+ node.forEach((v, i) => {
365
+ if (typeof v === 'string') out.push({ holder: node, key: i, length: v.length });
366
+ else visit(v);
367
+ });
368
+ } else if (isPlainObject(node)) {
369
+ for (const [k, v] of Object.entries(node)) {
370
+ if (typeof v === 'string') out.push({ holder: node, key: k, length: v.length });
371
+ else visit(v);
372
+ }
373
+ }
374
+ };
375
+ visit(root);
376
+ return out;
377
+ }
378
+
379
+ /** Cut a string to a prefix of at most `length` code units without leaving
380
+ * half a surrogate pair at the end. */
381
+ function prefixOf(s: string, length: number): string {
382
+ let cut = s.slice(0, Math.max(0, length));
383
+ const last = cut.charCodeAt(cut.length - 1);
384
+ if (last >= 0xd800 && last <= 0xdbff) cut = cut.slice(0, -1);
385
+ return cut;
386
+ }
387
+
388
+ /**
389
+ * Bound `value` to `maxBytes` of serialized JSON (RFC-007 §5.2): first by
390
+ * replacing the longest strings with prefixes of themselves, then by removing
391
+ * the largest members. The result is always a JSON object. Returns null when
392
+ * nothing meaningful fits — the caller withholds instead.
393
+ */
394
+ export function boundInput(
395
+ value: Record<string, unknown>,
396
+ maxBytes: number,
397
+ ): { value: Record<string, unknown>; altered: boolean } | null {
398
+ if (byteLength(value) <= maxBytes) return { value, altered: false };
399
+ const clone = cloneJson(value);
400
+
401
+ const MIN_STRING = 32;
402
+ for (let guard = 0; guard < 256; guard++) {
403
+ const over = byteLength(clone) - maxBytes;
404
+ if (over <= 0) return { value: clone, altered: true };
405
+ const strings = collectStrings(clone).filter((s) => s.length > MIN_STRING);
406
+ if (strings.length === 0) break;
407
+ strings.sort((a, b) => b.length - a.length);
408
+ const longest = strings[0];
409
+ const current = (longest.holder as Record<string | number, string>)[longest.key];
410
+ const target = Math.max(MIN_STRING, Math.min(Math.floor(current.length / 2), current.length - over));
411
+ (longest.holder as Record<string | number, unknown>)[longest.key] = prefixOf(current, target);
412
+ }
413
+
414
+ // Still over: remove top-level members, largest first, in ONE pass (sizes
415
+ // measured once; a running estimate decides when to stop). A member that
416
+ // is itself a huge nested structure goes whole — what remains must be
417
+ // meaningful, or the caller withholds.
418
+ let total = byteLength(clone);
419
+ const members = Object.keys(clone)
420
+ .map((key) => ({ key, size: byteLength(key) + 1 + byteLength(clone[key]) + 1 }))
421
+ .sort((a, b) => b.size - a.size);
422
+ for (const { key, size } of members) {
423
+ if (total <= maxBytes) break;
424
+ delete clone[key];
425
+ total -= size;
426
+ }
427
+ return byteLength(clone) <= maxBytes && Object.keys(clone).length > 0 ? { value: clone, altered: true } : null;
428
+ }
429
+
430
+ // ============================================================================
431
+ // Per-connection decision
432
+ // ============================================================================
433
+
434
+ /** What the emitter needs from a connection. */
435
+ export interface ToolLifecycleObserver {
436
+ id: string;
437
+ grant?: CapabilityGrant;
438
+ toolObserveFilter?: ToolObserveRule[] | null;
439
+ /** Increments at every transport boundary (reconnect). Terminals go only
440
+ * to the epoch the opening went to. Absent = 0. */
441
+ transportEpoch?: number;
442
+ sendToolLifecycle(params: ToolLifecycleParams): void;
443
+ }
444
+
445
+ function baseParams(call: ToolCallDescriptor, phase: ToolPhase): ToolLifecycleParams {
446
+ return {
447
+ toolCallId: call.toolCallId,
448
+ inferenceId: call.inferenceId,
449
+ conversationId: call.conversationId,
450
+ tool: call.tool,
451
+ class: [...call.class],
452
+ ...(call.serverId !== undefined ? { serverId: call.serverId, serverTool: call.serverTool } : {}),
453
+ phase,
454
+ };
455
+ }
456
+
457
+ /**
458
+ * The opening notification (`pending` or `started`) this connection should
459
+ * receive for `call`, or null when it receives nothing. Steps 1–5 of
460
+ * RFC-007 §6.4.
461
+ */
462
+ export function openingFor(
463
+ observer: ToolLifecycleObserver,
464
+ config: ToolLifecycleConfig | undefined,
465
+ call: ToolCallDescriptor,
466
+ phase: 'pending' | 'started',
467
+ ): ToolLifecycleParams | null {
468
+ const grant = CapabilityGrant.of(observer);
469
+ if (!grant.has(TOOL_LIFECYCLE_OBSERVE)) return null;
470
+ // A connection's own tools: it already sees the call as tools/call (§7.1).
471
+ if (call.serverId !== undefined && call.serverId === observer.id) return null;
472
+ if (!observeAdmits(config, call)) return null;
473
+
474
+ const filter = observer.toolObserveFilter ?? null;
475
+ let requested: boolean | string[] = false; // no filter → metadata only (§5.1)
476
+ if (filter) {
477
+ const rule = filter.find((r) => ruleMatches(r.match, call));
478
+ if (!rule || !rule.report) return null;
479
+ requested = rule.input;
480
+ }
481
+
482
+ const params = baseParams(call, phase);
483
+ if (phase !== 'started' || requested === false) return params;
484
+ // Arguments were requested. Without the inputs grant the server learns
485
+ // nothing more (no inputWithheld — §5.1).
486
+ if (!grant.has(TOOL_LIFECYCLE_INPUTS)) return params;
487
+ if (!inputsAdmit(config, call)) {
488
+ params.inputWithheld = true;
489
+ return params;
490
+ }
491
+ const args: Record<string, unknown> = isPlainObject(call.input) ? call.input : {};
492
+ const selected = requested === true ? cloneJson(args) : selectFields(args, requested);
493
+ const bounded = boundInput(selected, config?.maxInputBytes ?? DEFAULT_MAX_INPUT_BYTES);
494
+ if (!bounded) {
495
+ params.inputWithheld = true;
496
+ return params;
497
+ }
498
+ params.input = bounded.value;
499
+ if (bounded.altered) params.inputAltered = true;
500
+ return params;
501
+ }
502
+
503
+ /** Does this connection still get the terminal for a call it was opened to? */
504
+ export function terminalStillAllowed(
505
+ observer: ToolLifecycleObserver,
506
+ config: ToolLifecycleConfig | undefined,
507
+ call: ToolCallDescriptor,
508
+ ): boolean {
509
+ return CapabilityGrant.of(observer).has(TOOL_LIFECYCLE_OBSERVE) && observeAdmits(config, call);
510
+ }
511
+
512
+ // ============================================================================
513
+ // The emitter
514
+ // ============================================================================
515
+
516
+ export interface ToolLifecycleHost {
517
+ /** Every live connection that might observe. */
518
+ observers(): Iterable<ToolLifecycleObserver>;
519
+ /** Host policy for one connection. */
520
+ configFor(serverId: string): ToolLifecycleConfig | undefined;
521
+ /** Class and provider for a model-facing tool name. */
522
+ describe(tool: string): { class: ToolClass[]; serverId?: string; serverTool?: string };
523
+ }
524
+
525
+ interface TrackedCall {
526
+ agentName: string;
527
+ modelCallId: string;
528
+ call: ToolCallDescriptor;
529
+ /** Connection id → transport epoch the opening went to. */
530
+ openedTo: Map<string, number>;
531
+ /** `started` has been sent (open() ran and the call was not refused). */
532
+ opened: boolean;
533
+ startedAt: number;
534
+ /** The host could not obtain a result (a dispatch catch path). */
535
+ failed: boolean;
536
+ }
537
+
538
+ /**
539
+ * Provider identifiers trusted as unique (RFC-007 §3 rev 3: reuse the
540
+ * model's id only where the provider guarantees it). Anthropic `tool_use`
541
+ * ids are random and at least this long; everything else — per-response
542
+ * counters like `call_0`, other providers' formats — is minted over.
543
+ */
544
+ const PROVIDER_UNIQUE_ID = /^toolu_[A-Za-z0-9]{20,}$/;
545
+ const RECENT_ID_WINDOW = 8192;
546
+
547
+ const callKey = (agentName: string, callId: string): string => `${agentName}\u0000${callId}`;
548
+
549
+ export class ToolLifecycleEmitter {
550
+ private readonly host: ToolLifecycleHost;
551
+ private readonly now: () => number;
552
+ /** Opened calls awaiting their terminal, keyed by (agent, model call id). */
553
+ private readonly calls = new Map<string, TrackedCall>();
554
+ private readonly recentIds = new Set<string>();
555
+ private readonly recentOrder: string[] = [];
556
+ private mintSeq = 0;
557
+
558
+ constructor(host: ToolLifecycleHost, now: () => number = Date.now) {
559
+ this.host = host;
560
+ this.now = now;
561
+ }
562
+
563
+ /** Nothing observes: skip all work on the hot path. */
564
+ private anyObserver(): boolean {
565
+ for (const o of this.host.observers()) {
566
+ if (CapabilityGrant.of(o).has(TOOL_LIFECYCLE_OBSERVE)) return true;
567
+ }
568
+ return false;
569
+ }
570
+
571
+ /**
572
+ * A host-unique id for a call (RFC-007 §3): the model's own only where the
573
+ * provider guarantees it unique and it was not reported recently; minted
574
+ * otherwise. Minted ids carry a per-process counter, so they never repeat.
575
+ */
576
+ private hostCallId(modelCallId: string): string {
577
+ let id = modelCallId;
578
+ if (!PROVIDER_UNIQUE_ID.test(id) || this.recentIds.has(id)) {
579
+ id = `${modelCallId}~h${(++this.mintSeq).toString(36)}`;
580
+ }
581
+ this.recentIds.add(id);
582
+ this.recentOrder.push(id);
583
+ if (this.recentOrder.length > RECENT_ID_WINDOW) {
584
+ this.recentIds.delete(this.recentOrder.shift()!);
585
+ }
586
+ return id;
587
+ }
588
+
589
+ /**
590
+ * A model-issued call is about to be dispatched. Recorded, keyed by
591
+ * (agent, model call id) — never matched through the trace stream, so two
592
+ * agents' identical short ids cannot cross — but nothing is sent yet: the
593
+ * host may still refuse it (refuse()) before it executes.
594
+ */
595
+ register(agentName: string, inferenceId: string, call: { id: string; name: string; input: unknown }): void {
596
+ if (!this.anyObserver()) return;
597
+ const described = this.host.describe(call.name);
598
+ this.calls.set(callKey(agentName, call.id), {
599
+ agentName,
600
+ modelCallId: call.id,
601
+ call: {
602
+ toolCallId: this.hostCallId(call.id),
603
+ inferenceId,
604
+ conversationId: agentName,
605
+ tool: call.name,
606
+ class: described.class,
607
+ ...(described.serverId !== undefined
608
+ ? { serverId: described.serverId, serverTool: described.serverTool }
609
+ : {}),
610
+ input: call.input,
611
+ },
612
+ openedTo: new Map(),
613
+ opened: false,
614
+ // Taken before dispatch, so durationMs includes work a synchronous
615
+ // tool does inside dispatch (its `started` is sent only after).
616
+ startedAt: this.now(),
617
+ failed: false,
618
+ });
619
+ }
620
+
621
+ /**
622
+ * The host refused the call before executing it — its provider is gone,
623
+ * host tool policy denies it, a conversation-bound agent reached outside
624
+ * its channel. Called from the refusal site itself, synchronously within
625
+ * dispatch, so the call is forgotten before open() would send anything:
626
+ * a refused call produces no events (RFC-007 §3).
627
+ */
628
+ refuse(agentName: string, callId: string): void {
629
+ const key = callKey(agentName, callId);
630
+ const tracked = this.calls.get(key);
631
+ if (tracked && !tracked.opened) this.calls.delete(key);
632
+ }
633
+
634
+ /**
635
+ * Dispatch returned and the call was not refused: execution has begun.
636
+ * Send `started` to every connection that should see it. Refusal sites
637
+ * run synchronously inside dispatch, before this.
638
+ */
639
+ open(agentName: string, callId: string): void {
640
+ const key = callKey(agentName, callId);
641
+ const tracked = this.calls.get(key);
642
+ if (!tracked || tracked.opened) return;
643
+ tracked.opened = true;
644
+ for (const observer of this.host.observers()) {
645
+ let params: ToolLifecycleParams | null;
646
+ try {
647
+ params = openingFor(observer, this.host.configFor(observer.id), tracked.call, 'started');
648
+ } catch (err) {
649
+ console.error(`[mcpl] ${observer.id}: tools/lifecycle decision failed: ${(err as Error).message}`);
650
+ continue;
651
+ }
652
+ if (!params) continue;
653
+ try {
654
+ observer.sendToolLifecycle(params);
655
+ tracked.openedTo.set(observer.id, observer.transportEpoch ?? 0);
656
+ } catch {
657
+ /* best-effort */
658
+ }
659
+ }
660
+ // Nobody received an opening, so nobody is owed a terminal.
661
+ if (tracked.openedTo.size === 0) this.calls.delete(key);
662
+ }
663
+
664
+ /**
665
+ * The host could not obtain a result for this call (its dispatch threw,
666
+ * the provider's transport failed, the request timed out). Its terminal
667
+ * will be `failed`. An error RESULT returned by the tool is not this: it
668
+ * is `completed` with `isError` (RFC-007 §3).
669
+ */
670
+ markDispatchFailure(agentName: string, callId: string): void {
671
+ const tracked = this.calls.get(callKey(agentName, callId));
672
+ if (tracked) tracked.failed = true;
673
+ }
674
+
675
+ private terminate(tracked: TrackedCall, phase: 'completed' | 'failed' | 'aborted', isError?: boolean): void {
676
+ this.calls.delete(callKey(tracked.agentName, tracked.modelCallId));
677
+ const durationMs = Math.max(0, this.now() - tracked.startedAt);
678
+ for (const observer of this.host.observers()) {
679
+ const epoch = tracked.openedTo.get(observer.id);
680
+ // Only the transport epoch the opening went to: a reconnected observer
681
+ // never receives a terminal for an opening it did not see (§7.3).
682
+ if (epoch === undefined || epoch !== (observer.transportEpoch ?? 0)) continue;
683
+ if (!terminalStillAllowed(observer, this.host.configFor(observer.id), tracked.call)) continue;
684
+ const params = baseParams(tracked.call, phase);
685
+ if (phase === 'completed') params.isError = !!isError;
686
+ params.durationMs = durationMs;
687
+ try {
688
+ observer.sendToolLifecycle(params);
689
+ } catch {
690
+ /* best-effort */
691
+ }
692
+ }
693
+ }
694
+
695
+ /** A tool result reached the host: the call's terminal. */
696
+ onResult(agentName: string, callId: string, result: { success?: boolean; isError?: boolean } | undefined): void {
697
+ if (this.calls.size === 0) return;
698
+ const tracked = this.calls.get(callKey(agentName, callId));
699
+ if (!tracked) return;
700
+ if (!tracked.opened) {
701
+ // Never opened (a path that bypassed open()): nothing was sent.
702
+ this.calls.delete(callKey(agentName, callId));
703
+ return;
704
+ }
705
+ if (tracked.failed) this.terminate(tracked, 'failed');
706
+ else this.terminate(tracked, 'completed', result?.isError === true || result?.success === false);
707
+ }
708
+
709
+ /**
710
+ * A stream ended: its calls still open were cancelled before a result.
711
+ * Scoped to the inference ids that stream minted, so a successor stream
712
+ * for the same agent (a budget restart overlapping this one's teardown)
713
+ * keeps its live calls. A result arriving later finds nothing.
714
+ */
715
+ abortOpen(agentName: string, inferenceIds: Iterable<string>): void {
716
+ if (this.calls.size === 0) return;
717
+ const ids = new Set(inferenceIds);
718
+ for (const tracked of [...this.calls.values()]) {
719
+ if (tracked.agentName === agentName && ids.has(tracked.call.inferenceId)) {
720
+ if (tracked.opened) this.terminate(tracked, 'aborted');
721
+ else this.calls.delete(callKey(tracked.agentName, tracked.modelCallId));
722
+ }
723
+ }
724
+ }
725
+
726
+ /** For tests and diagnostics. */
727
+ get openCount(): number {
728
+ return this.calls.size;
729
+ }
730
+ }