@mcpmesh/sdk 2.8.2 → 3.0.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.
- package/dist/__tests__/agent-add-tool.spec.js +106 -0
- package/dist/__tests__/agent-add-tool.spec.js.map +1 -1
- package/dist/__tests__/claim-dispatcher-resume-cursor.test.d.ts +2 -0
- package/dist/__tests__/claim-dispatcher-resume-cursor.test.d.ts.map +1 -0
- package/dist/__tests__/claim-dispatcher-resume-cursor.test.js +145 -0
- package/dist/__tests__/claim-dispatcher-resume-cursor.test.js.map +1 -0
- package/dist/__tests__/service-view-types.spec.d.ts +2 -0
- package/dist/__tests__/service-view-types.spec.d.ts.map +1 -0
- package/dist/__tests__/service-view-types.spec.js +66 -0
- package/dist/__tests__/service-view-types.spec.js.map +1 -0
- package/dist/__tests__/service-view.spec.d.ts +2 -0
- package/dist/__tests__/service-view.spec.d.ts.map +1 -0
- package/dist/__tests__/service-view.spec.js +902 -0
- package/dist/__tests__/service-view.spec.js.map +1 -0
- package/dist/__tests__/superseded-signal.spec.d.ts +19 -0
- package/dist/__tests__/superseded-signal.spec.d.ts.map +1 -0
- package/dist/__tests__/superseded-signal.spec.js +222 -0
- package/dist/__tests__/superseded-signal.spec.js.map +1 -0
- package/dist/__tests__/worker-error-serde.spec.d.ts +2 -0
- package/dist/__tests__/worker-error-serde.spec.d.ts.map +1 -0
- package/dist/__tests__/worker-error-serde.spec.js +122 -0
- package/dist/__tests__/worker-error-serde.spec.js.map +1 -0
- package/dist/a2a/producer/mount.d.ts.map +1 -1
- package/dist/a2a/producer/mount.js +4 -0
- package/dist/a2a/producer/mount.js.map +1 -1
- package/dist/agent.d.ts +51 -3
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +356 -33
- package/dist/agent.js.map +1 -1
- package/dist/claim-dispatcher.d.ts +25 -1
- package/dist/claim-dispatcher.d.ts.map +1 -1
- package/dist/claim-dispatcher.js +51 -2
- package/dist/claim-dispatcher.js.map +1 -1
- package/dist/inbound-job-dispatch.d.ts +1 -1
- package/dist/inbound-job-dispatch.d.ts.map +1 -1
- package/dist/inbound-job-dispatch.js +8 -2
- package/dist/inbound-job-dispatch.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -2
- package/dist/index.js.map +1 -1
- package/dist/llm-provider.d.ts +14 -16
- package/dist/llm-provider.d.ts.map +1 -1
- package/dist/llm-provider.js +2 -4
- package/dist/llm-provider.js.map +1 -1
- package/dist/llm.d.ts +1 -2
- package/dist/llm.d.ts.map +1 -1
- package/dist/llm.js +1 -2
- package/dist/llm.js.map +1 -1
- package/dist/proxy.d.ts.map +1 -1
- package/dist/proxy.js +37 -0
- package/dist/proxy.js.map +1 -1
- package/dist/route.d.ts.map +1 -1
- package/dist/route.js +5 -0
- package/dist/route.js.map +1 -1
- package/dist/service-view.d.ts +245 -0
- package/dist/service-view.d.ts.map +1 -0
- package/dist/service-view.js +210 -0
- package/dist/service-view.js.map +1 -0
- package/dist/settle.d.ts +22 -0
- package/dist/settle.d.ts.map +1 -1
- package/dist/settle.js +51 -22
- package/dist/settle.js.map +1 -1
- package/dist/superseded.d.ts +70 -0
- package/dist/superseded.d.ts.map +1 -0
- package/dist/superseded.js +99 -0
- package/dist/superseded.js.map +1 -0
- package/dist/tool-worker-entry.js +1 -12
- package/dist/tool-worker-entry.js.map +1 -1
- package/dist/tool-worker-pool.d.ts.map +1 -1
- package/dist/tool-worker-pool.js +1 -12
- package/dist/tool-worker-pool.js.map +1 -1
- package/dist/types.d.ts +31 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/worker-error-serde.d.ts +40 -0
- package/dist/worker-error-serde.d.ts.map +1 -0
- package/dist/worker-error-serde.js +86 -0
- package/dist/worker-error-serde.js.map +1 -0
- package/package.json +2 -2
package/dist/agent.js
CHANGED
|
@@ -12,9 +12,10 @@ import { UserError } from "fastmcp";
|
|
|
12
12
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
13
13
|
import { isMainThread } from "node:worker_threads";
|
|
14
14
|
import { startAgent, } from "@mcpmesh/core";
|
|
15
|
+
import { expandDependencies, isServiceView, MeshServiceUnavailableError, CAPABILITY_NAME_PATTERN, defaultProducerParams, } from "./service-view.js";
|
|
15
16
|
import { resolveConfig, generateAgentIdSuffix, findAvailablePort, resolveStartupBindPort, MAX_CONSECUTIVE_NEXT_EVENT_FAILURES, NEXT_EVENT_BACKOFF_CAP_MS, } from "./config.js";
|
|
16
17
|
import { enrichSchemaWithMediaTypes } from "./media-param.js";
|
|
17
|
-
import { createProxy,
|
|
18
|
+
import { createProxy, runWithTraceContext, runWithPropagatedHeaders, PROXY_DISPATCH_META } from "./proxy.js";
|
|
18
19
|
import { readJobHeaders, runWithJobContext, makeJobController, spliceJobController, } from "./inbound-job-dispatch.js";
|
|
19
20
|
import { MeshJobSubmitter } from "./mesh-job-submitter.js";
|
|
20
21
|
import { getSettleState, } from "./settle.js";
|
|
@@ -276,6 +277,22 @@ export class MeshAgent {
|
|
|
276
277
|
addTool(def) {
|
|
277
278
|
const toolName = def.name;
|
|
278
279
|
const execute = def.execute;
|
|
280
|
+
// Issue #1293 item 6: validate the PRODUCED capability name — the value
|
|
281
|
+
// published to the registry (def.capability ?? toolName) — against the
|
|
282
|
+
// segment-wise dotted grammar, closing the parity gap with the Go registry
|
|
283
|
+
// (src/core/registry/validation.go) and the Python runtime
|
|
284
|
+
// (support_types.py validate_capability_name). Only the produced capability
|
|
285
|
+
// is checked; dependency capability names are deliberately left unvalidated
|
|
286
|
+
// (they are unvalidated in the Go registry and every runtime), so checking
|
|
287
|
+
// them here would be stricter than the contract, not parity. Reuses the
|
|
288
|
+
// same CAPABILITY_NAME_PATTERN that addService applies to synthesized names.
|
|
289
|
+
const producedCapability = def.capability ?? toolName;
|
|
290
|
+
if (!CAPABILITY_NAME_PATTERN.test(producedCapability)) {
|
|
291
|
+
throw new Error(`addTool: capability '${producedCapability}' is not a valid capability ` +
|
|
292
|
+
`name — each dot-separated segment must start with a letter and ` +
|
|
293
|
+
`contain only letters, digits, underscores, and hyphens ` +
|
|
294
|
+
`(cross-ref src/core/registry/validation.go).`);
|
|
295
|
+
}
|
|
279
296
|
// Phase 1 MeshJob substrate: validate `task: true` requires an
|
|
280
297
|
// async function. Long-running tools need a Promise-based control
|
|
281
298
|
// flow so the dispatch wrapper (Phase B) can await
|
|
@@ -388,6 +405,15 @@ export class MeshAgent {
|
|
|
388
405
|
}
|
|
389
406
|
}
|
|
390
407
|
}
|
|
408
|
+
// Issue #1277: validate resumeCursor at registration so misuse fails loud
|
|
409
|
+
// before the agent talks to the registry. Mirrors the retryOn check:
|
|
410
|
+
// resumeCursor requires task: true — only job-bound handlers have a
|
|
411
|
+
// controller (and a per-filter recvEvent cursor) to resume, so the flag
|
|
412
|
+
// is meaningless for synchronous tools.
|
|
413
|
+
if (def.resumeCursor === true && def.task !== true) {
|
|
414
|
+
throw new Error(`addTool({ resumeCursor }) for tool '${toolName}': resumeCursor is ` +
|
|
415
|
+
`only valid with task: true; remove resumeCursor or set task: true.`);
|
|
416
|
+
}
|
|
391
417
|
// Issue #917: validate a2aConfig at registration time so misuse fails
|
|
392
418
|
// loud BEFORE the agent talks to the registry. Match the Python
|
|
393
419
|
// `mesh.a2a_consumer` and Java `@A2AConsumer` startup-time checks.
|
|
@@ -446,16 +472,42 @@ export class MeshAgent {
|
|
|
446
472
|
_workerToolMap.set(toolName, execute);
|
|
447
473
|
return this;
|
|
448
474
|
}
|
|
449
|
-
// Normalize dependencies
|
|
450
|
-
|
|
475
|
+
// Normalize dependencies. RFC #1280: a `mesh.serviceView(...)` entry
|
|
476
|
+
// occupies ONE positional slot but expands into N ordinary edges (name-
|
|
477
|
+
// sorted, in-place). `normalizedDeps` is the FLAT edge array — the wire
|
|
478
|
+
// payload, settle keys, resolution events, and resolvedDeps all index it
|
|
479
|
+
// exactly as before; `depSlots` maps each positional execute slot to its
|
|
480
|
+
// edge (a `dep`) or edge-range (a `view`). For a view-free tool the edges
|
|
481
|
+
// are byte-identical to `deps.map(normalizeDependency)` and
|
|
482
|
+
// `depSlots[i].edgeIndex === i` — zero behavior change.
|
|
483
|
+
const { edges: normalizedDeps, slots: depSlots } = expandDependencies(def.dependencies ?? [], toolName);
|
|
451
484
|
const depEndpoints = normalizedDeps.map((d) => d.capability);
|
|
485
|
+
// Phase 1 MeshJob substrate: meshJobDepIndex is an index into the AUTHORED
|
|
486
|
+
// dependencies array (i.e. a positional slot). It must point at an ordinary
|
|
487
|
+
// dependency slot, never a service-view slot (a view has no single edge to
|
|
488
|
+
// swap for a submitter).
|
|
489
|
+
if (def.meshJobDepIndex !== undefined) {
|
|
490
|
+
const slot = depSlots[def.meshJobDepIndex];
|
|
491
|
+
if (slot && slot.kind === "view") {
|
|
492
|
+
throw new Error(`addTool({ meshJobDepIndex: ${def.meshJobDepIndex} }) for tool ` +
|
|
493
|
+
`'${toolName}' points at a mesh.serviceView slot — a MeshJob ` +
|
|
494
|
+
`submitter can only replace an ordinary dependency slot.`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
// The flat edge index of the MeshJob submitter slot (if any). Used by the
|
|
498
|
+
// edge-indexed skip logic below (settle declaration/wait, required guard).
|
|
499
|
+
// For a view-free tool this equals `def.meshJobDepIndex` (slot index ===
|
|
500
|
+
// edge index), so those loops are byte-identical to before.
|
|
501
|
+
const meshJobEdgeIndex = def.meshJobDepIndex === undefined
|
|
502
|
+
? undefined
|
|
503
|
+
: depSlots[def.meshJobDepIndex].edgeIndex;
|
|
452
504
|
// Settling-window grace (#1193): declare this tool's proxy deps with the
|
|
453
505
|
// process-wide settle state so the agent-level "all declared deps
|
|
454
506
|
// resolved" latch can flip eagerly. The MeshJob slot is excluded — its
|
|
455
507
|
// submitter is constructed locally, not resolved by an event.
|
|
456
508
|
const settleState = getSettleState();
|
|
457
509
|
normalizedDeps.forEach((_dep, depIndex) => {
|
|
458
|
-
if (depIndex !==
|
|
510
|
+
if (depIndex !== meshJobEdgeIndex) {
|
|
459
511
|
settleState.registerDeclared(`${toolName}:dep_${depIndex}`);
|
|
460
512
|
}
|
|
461
513
|
});
|
|
@@ -469,6 +521,10 @@ export class MeshAgent {
|
|
|
469
521
|
// registered in this.taskHandlers). Captured here so the closure
|
|
470
522
|
// sees a stable reference even if def is mutated post-registration.
|
|
471
523
|
const retryOn = def.retryOn;
|
|
524
|
+
// Issue #1277: per-tool durable-cursor resume opt-in. Captured here so
|
|
525
|
+
// the ClaimHandler registration below carries it into the dispatcher
|
|
526
|
+
// (which seeds the reclaimed controller from the claim's recv_cursor).
|
|
527
|
+
const resumeCursor = def.resumeCursor === true;
|
|
472
528
|
// Phase 1 MeshJob substrate: when a job-bound tool exists AND the
|
|
473
529
|
// user explicitly opted into worker isolation via env, log a single
|
|
474
530
|
// warning at registration time. The wrapper force-disables
|
|
@@ -487,6 +543,18 @@ export class MeshAgent {
|
|
|
487
543
|
`(controllers/AsyncLocalStorage don't cross worker boundaries). ` +
|
|
488
544
|
`Set 'task: true' explicitly if you intend a producer.`);
|
|
489
545
|
}
|
|
546
|
+
// RFC #1280: same force-disable + non-silent warning for view-bound tools.
|
|
547
|
+
// A serviceView facade wraps live closures over resolvedDeps and has no
|
|
548
|
+
// PROXY_DISPATCH_META, so it can't cross the worker_threads boundary — the
|
|
549
|
+
// wrapper runs view-bound tools inline. Warn once (like the job-bound case)
|
|
550
|
+
// so users who opted into MCP_MESH_TOOL_ISOLATION know it does not apply.
|
|
551
|
+
const isViewBoundForLog = depSlots.some((s) => s.kind === "view");
|
|
552
|
+
if (isViewBoundForLog && isolationEnvSet) {
|
|
553
|
+
console.warn(`[mesh-tool] '${toolName}' injects a mesh.serviceView facade; worker ` +
|
|
554
|
+
`isolation is disabled for view-bound tools (facades wrap live ` +
|
|
555
|
+
`closures over resolved dependencies that don't cross worker ` +
|
|
556
|
+
`boundaries).`);
|
|
557
|
+
}
|
|
490
558
|
// Create wrapper that injects dependencies positionally and handles tracing
|
|
491
559
|
const wrappedExecute = async (args) => {
|
|
492
560
|
// Settling-window grace (#1193): while the agent is still settling,
|
|
@@ -498,7 +566,7 @@ export class MeshAgent {
|
|
|
498
566
|
const pendingSettle = [];
|
|
499
567
|
normalizedDeps.forEach((dep, depIndex) => {
|
|
500
568
|
const depKey = `${toolName}:dep_${depIndex}`;
|
|
501
|
-
if (depIndex !==
|
|
569
|
+
if (depIndex !== meshJobEdgeIndex &&
|
|
502
570
|
!this.resolvedDeps.has(depKey)) {
|
|
503
571
|
pendingSettle.push({ depKey, capability: dep.capability });
|
|
504
572
|
}
|
|
@@ -507,14 +575,25 @@ export class MeshAgent {
|
|
|
507
575
|
await settleState.awaitPending(pendingSettle);
|
|
508
576
|
}
|
|
509
577
|
}
|
|
510
|
-
// Build positional deps array
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
|
|
514
|
-
//
|
|
515
|
-
//
|
|
516
|
-
|
|
517
|
-
|
|
578
|
+
// Build positional deps array (one entry per authored slot). A view slot
|
|
579
|
+
// yields a facade; the MeshJob slot yields a MeshJobSubmitter; every other
|
|
580
|
+
// slot yields resolvedDeps.get(...) ?? null.
|
|
581
|
+
const depsArray = this._buildDepSlots(toolName, depSlots, normalizedDeps, meshJobDepIndex, settleState);
|
|
582
|
+
// Trace metric: count resolved EDGES, not slots — a view slot always
|
|
583
|
+
// yields a non-null facade, so counting slots would inflate the numerator
|
|
584
|
+
// against the edge-based dependency total. Excludes the MeshJob submitter
|
|
585
|
+
// edge (built locally, never a resolved proxy) and adds it back as one
|
|
586
|
+
// injected slot so a view-free tool's count is byte-identical to before.
|
|
587
|
+
let injectedCount = 0;
|
|
588
|
+
for (let e = 0; e < normalizedDeps.length; e++) {
|
|
589
|
+
if (e === meshJobEdgeIndex)
|
|
590
|
+
continue;
|
|
591
|
+
if ((this.resolvedDeps.get(`${toolName}:dep_${e}`) ?? null) !== null) {
|
|
592
|
+
injectedCount++;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (meshJobDepIndex !== undefined)
|
|
596
|
+
injectedCount++;
|
|
518
597
|
// Issue #1273: detect a required dependency slot that is still
|
|
519
598
|
// unresolved AFTER the settle wait above (depsArray was built
|
|
520
599
|
// post-settle). The ACTION is deferred until the invocation flavor is
|
|
@@ -522,12 +601,16 @@ export class MeshAgent {
|
|
|
522
601
|
// direct tools/call refuses, an inbound JOB dispatch releases the
|
|
523
602
|
// lease. Optional deps keep null-passthrough; the MeshJob submitter
|
|
524
603
|
// slot is skipped (built locally, never a resolved proxy).
|
|
604
|
+
// Evaluate per-EDGE against resolvedDeps (not per-slot against depsArray):
|
|
605
|
+
// a view slot collapses N edges into one non-null facade, so a required
|
|
606
|
+
// view-method edge must be checked on the flat edge state. For a view-free
|
|
607
|
+
// tool this is equivalent to the old `depsArray[depIndex] == null` test.
|
|
525
608
|
let missingRequiredCap = null;
|
|
526
609
|
for (let depIndex = 0; depIndex < normalizedDeps.length; depIndex++) {
|
|
527
610
|
const dep = normalizedDeps[depIndex];
|
|
528
611
|
if (dep.required === true &&
|
|
529
|
-
depIndex !==
|
|
530
|
-
|
|
612
|
+
depIndex !== meshJobEdgeIndex &&
|
|
613
|
+
(this.resolvedDeps.get(`${toolName}:dep_${depIndex}`) ?? null) == null) {
|
|
531
614
|
missingRequiredCap = dep.capability;
|
|
532
615
|
break;
|
|
533
616
|
}
|
|
@@ -655,8 +738,14 @@ export class MeshAgent {
|
|
|
655
738
|
// cached client + connection pool intact across calls.
|
|
656
739
|
const isA2aBound = a2aClient !== null;
|
|
657
740
|
const isJobBound = isTaskTool || meshJobDepIndex !== undefined;
|
|
741
|
+
// RFC #1280: a service-view facade wraps live closures over resolvedDeps
|
|
742
|
+
// and cannot be serialised across the worker_threads boundary (it has no
|
|
743
|
+
// PROXY_DISPATCH_META). Force inline execution for view-bound tools, like
|
|
744
|
+
// job-bound and A2A-bound tools.
|
|
745
|
+
const isViewBound = depSlots.some((s) => s.kind === "view");
|
|
658
746
|
const isolationEnabled = !isJobBound &&
|
|
659
747
|
!isA2aBound &&
|
|
748
|
+
!isViewBound &&
|
|
660
749
|
(process.env.MCP_MESH_TOOL_ISOLATION ?? "true").toLowerCase() !== "false";
|
|
661
750
|
try {
|
|
662
751
|
let result;
|
|
@@ -832,7 +921,7 @@ export class MeshAgent {
|
|
|
832
921
|
const pendingSettle = [];
|
|
833
922
|
normalizedDeps.forEach((dep, depIndex) => {
|
|
834
923
|
const depKey = `${toolName}:dep_${depIndex}`;
|
|
835
|
-
if (depIndex !==
|
|
924
|
+
if (depIndex !== meshJobEdgeIndex &&
|
|
836
925
|
!this.resolvedDeps.has(depKey)) {
|
|
837
926
|
pendingSettle.push({ depKey, capability: dep.capability });
|
|
838
927
|
}
|
|
@@ -841,7 +930,7 @@ export class MeshAgent {
|
|
|
841
930
|
await settleState.awaitPending(pendingSettle);
|
|
842
931
|
}
|
|
843
932
|
}
|
|
844
|
-
const liveDeps = this._buildDepSlots(toolName, normalizedDeps, meshJobDepIndex, settleState);
|
|
933
|
+
const liveDeps = this._buildDepSlots(toolName, depSlots, normalizedDeps, meshJobDepIndex, settleState);
|
|
845
934
|
const callArgs = spliceJobController(payload, liveDeps, controller, meshJobParamIndex);
|
|
846
935
|
// Issue #917: A2A consumer tools dispatched via the claim
|
|
847
936
|
// path get the same trailing A2AClient argument as the
|
|
@@ -858,7 +947,7 @@ export class MeshAgent {
|
|
|
858
947
|
// covers both an absent key and an explicit null slot.
|
|
859
948
|
const requiredSlots = [];
|
|
860
949
|
normalizedDeps.forEach((dep, depIndex) => {
|
|
861
|
-
if (dep.required && depIndex !==
|
|
950
|
+
if (dep.required && depIndex !== meshJobEdgeIndex) {
|
|
862
951
|
requiredSlots.push([depIndex, dep.capability]);
|
|
863
952
|
}
|
|
864
953
|
});
|
|
@@ -873,7 +962,12 @@ export class MeshAgent {
|
|
|
873
962
|
return null;
|
|
874
963
|
}
|
|
875
964
|
: undefined;
|
|
876
|
-
this._taskHandlers.set(capability, {
|
|
965
|
+
this._taskHandlers.set(capability, {
|
|
966
|
+
handler,
|
|
967
|
+
retryOn,
|
|
968
|
+
requiredProbe,
|
|
969
|
+
resumeCursor,
|
|
970
|
+
});
|
|
877
971
|
}
|
|
878
972
|
// Store mesh metadata with JSON Schema for LLM tool resolution
|
|
879
973
|
const inputSchema = this.convertZodToJsonSchema(def.parameters);
|
|
@@ -884,6 +978,28 @@ export class MeshAgent {
|
|
|
884
978
|
if (def.outputSchema) {
|
|
885
979
|
outputSchemaRaw = this.convertZodToJsonSchema(def.outputSchema);
|
|
886
980
|
}
|
|
981
|
+
// dependencyKwargs is authored per positional SLOT but consumed per flat
|
|
982
|
+
// EDGE (handleDependencyAvailable reads meta.dependencyKwargs[depIndex] with
|
|
983
|
+
// the event's edge index). For a view-free tool slot index === edge index,
|
|
984
|
+
// so pass it through unchanged (byte-identical). When a view slot is present,
|
|
985
|
+
// remap each slot's kwargs onto its edge(s) — a view's single kwargs entry
|
|
986
|
+
// applies to each of its method edges.
|
|
987
|
+
let edgeDependencyKwargs = def.dependencyKwargs;
|
|
988
|
+
if (def.dependencyKwargs && depSlots.some((s) => s.kind === "view")) {
|
|
989
|
+
const remapped = new Array(normalizedDeps.length);
|
|
990
|
+
depSlots.forEach((slot, slotIndex) => {
|
|
991
|
+
const kw = def.dependencyKwargs?.[slotIndex];
|
|
992
|
+
if (slot.kind === "dep") {
|
|
993
|
+
remapped[slot.edgeIndex] = kw;
|
|
994
|
+
}
|
|
995
|
+
else {
|
|
996
|
+
for (const m of slot.methods) {
|
|
997
|
+
remapped[m.edgeIndex] = kw;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
});
|
|
1001
|
+
edgeDependencyKwargs = remapped;
|
|
1002
|
+
}
|
|
887
1003
|
this.tools.set(toolName, {
|
|
888
1004
|
capability: def.capability ?? toolName,
|
|
889
1005
|
version: def.version ?? "1.0.0",
|
|
@@ -894,7 +1010,7 @@ export class MeshAgent {
|
|
|
894
1010
|
// Issue #547 Phase 4: per-tool override (default true = current behavior).
|
|
895
1011
|
outputSchemaStrict: def.outputSchemaStrict !== false,
|
|
896
1012
|
dependencies: normalizedDeps,
|
|
897
|
-
dependencyKwargs:
|
|
1013
|
+
dependencyKwargs: edgeDependencyKwargs,
|
|
898
1014
|
// Phase 1 MeshJob substrate: stamp producer's long-running flag
|
|
899
1015
|
// so the heartbeat pipeline ships it to the registry. Consumers
|
|
900
1016
|
// read this to decide between job semantics and a regular
|
|
@@ -911,6 +1027,108 @@ export class MeshAgent {
|
|
|
911
1027
|
});
|
|
912
1028
|
return this;
|
|
913
1029
|
}
|
|
1030
|
+
/**
|
|
1031
|
+
* RFC #1280 producer sugar: publish each entry of `methods` as an ordinary
|
|
1032
|
+
* mesh tool with capability `prefix.<method>`, routed through the SAME
|
|
1033
|
+
* `addTool` machinery (schemas, heartbeat, DI, duplicate handling all fall
|
|
1034
|
+
* out). Entries are registered NAME-SORTED for deterministic registration.
|
|
1035
|
+
*
|
|
1036
|
+
* Each entry is either a bare execute function (shorthand) or an object
|
|
1037
|
+
* `{ execute, parameters?, tags?, version?, description?, dependencies?, ... }`
|
|
1038
|
+
* carrying any `addTool` passthrough (but NOT `name`/`capability` — those are
|
|
1039
|
+
* derived). A method with no `parameters` gets a permissive passthrough schema.
|
|
1040
|
+
*
|
|
1041
|
+
* The prefix AND every derived capability are validated against the segment-
|
|
1042
|
+
* wise dotted grammar (kept in lockstep with the Go registry validator —
|
|
1043
|
+
* src/core/registry/validation.go). addTool applies the same grammar to its
|
|
1044
|
+
* produced capability (#1293 item 6), so both surfaces validate a PRODUCED
|
|
1045
|
+
* capability name before it reaches the registry; dependency capability names
|
|
1046
|
+
* remain the registry's authority.
|
|
1047
|
+
*
|
|
1048
|
+
* @example
|
|
1049
|
+
* ```typescript
|
|
1050
|
+
* agent.addService("media", {
|
|
1051
|
+
* caption: async (args) => ({ caption: `...${args.text}` }),
|
|
1052
|
+
* thumbnail: { execute: async (args) => ({ url: "..." }), tags: ["fast"] },
|
|
1053
|
+
* });
|
|
1054
|
+
* // → tools/capabilities "media.caption" and "media.thumbnail"
|
|
1055
|
+
* ```
|
|
1056
|
+
*/
|
|
1057
|
+
addService(prefix, methods) {
|
|
1058
|
+
if (typeof prefix !== "string" || prefix.trim() === "") {
|
|
1059
|
+
throw new Error("addService: a non-empty capability prefix is required " +
|
|
1060
|
+
'(e.g. addService("media", { ... })).');
|
|
1061
|
+
}
|
|
1062
|
+
if (!CAPABILITY_NAME_PATTERN.test(prefix)) {
|
|
1063
|
+
throw new Error(`addService: prefix '${prefix}' is not a valid capability name — each ` +
|
|
1064
|
+
`dot-separated segment must start with a letter and contain only ` +
|
|
1065
|
+
`letters, digits, '_' or '-' (cross-ref src/core/registry/validation.go).`);
|
|
1066
|
+
}
|
|
1067
|
+
if (methods == null || typeof methods !== "object") {
|
|
1068
|
+
throw new Error(`addService("${prefix}", ...): methods must be an object mapping ` +
|
|
1069
|
+
`method names to producer functions/definitions.`);
|
|
1070
|
+
}
|
|
1071
|
+
const names = Object.keys(methods).sort();
|
|
1072
|
+
if (names.length === 0) {
|
|
1073
|
+
throw new Error(`addService("${prefix}", ...): at least one method is required.`);
|
|
1074
|
+
}
|
|
1075
|
+
// Pass 1 — validate EVERY derived name/entry before registering any tool so
|
|
1076
|
+
// an invalid method mid-map is atomic (registers nothing). Also rejects a
|
|
1077
|
+
// derived capability that collides with an already-registered tool (scoped
|
|
1078
|
+
// to addService: addTool's own last-wins behavior for hand-written tools is
|
|
1079
|
+
// unchanged).
|
|
1080
|
+
const prepared = [];
|
|
1081
|
+
for (const method of names) {
|
|
1082
|
+
const capability = `${prefix}.${method}`;
|
|
1083
|
+
// Validate the FULL derived capability, not just the prefix: a method
|
|
1084
|
+
// name may contain characters ('$', unicode, ...) the grammar rejects.
|
|
1085
|
+
if (!CAPABILITY_NAME_PATTERN.test(capability)) {
|
|
1086
|
+
throw new Error(`addService("${prefix}", ...): method '${method}' derives capability ` +
|
|
1087
|
+
`'${capability}', which is not a valid capability name (each ` +
|
|
1088
|
+
`dot-separated segment must start with a letter and contain only ` +
|
|
1089
|
+
`letters, digits, '_' or '-'). Rename the method.`);
|
|
1090
|
+
}
|
|
1091
|
+
if (this.tools.has(capability)) {
|
|
1092
|
+
throw new Error(`addService("${prefix}", ...): method '${method}' derives tool/` +
|
|
1093
|
+
`capability '${capability}', which is already registered. Rename ` +
|
|
1094
|
+
`the method or the conflicting tool.`);
|
|
1095
|
+
}
|
|
1096
|
+
const entry = methods[method];
|
|
1097
|
+
// Guard: a service-view value here is the consumer surface used in the
|
|
1098
|
+
// wrong role (producer). Fail loud.
|
|
1099
|
+
if (isServiceView(entry)) {
|
|
1100
|
+
throw new Error(`addService("${prefix}", ...): method '${method}' is a ` +
|
|
1101
|
+
`mesh.serviceView(...) — that is a CONSUMER view, not a producer ` +
|
|
1102
|
+
`method. Provide an execute function or { execute, ... } object.`);
|
|
1103
|
+
}
|
|
1104
|
+
let def;
|
|
1105
|
+
if (typeof entry === "function") {
|
|
1106
|
+
def = { execute: entry };
|
|
1107
|
+
}
|
|
1108
|
+
else if (entry != null &&
|
|
1109
|
+
typeof entry === "object" &&
|
|
1110
|
+
typeof entry.execute === "function") {
|
|
1111
|
+
def = entry;
|
|
1112
|
+
}
|
|
1113
|
+
else {
|
|
1114
|
+
throw new Error(`addService("${prefix}", ...): method '${method}' must be an execute ` +
|
|
1115
|
+
`function or an object with an execute function.`);
|
|
1116
|
+
}
|
|
1117
|
+
prepared.push({ capability, def });
|
|
1118
|
+
}
|
|
1119
|
+
// Pass 2 — register through the existing addTool machinery. Derived name ===
|
|
1120
|
+
// derived capability for deterministic uniqueness.
|
|
1121
|
+
for (const { capability, def } of prepared) {
|
|
1122
|
+
this.addTool({
|
|
1123
|
+
...def,
|
|
1124
|
+
name: capability,
|
|
1125
|
+
capability,
|
|
1126
|
+
parameters: def.parameters ?? defaultProducerParams(),
|
|
1127
|
+
execute: def.execute,
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
return this;
|
|
1131
|
+
}
|
|
914
1132
|
/**
|
|
915
1133
|
* Add an LLM provider to the agent.
|
|
916
1134
|
*
|
|
@@ -1014,27 +1232,36 @@ export class MeshAgent {
|
|
|
1014
1232
|
* Build the positional dependency-slot array for one tool call, shared by
|
|
1015
1233
|
* BOTH the inbound HTTP wrapper (wrappedExecute) and the claim-dispatch
|
|
1016
1234
|
* path (the ClaimHandler for task:true tools). Centralising this keeps the
|
|
1017
|
-
* two slot-assembly sites identical:
|
|
1235
|
+
* two slot-assembly sites identical. One entry per AUTHORED slot:
|
|
1236
|
+
* - a service-view slot (RFC #1280) → a facade over its method edges
|
|
1018
1237
|
* - the MeshJob slot (meshJobDepIndex) → a freshly-bound MeshJobSubmitter
|
|
1019
|
-
* - every other slot → resolvedDeps.get(`${toolName}:dep_${
|
|
1238
|
+
* - every other slot → resolvedDeps.get(`${toolName}:dep_${edgeIndex}`) ?? null
|
|
1239
|
+
*
|
|
1240
|
+
* `depSlots` carries the slot→edge mapping (a `dep` slot owns one edge; a
|
|
1241
|
+
* `view` slot owns a contiguous edge range). `edges` is the flat edge array;
|
|
1242
|
+
* a slot's proxy/capability is read via its edge index.
|
|
1020
1243
|
*
|
|
1021
1244
|
* #1231: a typed dep slot can resolve at the registry yet never receive an
|
|
1022
1245
|
* injected proxy, leaving the parameter null. Once the settle latch has
|
|
1023
1246
|
* flipped (during settling the proxy may still land), warn ONCE per
|
|
1024
1247
|
* tool+slot — the array is rebuilt per call, so a per-call warning would
|
|
1025
|
-
* spam. The warn-once dedupe keys on `${toolName}:dep_${
|
|
1248
|
+
* spam. The warn-once dedupe keys on `${toolName}:dep_${edgeIndex}` via the
|
|
1026
1249
|
* module-level `_unwiredSlotWarned` Set, so a tool exercised via BOTH the
|
|
1027
1250
|
* inbound and claim paths warns at most once total.
|
|
1028
1251
|
*/
|
|
1029
|
-
_buildDepSlots(toolName,
|
|
1030
|
-
return
|
|
1031
|
-
if (
|
|
1252
|
+
_buildDepSlots(toolName, depSlots, edges, meshJobDepIndex, settleState) {
|
|
1253
|
+
return depSlots.map((slot, slotIndex) => {
|
|
1254
|
+
if (slot.kind === "view") {
|
|
1255
|
+
return this._buildServiceViewFacade(toolName, slot, edges, settleState);
|
|
1256
|
+
}
|
|
1257
|
+
const edgeIndex = slot.edgeIndex;
|
|
1258
|
+
if (slotIndex === meshJobDepIndex) {
|
|
1032
1259
|
// Build the submitter lazily per call so we always pick up the
|
|
1033
1260
|
// current registryUrl (test harnesses sometimes mutate it between
|
|
1034
1261
|
// calls).
|
|
1035
|
-
return new MeshJobSubmitter(
|
|
1262
|
+
return new MeshJobSubmitter(edges[edgeIndex].capability, this.agentId, this.config.registryUrl);
|
|
1036
1263
|
}
|
|
1037
|
-
const slotKey = `${toolName}:dep_${
|
|
1264
|
+
const slotKey = `${toolName}:dep_${edgeIndex}`;
|
|
1038
1265
|
const proxy = this.resolvedDeps.get(slotKey) ?? null;
|
|
1039
1266
|
// #1231: a declared typed dep slot that is still null AFTER settling
|
|
1040
1267
|
// never received an injected proxy. Warn ONCE per tool+slot. Note this
|
|
@@ -1043,15 +1270,111 @@ export class MeshAgent {
|
|
|
1043
1270
|
if (proxy === null && settleState.isSettled()) {
|
|
1044
1271
|
if (!_unwiredSlotWarned.has(slotKey)) {
|
|
1045
1272
|
_unwiredSlotWarned.add(slotKey);
|
|
1046
|
-
console.warn(`[mesh-tool] dependency '${
|
|
1047
|
-
`is still null after settling — no proxy was
|
|
1048
|
-
`positional slot ${
|
|
1049
|
-
`'${
|
|
1273
|
+
console.warn(`[mesh-tool] dependency '${edges[edgeIndex].capability}' on ` +
|
|
1274
|
+
`'${toolName}' is still null after settling — no proxy was ` +
|
|
1275
|
+
`injected into positional slot ${slotIndex}. Fix: ensure the ` +
|
|
1276
|
+
`provider for '${edges[edgeIndex].capability}' is registered ` +
|
|
1277
|
+
`and reachable.`);
|
|
1050
1278
|
}
|
|
1051
1279
|
}
|
|
1052
1280
|
return proxy;
|
|
1053
1281
|
});
|
|
1054
1282
|
}
|
|
1283
|
+
/**
|
|
1284
|
+
* RFC #1280: build the facade injected at a service-view slot. Each facade
|
|
1285
|
+
* method reads its edge's CURRENT proxy from `resolvedDeps` at call time —
|
|
1286
|
+
* rebinding-free: a `dependency_available`/`unavailable` event swapping the
|
|
1287
|
+
* per-edge proxy is observed on the next call without rebuilding the facade.
|
|
1288
|
+
*
|
|
1289
|
+
* The `minAvailable` floor is enforced BEFORE delegation on every call: below
|
|
1290
|
+
* the floor during the settling window (#1193) the method performs the same
|
|
1291
|
+
* bounded, capability-keyed wait the injected-proxy path uses, recounts, then
|
|
1292
|
+
* throws {@link MeshServiceUnavailableError}. An unresolved OPTIONAL method
|
|
1293
|
+
* edge throws a `TypeError` — the same failure shape a directly-injected
|
|
1294
|
+
* unresolved `McpMeshTool` (a null proxy) produces when called.
|
|
1295
|
+
*/
|
|
1296
|
+
_buildServiceViewFacade(toolName, slot, edges, settleState) {
|
|
1297
|
+
const total = slot.methods.length;
|
|
1298
|
+
const floor = slot.minAvailable;
|
|
1299
|
+
const viewName = slot.name;
|
|
1300
|
+
const countAvailable = () => {
|
|
1301
|
+
let n = 0;
|
|
1302
|
+
for (const m of slot.methods) {
|
|
1303
|
+
if ((this.resolvedDeps.get(`${toolName}:dep_${m.edgeIndex}`) ?? null) !== null) {
|
|
1304
|
+
n++;
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
return n;
|
|
1308
|
+
};
|
|
1309
|
+
const enforceFloor = async () => {
|
|
1310
|
+
if (floor <= 0)
|
|
1311
|
+
return;
|
|
1312
|
+
let available = countAvailable();
|
|
1313
|
+
// Below the floor during the settling window: perform a bounded wait that
|
|
1314
|
+
// RACES all of the view's still-pending edges (wake on ANY resolution),
|
|
1315
|
+
// recounts, and breaks the moment the floor is met — so a call gated on
|
|
1316
|
+
// the floor wakes when ANY qualifying edge lands, never sleeping the full
|
|
1317
|
+
// window on a still-pending sibling key. Once settled the waits are
|
|
1318
|
+
// no-ops and the floor fails fast.
|
|
1319
|
+
while (available < floor &&
|
|
1320
|
+
!settleState.isSettled() &&
|
|
1321
|
+
settleState.remainingMs() > 0) {
|
|
1322
|
+
// Only edges that can still fire a settle WAKE are worth waiting on: a
|
|
1323
|
+
// key already in the settle ratchet (resolved at least once) will never
|
|
1324
|
+
// re-fire its waiter, so if such an edge has since flapped to a null
|
|
1325
|
+
// proxy `waitForAny` would filter it out and return immediately — a hot
|
|
1326
|
+
// busy-loop until the window expires. Excluding ratchet-resolved keys
|
|
1327
|
+
// here means `pending` empties and we break to the final recount/throw
|
|
1328
|
+
// the instant no wakeable edge remains (no spin).
|
|
1329
|
+
const pending = [];
|
|
1330
|
+
for (const m of slot.methods) {
|
|
1331
|
+
const key = `${toolName}:dep_${m.edgeIndex}`;
|
|
1332
|
+
if ((this.resolvedDeps.get(key) ?? null) === null &&
|
|
1333
|
+
!settleState.isResolved(key)) {
|
|
1334
|
+
pending.push({ depKey: key, capability: edges[m.edgeIndex].capability });
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
if (pending.length === 0)
|
|
1338
|
+
break;
|
|
1339
|
+
await settleState.waitForAny(pending);
|
|
1340
|
+
available = countAvailable();
|
|
1341
|
+
}
|
|
1342
|
+
if (available < floor) {
|
|
1343
|
+
throw new MeshServiceUnavailableError(viewName, available, total, floor);
|
|
1344
|
+
}
|
|
1345
|
+
};
|
|
1346
|
+
const facade = {};
|
|
1347
|
+
for (const m of slot.methods) {
|
|
1348
|
+
const key = `${toolName}:dep_${m.edgeIndex}`;
|
|
1349
|
+
const capability = edges[m.edgeIndex].capability;
|
|
1350
|
+
// #1231 parity for view edges: a method edge that resolved at the registry
|
|
1351
|
+
// yet never received an injected proxy stays null after settling. Warn
|
|
1352
|
+
// ONCE per tool+edge (facade is rebuilt per call), naming the view method
|
|
1353
|
+
// and capability so the diagnostic matches the slot-dep warning.
|
|
1354
|
+
if ((this.resolvedDeps.get(key) ?? null) === null && settleState.isSettled()) {
|
|
1355
|
+
if (!_unwiredSlotWarned.has(key)) {
|
|
1356
|
+
_unwiredSlotWarned.add(key);
|
|
1357
|
+
console.warn(`[mesh-tool] service view '${viewName}' method '${m.method}' ` +
|
|
1358
|
+
`(capability '${capability}') on '${toolName}' is still null ` +
|
|
1359
|
+
`after settling — no proxy was injected. Fix: ensure the provider ` +
|
|
1360
|
+
`for '${capability}' is registered and reachable.`);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
facade[m.method] = async (args, options) => {
|
|
1364
|
+
await enforceFloor();
|
|
1365
|
+
const proxy = this.resolvedDeps.get(key) ?? null;
|
|
1366
|
+
if (proxy === null) {
|
|
1367
|
+
// Mirror the failure shape of calling an unresolved McpMeshTool
|
|
1368
|
+
// (a null proxy) directly — a TypeError.
|
|
1369
|
+
throw new TypeError(`service view '${viewName}' method '${m.method}' (capability ` +
|
|
1370
|
+
`'${capability}') is unavailable — the dependency did not ` +
|
|
1371
|
+
`resolve (unresolved optional edge).`);
|
|
1372
|
+
}
|
|
1373
|
+
return proxy(args, options);
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
return facade;
|
|
1377
|
+
}
|
|
1055
1378
|
_getOrBuildA2AClient(config) {
|
|
1056
1379
|
const key = [
|
|
1057
1380
|
config.url,
|
|
@@ -1259,7 +1582,7 @@ export class MeshAgent {
|
|
|
1259
1582
|
if (this._taskHandlers.size === 0)
|
|
1260
1583
|
return;
|
|
1261
1584
|
for (const [capability, entry] of this._taskHandlers.entries()) {
|
|
1262
|
-
const dispatcher = new ClaimDispatcher(capability, this.agentId, this.config.registryUrl, entry.handler, entry.retryOn, entry.requiredProbe);
|
|
1585
|
+
const dispatcher = new ClaimDispatcher(capability, this.agentId, this.config.registryUrl, entry.handler, entry.retryOn, entry.requiredProbe, entry.resumeCursor);
|
|
1263
1586
|
dispatcher.start();
|
|
1264
1587
|
this._claimDispatchers.push(dispatcher);
|
|
1265
1588
|
}
|