@spendgraph/graph 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +87 -0
  2. package/dist/compile/compile.d.ts +29 -0
  3. package/dist/compile/compile.js +31 -0
  4. package/dist/compile/index.d.ts +1 -0
  5. package/dist/compile/index.js +1 -0
  6. package/dist/compile/validate.d.ts +17 -0
  7. package/dist/compile/validate.js +66 -0
  8. package/dist/execute/execute.d.ts +16 -0
  9. package/dist/execute/execute.js +108 -0
  10. package/dist/execute/index.d.ts +4 -0
  11. package/dist/execute/index.js +2 -0
  12. package/dist/execute/outcome.d.ts +46 -0
  13. package/dist/execute/outcome.js +59 -0
  14. package/dist/execute/step.d.ts +5 -0
  15. package/dist/execute/step.js +20 -0
  16. package/dist/execute/stream.d.ts +19 -0
  17. package/dist/execute/stream.js +63 -0
  18. package/dist/index.d.ts +5 -0
  19. package/dist/index.js +2 -0
  20. package/dist/node/edge.d.ts +10 -0
  21. package/dist/node/edge.js +13 -0
  22. package/dist/node/index.d.ts +3 -0
  23. package/dist/node/index.js +2 -0
  24. package/dist/node/node.d.ts +15 -0
  25. package/dist/node/node.js +33 -0
  26. package/dist/types/args.d.ts +56 -0
  27. package/dist/types/args.js +1 -0
  28. package/dist/types/context.d.ts +25 -0
  29. package/dist/types/context.js +1 -0
  30. package/dist/types/edge.d.ts +12 -0
  31. package/dist/types/edge.js +1 -0
  32. package/dist/types/event.d.ts +32 -0
  33. package/dist/types/event.js +1 -0
  34. package/dist/types/index.d.ts +7 -0
  35. package/dist/types/index.js +1 -0
  36. package/dist/types/node.d.ts +20 -0
  37. package/dist/types/node.js +1 -0
  38. package/dist/types/result.d.ts +20 -0
  39. package/dist/types/result.js +1 -0
  40. package/dist/types/spec.d.ts +15 -0
  41. package/dist/types/spec.js +1 -0
  42. package/package.json +52 -0
package/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # @spendgraph/graph
2
+
3
+ Wire nodes into a graph, run it, and get back a rollout with every step.
4
+
5
+ ```sh
6
+ npm install @spendgraph/graph
7
+ ```
8
+
9
+ Four things are exported. Everything else hangs off what they return.
10
+
11
+ ```ts
12
+ import { graph, node, edge, end } from "@spendgraph/graph";
13
+ ```
14
+
15
+ ## A node
16
+
17
+ One unit of work. `input` is what makes it reusable: the node says what it
18
+ needs, the graph says where it comes from. Without that, every node has to know
19
+ about the whole run.
20
+
21
+ ```ts
22
+ const classify = node({
23
+ name: "classify",
24
+ args: [
25
+ { name: "text", type: "string", required: true },
26
+ { name: "top_k", type: "number", required: false },
27
+ ] as const,
28
+ input: (ctx) => ({ text: ctx.values.question }),
29
+ run: ({ text, top_k }) => model.classify(text, top_k ?? 3),
30
+ });
31
+ ```
32
+
33
+ **Write `as const` and `run` types itself** from the args — `text` a string,
34
+ `top_k` a number, optionals optional. Rename an argument and the handler stops
35
+ compiling, rather than being handed `undefined` halfway through a graph, which
36
+ is the most expensive place to find a typo.
37
+
38
+ Args are validated before `run` does any work.
39
+
40
+ ## Wiring
41
+
42
+ ```ts
43
+ const flow = graph({
44
+ start: "classify",
45
+ nodes: [classify, lookup, answer],
46
+ edges: [
47
+ edge("classify", (ctx) => (ctx.outputs.classify === "billing" ? "lookup" : "answer")),
48
+ edge("lookup", "answer"),
49
+ end("answer"),
50
+ ],
51
+ });
52
+
53
+ const result = await flow.execute({ question: "Why was I charged twice?" });
54
+ ```
55
+
56
+ An edge says what runs next; a node's `input` says on what. Compilation checks
57
+ the wiring — an edge to a node that does not exist, a node nothing reaches, a
58
+ cycle — before anything runs.
59
+
60
+ ## What comes back
61
+
62
+ ```ts
63
+ result.status; // "completed" | "failed"
64
+ result.output; // the last node's return, stringified
65
+ result.steps; // every node that ran, in order
66
+ result.outputs; // each node's return, by name
67
+ result.latencyMs;
68
+ ```
69
+
70
+ A graph run is a rollout with several steps, so `GraphResult` hands straight to
71
+ `@spendgraph/prompt`'s `report` and `call` without being translated first:
72
+
73
+ ```ts
74
+ await prompt.call(values, () => flow.execute(values));
75
+ ```
76
+
77
+ ## Context
78
+
79
+ `outputs` is the only channel between nodes. A shared mutable bag would let node
80
+ four depend on a key node two happens to set — a dependency the graph does not
81
+ declare and compilation cannot check.
82
+
83
+ ```ts
84
+ ctx.values; // what execute() was called with
85
+ ctx.outputs; // what each finished node returned, by name
86
+ ctx.steps; // the steps so far; a copy, so writing to it does nothing
87
+ ```
@@ -0,0 +1,29 @@
1
+ import { type GraphStream } from "../execute/index.js";
2
+ import type { Edge, GraphResult, GraphSpec, Node } from "../types/index.js";
3
+ export interface GraphOptions {
4
+ /** Injectable clock, so tests do not measure real time. */
5
+ now?: () => number;
6
+ }
7
+ /** Compiles a graph, refusing one that cannot run as written. */
8
+ export declare function graph(spec: GraphSpec, opts?: GraphOptions): {
9
+ entry: string;
10
+ maxSteps: number;
11
+ /** Every node, in declaration order. */
12
+ nodes: () => Node<never>[];
13
+ /** Edges leaving a node, in the order they will be tried. */
14
+ edgesFrom: (name: string) => Edge[];
15
+ /**
16
+ * Runs it once. Never throws — the result carries every step taken, so a
17
+ * run that stopped at node three still says which three and why.
18
+ */
19
+ execute: (values?: Record<string, unknown>) => Promise<GraphResult>;
20
+ /**
21
+ * The same run, narrated. Yields a node's start and end, whatever its nodes
22
+ * emit while running, and the finished result last.
23
+ *
24
+ * The run begins on the call, not on the first read, so `result` is there
25
+ * for the caller who wants the rollout and not the commentary.
26
+ */
27
+ stream: (values?: Record<string, unknown>) => GraphStream;
28
+ };
29
+ export type Graph = ReturnType<typeof graph>;
@@ -0,0 +1,31 @@
1
+ import { executeGraph, streamGraph } from "../execute/index.js";
2
+ import { assertAllReachable, indexEdges, indexNodes } from "./validate.js";
3
+ /** Compiles a graph, refusing one that cannot run as written. */
4
+ export function graph(spec, opts = {}) {
5
+ const now = opts.now ?? (() => Date.now());
6
+ const maxSteps = Math.max(1, spec.maxSteps ?? 25);
7
+ const byName = indexNodes(spec);
8
+ const edgesFrom = indexEdges(spec.edges ?? [], byName);
9
+ assertAllReachable(spec.entry, byName, edgesFrom);
10
+ return {
11
+ entry: spec.entry,
12
+ maxSteps,
13
+ /** Every node, in declaration order. */
14
+ nodes: () => [...byName.values()],
15
+ /** Edges leaving a node, in the order they will be tried. */
16
+ edgesFrom: (name) => [...(edgesFrom.get(name) ?? [])],
17
+ /**
18
+ * Runs it once. Never throws — the result carries every step taken, so a
19
+ * run that stopped at node three still says which three and why.
20
+ */
21
+ execute: (values = {}) => executeGraph(byName, edgesFrom, spec.entry, maxSteps, values, now),
22
+ /**
23
+ * The same run, narrated. Yields a node's start and end, whatever its nodes
24
+ * emit while running, and the finished result last.
25
+ *
26
+ * The run begins on the call, not on the first read, so `result` is there
27
+ * for the caller who wants the rollout and not the commentary.
28
+ */
29
+ stream: (values = {}) => streamGraph(byName, edgesFrom, spec.entry, maxSteps, values, now),
30
+ };
31
+ }
@@ -0,0 +1 @@
1
+ export { type Graph, type GraphOptions, graph } from "./compile.js";
@@ -0,0 +1 @@
1
+ export { graph } from "./compile.js";
@@ -0,0 +1,17 @@
1
+ import type { Edge, GraphSpec, Node } from "../types/index.js";
2
+ /**
3
+ * Everything about a graph that is wrong without running it.
4
+ *
5
+ * The argument for a graph over a hand-written loop: the shape is data, so it
6
+ * can be wrong at build time rather than on the branch nobody exercised. What
7
+ * no static check catches is a loop that never exits — that is `maxSteps`.
8
+ */
9
+ export declare function indexNodes(spec: GraphSpec): Map<string, Node<never>>;
10
+ export declare function indexEdges(edges: Edge[], byName: Map<string, Node<never>>): Map<string, Edge[]>;
11
+ /**
12
+ * Refuses a node nothing reaches.
13
+ *
14
+ * Refused rather than warned about: the usual cause is a typo in an edge, and
15
+ * the usual symptom is a branch that quietly never runs.
16
+ */
17
+ export declare function assertAllReachable(entry: string, byName: Map<string, Node<never>>, edgesFrom: Map<string, Edge[]>): void;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Everything about a graph that is wrong without running it.
3
+ *
4
+ * The argument for a graph over a hand-written loop: the shape is data, so it
5
+ * can be wrong at build time rather than on the branch nobody exercised. What
6
+ * no static check catches is a loop that never exits — that is `maxSteps`.
7
+ */
8
+ export function indexNodes(spec) {
9
+ if (spec.nodes.length === 0)
10
+ throw new Error("A graph needs at least one node.");
11
+ const byName = new Map();
12
+ for (const n of spec.nodes) {
13
+ if (byName.has(n.name))
14
+ throw new Error(`Two nodes are called "${n.name}".`);
15
+ byName.set(n.name, n);
16
+ }
17
+ if (!byName.has(spec.entry)) {
18
+ throw new Error(`The entry node "${spec.entry}" is not in this graph. Known: ${[...byName.keys()].join(", ")}.`);
19
+ }
20
+ return byName;
21
+ }
22
+ export function indexEdges(edges, byName) {
23
+ const edgesFrom = new Map();
24
+ for (const e of edges) {
25
+ if (!byName.has(e.from)) {
26
+ throw new Error(`An edge starts at "${e.from}", which is not a node in this graph.`);
27
+ }
28
+ if (e.to !== null && !byName.has(e.to)) {
29
+ throw new Error(`An edge from "${e.from}" points at "${e.to}", which is not a node in this graph.`);
30
+ }
31
+ const list = edgesFrom.get(e.from) ?? [];
32
+ // First match wins, so anything after an unconditional edge is dead. The
33
+ // graph would look like it handles a case it does not.
34
+ const open = list.find((prior) => !prior.when);
35
+ if (open) {
36
+ throw new Error(`The edge from "${e.from}" to "${open.to ?? "the end"}" has no condition, so the edge ` +
37
+ `to "${e.to ?? "the end"}" declared after it can never be taken. Put the unconditional edge last.`);
38
+ }
39
+ list.push(e);
40
+ edgesFrom.set(e.from, list);
41
+ }
42
+ return edgesFrom;
43
+ }
44
+ /**
45
+ * Refuses a node nothing reaches.
46
+ *
47
+ * Refused rather than warned about: the usual cause is a typo in an edge, and
48
+ * the usual symptom is a branch that quietly never runs.
49
+ */
50
+ export function assertAllReachable(entry, byName, edgesFrom) {
51
+ const reached = new Set([entry]);
52
+ const queue = [entry];
53
+ while (queue.length) {
54
+ for (const e of edgesFrom.get(queue.shift()) ?? []) {
55
+ if (e.to && !reached.has(e.to)) {
56
+ reached.add(e.to);
57
+ queue.push(e.to);
58
+ }
59
+ }
60
+ }
61
+ const orphans = [...byName.keys()].filter((n) => !reached.has(n));
62
+ if (orphans.length) {
63
+ throw new Error(`Nothing reaches ${orphans.map((n) => `"${n}"`).join(", ")} from "${entry}". ` +
64
+ `Add an edge, or take the node out.`);
65
+ }
66
+ }
@@ -0,0 +1,16 @@
1
+ import type { Edge, GraphEvent, GraphResult, Node } from "../types/index.js";
2
+ /**
3
+ * Runs a compiled graph.
4
+ *
5
+ * Never throws, for the reason `invoke` never throws: an exception discards the
6
+ * two steps that worked along with the evidence of where it stopped. Every exit
7
+ * here is a `GraphResult` carrying the steps taken.
8
+ *
9
+ * A failed node halts the run. Continuing would leave every later node reading
10
+ * `outputs` for something that is not there.
11
+ *
12
+ * `sink` is how a run narrates itself. It is called as the run proceeds and its
13
+ * absence changes nothing, so the streamed and unstreamed paths stay one piece
14
+ * of code rather than two that drift.
15
+ */
16
+ export declare function executeGraph(byName: Map<string, Node<never>>, edgesFrom: Map<string, Edge[]>, entry: string, maxSteps: number, values: Record<string, unknown>, now: () => number, sink?: (event: GraphEvent) => void): Promise<GraphResult>;
@@ -0,0 +1,108 @@
1
+ import { FieldValidationError, validateFields } from "@spendgraph/sdk";
2
+ import { nestedSteps, outputOf, tokensOf, totalTokens } from "./outcome.js";
3
+ import { failedStep, message } from "./step.js";
4
+ function ended(node, index, status, latencyMs, output, error) {
5
+ return { type: "node_end", node, index, status, output, error, latencyMs };
6
+ }
7
+ /**
8
+ * Runs a compiled graph.
9
+ *
10
+ * Never throws, for the reason `invoke` never throws: an exception discards the
11
+ * two steps that worked along with the evidence of where it stopped. Every exit
12
+ * here is a `GraphResult` carrying the steps taken.
13
+ *
14
+ * A failed node halts the run. Continuing would leave every later node reading
15
+ * `outputs` for something that is not there.
16
+ *
17
+ * `sink` is how a run narrates itself. It is called as the run proceeds and its
18
+ * absence changes nothing, so the streamed and unstreamed paths stay one piece
19
+ * of code rather than two that drift.
20
+ */
21
+ export async function executeGraph(byName, edgesFrom, entry, maxSteps, values, now, sink = () => { }) {
22
+ const startedAt = now();
23
+ const steps = [];
24
+ const outputs = {};
25
+ const context = (node) => ({
26
+ values,
27
+ outputs,
28
+ steps: [...steps],
29
+ emit: (text) => sink({ type: "token", node, text }),
30
+ });
31
+ const finish = (status, error) => ({
32
+ status,
33
+ output: status === "completed" ? (steps.at(-1)?.output ?? "") : "",
34
+ error,
35
+ steps,
36
+ outputs,
37
+ latencyMs: now() - startedAt,
38
+ ...totalTokens(steps),
39
+ });
40
+ let current = entry;
41
+ while (current) {
42
+ if (steps.length >= maxSteps) {
43
+ return finish("failed", `Ran ${maxSteps} steps without reaching an end, stopping at "${current}". ` +
44
+ `Either a conditional edge never turns false, or maxSteps is too low for this graph.`);
45
+ }
46
+ const node = byName.get(current);
47
+ const startedNode = now();
48
+ const ctx = context(current);
49
+ const index = steps.length;
50
+ let input;
51
+ try {
52
+ input = node.input ? node.input(ctx) : values;
53
+ }
54
+ catch (err) {
55
+ steps.push(failedStep(index, node.name, {}, now() - startedNode, message(err)));
56
+ sink(ended(node.name, index, "failed", now() - startedNode, undefined, message(err)));
57
+ return finish("failed", `Building the input for "${node.name}" threw: ${message(err)}`);
58
+ }
59
+ sink({ type: "node_start", node: node.name, index, input });
60
+ if (node.args?.length) {
61
+ const errors = validateFields(input, node.args);
62
+ if (errors.length > 0) {
63
+ const detail = new FieldValidationError(errors).message;
64
+ steps.push(failedStep(index, node.name, input, now() - startedNode, detail));
65
+ sink(ended(node.name, index, "failed", now() - startedNode, undefined, detail));
66
+ return finish("failed", `"${node.name}" was given ${detail}`);
67
+ }
68
+ }
69
+ let value;
70
+ try {
71
+ value = await node.run(input, ctx);
72
+ }
73
+ catch (err) {
74
+ steps.push(failedStep(index, node.name, input, now() - startedNode, message(err)));
75
+ sink(ended(node.name, index, "failed", now() - startedNode, undefined, message(err)));
76
+ return finish("failed", `"${node.name}" failed: ${message(err)}`);
77
+ }
78
+ outputs[node.name] = value;
79
+ steps.push({
80
+ index,
81
+ source: node.name,
82
+ input,
83
+ output: outputOf(value),
84
+ status: "completed",
85
+ latencyMs: now() - startedNode,
86
+ ...tokensOf(value),
87
+ });
88
+ steps.push(...nestedSteps(value, steps.length, node.name));
89
+ sink(ended(node.name, index, "completed", now() - startedNode, value));
90
+ // First match wins, so an unconditional edge is the default branch. No match
91
+ // is an end, which is what a node with no outgoing edges already means.
92
+ let next = null;
93
+ try {
94
+ const after = context(node.name);
95
+ for (const e of edgesFrom.get(node.name) ?? []) {
96
+ if (e.when && !e.when(after))
97
+ continue;
98
+ next = e.to;
99
+ break;
100
+ }
101
+ }
102
+ catch (err) {
103
+ return finish("failed", `Choosing what runs after "${node.name}" threw: ${message(err)}`);
104
+ }
105
+ current = next;
106
+ }
107
+ return finish("completed");
108
+ }
@@ -0,0 +1,4 @@
1
+ export { executeGraph } from "./execute.js";
2
+ export type { NodeOutcome } from "./outcome.js";
3
+ export type { GraphStream } from "./stream.js";
4
+ export { streamGraph } from "./stream.js";
@@ -0,0 +1,2 @@
1
+ export { executeGraph } from "./execute.js";
2
+ export { streamGraph } from "./stream.js";
@@ -0,0 +1,46 @@
1
+ import type { RolloutStep } from "@spendgraph/sdk";
2
+ /**
3
+ * What a node returned, when it returned the shape a model call produces.
4
+ *
5
+ * Recognised structurally rather than declared: a node may return anything, and
6
+ * a plain object is the common case. But a node that called a model, ran a
7
+ * prompt or nested another workflow returns tokens and sometimes its own steps,
8
+ * and both used to be flattened into the output string and lost — which made a
9
+ * graph run report as costing nothing.
10
+ */
11
+ export interface NodeOutcome {
12
+ output?: string;
13
+ model?: string;
14
+ status?: "completed" | "failed";
15
+ error?: string;
16
+ inputTokens?: number;
17
+ outputTokens?: number;
18
+ cacheReadTokens?: number;
19
+ cacheWriteTokens?: number;
20
+ /** A nested run's own steps, spliced in rather than stringified away. */
21
+ steps?: RolloutStep[];
22
+ }
23
+ /** The text a step should carry: an outcome's own output, or the whole value. */
24
+ export declare function outputOf(value: unknown): string;
25
+ /**
26
+ * The token counts a node reported, or nothing when it reported none.
27
+ *
28
+ * A node that also returns nested steps reports none here: its own totals are
29
+ * the sum of those steps, and counting both makes a graph inside a graph cost
30
+ * exactly twice what it did.
31
+ */
32
+ export declare function tokensOf(value: unknown): Pick<RolloutStep, "inputTokens" | "outputTokens"> & {
33
+ model?: string;
34
+ };
35
+ /**
36
+ * A nested run's steps, renumbered to sit in this run's sequence.
37
+ *
38
+ * Prefixed with the node that produced them, so a step from a refine loop
39
+ * inside a router still says which branch it came from.
40
+ */
41
+ export declare function nestedSteps(value: unknown, from: number, source: string): RolloutStep[];
42
+ /** What every step in a run adds up to. */
43
+ export declare function totalTokens(steps: RolloutStep[]): {
44
+ inputTokens: number;
45
+ outputTokens: number;
46
+ };
@@ -0,0 +1,59 @@
1
+ import { stringify } from "./step.js";
2
+ function isOutcome(value) {
3
+ if (!value || typeof value !== "object" || Array.isArray(value))
4
+ return false;
5
+ const v = value;
6
+ return (typeof v.output === "string" ||
7
+ typeof v.inputTokens === "number" ||
8
+ typeof v.outputTokens === "number" ||
9
+ Array.isArray(v.steps));
10
+ }
11
+ /** The text a step should carry: an outcome's own output, or the whole value. */
12
+ export function outputOf(value) {
13
+ const outcome = isOutcome(value) ? value : undefined;
14
+ return outcome && typeof outcome.output === "string" ? outcome.output : stringify(value);
15
+ }
16
+ /**
17
+ * The token counts a node reported, or nothing when it reported none.
18
+ *
19
+ * A node that also returns nested steps reports none here: its own totals are
20
+ * the sum of those steps, and counting both makes a graph inside a graph cost
21
+ * exactly twice what it did.
22
+ */
23
+ export function tokensOf(value) {
24
+ if (!isOutcome(value))
25
+ return {};
26
+ const nested = Array.isArray(value.steps) && value.steps.length > 0;
27
+ return {
28
+ ...(!nested && typeof value.inputTokens === "number" ? { inputTokens: value.inputTokens } : {}),
29
+ ...(!nested && typeof value.outputTokens === "number"
30
+ ? { outputTokens: value.outputTokens }
31
+ : {}),
32
+ ...(value.model ? { model: value.model } : {}),
33
+ };
34
+ }
35
+ /**
36
+ * A nested run's steps, renumbered to sit in this run's sequence.
37
+ *
38
+ * Prefixed with the node that produced them, so a step from a refine loop
39
+ * inside a router still says which branch it came from.
40
+ */
41
+ export function nestedSteps(value, from, source) {
42
+ if (!isOutcome(value) || !Array.isArray(value.steps))
43
+ return [];
44
+ return value.steps.map((step, at) => ({
45
+ ...step,
46
+ index: from + at,
47
+ source: `${source}.${step.source}`,
48
+ }));
49
+ }
50
+ /** What every step in a run adds up to. */
51
+ export function totalTokens(steps) {
52
+ let inputTokens = 0;
53
+ let outputTokens = 0;
54
+ for (const step of steps) {
55
+ inputTokens += step.inputTokens ?? 0;
56
+ outputTokens += step.outputTokens ?? 0;
57
+ }
58
+ return { inputTokens, outputTokens };
59
+ }
@@ -0,0 +1,5 @@
1
+ import type { RolloutStep } from "@spendgraph/sdk";
2
+ /** Same convention as a tool result: a string is itself, anything else is JSON. */
3
+ export declare function stringify(value: unknown): string;
4
+ export declare function failedStep(index: number, source: string, input: Record<string, unknown>, latencyMs: number, error: string): RolloutStep;
5
+ export declare function message(err: unknown): string;
@@ -0,0 +1,20 @@
1
+ /** Same convention as a tool result: a string is itself, anything else is JSON. */
2
+ export function stringify(value) {
3
+ if (value === undefined || value === null)
4
+ return "";
5
+ return typeof value === "string" ? value : JSON.stringify(value);
6
+ }
7
+ export function failedStep(index, source, input, latencyMs, error) {
8
+ return {
9
+ index,
10
+ source,
11
+ input,
12
+ output: "",
13
+ status: "failed",
14
+ error,
15
+ latencyMs,
16
+ };
17
+ }
18
+ export function message(err) {
19
+ return err instanceof Error ? err.message : String(err);
20
+ }
@@ -0,0 +1,19 @@
1
+ import type { Edge, GraphEvent, GraphResult, Node } from "../types/index.js";
2
+ /** A run as it happens, and the same run once it is over. */
3
+ export interface GraphStream extends AsyncIterable<GraphEvent> {
4
+ /**
5
+ * The finished run.
6
+ *
7
+ * Resolves whether or not anyone iterated, so a caller can pipe the events to
8
+ * a client and still record the rollout from the same run.
9
+ */
10
+ result: Promise<GraphResult>;
11
+ }
12
+ /**
13
+ * Runs a compiled graph, narrating it as it goes.
14
+ *
15
+ * The run starts immediately rather than on the first read: a caller who only
16
+ * wants the result should not have to iterate to get one, and a client that
17
+ * connects late should see what already happened.
18
+ */
19
+ export declare function streamGraph(byName: Map<string, Node<never>>, edgesFrom: Map<string, Edge[]>, entry: string, maxSteps: number, values: Record<string, unknown>, now: () => number): GraphStream;
@@ -0,0 +1,63 @@
1
+ import { executeGraph } from "./execute.js";
2
+ /**
3
+ * Events held between the run and whoever is reading them.
4
+ *
5
+ * A queue rather than a callback because the two sides move at different
6
+ * speeds: a graph does not wait for a slow reader, and a reader must not miss
7
+ * what arrived before it asked.
8
+ */
9
+ class Events {
10
+ waiting = [];
11
+ wake = null;
12
+ closed = false;
13
+ push(event) {
14
+ this.waiting.push(event);
15
+ this.release();
16
+ }
17
+ close() {
18
+ this.closed = true;
19
+ this.release();
20
+ }
21
+ release() {
22
+ const wake = this.wake;
23
+ this.wake = null;
24
+ wake?.();
25
+ }
26
+ async *drain() {
27
+ while (true) {
28
+ while (this.waiting.length > 0) {
29
+ yield this.waiting.shift();
30
+ }
31
+ if (this.closed)
32
+ return;
33
+ await new Promise((resolve) => {
34
+ this.wake = resolve;
35
+ });
36
+ }
37
+ }
38
+ }
39
+ /**
40
+ * Runs a compiled graph, narrating it as it goes.
41
+ *
42
+ * The run starts immediately rather than on the first read: a caller who only
43
+ * wants the result should not have to iterate to get one, and a client that
44
+ * connects late should see what already happened.
45
+ */
46
+ export function streamGraph(byName, edgesFrom, entry, maxSteps, values, now) {
47
+ const events = new Events();
48
+ const result = (async () => {
49
+ try {
50
+ const finished = await executeGraph(byName, edgesFrom, entry, maxSteps, values, now, (e) => events.push(e));
51
+ events.push({ type: "result", result: finished });
52
+ return finished;
53
+ }
54
+ finally {
55
+ events.close();
56
+ }
57
+ })();
58
+ void result.catch(() => { });
59
+ return {
60
+ result,
61
+ [Symbol.asyncIterator]: () => events.drain(),
62
+ };
63
+ }
@@ -0,0 +1,5 @@
1
+ export { type Graph, type GraphOptions, graph } from "./compile/index.js";
2
+ export type { GraphStream } from "./execute/index.js";
3
+ export type { NodeSpec } from "./node/index.js";
4
+ export { edge, end, node } from "./node/index.js";
5
+ export type { ArgSpec, ArgsOf, ArgValue, Edge, Emit, GraphContext, GraphEvent, GraphResult, GraphSpec, Node, } from "./types/index.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { graph } from "./compile/index.js";
2
+ export { edge, end, node } from "./node/index.js";
@@ -0,0 +1,10 @@
1
+ import type { Edge } from "../types/index.js";
2
+ /**
3
+ * An edge from one node to the next, or to the end of the run.
4
+ *
5
+ * A helper so the argument order is fixed by something other than memory:
6
+ * `edge("a", "b")` reads in the direction the run travels.
7
+ */
8
+ export declare function edge(from: string, to: string | null, when?: Edge["when"]): Edge;
9
+ /** Ends the run after `from`. The same as `edge(from, null)`, said out loud. */
10
+ export declare function end(from: string, when?: Edge["when"]): Edge;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * An edge from one node to the next, or to the end of the run.
3
+ *
4
+ * A helper so the argument order is fixed by something other than memory:
5
+ * `edge("a", "b")` reads in the direction the run travels.
6
+ */
7
+ export function edge(from, to, when) {
8
+ return when ? { from, to, when } : { from, to };
9
+ }
10
+ /** Ends the run after `from`. The same as `edge(from, null)`, said out loud. */
11
+ export function end(from, when) {
12
+ return edge(from, null, when);
13
+ }
@@ -0,0 +1,3 @@
1
+ export { edge, end } from "./edge.js";
2
+ export type { NodeSpec } from "./node.js";
3
+ export { node } from "./node.js";
@@ -0,0 +1,2 @@
1
+ export { edge, end } from "./edge.js";
2
+ export { node } from "./node.js";
@@ -0,0 +1,15 @@
1
+ import type { ArgSpec, ArgsOf, GraphContext, Node } from "../types/index.js";
2
+ export interface NodeSpec<T extends readonly ArgSpec[]> {
3
+ name: string;
4
+ args?: T;
5
+ input?: (ctx: GraphContext) => Record<string, unknown>;
6
+ run(input: ArgsOf<T>, ctx: GraphContext): Promise<unknown> | unknown;
7
+ }
8
+ /**
9
+ * Declares a node, checking what a graph cannot check later.
10
+ *
11
+ * The same argument as `tool()`: a duplicated argument silently loses one, and a
12
+ * hyphenated name reads badly in the step record that is the only trace of a
13
+ * failed run.
14
+ */
15
+ export declare function node<const T extends readonly ArgSpec[]>(spec: NodeSpec<T>): Node<ArgsOf<T>>;
@@ -0,0 +1,33 @@
1
+ /** Names that read back cleanly in a step record and a compile error. */
2
+ const VALID_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
3
+ /**
4
+ * Declares a node, checking what a graph cannot check later.
5
+ *
6
+ * The same argument as `tool()`: a duplicated argument silently loses one, and a
7
+ * hyphenated name reads badly in the step record that is the only trace of a
8
+ * failed run.
9
+ */
10
+ export function node(spec) {
11
+ if (!VALID_NAME.test(spec.name)) {
12
+ throw new Error(`Node name "${spec.name}" must be letters, digits and underscores, starting with a letter.`);
13
+ }
14
+ const seen = new Set();
15
+ for (const arg of spec.args ?? []) {
16
+ if (!VALID_NAME.test(arg.name)) {
17
+ throw new Error(`Node "${spec.name}" argument "${arg.name}" is not a usable name.`);
18
+ }
19
+ if (seen.has(arg.name)) {
20
+ throw new Error(`Node "${spec.name}" declares "${arg.name}" twice.`);
21
+ }
22
+ seen.add(arg.name);
23
+ if (arg.type === "enum" && !arg.options?.length) {
24
+ throw new Error(`Node "${spec.name}" argument "${arg.name}" is an enum with no options, so nothing can satisfy it.`);
25
+ }
26
+ }
27
+ return {
28
+ name: spec.name,
29
+ args: spec.args?.map((a) => ({ ...a, options: a.options ? [...a.options] : undefined })),
30
+ input: spec.input,
31
+ run: spec.run,
32
+ };
33
+ }
@@ -0,0 +1,56 @@
1
+ import type { FieldType } from "@spendgraph/sdk";
2
+ /**
3
+ * A declared argument, as written.
4
+ *
5
+ * Structurally a `FieldSpec` with every field readonly, because that is what an
6
+ * `as const` array produces — and `FieldSpec.options` being a mutable `string[]`
7
+ * is enough to make the whole tuple fail the constraint and inference fall back
8
+ * to nothing, silently.
9
+ */
10
+ export interface ArgSpec {
11
+ readonly name: string;
12
+ readonly type: FieldType;
13
+ readonly required: boolean;
14
+ readonly default?: string;
15
+ readonly description?: string;
16
+ readonly options?: readonly string[];
17
+ readonly separator?: string;
18
+ readonly min?: number;
19
+ readonly max?: number;
20
+ readonly maxLength?: number;
21
+ readonly trueText?: string;
22
+ readonly falseText?: string;
23
+ readonly datasetKey?: boolean;
24
+ }
25
+ /** What one declared argument is worth at runtime. */
26
+ export type ArgValue<F> = F extends {
27
+ type: "number";
28
+ } ? number : F extends {
29
+ type: "boolean";
30
+ } ? boolean : F extends {
31
+ type: "list";
32
+ } ? string[] : F extends {
33
+ type: "json";
34
+ } ? unknown : F extends {
35
+ type: "enum";
36
+ options: readonly (infer O)[];
37
+ } ? O : string;
38
+ type Required<T> = T extends {
39
+ required: true;
40
+ } ? T : never;
41
+ type Optional<T> = T extends {
42
+ required: true;
43
+ } ? never : T;
44
+ /**
45
+ * The object `run` is handed, derived from the args you declared.
46
+ *
47
+ * Declare `args` with `as const` and the handler's parameter types follow, so a
48
+ * renamed argument is a compile error rather than an `undefined` discovered
49
+ * halfway through a graph.
50
+ */
51
+ export type ArgsOf<T extends readonly ArgSpec[]> = {
52
+ [F in Required<T[number]> as F["name"]]: ArgValue<F>;
53
+ } & {
54
+ [F in Optional<T[number]> as F["name"]]?: ArgValue<F>;
55
+ };
56
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,25 @@
1
+ import type { RolloutStep } from "@spendgraph/sdk";
2
+ import type { Emit } from "./event.js";
3
+ /**
4
+ * What a node can see while it decides and while it runs.
5
+ *
6
+ * `outputs` is the only channel between nodes. A shared mutable bag would let
7
+ * node four depend on a key node two happens to set — a dependency the graph
8
+ * does not declare and compilation cannot check.
9
+ */
10
+ export interface GraphContext {
11
+ /** The object `execute` was called with. */
12
+ values: Record<string, unknown>;
13
+ /** What each node that has finished returned, by name. */
14
+ outputs: Record<string, unknown>;
15
+ /** The steps recorded so far, in order. A copy; writing to it does nothing. */
16
+ steps: RolloutStep[];
17
+ /**
18
+ * Sends a token out of the run, tagged with the node that emitted it.
19
+ *
20
+ * Hand it straight to a model call as its `onText` and the deltas leave the
21
+ * graph as they arrive. Always present, and a no-op unless someone is
22
+ * streaming, so a node never has to ask whether anyone is listening.
23
+ */
24
+ emit: Emit;
25
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,12 @@
1
+ import type { GraphContext } from "./context.js";
2
+ /** What runs after what, and whether it runs at all. */
3
+ export interface Edge {
4
+ from: string;
5
+ /** The next node, or null to end the run here. */
6
+ to: string | null;
7
+ /**
8
+ * Taken only when true. Tried in declaration order, first match wins — so an
9
+ * unconditional edge is the default branch and belongs last.
10
+ */
11
+ when?: (ctx: GraphContext) => boolean;
12
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,32 @@
1
+ import type { GraphResult } from "./result.js";
2
+ /**
3
+ * What a run says about itself while it is still running.
4
+ *
5
+ * Every event names the node it came from, because a stream carrying tokens
6
+ * from four nodes is unreadable without it — and which nodes are worth showing
7
+ * is the consumer's decision, not the graph's.
8
+ */
9
+ export type GraphEvent = {
10
+ type: "node_start";
11
+ node: string;
12
+ /** The step index this node will occupy. */
13
+ index: number;
14
+ input: Record<string, unknown>;
15
+ } | {
16
+ type: "token";
17
+ node: string;
18
+ text: string;
19
+ } | {
20
+ type: "node_end";
21
+ node: string;
22
+ index: number;
23
+ status: "completed" | "failed";
24
+ output?: unknown;
25
+ error?: string;
26
+ latencyMs: number;
27
+ } | {
28
+ type: "result";
29
+ result: GraphResult;
30
+ };
31
+ /** Where a node's tokens go. A run that nobody is streaming drops them. */
32
+ export type Emit = (text: string) => void;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,7 @@
1
+ export type { ArgSpec, ArgsOf, ArgValue } from "./args.js";
2
+ export type { GraphContext } from "./context.js";
3
+ export type { Edge } from "./edge.js";
4
+ export type { Emit, GraphEvent } from "./event.js";
5
+ export type { Node } from "./node.js";
6
+ export type { GraphResult } from "./result.js";
7
+ export type { GraphSpec } from "./spec.js";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,20 @@
1
+ import type { FieldSpec } from "@spendgraph/sdk";
2
+ import type { GraphContext } from "./context.js";
3
+ /**
4
+ * One unit of work in a graph.
5
+ *
6
+ * `input` is what makes a node reusable: the node says what it needs, the graph
7
+ * says where it comes from. Without it every node has to know the whole run.
8
+ */
9
+ export interface Node<Args = Record<string, unknown>> {
10
+ /** Unique in the graph, and the key its output is stored under. */
11
+ name: string;
12
+ /**
13
+ * What this node needs, validated before `run` does any work. Halfway through
14
+ * a graph is the most expensive place to discover a typo.
15
+ */
16
+ args?: FieldSpec[];
17
+ /** The edge's data half: an edge says what runs next, this says on what. */
18
+ input?: (ctx: GraphContext) => Record<string, unknown>;
19
+ run(input: Args, ctx: GraphContext): Promise<unknown> | unknown;
20
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,20 @@
1
+ import type { RolloutStep } from "@spendgraph/sdk";
2
+ /**
3
+ * What one run produced, in the shape of a rollout.
4
+ *
5
+ * A graph run is a rollout with several steps, so this hands straight to
6
+ * `report()` rather than being translated first.
7
+ */
8
+ export interface GraphResult {
9
+ status: "completed" | "failed";
10
+ /** The last node's return, stringified the way a tool result is. */
11
+ output: string;
12
+ error?: string;
13
+ steps: RolloutStep[];
14
+ /** Every node's return, by name, for reading a run apart afterwards. */
15
+ outputs: Record<string, unknown>;
16
+ latencyMs: number;
17
+ /** Summed across every step, nested ones included. */
18
+ inputTokens: number;
19
+ outputTokens: number;
20
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ import type { Edge } from "./edge.js";
2
+ import type { Node } from "./node.js";
3
+ export interface GraphSpec {
4
+ /** Where a run starts. */
5
+ entry: string;
6
+ nodes: Node<never>[];
7
+ edges?: Edge[];
8
+ /**
9
+ * How many nodes a run may execute before it is called a loop. Default 25.
10
+ *
11
+ * A backwards edge is a feature and also how a graph hangs. Nothing tells the
12
+ * two apart statically, so the guard is a count and a clear failure.
13
+ */
14
+ maxSteps?: number;
15
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@spendgraph/graph",
3
+ "version": "0.2.0",
4
+ "description": "Wire nodes into a graph, run it, and get back a rollout with every step.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/fnLog0/spendgraph.git",
9
+ "directory": "packages/graph"
10
+ },
11
+ "homepage": "https://github.com/fnLog0/spendgraph#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/fnLog0/spendgraph/issues"
14
+ },
15
+ "keywords": [
16
+ "llm",
17
+ "agent",
18
+ "graph",
19
+ "workflow",
20
+ "orchestration"
21
+ ],
22
+ "type": "module",
23
+ "main": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.js"
29
+ },
30
+ "./package.json": "./package.json"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "README.md"
35
+ ],
36
+ "scripts": {
37
+ "build": "tsc -p tsconfig.json",
38
+ "test": "vitest run"
39
+ },
40
+ "dependencies": {
41
+ "@spendgraph/sdk": "^0.2.0"
42
+ },
43
+ "devDependencies": {
44
+ "typescript": "^5"
45
+ },
46
+ "engines": {
47
+ "node": ">=18"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ }
52
+ }