@loomwork/ui-hydration 0.0.0-stage → 0.1.1
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/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +398 -2
- package/dist/cache.d.ts +71 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +157 -0
- package/dist/cache.js.map +1 -0
- package/dist/chatSource.d.ts +105 -0
- package/dist/chatSource.d.ts.map +1 -0
- package/dist/chatSource.js +264 -0
- package/dist/chatSource.js.map +1 -0
- package/dist/client.d.ts +17 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +36 -0
- package/dist/client.js.map +1 -0
- package/dist/contentRefs.d.ts +78 -0
- package/dist/contentRefs.d.ts.map +1 -0
- package/dist/contentRefs.js +129 -0
- package/dist/contentRefs.js.map +1 -0
- package/dist/controller.d.ts +228 -0
- package/dist/controller.d.ts.map +1 -0
- package/dist/controller.js +454 -0
- package/dist/controller.js.map +1 -0
- package/dist/dataProvider.d.ts +53 -0
- package/dist/dataProvider.d.ts.map +1 -0
- package/dist/dataProvider.js +77 -0
- package/dist/dataProvider.js.map +1 -0
- package/dist/esm/cache.js +151 -0
- package/dist/esm/cache.js.map +1 -0
- package/dist/esm/chatSource.js +259 -0
- package/dist/esm/chatSource.js.map +1 -0
- package/dist/esm/client.js +15 -0
- package/dist/esm/client.js.map +1 -0
- package/dist/esm/contentRefs.js +119 -0
- package/dist/esm/contentRefs.js.map +1 -0
- package/dist/esm/controller.js +449 -0
- package/dist/esm/controller.js.map +1 -0
- package/dist/esm/dataProvider.js +72 -0
- package/dist/esm/dataProvider.js.map +1 -0
- package/dist/esm/index.js +15 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/invoker.js +2 -0
- package/dist/esm/invoker.js.map +1 -0
- package/dist/esm/invokers.js +310 -0
- package/dist/esm/invokers.js.map +1 -0
- package/dist/esm/loomEffectProvider.js +96 -0
- package/dist/esm/loomEffectProvider.js.map +1 -0
- package/dist/esm/params.js +207 -0
- package/dist/esm/params.js.map +1 -0
- package/dist/esm/path.js +24 -0
- package/dist/esm/path.js.map +1 -0
- package/dist/esm/plan.js +190 -0
- package/dist/esm/plan.js.map +1 -0
- package/dist/esm/projection.js +230 -0
- package/dist/esm/projection.js.map +1 -0
- package/dist/esm/provider.js +416 -0
- package/dist/esm/provider.js.map +1 -0
- package/dist/esm/repair.js +53 -0
- package/dist/esm/repair.js.map +1 -0
- package/dist/esm/reshape.js +156 -0
- package/dist/esm/reshape.js.map +1 -0
- package/dist/esm/restricted.js +54 -0
- package/dist/esm/restricted.js.map +1 -0
- package/dist/esm/scheduler.js +247 -0
- package/dist/esm/scheduler.js.map +1 -0
- package/dist/esm/select.js +18 -0
- package/dist/esm/select.js.map +1 -0
- package/dist/esm/shape.js +34 -0
- package/dist/esm/shape.js.map +1 -0
- package/dist/esm/split/client.js +312 -0
- package/dist/esm/split/client.js.map +1 -0
- package/dist/esm/split/index.js +3 -0
- package/dist/esm/split/index.js.map +1 -0
- package/dist/esm/split/server.js +488 -0
- package/dist/esm/split/server.js.map +1 -0
- package/dist/esm/split/shared.js +13 -0
- package/dist/esm/split/shared.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/dist/invoker.d.ts +10 -0
- package/dist/invoker.d.ts.map +1 -0
- package/dist/invoker.js +3 -0
- package/dist/invoker.js.map +1 -0
- package/dist/invokers.d.ts +36 -0
- package/dist/invokers.d.ts.map +1 -0
- package/dist/invokers.js +315 -0
- package/dist/invokers.js.map +1 -0
- package/dist/loomEffectProvider.d.ts +67 -0
- package/dist/loomEffectProvider.d.ts.map +1 -0
- package/dist/loomEffectProvider.js +101 -0
- package/dist/loomEffectProvider.js.map +1 -0
- package/dist/params.d.ts +103 -0
- package/dist/params.d.ts.map +1 -0
- package/dist/params.js +221 -0
- package/dist/params.js.map +1 -0
- package/dist/path.d.ts +12 -0
- package/dist/path.d.ts.map +1 -0
- package/dist/path.js +28 -0
- package/dist/path.js.map +1 -0
- package/dist/plan.d.ts +56 -0
- package/dist/plan.d.ts.map +1 -0
- package/dist/plan.js +199 -0
- package/dist/plan.js.map +1 -0
- package/dist/projection.d.ts +122 -0
- package/dist/projection.d.ts.map +1 -0
- package/dist/projection.js +238 -0
- package/dist/projection.js.map +1 -0
- package/dist/provider.d.ts +152 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +421 -0
- package/dist/provider.js.map +1 -0
- package/dist/repair.d.ts +18 -0
- package/dist/repair.d.ts.map +1 -0
- package/dist/repair.js +57 -0
- package/dist/repair.js.map +1 -0
- package/dist/reshape.d.ts +32 -0
- package/dist/reshape.d.ts.map +1 -0
- package/dist/reshape.js +164 -0
- package/dist/reshape.js.map +1 -0
- package/dist/restricted.d.ts +21 -0
- package/dist/restricted.d.ts.map +1 -0
- package/dist/restricted.js +61 -0
- package/dist/restricted.js.map +1 -0
- package/dist/scheduler.d.ts +82 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +254 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/select.d.ts +34 -0
- package/dist/select.d.ts.map +1 -0
- package/dist/select.js +21 -0
- package/dist/select.js.map +1 -0
- package/dist/shape.d.ts +3 -0
- package/dist/shape.d.ts.map +1 -0
- package/dist/shape.js +37 -0
- package/dist/shape.js.map +1 -0
- package/dist/split/client.d.ts +135 -0
- package/dist/split/client.d.ts.map +1 -0
- package/dist/split/client.js +316 -0
- package/dist/split/client.js.map +1 -0
- package/dist/split/index.d.ts +5 -0
- package/dist/split/index.d.ts.map +1 -0
- package/dist/split/index.js +14 -0
- package/dist/split/index.js.map +1 -0
- package/dist/split/server.d.ts +160 -0
- package/dist/split/server.d.ts.map +1 -0
- package/dist/split/server.js +496 -0
- package/dist/split/server.js.map +1 -0
- package/dist/split/shared.d.ts +6 -0
- package/dist/split/shared.d.ts.map +1 -0
- package/dist/split/shared.js +19 -0
- package/dist/split/shared.js.map +1 -0
- package/package.json +77 -3
- package/src/cache.ts +211 -0
- package/src/chatSource.ts +378 -0
- package/src/client.ts +46 -0
- package/src/contentRefs.ts +195 -0
- package/src/controller.ts +708 -0
- package/src/dataProvider.ts +141 -0
- package/src/index.ts +86 -0
- package/src/invoker.ts +14 -0
- package/src/invokers.ts +348 -0
- package/src/loomEffectProvider.ts +136 -0
- package/src/params.ts +314 -0
- package/src/path.ts +26 -0
- package/src/plan.ts +197 -0
- package/src/projection.ts +297 -0
- package/src/provider.ts +657 -0
- package/src/repair.ts +70 -0
- package/src/reshape.ts +193 -0
- package/src/restricted.ts +69 -0
- package/src/scheduler.ts +284 -0
- package/src/select.ts +59 -0
- package/src/shape.ts +39 -0
- package/src/split/client.ts +452 -0
- package/src/split/index.ts +18 -0
- package/src/split/server.ts +746 -0
- package/src/split/shared.ts +18 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { toolInputJsonSchema } from "@loomwork/agent";
|
|
2
|
+
import { TypedFailure, jsonSchemaHash } from "@loomwork/core";
|
|
3
|
+
import type { EffectRuntime } from "@loomwork/effect-runtime";
|
|
4
|
+
import { MCP_INVOKE_TOOL_ID } from "@loomwork/plugin-mcp";
|
|
5
|
+
import { SANDBOX_TOOL_ID } from "@loomwork/plugin-sandbox";
|
|
6
|
+
import type { DataProvider } from "./dataProvider";
|
|
7
|
+
|
|
8
|
+
/** The kind id of a step whose target is a registered loom effect. */
|
|
9
|
+
export const LOOM_EFFECT_KIND = "loom-effect";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The failure channel for a target the host did not declare. Served to a client as the fixed
|
|
13
|
+
* `tool-targetUnregistered` code — never the target id or any text of the effect's own.
|
|
14
|
+
*/
|
|
15
|
+
export const TARGET_UNREGISTERED = "targetUnregistered";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The failure channel for an effect that itself raised `schemaDrift` (e.g. a target wrapping
|
|
19
|
+
* `mcp.invoke`). Only the provider's OWN pin check may emit `schemaDrift`: a target's drift
|
|
20
|
+
* would otherwise be read as this kind's pin drift, and a repair would re-pin to the inner
|
|
21
|
+
* hash and flip forever. Served as the `tool-targetFailed` code (`tool-failed` to a client).
|
|
22
|
+
*/
|
|
23
|
+
export const TARGET_FAILED = "targetFailed";
|
|
24
|
+
|
|
25
|
+
/** The schema of an effect's input, as `tool$({ inputSchema })` takes it. */
|
|
26
|
+
export type EffectInputSchema = Parameters<typeof toolInputJsonSchema>[0];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The pin of an effect's input contract: `jsonSchemaHash` (the one canonical hash MCP pins use)
|
|
30
|
+
* of the schema's JSON-Schema form. Use it BOTH when the host builds a planner `CapabilityRef`
|
|
31
|
+
* (`toolSchemaHash`) and when it lists the effect for {@link loomEffectProvider} (via
|
|
32
|
+
* `{ inputSchema }`) so the two come from one source: editing the effect's zod schema moves
|
|
33
|
+
* the pin, and every stored step drifts.
|
|
34
|
+
*/
|
|
35
|
+
export function effectPin(schema: EffectInputSchema): string {
|
|
36
|
+
return jsonSchemaHash(toolInputJsonSchema(schema));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The effect kinds a data read can never reach: a person's answer, a model call. */
|
|
40
|
+
const REFUSED_KINDS: ReadonlySet<string> = new Set(["human", "llm"]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Effects hydration already reaches through a dedicated plane (the MCP kind, the sandbox
|
|
44
|
+
* reshaper). Exposing them as a data TARGET would let a stored step bypass the pin and the
|
|
45
|
+
* reshaping boundary, so a host cannot list them.
|
|
46
|
+
*/
|
|
47
|
+
const RESERVED_TARGETS: ReadonlySet<string> = new Set([MCP_INVOKE_TOOL_ID, SANDBOX_TOOL_ID]);
|
|
48
|
+
|
|
49
|
+
/** How a listed effect's pin is obtained: computed from its schema, or asserted by the host. */
|
|
50
|
+
export type EffectEntry =
|
|
51
|
+
| { readonly inputSchema: NonNullable<EffectInputSchema> }
|
|
52
|
+
| { readonly pin: string };
|
|
53
|
+
|
|
54
|
+
export interface LoomEffectProviderOptions {
|
|
55
|
+
/**
|
|
56
|
+
* The runtime the effects are registered with. Read at CONSTRUCTION only (`has` / `modeOf` /
|
|
57
|
+
* `kindOf`): hydration is a read path that runs per viewer load, unmemoised and retried, so
|
|
58
|
+
* every listed effect must be registered, explicitly `mode: "ambient"` (ADR 0073 — loom
|
|
59
|
+
* refuses only an explicit `journaled` one on the transient path, so an UNDECLARED mode is
|
|
60
|
+
* not a read declaration and is refused here), and not a `human` / `llm` kind.
|
|
61
|
+
*/
|
|
62
|
+
readonly runtime: Pick<EffectRuntime, "has" | "modeOf" | "kindOf">;
|
|
63
|
+
/**
|
|
64
|
+
* The effects a stored step may name. `{ inputSchema }` computes the pin with
|
|
65
|
+
* {@link effectPin} (a schema change drifts every stored step); `{ pin }` is HOST-ASSERTED —
|
|
66
|
+
* nothing checks it against the effect, so the host owns changing it when callers must
|
|
67
|
+
* re-author. A step naming an effect not listed is refused (`tool-targetUnregistered`) and
|
|
68
|
+
* nothing runs. Pins must be non-empty.
|
|
69
|
+
*/
|
|
70
|
+
readonly effects: Readonly<Record<string, EffectEntry>>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The `loom-effect` kind: a step whose `toolName` is the id of an effect registered with the
|
|
75
|
+
* loom effect runtime (a `tool$`, a read-model query effect, an A2A call…), invoked through the
|
|
76
|
+
* SAME transient invoker and therefore the same authoriser, budget and principal as the MCP
|
|
77
|
+
* kind. Construction refuses an effect that is unregistered, not explicitly `ambient`, or a
|
|
78
|
+
* `human` / `llm` kind.
|
|
79
|
+
*
|
|
80
|
+
* An effect's call is not memoised across a pass (the in-pass memo coalesces `mcp.invoke` only:
|
|
81
|
+
* an arbitrary effect is not known to be a pure read), so two steps naming one effect with equal
|
|
82
|
+
* arguments call it twice. (Memoising `ambient` effects is a possible follow-up.)
|
|
83
|
+
*
|
|
84
|
+
* A persisted experience crosses a version boundary only through the log: an older in-memory
|
|
85
|
+
* `executeProvider` / `seal` that predates kinds would run a kinded step as an MCP call.
|
|
86
|
+
*/
|
|
87
|
+
export function loomEffectProvider(options: LoomEffectProviderOptions): DataProvider {
|
|
88
|
+
const { runtime } = options;
|
|
89
|
+
const pins = new Map<string, string>();
|
|
90
|
+
for (const [id, entry] of Object.entries(options.effects)) {
|
|
91
|
+
if (id === "") throw new TypeError("loomEffectProvider: an effect id is empty");
|
|
92
|
+
if (RESERVED_TARGETS.has(id))
|
|
93
|
+
throw new TypeError(
|
|
94
|
+
`loomEffectProvider: "${id}" is reached through its own plane (the MCP kind / the sandbox) and cannot be a data target`
|
|
95
|
+
);
|
|
96
|
+
if (!runtime.has(id))
|
|
97
|
+
throw new TypeError(`loomEffectProvider: effect "${id}" is not registered with the runtime`);
|
|
98
|
+
if (runtime.modeOf(id) !== "ambient")
|
|
99
|
+
throw new TypeError(
|
|
100
|
+
`loomEffectProvider: effect "${id}" is not declared mode "ambient" (ADR 0073); a data read may not run an undeclared or journaled effect`
|
|
101
|
+
);
|
|
102
|
+
const kind = runtime.kindOf(id);
|
|
103
|
+
if (kind !== undefined && REFUSED_KINDS.has(kind))
|
|
104
|
+
throw new TypeError(
|
|
105
|
+
`loomEffectProvider: effect "${id}" is a ${kind} effect, not a data read`
|
|
106
|
+
);
|
|
107
|
+
const pin =
|
|
108
|
+
entry !== null && typeof entry === "object" && "inputSchema" in entry
|
|
109
|
+
? effectPin(entry.inputSchema)
|
|
110
|
+
: (entry as { pin?: unknown } | null)?.pin;
|
|
111
|
+
if (typeof pin !== "string" || pin === "")
|
|
112
|
+
throw new TypeError(`loomEffectProvider: effect "${id}" needs a non-empty pin`);
|
|
113
|
+
pins.set(id, pin);
|
|
114
|
+
}
|
|
115
|
+
return Object.freeze<DataProvider>({
|
|
116
|
+
kind: LOOM_EFFECT_KIND,
|
|
117
|
+
async invoke(call, invoker, opts) {
|
|
118
|
+
const current = pins.get(call.target);
|
|
119
|
+
if (current === undefined)
|
|
120
|
+
throw new TypedFailure(TARGET_UNREGISTERED, { target: call.target });
|
|
121
|
+
if (current !== call.expectedPin)
|
|
122
|
+
throw new TypedFailure("schemaDrift", {
|
|
123
|
+
toolName: call.target,
|
|
124
|
+
expectedSchemaHash: call.expectedPin,
|
|
125
|
+
observedHash: current,
|
|
126
|
+
});
|
|
127
|
+
try {
|
|
128
|
+
return await invoker.invokeTransient(call.target, call.args, opts);
|
|
129
|
+
} catch (e) {
|
|
130
|
+
if (e instanceof TypedFailure && e.channel === "schemaDrift")
|
|
131
|
+
throw new TypedFailure(TARGET_FAILED, { target: call.target });
|
|
132
|
+
throw e;
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
}
|
package/src/params.ts
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { canonicalJson, fnv1a64 } from "@loomwork/core";
|
|
2
|
+
import type { Finding, MCPToolNode, ProviderSpec } from "@loomwork/ui-core";
|
|
3
|
+
import { resolveArguments } from "./plan";
|
|
4
|
+
|
|
5
|
+
// ── Per-load params and relative time ─────────────────────────────────────────
|
|
6
|
+
// A step opts arguments INTO being overridden (`MCPToolNode.overridable`). A load's params
|
|
7
|
+
// replace exactly those — never an argument holding a `{{steps.…}}` template (that value is
|
|
8
|
+
// another step's output; a param overwriting it would silently cut the graph), and never with a
|
|
9
|
+
// value that is itself a template (a param is a literal). Relative-time tokens (`$now`,
|
|
10
|
+
// `$now-7d`, `$now+1h`) resolve against ONE injected instant per load, so two steps asking for
|
|
11
|
+
// "the last day" in one pass ask the same question. Arguments are prepared in a fixed order:
|
|
12
|
+
// params → time tokens → templates → the host's argument hook.
|
|
13
|
+
|
|
14
|
+
export type LoadParams = Readonly<Record<string, unknown>>;
|
|
15
|
+
|
|
16
|
+
/** A time-range default a host may pass as `ControllerOptions.defaultParams`: the last 24 hours, relative to each pass. */
|
|
17
|
+
export const DEFAULT_TIME_RANGE: LoadParams = Object.freeze({ from: "$now-24h", to: "$now" });
|
|
18
|
+
|
|
19
|
+
/** Token-shaped: `$now` not followed by a word character. `$nowcast` is an ordinary string. */
|
|
20
|
+
const TOKEN_LIKE = /^\$now(?![A-Za-z0-9_])/;
|
|
21
|
+
const TIME_TOKEN = /^\$now(?:([+-])(\d{1,9})([smhdw]))?$/;
|
|
22
|
+
const UNIT_MS: Readonly<Record<string, number>> = {
|
|
23
|
+
s: 1_000,
|
|
24
|
+
m: 60_000,
|
|
25
|
+
h: 3_600_000,
|
|
26
|
+
d: 86_400_000,
|
|
27
|
+
w: 604_800_000,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const bad = (code: string, message: string): Finding => ({ code, severity: "error", message });
|
|
31
|
+
const note = (code: string, message: string): Finding => ({ code, severity: "warn", message });
|
|
32
|
+
|
|
33
|
+
/** An async argument hook returns one of these: `prepareArguments` never awaits it. */
|
|
34
|
+
function isThenable(v: unknown): v is PromiseLike<unknown> {
|
|
35
|
+
return (
|
|
36
|
+
v !== null && typeof v === "object" && typeof (v as { then?: unknown }).then === "function"
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `$now`, `$now-7d`, `$now+1h` (units s m h d w) as an ISO-8601 instant; `undefined` otherwise. */
|
|
41
|
+
export function resolveTimeToken(token: string, now: number): string | undefined {
|
|
42
|
+
const m = TIME_TOKEN.exec(token);
|
|
43
|
+
if (!m) return undefined;
|
|
44
|
+
const offset =
|
|
45
|
+
m[1] === undefined
|
|
46
|
+
? 0
|
|
47
|
+
: (m[1] === "-" ? -1 : 1) * Number(m[2]) * (UNIT_MS[m[3] as string] as number);
|
|
48
|
+
const at = new Date(now + offset);
|
|
49
|
+
// Past ±8.64e15 ms a Date is invalid and `toISOString` would throw: refuse instead.
|
|
50
|
+
return Number.isNaN(at.getTime()) ? undefined : at.toISOString();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Every token-shaped string leaf is resolved, or the whole value is refused. Other leaves pass. */
|
|
54
|
+
export function resolveTimeTokens(
|
|
55
|
+
value: unknown,
|
|
56
|
+
now: number,
|
|
57
|
+
where: string
|
|
58
|
+
): { ok: true; value: unknown } | { ok: false; finding: Finding } {
|
|
59
|
+
let failure: Finding | undefined;
|
|
60
|
+
const visit = (v: unknown): unknown => {
|
|
61
|
+
if (failure) return v;
|
|
62
|
+
if (typeof v === "string") {
|
|
63
|
+
if (!TOKEN_LIKE.test(v)) return v;
|
|
64
|
+
const iso = resolveTimeToken(v, now);
|
|
65
|
+
if (iso !== undefined) return iso;
|
|
66
|
+
failure = bad(
|
|
67
|
+
"invalid-time-token",
|
|
68
|
+
`${where}: "${v.slice(0, 64)}" is not a relative-time token ($now, $now-7d, $now+1h; units s m h d w)`
|
|
69
|
+
);
|
|
70
|
+
return v;
|
|
71
|
+
}
|
|
72
|
+
if (Array.isArray(v)) return v.map(visit);
|
|
73
|
+
if (v !== null && typeof v === "object")
|
|
74
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, visit(x)]));
|
|
75
|
+
return v;
|
|
76
|
+
};
|
|
77
|
+
const out = visit(value);
|
|
78
|
+
return failure ? { ok: false, finding: failure } : { ok: true, value: out };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const STEP_REF = /\{\{\s*steps\./;
|
|
82
|
+
function containsStepTemplate(v: unknown): boolean {
|
|
83
|
+
if (typeof v === "string") return STEP_REF.test(v);
|
|
84
|
+
if (Array.isArray(v)) return v.some(containsStepTemplate);
|
|
85
|
+
if (v !== null && typeof v === "object") return Object.values(v).some(containsStepTemplate);
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A plain object `canonicalJson` can represent (no NaN/±Infinity, bigint, function, over-deep nest). */
|
|
90
|
+
export function checkParams(params: unknown): Finding | undefined {
|
|
91
|
+
if (params === null || typeof params !== "object" || Array.isArray(params))
|
|
92
|
+
return bad("invalid-params", "params must be a plain object");
|
|
93
|
+
try {
|
|
94
|
+
canonicalJson(params);
|
|
95
|
+
return undefined;
|
|
96
|
+
} catch (e) {
|
|
97
|
+
return bad(
|
|
98
|
+
"invalid-params",
|
|
99
|
+
`params cannot be represented as JSON: ${e instanceof Error ? e.message : String(e)}`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Canonical identity of a param set (key order irrelevant); `""` for none. Throws only on what `checkParams` refuses. */
|
|
105
|
+
export function paramsKey(params: LoadParams): string {
|
|
106
|
+
return Object.keys(params).length === 0 ? "" : fnv1a64(canonicalJson(params));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Every argument name any step of the provider declares overridable, sorted. */
|
|
110
|
+
export function providerParams(spec: ProviderSpec): string[] {
|
|
111
|
+
const names = new Set<string>();
|
|
112
|
+
for (const step of spec.executionGraph) for (const n of step.overridable ?? []) names.add(n);
|
|
113
|
+
return [...names].sort();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The named params the set actually holds (own keys only), as a fresh, FROZEN object — this is
|
|
118
|
+
* the object every step's `prepareArguments` call (and its argument hook) receives as `ctx.params`
|
|
119
|
+
* for the whole load, so a hook mutating it would leak into every other step's view of the load.
|
|
120
|
+
* Reads `params[n]` once per name: on params that crossed a trust boundary, snapshot them first
|
|
121
|
+
* (`snapshotParams`) — a live accessor that answered safely for an earlier, separate check (e.g.
|
|
122
|
+
* `checkParams` alone) is free to answer differently by the time THIS read runs.
|
|
123
|
+
*/
|
|
124
|
+
export function pickParams(params: LoadParams, names: readonly string[]): LoadParams {
|
|
125
|
+
return Object.freeze(
|
|
126
|
+
Object.fromEntries(names.filter((n) => Object.hasOwn(params, n)).map((n) => [n, params[n]]))
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* ONE plain, detached copy of untrusted params — a JSON round-trip through `canonicalJson`, so a
|
|
132
|
+
* value it refuses (a function, bigint, non-finite number, a cycle, past the depth cap) is caught
|
|
133
|
+
* HERE, and `JSON.parse` builds the result from that already-captured string alone: nothing
|
|
134
|
+
* downstream (`checkParams`, `pickParams`, `relevantParams`) ever reads a property of the SOURCE
|
|
135
|
+
* object again. Required wherever params cross a trust boundary (`serveHandle(s)`) — validating a
|
|
136
|
+
* live accessor and then separately picking named keys off it re-reads it, and a hostile getter is
|
|
137
|
+
* free to answer only the validating read safely and answer a later, picking read with something
|
|
138
|
+
* else entirely (observed: a plain value for the first two reads, a function for the third).
|
|
139
|
+
*/
|
|
140
|
+
export function snapshotParams(
|
|
141
|
+
params: unknown
|
|
142
|
+
): { ok: true; params: LoadParams } | { ok: false; finding: Finding } {
|
|
143
|
+
if (params === null || typeof params !== "object" || Array.isArray(params))
|
|
144
|
+
return { ok: false, finding: bad("invalid-params", "params must be a plain object") };
|
|
145
|
+
try {
|
|
146
|
+
return { ok: true, params: JSON.parse(canonicalJson(params)) as LoadParams };
|
|
147
|
+
} catch (e) {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
finding: bad(
|
|
151
|
+
"invalid-params",
|
|
152
|
+
`params cannot be represented as JSON: ${e instanceof Error ? e.message : String(e)}`
|
|
153
|
+
),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The params this provider can use: everything else cannot change its answer. */
|
|
159
|
+
export function relevantParams(spec: ProviderSpec, params: LoadParams): LoadParams {
|
|
160
|
+
return pickParams(params, providerParams(spec));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function applyParams(
|
|
164
|
+
node: MCPToolNode,
|
|
165
|
+
params: LoadParams
|
|
166
|
+
): { args: Record<string, unknown>; findings: Finding[] } {
|
|
167
|
+
const { args, findings } = applyParamsDetailed(node, params);
|
|
168
|
+
return { args, findings };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** {@link applyParams}, plus which top-level arguments a param actually replaced (U9b needs them). */
|
|
172
|
+
export function applyParamsDetailed(
|
|
173
|
+
node: MCPToolNode,
|
|
174
|
+
params: LoadParams
|
|
175
|
+
): { args: Record<string, unknown>; findings: Finding[]; replaced: string[] } {
|
|
176
|
+
const names = node.overridable ?? [];
|
|
177
|
+
const findings: Finding[] = [];
|
|
178
|
+
const replaced: string[] = [];
|
|
179
|
+
if (names.length === 0) return { args: node.arguments, findings, replaced };
|
|
180
|
+
// A Map, then `Object.fromEntries`: every write is CreateDataProperty, so an argument named
|
|
181
|
+
// `__proto__` lands as an own key instead of setting a prototype.
|
|
182
|
+
const args = new Map(Object.entries(node.arguments));
|
|
183
|
+
for (const name of names) {
|
|
184
|
+
if (!Object.hasOwn(params, name)) continue;
|
|
185
|
+
if (containsStepTemplate(args.get(name))) {
|
|
186
|
+
findings.push(
|
|
187
|
+
note(
|
|
188
|
+
"param-templated",
|
|
189
|
+
`step "${node.stepId}": argument "${name}" is a {{steps.…}} template — another step's output — so no param replaces it`
|
|
190
|
+
)
|
|
191
|
+
);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const value = params[name];
|
|
195
|
+
if (containsStepTemplate(value)) {
|
|
196
|
+
findings.push(
|
|
197
|
+
note(
|
|
198
|
+
"param-template-refused",
|
|
199
|
+
`step "${node.stepId}": the param for "${name}" contains a {{steps.…}} template; a param is a literal, so it is not applied`
|
|
200
|
+
)
|
|
201
|
+
);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
args.set(name, value);
|
|
205
|
+
replaced.push(name);
|
|
206
|
+
}
|
|
207
|
+
return { args: Object.fromEntries(args), findings, replaced };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface ArgumentHookContext {
|
|
211
|
+
readonly handle: string;
|
|
212
|
+
readonly stepId: string;
|
|
213
|
+
readonly toolName: string;
|
|
214
|
+
/**
|
|
215
|
+
* The step's data-provider kind; absent for an MCP step. A hook that branches on `toolName`
|
|
216
|
+
* must also read this: an MCP tool `x` and a loom effect `x` are different targets.
|
|
217
|
+
*/
|
|
218
|
+
readonly kind?: string;
|
|
219
|
+
/**
|
|
220
|
+
* The load's params as declared — under `executeProvider`, only those some step of the provider
|
|
221
|
+
* declares overridable — with time tokens NOT resolved (`now` is the instant they resolve against).
|
|
222
|
+
*/
|
|
223
|
+
readonly params: LoadParams;
|
|
224
|
+
/** The instant this load resolved its time tokens against (epoch ms). */
|
|
225
|
+
readonly now: number;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The host's last word on a step's arguments: after params, time tokens and templates; before the call. */
|
|
229
|
+
export type ArgumentHook = (
|
|
230
|
+
args: Record<string, unknown>,
|
|
231
|
+
ctx: ArgumentHookContext
|
|
232
|
+
) => Record<string, unknown>;
|
|
233
|
+
|
|
234
|
+
export interface PrepareContext {
|
|
235
|
+
readonly handle: string;
|
|
236
|
+
readonly params: LoadParams;
|
|
237
|
+
readonly now: number;
|
|
238
|
+
readonly hook?: ArgumentHook;
|
|
239
|
+
/**
|
|
240
|
+
* Runs after params are applied and before time tokens, templates or the hook (U9b's content-reference
|
|
241
|
+
* guard, `contentRefs.ts`, is the one user); a returned finding refuses the step. `replaced` names the
|
|
242
|
+
* top-level arguments a param actually replaced.
|
|
243
|
+
*/
|
|
244
|
+
readonly guard?: (
|
|
245
|
+
node: MCPToolNode,
|
|
246
|
+
applied: { readonly args: Record<string, unknown>; readonly replaced: readonly string[] }
|
|
247
|
+
) => Finding | undefined;
|
|
248
|
+
/** Rewrites the arguments after templates resolve and before the hook (U9b strips JSON null at marked paths). */
|
|
249
|
+
readonly finish?: (args: Record<string, unknown>) => Record<string, unknown>;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function prepareArguments(
|
|
253
|
+
node: MCPToolNode,
|
|
254
|
+
outputs: ReadonlyMap<string, unknown>,
|
|
255
|
+
ctx: PrepareContext
|
|
256
|
+
):
|
|
257
|
+
| { ok: true; args: Record<string, unknown>; findings: Finding[] }
|
|
258
|
+
| { ok: false; finding: Finding } {
|
|
259
|
+
const applied = applyParamsDetailed(node, ctx.params);
|
|
260
|
+
const refused = ctx.guard?.(node, applied);
|
|
261
|
+
if (refused !== undefined) return { ok: false, finding: refused };
|
|
262
|
+
const timed = resolveTimeTokens(applied.args, ctx.now, `step "${node.stepId}"`);
|
|
263
|
+
if (!timed.ok) return timed;
|
|
264
|
+
const templated = resolveArguments(
|
|
265
|
+
{ ...node, arguments: timed.value as Record<string, unknown> },
|
|
266
|
+
outputs
|
|
267
|
+
);
|
|
268
|
+
if (!templated.ok) return templated;
|
|
269
|
+
const finished = ctx.finish ? ctx.finish(templated.args) : templated.args;
|
|
270
|
+
if (ctx.hook === undefined) return { ok: true, args: finished, findings: applied.findings };
|
|
271
|
+
let hooked: unknown;
|
|
272
|
+
try {
|
|
273
|
+
hooked = ctx.hook(finished, {
|
|
274
|
+
handle: ctx.handle,
|
|
275
|
+
stepId: node.stepId,
|
|
276
|
+
toolName: node.toolName,
|
|
277
|
+
...(node.kind !== undefined && { kind: node.kind }),
|
|
278
|
+
params: ctx.params,
|
|
279
|
+
now: ctx.now,
|
|
280
|
+
});
|
|
281
|
+
} catch (e) {
|
|
282
|
+
return {
|
|
283
|
+
ok: false,
|
|
284
|
+
finding: bad(
|
|
285
|
+
"argument-hook-failed",
|
|
286
|
+
`step "${node.stepId}": the argument hook threw: ${e instanceof Error ? e.message : String(e)}`
|
|
287
|
+
),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
if (isThenable(hooked)) {
|
|
291
|
+
// The step callback never awaits a hook (it is documented as synchronous): swallow whatever
|
|
292
|
+
// this thenable eventually does, so an async hook never surfaces as an unhandled rejection.
|
|
293
|
+
hooked.then(
|
|
294
|
+
() => {},
|
|
295
|
+
() => {}
|
|
296
|
+
);
|
|
297
|
+
return {
|
|
298
|
+
ok: false,
|
|
299
|
+
finding: bad(
|
|
300
|
+
"argument-hook-failed",
|
|
301
|
+
`step "${node.stepId}": the argument hook returned a thenable — hooks must return the arguments object synchronously, not a Promise`
|
|
302
|
+
),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
if (hooked === null || typeof hooked !== "object" || Array.isArray(hooked))
|
|
306
|
+
return {
|
|
307
|
+
ok: false,
|
|
308
|
+
finding: bad(
|
|
309
|
+
"argument-hook-failed",
|
|
310
|
+
`step "${node.stepId}": the argument hook returned ${hooked === null ? "null" : Array.isArray(hooked) ? "an array" : typeof hooked}, not an arguments object`
|
|
311
|
+
),
|
|
312
|
+
};
|
|
313
|
+
return { ok: true, args: hooked as Record<string, unknown>, findings: applied.findings };
|
|
314
|
+
}
|
package/src/path.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// ── Binding path ↔ hydration handle, browser-safe ────────────────────────────────
|
|
2
|
+
// A compiled widget binds to `values.<handle>`. `@loomwork/ui-core` owns that spelling for the
|
|
3
|
+
// compile side (`hydrationPath`), but the client entry must not pull core's runtime module graph
|
|
4
|
+
// into a browser bundle, and core cannot import hydration (hydration depends on core). So the
|
|
5
|
+
// client entry carries its own definition of the prefix and the inverse, pinned against core's
|
|
6
|
+
// twice: at compile time (the `satisfies` below, type-only, erased at build) and by
|
|
7
|
+
// `path.test.ts`, which compares the values and round-trips `hydrationPath`.
|
|
8
|
+
|
|
9
|
+
import type { HYDRATION_VALUES_PREFIX as CoreValuesPrefix } from "@loomwork/ui-core";
|
|
10
|
+
|
|
11
|
+
/** The hydration projection's top-level key; equal to `@loomwork/ui-core`'s constant of the same name. */
|
|
12
|
+
export const HYDRATION_VALUES_PREFIX = "values" satisfies typeof CoreValuesPrefix;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The hydration handle a binding path names: the exact inverse of core's `hydrationPath`.
|
|
16
|
+
* `undefined` for a path not of the form `values.<handle>` with a non-empty handle (including the
|
|
17
|
+
* bare prefix and `values.`). Everything after the first `values.` is the handle, spelled exactly
|
|
18
|
+
* as `hydrationPath` spelled it. Note: blueprint-runtime resolves a binding path by splitting on
|
|
19
|
+
* `.`, so a handle that itself contains a dot round-trips here but will not resolve at render time —
|
|
20
|
+
* keep handles dot-free. A reserved handle (`__proto__` etc.) is returned as-is; the store refuses it.
|
|
21
|
+
*/
|
|
22
|
+
export function handleOfPath(path: string): string | undefined {
|
|
23
|
+
const head = `${HYDRATION_VALUES_PREFIX}.`;
|
|
24
|
+
if (!path.startsWith(head) || path.length === head.length) return undefined;
|
|
25
|
+
return path.slice(head.length);
|
|
26
|
+
}
|
package/src/plan.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import type { Finding, MCPToolNode } from "@loomwork/ui-core";
|
|
2
|
+
|
|
3
|
+
// ── Graph scheduling and argument templating ──────────────────────────────────
|
|
4
|
+
// A ProviderSpec's executionGraph is a DAG of MCP steps. Steps run in topological WAVES (every
|
|
5
|
+
// step in a wave has all its in-graph dependencies in earlier waves). A dependency naming a step
|
|
6
|
+
// outside the graph is treated as satisfied — the planner may reference a step it pruned — and a
|
|
7
|
+
// cycle is reported, never looped over. Arguments may embed `{{steps.<id>.output.<path>}}`,
|
|
8
|
+
// resolved from an earlier step's raw output over OWN properties only: the path is model-written
|
|
9
|
+
// and walked over a tool response, so an unguarded walk is a prototype-pollution read. An index
|
|
10
|
+
// past the end of a present array is not a bad path — the upstream answered with no such row —
|
|
11
|
+
// and is reported apart from every other miss (`walkPath`), so it can publish `empty`.
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Kahn's algorithm over step IDS, O(V + E): in-degree counts (distinct in-graph dependencies per
|
|
15
|
+
* step) and a dependents list built once, then a FIFO of ids whose level never decreases. A step's
|
|
16
|
+
* wave is 1 + the latest wave among its in-graph dependencies (0 with none); an id is placed at the
|
|
17
|
+
* earliest wave any step carrying it is ready. Waves are then filled in the input's own order, so a
|
|
18
|
+
* wave lists its steps exactly as the input does. This is the semantics of the original
|
|
19
|
+
* re-filtering loop, pinned by an equivalence test: a step whose id another step already placed in
|
|
20
|
+
* an EARLIER wave never runs (a duplicate id), and `cycle` lists — in input order — every step
|
|
21
|
+
* whose id is never placed (a cycle, or anything behind one).
|
|
22
|
+
*/
|
|
23
|
+
export function topoWaves(nodes: readonly MCPToolNode[]): {
|
|
24
|
+
waves: MCPToolNode[][];
|
|
25
|
+
cycle: string[];
|
|
26
|
+
} {
|
|
27
|
+
const ids = new Set(nodes.map((n) => n.stepId));
|
|
28
|
+
const pending = new Array<number>(nodes.length);
|
|
29
|
+
const readyAt = new Array<number>(nodes.length).fill(-1);
|
|
30
|
+
const latestDep = new Array<number>(nodes.length).fill(-1);
|
|
31
|
+
const dependents = new Map<string, number[]>();
|
|
32
|
+
const placedAt = new Map<string, number>();
|
|
33
|
+
const queue: string[] = [];
|
|
34
|
+
const ready = (i: number, level: number): void => {
|
|
35
|
+
readyAt[i] = level;
|
|
36
|
+
const id = (nodes[i] as MCPToolNode).stepId;
|
|
37
|
+
if (placedAt.has(id)) return;
|
|
38
|
+
placedAt.set(id, level);
|
|
39
|
+
queue.push(id);
|
|
40
|
+
};
|
|
41
|
+
nodes.forEach((n, i) => {
|
|
42
|
+
const deps = new Set(n.dependsOn.filter((d) => ids.has(d)));
|
|
43
|
+
pending[i] = deps.size;
|
|
44
|
+
for (const d of deps) {
|
|
45
|
+
const list = dependents.get(d);
|
|
46
|
+
if (list === undefined) dependents.set(d, [i]);
|
|
47
|
+
else list.push(i);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
nodes.forEach((_, i) => {
|
|
51
|
+
if (pending[i] === 0) ready(i, 0);
|
|
52
|
+
});
|
|
53
|
+
for (let head = 0; head < queue.length; head++) {
|
|
54
|
+
const id = queue[head] as string;
|
|
55
|
+
const level = placedAt.get(id) as number;
|
|
56
|
+
for (const i of dependents.get(id) ?? []) {
|
|
57
|
+
latestDep[i] = Math.max(latestDep[i] as number, level);
|
|
58
|
+
pending[i] = (pending[i] as number) - 1;
|
|
59
|
+
if (pending[i] === 0) ready(i, (latestDep[i] as number) + 1);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const waves: MCPToolNode[][] = [];
|
|
63
|
+
const cycle: string[] = [];
|
|
64
|
+
nodes.forEach((n, i) => {
|
|
65
|
+
const at = placedAt.get(n.stepId);
|
|
66
|
+
if (at === undefined) cycle.push(n.stepId);
|
|
67
|
+
else if (readyAt[i] === at) {
|
|
68
|
+
while (waves.length <= at) waves.push([]);
|
|
69
|
+
(waves[at] as MCPToolNode[]).push(n);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
return { waves, cycle };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function terminalSteps(nodes: readonly MCPToolNode[]): MCPToolNode[] {
|
|
76
|
+
const depended = new Set(nodes.flatMap((n) => n.dependsOn));
|
|
77
|
+
return nodes.filter((n) => !depended.has(n.stepId));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const SEGMENT = /^([^[\]]+)((?:\[\d+\])*)$/;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Why a path walk found nothing. `out-of-range`: the walk stopped at an `[i]` applied to a value
|
|
84
|
+
* that IS an array, with `i >= length` — the path is right, the upstream just has no such row (the
|
|
85
|
+
* row count is DATA). `absent`: every other miss — a missing own key, `null` or a primitive
|
|
86
|
+
* mid-path, an index on a non-array, a malformed segment — a BAD PATH.
|
|
87
|
+
*/
|
|
88
|
+
export type PathMiss = "out-of-range" | "absent";
|
|
89
|
+
|
|
90
|
+
/** Dotted path with optional `[i]` indices, own properties only. A present `null` resolves to `null`. */
|
|
91
|
+
export function walkPath(
|
|
92
|
+
obj: unknown,
|
|
93
|
+
dotted: string
|
|
94
|
+
): { ok: true; value: unknown } | { ok: false; cause: PathMiss } {
|
|
95
|
+
const absent = { ok: false, cause: "absent" } as const;
|
|
96
|
+
let cur: unknown = obj;
|
|
97
|
+
for (const part of dotted.split(".")) {
|
|
98
|
+
const m = SEGMENT.exec(part);
|
|
99
|
+
if (!m || cur === null || typeof cur !== "object") return absent;
|
|
100
|
+
const key = m[1] as string;
|
|
101
|
+
if (!Object.hasOwn(cur, key)) return absent;
|
|
102
|
+
cur = (cur as Record<string, unknown>)[key];
|
|
103
|
+
for (const idx of (m[2] ?? "").match(/\d+/g) ?? []) {
|
|
104
|
+
if (!Array.isArray(cur)) return absent;
|
|
105
|
+
const i = Number(idx);
|
|
106
|
+
if (i >= cur.length) return { ok: false, cause: "out-of-range" };
|
|
107
|
+
cur = cur[i];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { ok: true, value: cur };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** `walkPath`'s value, or `undefined` when anything is missing. */
|
|
114
|
+
export function resolvePath(obj: unknown, dotted: string): unknown {
|
|
115
|
+
const r = walkPath(obj, dotted);
|
|
116
|
+
return r.ok ? r.value : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const STEP_TEMPLATE = /^\{\{steps\.([A-Za-z0-9_-]+)\.output(?:\.(.+))?\}\}$/;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Every step id `arguments` references through a `{{steps.<id>.output…}}` template, in first-seen
|
|
123
|
+
* order, deduplicated — the same template grammar `resolveArguments` substitutes, so a planner can
|
|
124
|
+
* check a graph it builds before it ships. At execution a reference resolves only against a step
|
|
125
|
+
* in a STRICTLY EARLIER `topoWaves` wave than the referencing step; a step outside the graph, or in
|
|
126
|
+
* the same or a later wave, has no output yet and is `unresolved-step-reference`.
|
|
127
|
+
*/
|
|
128
|
+
export function stepReferences(args: unknown): string[] {
|
|
129
|
+
const found = new Set<string>();
|
|
130
|
+
const visit = (v: unknown): void => {
|
|
131
|
+
if (typeof v === "string") {
|
|
132
|
+
const m = STEP_TEMPLATE.exec(v);
|
|
133
|
+
if (m) found.add(m[1] as string);
|
|
134
|
+
} else if (Array.isArray(v)) for (const x of v) visit(x);
|
|
135
|
+
else if (v !== null && typeof v === "object") for (const x of Object.values(v)) visit(x);
|
|
136
|
+
};
|
|
137
|
+
visit(args);
|
|
138
|
+
return [...found];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Substitute every `{{steps.<id>.output.<path>}}` in the step's arguments. EVERY template is
|
|
143
|
+
* walked: a bad path anywhere (`absent`, or a step with no output) is `unresolved-step-reference`
|
|
144
|
+
* (an error) and wins; otherwise an index past the end of a present upstream array is
|
|
145
|
+
* `upstream-empty` (a warn — the upstream answered with no such row; loom ADR 0100's `empty`).
|
|
146
|
+
*/
|
|
147
|
+
export function resolveArguments(
|
|
148
|
+
node: MCPToolNode,
|
|
149
|
+
outputs: ReadonlyMap<string, unknown>
|
|
150
|
+
): { ok: true; args: Record<string, unknown> } | { ok: false; finding: Finding } {
|
|
151
|
+
let bad: Finding | undefined;
|
|
152
|
+
let empty: Finding | undefined;
|
|
153
|
+
const visit = (v: unknown): unknown => {
|
|
154
|
+
if (typeof v === "string") {
|
|
155
|
+
const m = STEP_TEMPLATE.exec(v);
|
|
156
|
+
if (!m) return v;
|
|
157
|
+
const stepId = m[1] as string;
|
|
158
|
+
if (!outputs.has(stepId)) {
|
|
159
|
+
bad ??= {
|
|
160
|
+
code: "unresolved-step-reference",
|
|
161
|
+
severity: "error",
|
|
162
|
+
message: `step "${node.stepId}" references "${stepId}", which has no output`,
|
|
163
|
+
};
|
|
164
|
+
return v;
|
|
165
|
+
}
|
|
166
|
+
const path = m[2];
|
|
167
|
+
const out: ReturnType<typeof walkPath> =
|
|
168
|
+
path === undefined
|
|
169
|
+
? { ok: true, value: outputs.get(stepId) }
|
|
170
|
+
: walkPath(outputs.get(stepId), path);
|
|
171
|
+
// A present `undefined` (never produced by JSON) is treated as nothing, as before.
|
|
172
|
+
if (out.ok && out.value !== undefined) return out.value;
|
|
173
|
+
if (!out.ok && out.cause === "out-of-range")
|
|
174
|
+
empty ??= {
|
|
175
|
+
code: "upstream-empty",
|
|
176
|
+
severity: "warn",
|
|
177
|
+
message: `step "${node.stepId}" references "${stepId}" at path "${path ?? ""}": the upstream has no such row`,
|
|
178
|
+
};
|
|
179
|
+
else
|
|
180
|
+
bad ??= {
|
|
181
|
+
code: "unresolved-step-reference",
|
|
182
|
+
severity: "error",
|
|
183
|
+
message: `step "${node.stepId}" references "${stepId}" at path "${path ?? ""}", which resolves to nothing`,
|
|
184
|
+
};
|
|
185
|
+
return v;
|
|
186
|
+
}
|
|
187
|
+
if (Array.isArray(v)) return v.map(visit);
|
|
188
|
+
// `Object.fromEntries` writes with CreateDataProperty: an own `__proto__` argument stays an
|
|
189
|
+
// own key instead of becoming the args object's prototype (an assignment would set it).
|
|
190
|
+
if (v !== null && typeof v === "object")
|
|
191
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, visit(x)]));
|
|
192
|
+
return v;
|
|
193
|
+
};
|
|
194
|
+
const args = visit(node.arguments) as Record<string, unknown>;
|
|
195
|
+
const failure = bad ?? empty;
|
|
196
|
+
return failure ? { ok: false, finding: failure } : { ok: true, args };
|
|
197
|
+
}
|