@net-mesh/core 0.26.0 → 0.27.0-beta.2

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.
package/tool.js ADDED
@@ -0,0 +1,554 @@
1
+ "use strict";
2
+ // TypeScript layer for AI tool calling on net.
3
+ //
4
+ // Wraps the existing `TypedMeshRpc` napi surface with the `tool()` /
5
+ // `callTool()` ergonomic helpers + format translators that lower
6
+ // `ToolDescriptor`s to OpenAI / Anthropic / MCP / Gemini tool shapes
7
+ // and parse provider tool-call replies back into nRPC dispatches.
8
+ //
9
+ // This is the Wave 3 / B-1 + B-4 starting point. v1 covers unary
10
+ // register + invoke + format conversion. Streaming (B-2) and
11
+ // discovery (B-3 list_tools / watch_tools) follow once the
12
+ // underlying napi surface exposes them; today the only available
13
+ // streaming primitive is direct-addressed (`callStreaming(nodeId,
14
+ // ...)`), so capability-routed streaming has to wait on a
15
+ // `callServiceStreaming` TS wrapper or a `findServiceNodes` +
16
+ // direct-call composition (TODO).
17
+ //
18
+ // Plan: see
19
+ // `crates/net/docs/plans/NRPC_AI_TOOL_CALLING_AND_AGENT_DX.md`,
20
+ // slices B-1 / B-2 / B-4. Mirror of the Rust SDK's
21
+ // `net_sdk::tool` + `net_sdk::tool::formats` modules — cross-
22
+ // language tests (T-1) will pin byte equality across both.
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.gemini = exports.mcp = exports.anthropic = exports.openai = exports.ToolCallParseError = exports.TOOL_METADATA_FETCH_SERVICE = void 0;
25
+ exports.isTerminalEvent = isTerminalEvent;
26
+ exports.descriptorFrom = descriptorFrom;
27
+ exports.serveTool = serveTool;
28
+ exports.serveToolStreaming = serveToolStreaming;
29
+ exports.callTool = callTool;
30
+ exports.callToolStreaming = callToolStreaming;
31
+ exports.addToolCapabilitiesToAnnounce = addToolCapabilitiesToAnnounce;
32
+ exports.listTools = listTools;
33
+ exports.watchTools = watchTools;
34
+ exports.fetchToolMetadata = fetchToolMetadata;
35
+ /** True if `event` is a terminal envelope (`result` or `error`). */
36
+ function isTerminalEvent(event) {
37
+ return event.type === 'result' || event.type === 'error';
38
+ }
39
+ /** Construct a [`ToolDescriptor`] from a `ToolOptions` literal. */
40
+ function descriptorFrom(options) {
41
+ return {
42
+ toolId: options.name,
43
+ name: options.name,
44
+ version: options.version ?? '1.0.0',
45
+ description: options.description,
46
+ inputSchema: options.inputSchema ? JSON.stringify(options.inputSchema) : undefined,
47
+ outputSchema: options.outputSchema ? JSON.stringify(options.outputSchema) : undefined,
48
+ requires: options.requires ?? [],
49
+ estimatedTimeMs: options.estimatedTimeMs ?? 0,
50
+ stateless: options.stateless ?? true,
51
+ streaming: false,
52
+ tags: options.tags ?? [],
53
+ nodeCount: 0,
54
+ };
55
+ }
56
+ const _toolRegistries = new WeakMap();
57
+ function _ensureFetchInstalled(rpc) {
58
+ let entry = _toolRegistries.get(rpc);
59
+ if (entry)
60
+ return entry;
61
+ entry = { registry: new Map(), fetchHandle: null };
62
+ _toolRegistries.set(rpc, entry);
63
+ // Register the fetch handler. The handler queries `entry.registry`
64
+ // for the name; falls back to NotFound. Mirrors the Rust SDK's
65
+ // `tool.metadata.fetch` handler shape.
66
+ try {
67
+ entry.fetchHandle = rpc.serve(exports.TOOL_METADATA_FETCH_SERVICE, (req) => {
68
+ const d = entry.registry.get(req.name);
69
+ return d
70
+ ? { type: 'found', descriptor: d }
71
+ : { type: 'not_found', name: req.name };
72
+ });
73
+ }
74
+ catch {
75
+ // If install fails (e.g. another caller already registered the
76
+ // service manually), leave fetchHandle null. Subsequent serveTool
77
+ // calls retry. Silent because the failure is recoverable +
78
+ // observable via `fetchToolMetadata` returning NoRoute / NotFound
79
+ // on the agent side.
80
+ }
81
+ return entry;
82
+ }
83
+ /**
84
+ * Register an AI tool against `rpc`. The handler is registered as
85
+ * an nRPC service at `descriptor.toolId` with JSON codec (same as
86
+ * the Rust SDK's `Mesh::serve_tool`).
87
+ *
88
+ * Atomically:
89
+ * 1. Inserts the descriptor into a per-rpc local registry keyed on
90
+ * `toolId`. The next [`fetchToolMetadata`] call against this
91
+ * host can resolve the descriptor by name.
92
+ * 2. Registers the typed handler at `toolId` with JSON codec.
93
+ * 3. On the FIRST `serveTool` call against this rpc, lazy-
94
+ * installs the `tool.metadata.fetch` nRPC service handler so
95
+ * remote agents can pull the full descriptor for any
96
+ * registered tool. Subsequent `serveTool` calls reuse the
97
+ * same fetch handler. Mirrors the Rust SDK's
98
+ * `ensure_tool_metadata_fetch_installed` pattern.
99
+ *
100
+ * The caller is still responsible for announcing the tool to
101
+ * peers — use [`addToolCapabilitiesToAnnounce`] on the
102
+ * `CapabilitySetJs` you pass to `mesh.announceCapabilities(...)`.
103
+ *
104
+ * On `handle.close()`: removes the descriptor from the per-rpc
105
+ * registry and unregisters the handler. The lazy `tool.metadata.fetch`
106
+ * service stays installed for the lifetime of the rpc — harmless
107
+ * when empty (returns NotFound for every request).
108
+ */
109
+ function serveTool(rpc, options, handler) {
110
+ const descriptor = descriptorFrom(options);
111
+ const entry = _ensureFetchInstalled(rpc);
112
+ entry.registry.set(descriptor.toolId, descriptor);
113
+ const inner = rpc.serve(descriptor.toolId, handler);
114
+ let closed = false;
115
+ return {
116
+ descriptor,
117
+ close() {
118
+ if (closed)
119
+ return;
120
+ closed = true;
121
+ entry.registry.delete(descriptor.toolId);
122
+ inner.close();
123
+ },
124
+ };
125
+ }
126
+ /**
127
+ * Register a streaming tool handler. The handler is an async
128
+ * generator that yields ToolEvents — each yielded event is
129
+ * forwarded to the caller via `callToolStreaming` (or any other
130
+ * client that drains a `tool.metadata.fetch`-discoverable
131
+ * streaming service).
132
+ *
133
+ * Atomic register + lazy auto-install of `tool.metadata.fetch` —
134
+ * same pattern as `serveTool` for unary handlers. Stamps
135
+ * `streaming: true` on the descriptor so peers can discover the
136
+ * streaming variant explicitly.
137
+ */
138
+ function serveToolStreaming(rpc, options, handler) {
139
+ const baseDescriptor = descriptorFrom(options);
140
+ const descriptor = { ...baseDescriptor, streaming: true };
141
+ const entry = _ensureFetchInstalled(rpc);
142
+ entry.registry.set(descriptor.toolId, descriptor);
143
+ const inner = rpc.serveStreaming(descriptor.toolId, async (req, sink) => {
144
+ let sawTerminal = false;
145
+ try {
146
+ for await (const event of handler(req)) {
147
+ sink.send(event);
148
+ if (isTerminalEvent(event))
149
+ sawTerminal = true;
150
+ }
151
+ if (!sawTerminal) {
152
+ sink.send({
153
+ type: 'error',
154
+ code: 'missing_terminal',
155
+ message: 'tool stream ended without a terminal result or error envelope',
156
+ });
157
+ }
158
+ }
159
+ catch (err) {
160
+ // Convert handler exceptions into a terminal error envelope
161
+ // so the caller sees a typed error rather than relying on
162
+ // the client-side missing_terminal fallback.
163
+ const message = err instanceof Error ? err.message : String(err);
164
+ const errEvent = {
165
+ type: 'error',
166
+ code: 'handler_error',
167
+ message,
168
+ };
169
+ sink.send(errEvent);
170
+ }
171
+ });
172
+ let closed = false;
173
+ return {
174
+ descriptor,
175
+ close() {
176
+ if (closed)
177
+ return;
178
+ closed = true;
179
+ entry.registry.delete(descriptor.toolId);
180
+ inner.close();
181
+ },
182
+ };
183
+ }
184
+ /**
185
+ * Capability-routed unary tool invocation. Encodes `req` as JSON
186
+ * (the codec every AI provider consumes for tool input/output),
187
+ * dispatches via `rpc.callService(toolId, req, opts)`.
188
+ *
189
+ * Throws `NoRouteError` if no host advertises `nrpc:<toolId>` in
190
+ * the local capability fold; bubbles handler errors as
191
+ * `RpcServerError` with the typed-handler status code.
192
+ */
193
+ async function callTool(rpc, toolId, req, opts) {
194
+ return rpc.callService(toolId, req, opts);
195
+ }
196
+ /**
197
+ * Capability-routed streaming tool invocation. Returns an
198
+ * `AsyncIterable<ToolEvent>` — drain via `for await (...)` until
199
+ * the stream terminates. The substrate routes the call via the
200
+ * cap-auth gate just like `callService`; the iterator yields each
201
+ * JSON-decoded `ToolEvent` envelope.
202
+ *
203
+ * Synthesizes a terminal `error` event with code
204
+ * `missing_terminal` if the stream ends without a `result` /
205
+ * `error` envelope — matches the Rust SDK's `serve_tool_streaming`
206
+ * contract and the T-2 cross-language fixture.
207
+ *
208
+ * Cancel mid-stream by aborting `opts.signal` (wired through the
209
+ * substrate's cancel-registry on the underlying RpcStream).
210
+ */
211
+ async function* callToolStreaming(rpc, toolId, req, opts) {
212
+ const stream = await rpc.callServiceStreaming(toolId, req, opts);
213
+ let sawTerminal = false;
214
+ try {
215
+ for await (const event of stream) {
216
+ yield event;
217
+ if (isTerminalEvent(event)) {
218
+ sawTerminal = true;
219
+ }
220
+ }
221
+ }
222
+ finally {
223
+ await stream.close().catch(() => { });
224
+ }
225
+ if (!sawTerminal) {
226
+ const synthesized = {
227
+ type: 'error',
228
+ code: 'missing_terminal',
229
+ message: 'tool stream ended without a terminal result or error envelope',
230
+ };
231
+ yield synthesized;
232
+ }
233
+ }
234
+ /**
235
+ * Merge tool descriptors into a `CapabilitySetJs` so the next
236
+ * `mesh.announceCapabilities(caps)` carries:
237
+ *
238
+ * - `ai-tool:<toolId>` tag — peer fold's tag-prefix lookup hits.
239
+ * - A `ToolJs` entry — peer's `list_tools` walk sees the
240
+ * tool's tag-encoded fields.
241
+ *
242
+ * Caller still owns the `caps` object — pass it through
243
+ * `mesh.announceCapabilities(caps)` to publish. Returns the same
244
+ * object for chaining.
245
+ *
246
+ * This is a v1 convenience; once the napi surface exposes
247
+ * `tool_registry()`, the announce-time merge happens
248
+ * automatically and this helper becomes optional.
249
+ */
250
+ function addToolCapabilitiesToAnnounce(caps, descriptors) {
251
+ if (descriptors.length === 0)
252
+ return caps;
253
+ const tags = new Set(caps.tags ?? []);
254
+ const tools = [...(caps.tools ?? [])];
255
+ for (const desc of descriptors) {
256
+ tags.add(`ai-tool:${desc.toolId}`);
257
+ tools.push({
258
+ toolId: desc.toolId,
259
+ name: desc.name,
260
+ version: desc.version,
261
+ inputSchema: desc.inputSchema,
262
+ outputSchema: desc.outputSchema,
263
+ requires: desc.requires,
264
+ estimatedTimeMs: desc.estimatedTimeMs,
265
+ stateless: desc.stateless,
266
+ });
267
+ }
268
+ caps.tags = Array.from(tags);
269
+ caps.tools = tools;
270
+ return caps;
271
+ }
272
+ /**
273
+ * Walk the local capability fold for every published AI tool.
274
+ * Returns one [`ToolDescriptor`] per `(toolId, version)` slot,
275
+ * with `nodeCount` filled in by the aggregating walk.
276
+ *
277
+ * Pure delegation to the napi binding's `NetMesh.listTools()` (B-3
278
+ * of the plan). Requires the napi binding's `tool` Cargo feature
279
+ * (default-on); throws if the underlying mesh wasn't built with it.
280
+ *
281
+ * Schemas come back as JSON-encoded strings on
282
+ * `descriptor.inputSchema` / `descriptor.outputSchema` — call
283
+ * `JSON.parse(...)` for the parsed shape that adapter packages
284
+ * consume when lowering into provider-specific tool definitions.
285
+ */
286
+ function listTools(mesh) {
287
+ return mesh.listTools();
288
+ }
289
+ /**
290
+ * Subscribe to a stream of [`ToolListChange`] events for every
291
+ * dynamic addition / removal / publisher-count change in the
292
+ * local capability fold's tool view.
293
+ *
294
+ * Event-driven: consumes the substrate's `MeshNode::watch_tools`
295
+ * stream via the napi `ToolWatchIter` — a change is delivered the
296
+ * moment the fold mutates (latency is bounded by fold-apply, not a
297
+ * timer), and an idle fold does zero periodic work. The diff happens
298
+ * substrate-side; this just `JSON.parse`s each emitted change. No
299
+ * client-side `setTimeout` / `listTools` re-diff loop.
300
+ *
301
+ * The native subscription is kicked off eagerly — when `watchTools`
302
+ * is *called*, not on the first iteration — so a change published
303
+ * between the call and the first `for await` is not lost (the prior
304
+ * version subscribed lazily and could drop that first event). Because
305
+ * the subscription is started at call time, the returned iterable
306
+ * holds a live substrate watch: consume it (or abort via `signal`) so
307
+ * it is closed. Call `listTools(mesh)` once for the starting shape.
308
+ *
309
+ * Mirror of the Rust SDK's `Mesh::watch_tools(matcher, interval)`
310
+ * and the Python `watch_tools` — all three are event-driven off the
311
+ * same substrate change signal, and all three subscribe eagerly.
312
+ *
313
+ * Returns an `AsyncIterable<ToolListChange>` suitable for
314
+ * `for await (const change of watchTools(mesh)) { ... }`. The
315
+ * iterator ends when `options.signal` aborts, when the stream is
316
+ * closed, or on an unrecoverable error.
317
+ */
318
+ function watchTools(mesh, options = {}) {
319
+ const intervalMs = options.intervalMs;
320
+ const signal = options.signal;
321
+ // Subscribe eagerly — at call time, not on the first iteration — so a
322
+ // change published before iteration begins is still observed. The
323
+ // generator below awaits this same promise; the extra no-op `.catch`
324
+ // keeps an unhandled-rejection warning from firing if the returned
325
+ // iterable is created but never consumed (the generator's own `await`
326
+ // still surfaces the real rejection to a consumer that does iterate).
327
+ const nativePromise = mesh.watchTools(intervalMs ?? null);
328
+ void nativePromise.catch(() => { });
329
+ async function* iterator() {
330
+ const native = await nativePromise;
331
+ const onAbort = () => native.close();
332
+ if (signal) {
333
+ if (signal.aborted) {
334
+ native.close();
335
+ return;
336
+ }
337
+ signal.addEventListener('abort', onAbort, { once: true });
338
+ }
339
+ try {
340
+ while (true) {
341
+ const raw = await native.next();
342
+ if (raw == null) {
343
+ return;
344
+ }
345
+ yield JSON.parse(raw);
346
+ }
347
+ }
348
+ finally {
349
+ if (signal) {
350
+ signal.removeEventListener('abort', onAbort);
351
+ }
352
+ native.close();
353
+ }
354
+ }
355
+ return { [Symbol.asyncIterator]: iterator };
356
+ }
357
+ /** nRPC service name for the on-demand tool-descriptor pull. */
358
+ exports.TOOL_METADATA_FETCH_SERVICE = 'tool.metadata.fetch';
359
+ /**
360
+ * Pull a tool's full descriptor from a specific host by calling
361
+ * the auto-installed `tool.metadata.fetch` nRPC service. Useful
362
+ * when the local fold's capability-fold entry dropped the schema
363
+ * (size-budget-exceeded) and the agent needs the full
364
+ * input/output schemas for strict-mode provider lowering.
365
+ *
366
+ * Mirror of calling `mesh.call_typed(host, TOOL_METADATA_FETCH_SERVICE,
367
+ * { name: tool_id })` in the Rust SDK. The
368
+ * `tool.metadata.fetch` server-side handler is auto-installed on
369
+ * the host's first `serveTool` call.
370
+ */
371
+ async function fetchToolMetadata(rpc, hostNodeId, toolId, opts) {
372
+ return rpc.call(hostNodeId, exports.TOOL_METADATA_FETCH_SERVICE, { name: toolId }, opts);
373
+ }
374
+ /** Thrown when a provider's tool-call reply doesn't match its spec. */
375
+ class ToolCallParseError extends Error {
376
+ constructor(message) {
377
+ super(message);
378
+ this.name = 'ToolCallParseError';
379
+ }
380
+ }
381
+ exports.ToolCallParseError = ToolCallParseError;
382
+ function inputSchemaValue(desc) {
383
+ if (!desc.inputSchema)
384
+ return { type: 'object', properties: {} };
385
+ try {
386
+ return JSON.parse(desc.inputSchema);
387
+ }
388
+ catch {
389
+ // Schema string was malformed (shouldn't happen for descriptors
390
+ // built via `descriptorFrom`). Empty-object fallback keeps
391
+ // provider validators happy.
392
+ return { type: 'object', properties: {} };
393
+ }
394
+ }
395
+ /** OpenAI Chat Completions / Responses API `tools` array. */
396
+ exports.openai = {
397
+ /**
398
+ * Lower a descriptor to an OpenAI tool definition. Shape:
399
+ * ```
400
+ * { type: "function", function: { name, description, parameters, strict } }
401
+ * ```
402
+ * `strict` is true when the descriptor carried an `inputSchema`.
403
+ */
404
+ toOpenaiTool(desc) {
405
+ return {
406
+ type: 'function',
407
+ function: {
408
+ name: desc.toolId,
409
+ description: desc.description ?? '',
410
+ parameters: inputSchemaValue(desc),
411
+ strict: desc.inputSchema !== undefined,
412
+ },
413
+ };
414
+ },
415
+ /**
416
+ * Parse one OpenAI `tool_calls[]` entry into a `ToolCallSpec`.
417
+ * OpenAI's `function.arguments` is a JSON-encoded STRING; this
418
+ * helper validates it parses up front so malformed payloads fail
419
+ * fast instead of riding through `callTool`.
420
+ */
421
+ lowerOpenaiToolCall(call) {
422
+ const fn = call['function'];
423
+ if (!fn)
424
+ throw new ToolCallParseError('tool-call reply missing field `function`');
425
+ const name = fn['name'];
426
+ if (typeof name !== 'string') {
427
+ throw new ToolCallParseError('tool-call reply field `function.name` must be a string');
428
+ }
429
+ const argumentsField = fn['arguments'];
430
+ if (typeof argumentsField !== 'string') {
431
+ throw new ToolCallParseError('tool-call reply field `function.arguments` must be a JSON-encoded string');
432
+ }
433
+ try {
434
+ JSON.parse(argumentsField);
435
+ }
436
+ catch (e) {
437
+ throw new ToolCallParseError(`tool-call arguments were not valid JSON: ${e.message}`);
438
+ }
439
+ const id = call['id'];
440
+ return {
441
+ name,
442
+ argumentsJson: argumentsField,
443
+ providerCallId: typeof id === 'string' ? id : undefined,
444
+ };
445
+ },
446
+ };
447
+ /** Anthropic Messages API `tools` array + `tool_use` content blocks. */
448
+ exports.anthropic = {
449
+ /**
450
+ * Lower a descriptor to an Anthropic tool definition. Shape:
451
+ * ```
452
+ * { name, description, input_schema }
453
+ * ```
454
+ * No tool-level `strict` flag — Anthropic relies on schema-
455
+ * validated tool input as the default.
456
+ */
457
+ toAnthropicTool(desc) {
458
+ return {
459
+ name: desc.toolId,
460
+ description: desc.description ?? '',
461
+ input_schema: inputSchemaValue(desc),
462
+ };
463
+ },
464
+ /**
465
+ * Parse one Anthropic `tool_use` content block into a
466
+ * `ToolCallSpec`. `input` is already a parsed object (not a
467
+ * string like OpenAI); re-serializes once to preserve the
468
+ * `argumentsJson: string` invariant.
469
+ */
470
+ lowerAnthropicToolUse(block) {
471
+ const name = block['name'];
472
+ if (typeof name !== 'string') {
473
+ throw new ToolCallParseError('tool_use block field `name` must be a string');
474
+ }
475
+ if (!('input' in block)) {
476
+ throw new ToolCallParseError('tool_use block missing field `input`');
477
+ }
478
+ const argumentsJson = JSON.stringify(block['input']);
479
+ const id = block['id'];
480
+ return {
481
+ name,
482
+ argumentsJson,
483
+ providerCallId: typeof id === 'string' ? id : undefined,
484
+ };
485
+ },
486
+ };
487
+ /** Model Context Protocol `tools/list` + `tools/call`. */
488
+ exports.mcp = {
489
+ /** Lower a descriptor to an MCP tool definition. Shape: `{ name, description, inputSchema }` (camelCase). */
490
+ toMcpTool(desc) {
491
+ return {
492
+ name: desc.toolId,
493
+ description: desc.description ?? '',
494
+ inputSchema: inputSchemaValue(desc),
495
+ };
496
+ },
497
+ /**
498
+ * Parse an MCP `tools/call` request's `params` into a
499
+ * `ToolCallSpec`. `providerCallId` is left `undefined` — MCP's
500
+ * JSON-RPC `id` lives one envelope layer up, threaded
501
+ * independently.
502
+ */
503
+ lowerMcpToolsCall(params) {
504
+ const name = params['name'];
505
+ if (typeof name !== 'string') {
506
+ throw new ToolCallParseError('tools/call params field `name` must be a string');
507
+ }
508
+ if (!('arguments' in params)) {
509
+ throw new ToolCallParseError('tools/call params missing field `arguments`');
510
+ }
511
+ return {
512
+ name,
513
+ argumentsJson: JSON.stringify(params['arguments']),
514
+ providerCallId: undefined,
515
+ };
516
+ },
517
+ };
518
+ /** Gemini `generateContent` function-calling shape. */
519
+ exports.gemini = {
520
+ /**
521
+ * Lower a descriptor to one Gemini `FunctionDeclaration`. Shape:
522
+ * ```
523
+ * { name, description, parameters }
524
+ * ```
525
+ * Caller wraps these into the outer
526
+ * `tools: [{ function_declarations: [ … ] }]` array.
527
+ */
528
+ toGeminiFunctionDeclaration(desc) {
529
+ return {
530
+ name: desc.toolId,
531
+ description: desc.description ?? '',
532
+ parameters: inputSchemaValue(desc),
533
+ };
534
+ },
535
+ /**
536
+ * Parse one Gemini `functionCall` part into a `ToolCallSpec`.
537
+ * Gemini has no per-call id; the spec leaves `providerCallId`
538
+ * `undefined` (multi-call sequences are positional).
539
+ */
540
+ lowerGeminiFunctionCall(call) {
541
+ const name = call['name'];
542
+ if (typeof name !== 'string') {
543
+ throw new ToolCallParseError('functionCall field `name` must be a string');
544
+ }
545
+ if (!('args' in call)) {
546
+ throw new ToolCallParseError('functionCall missing field `args`');
547
+ }
548
+ return {
549
+ name,
550
+ argumentsJson: JSON.stringify(call['args']),
551
+ providerCallId: undefined,
552
+ };
553
+ },
554
+ };