@spendgraph/graph 0.6.0 → 0.7.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 (71) hide show
  1. package/README.md +2 -0
  2. package/dist/compile/ceiling.d.ts +3 -0
  3. package/dist/compile/ceiling.js +1 -0
  4. package/dist/compile/compile.d.ts +5 -14
  5. package/dist/compile/compile.js +1 -1
  6. package/dist/compile/covered.d.ts +3 -0
  7. package/dist/compile/covered.js +1 -0
  8. package/dist/compile/edges.d.ts +5 -0
  9. package/dist/compile/edges.js +1 -0
  10. package/dist/compile/errors.d.ts +14 -0
  11. package/dist/compile/errors.js +1 -0
  12. package/dist/compile/index.d.ts +2 -0
  13. package/dist/compile/index.js +1 -1
  14. package/dist/compile/nodes.d.ts +4 -0
  15. package/dist/compile/nodes.js +1 -0
  16. package/dist/compile/reachable.d.ts +9 -0
  17. package/dist/compile/reachable.js +1 -0
  18. package/dist/execute/context.d.ts +4 -0
  19. package/dist/execute/context.js +1 -0
  20. package/dist/execute/errors.d.ts +6 -0
  21. package/dist/execute/errors.js +1 -0
  22. package/dist/execute/index.d.ts +3 -3
  23. package/dist/execute/index.js +1 -1
  24. package/dist/execute/outcome.d.ts +4 -25
  25. package/dist/execute/outcome.js +1 -1
  26. package/dist/execute/resume.d.ts +16 -0
  27. package/dist/execute/resume.js +1 -0
  28. package/dist/execute/run.d.ts +33 -0
  29. package/dist/execute/run.js +1 -0
  30. package/dist/execute/steps.d.ts +7 -0
  31. package/dist/execute/steps.js +1 -0
  32. package/dist/execute/visit.d.ts +10 -0
  33. package/dist/execute/visit.js +1 -0
  34. package/dist/execute/walk.d.ts +4 -0
  35. package/dist/execute/walk.js +1 -0
  36. package/dist/index.d.ts +4 -3
  37. package/dist/index.js +1 -1
  38. package/dist/node/edge.d.ts +1 -6
  39. package/dist/node/node.d.ts +1 -7
  40. package/dist/node/node.js +1 -1
  41. package/dist/stream/events.d.ts +22 -0
  42. package/dist/stream/events.js +1 -0
  43. package/dist/stream/index.d.ts +2 -0
  44. package/dist/stream/index.js +1 -0
  45. package/dist/stream/stream.d.ts +9 -0
  46. package/dist/stream/stream.js +1 -0
  47. package/dist/types/args.d.ts +2 -15
  48. package/dist/types/context.d.ts +39 -14
  49. package/dist/types/edge.d.ts +1 -4
  50. package/dist/types/event.d.ts +9 -11
  51. package/dist/types/index.d.ts +1 -1
  52. package/dist/types/node.d.ts +2 -10
  53. package/dist/types/result.d.ts +6 -46
  54. package/dist/types/spec.d.ts +13 -4
  55. package/dist/types/wait.d.ts +9 -17
  56. package/dist/types/wait.js +1 -1
  57. package/docs/nodes.mdx +59 -0
  58. package/docs/overview.mdx +96 -0
  59. package/docs/pausing.mdx +109 -0
  60. package/docs/running.mdx +71 -0
  61. package/docs/streaming.mdx +62 -0
  62. package/docs/wiring.mdx +76 -0
  63. package/package.json +10 -8
  64. package/dist/compile/validate.d.ts +0 -49
  65. package/dist/compile/validate.js +0 -1
  66. package/dist/execute/execute.d.ts +0 -16
  67. package/dist/execute/execute.js +0 -1
  68. package/dist/execute/step.d.ts +0 -37
  69. package/dist/execute/step.js +0 -1
  70. package/dist/execute/stream.d.ts +0 -25
  71. package/dist/execute/stream.js +0 -1
@@ -1,11 +1,6 @@
1
1
  import type { RolloutStep } from "@spendgraph/sdk";
2
2
  import type { Wait } from "./wait.js";
3
- /**
4
- * What one run produced, in the shape of a rollout.
5
- *
6
- * A graph run is a rollout with several steps, so this hands straight to
7
- * `report()` rather than being translated first.
8
- */
3
+ /** What one run produced, in the shape of a rollout. */
9
4
  export interface GraphResult {
10
5
  status: "completed" | "failed";
11
6
  /** The last node's return, stringified the way a tool result is. */
@@ -18,52 +13,17 @@ export interface GraphResult {
18
13
  /** Summed across every step, nested ones included. */
19
14
  inputTokens: number;
20
15
  outputTokens: number;
21
- /**
22
- * The question a node stopped to ask, where one did.
23
- *
24
- * Carried rather than said with a status: `status` reports whether the graph
25
- * itself ran, and a paused run ran correctly as far as it got. Every workflow
26
- * in this repo signals a pause the same way, by carrying the question.
27
- */
16
+ /** The question a node stopped to ask, where one did; `status` still says the graph itself ran. */
28
17
  waitingOn?: Wait;
29
18
  /** The nested run to resume, where it named one. */
30
19
  waitingFor?: string;
31
20
  }
32
- /** What a run may be given beyond its values. */
21
+ /** What a run may be given beyond its values, mostly for picking up where an earlier run stopped. */
33
22
  export interface GraphRunOptions {
34
- /**
35
- * What nodes that already ran returned, for a graph picking up where an
36
- * earlier run left off.
37
- *
38
- * Seeded into `outputs` before the entry node, so a node resuming mid-flow
39
- * reads what came before it rather than an empty bag. The seeded nodes are
40
- * not re-run and record no steps of their own — they already did.
41
- */
23
+ /** What nodes that already ran returned; seeded into `outputs`, not re-run, no steps of their own. */
42
24
  outputs?: Record<string, unknown>;
43
- /**
44
- * Steps an earlier run already took, for the same graph picking up again.
45
- *
46
- * Seeded into `steps` before the entry node, so `ctx.steps` reads the whole
47
- * run rather than only what has happened since the resume: an edge counting
48
- * steps counts them all, `index` carries on instead of restarting at 0, and
49
- * the result comes back as one rollout a caller need not renumber.
50
- *
51
- * `maxSteps` still counts this run alone — seeded steps are history, not
52
- * budget.
53
- */
25
+ /** Steps an earlier run already took; `index` carries on from them, `maxSteps` does not count them. */
54
26
  steps?: RolloutStep[];
55
- /**
56
- * Where the walk starts, instead of the compiled entry.
57
- *
58
- * For resuming: a run that stopped to ask says which node it stopped at, and
59
- * without this that node name is a resume point nobody can use — the walk
60
- * would begin at the entry again and re-run everything before it.
61
- *
62
- * Reachability is checked from the compiled entry at build time. A node this
63
- * names only has to exist: whatever it made unreachable has already run.
64
- *
65
- * `maxSteps` counts this run alone, so a resumed one starts its budget over.
66
- * A caller resuming a cyclic graph repeatedly is the one holding the ceiling.
67
- */
27
+ /** Where the walk starts instead of the compiled entry; only has to exist, reachability was checked at build. */
68
28
  entry?: string;
69
29
  }
@@ -5,11 +5,20 @@ export interface GraphSpec {
5
5
  entry: string;
6
6
  nodes: Node<never>[];
7
7
  edges?: Edge[];
8
+ /** How many nodes a run may visit before it is called a loop; a nested run's steps do not count. */
9
+ maxSteps?: number;
8
10
  /**
9
- * How many nodes a run may execute before it is called a loop. Default 25.
11
+ * Where the walk goes when a node fails, instead of ending the run.
12
+ *
13
+ * A node that throws otherwise ends the walk where it stands, which is the
14
+ * wrong ending for a run that has already done something outside itself: the
15
+ * seat is held, the card is charged, and nothing is left to put them back.
16
+ * Naming a node here gives those steps somewhere to be undone, and the
17
+ * handler reads `ctx.failure` for what fell over and why.
10
18
  *
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.
19
+ * It does not catch its own failure a handler that throws ends the run, or
20
+ * a graph could compensate forever and it does not catch an edge condition
21
+ * throwing, since choosing where to go next is the part that is broken there.
13
22
  */
14
- maxSteps?: number;
23
+ onFailure?: string;
15
24
  }
@@ -1,11 +1,4 @@
1
- /**
2
- * A run that stopped to ask something, and what it asked.
3
- *
4
- * Structural rather than imported: `@spendgraph/harness` owns durability and
5
- * depends on this package, so the shape is declared here and its `Wait`
6
- * satisfies it. The same arrangement as `llms`' `LlmReply` and the
7
- * `TraceOutcome` that `prompt` accepts.
8
- */
1
+ /** A run that stopped to ask something. Structural, so `harness`'s own `Wait` satisfies it without an import. */
9
2
  export interface Wait {
10
3
  question: string;
11
4
  /** Whatever answering it needs — options to pick from, a diff, an id. */
@@ -18,15 +11,14 @@ export interface Paused {
18
11
  runId?: string;
19
12
  }
20
13
  /**
21
- * A node's return that is really a question, recognised by its shape.
22
- *
23
- * Structural because a node may return anything: asking callers to wrap a
24
- * paused nested run in a marker would be a rule they learn by having the graph
25
- * walk on past the question and report the run as done.
14
+ * A node's return that is really a question, recognised by its shape rather than
15
+ * a marker.
26
16
  *
27
- * Every node's return is put through this, so a node returning a `waitingOn`
28
- * that is data rather than a question stops the run. Nothing else in a reply
29
- * has that shape, and the alternative trusting a marker fails the other
30
- * way, silently.
17
+ * The id is read from `runId` or from `waitingFor`, which are the same thing
18
+ * under two names: a stored run calls it `runId`, and a graph that parked on one
19
+ * reports it as `waitingFor`. Reading only the first lost it at the second level
20
+ * of nesting — a graph inside a graph surfaced the question and not the run to
21
+ * answer it against, which is the one part of a pause a caller cannot work out
22
+ * for itself.
31
23
  */
32
24
  export declare function pausedInside(value: unknown): Paused | null;
@@ -1 +1 @@
1
- function e(t){if(!t||typeof t!="object")return null;const r=t,n=r.waitingOn;return!n||typeof n!="object"||typeof n.question!="string"?null:{waitingOn:n,...typeof r.runId=="string"?{runId:r.runId}:{}}}export{e as pausedInside};
1
+ function o(r){if(!r||typeof r!="object")return null;const n=r,t=n.waitingOn;if(!t||typeof t!="object"||typeof t.question!="string")return null;const i=typeof n.runId=="string"?n.runId:n.waitingFor;return{waitingOn:t,...typeof i=="string"?{runId:i}:{}}}export{o as pausedInside};
package/docs/nodes.mdx ADDED
@@ -0,0 +1,59 @@
1
+ export const meta = {
2
+ title: "Nodes — spendgraph docs",
3
+ description:
4
+ "One unit of work. Declare its arguments with `as const` and the handler types itself; say where they come from with `input`, and the node stays reusable.",
5
+ };
6
+
7
+ # Nodes
8
+
9
+ A node is one unit of work: a name, the arguments it needs, where they come from, and what to do with them.
10
+
11
+ ```ts
12
+ const classify = node({
13
+ name: "classify",
14
+ args: [
15
+ { name: "text", type: "string", required: true },
16
+ { name: "top_k", type: "number", required: false },
17
+ ] as const,
18
+ input: (ctx) => ({ text: ctx.values.question }),
19
+ run: ({ text, top_k }) => model.classify(text, top_k ?? 3),
20
+ });
21
+ ```
22
+
23
+ ## `input` is what makes it reusable
24
+
25
+ The node says what it **needs**; the graph says where it **comes from**. Without that split, every node has to know about the whole run — and a node that reads `ctx.values.question` directly is a node you cannot drop into a second graph where the question is called something else.
26
+
27
+ ```ts
28
+ input: (ctx) => ({ text: ctx.values.question }); // from the run's values
29
+ input: (ctx) => ({ text: String(ctx.outputs.draft) }); // from an earlier node
30
+ ```
31
+
32
+ Leave `input` off and the node is handed the run's values as they are.
33
+
34
+ ## Write `as const`
35
+
36
+ ```ts
37
+ args: [{ name: "text", type: "string", required: true }] as const,
38
+ run: ({ text }) => text.toUpperCase(), // `text` is a string, here and now
39
+ ```
40
+
41
+ The handler types itself from the args: `text` a string, a `number` arg a number, an optional one optional. Rename an argument and the handler stops compiling.
42
+
43
+ **Without `as const` inference falls back to `Record<string, unknown>` silently.** Nothing errors; the types simply stop meaning anything, and a typo that used to be a compile error becomes `undefined` halfway through a graph — the most expensive place to find one.
44
+
45
+ <Callout tone="trap" title="A node cannot rewrite what an earlier node did">
46
+ The steps a node is handed are a frozen copy: writing to one changes nothing, and it will not throw to tell you so. Pass what the next node needs through the return value, which is what `input` reads.
47
+ </Callout>
48
+
49
+ ## What is checked, and when
50
+
51
+ Arguments are validated **before `run` does any work**, against the same `FieldSpec` types the SDK, tools and prompts all use. A node handed something that does not validate fails the run rather than being let through to fail further in.
52
+
53
+ A node name must be letters, digits and underscores starting with a letter, and that is checked **at import** rather than on the first run.
54
+
55
+ ## Returning something
56
+
57
+ Whatever `run` returns becomes the node's output: a string is itself, anything else is JSON.
58
+
59
+ A value that cannot be written down — a circular object, a `BigInt`, something whose own `toJSON` throws — is recorded as `[not recordable: …]` rather than `""`. An empty output reads as a node that produced nothing, and this is a node that produced something the record could not hold. The two are different facts and the step keeps them apart.
@@ -0,0 +1,96 @@
1
+ export const meta = {
2
+ title: "Graph — spendgraph docs",
3
+ description:
4
+ "Wire nodes into a graph, run it, and get back a rollout with every step priced. Four exports, compilation that catches the wiring mistakes, and a run that never throws.",
5
+ };
6
+
7
+ # Graph
8
+
9
+ Multi-step work hand-rolled in `if`/`await` runs fine and leaves nothing behind: when step four fails at 2am, what ran, in what order, and what it cost are all gone.
10
+
11
+ A graph is nodes and the edges between them. You describe the units of work and where control goes next; running it hands back a rollout — every step, in order, with what each one cost.
12
+
13
+ ```sh
14
+ npm install @spendgraph/graph
15
+ ```
16
+
17
+ Four things are exported. Everything else hangs off what they return.
18
+
19
+ ```ts
20
+ import { graph, node, edge, end } from "@spendgraph/graph";
21
+ ```
22
+
23
+ ## The whole thing
24
+
25
+ ```ts
26
+ const classify = node({
27
+ name: "classify",
28
+ args: [{ name: "text", type: "string", required: true }] as const,
29
+ input: (ctx) => ({ text: ctx.values.question }),
30
+ run: ({ text }) => model.classify(text),
31
+ });
32
+
33
+ const lookup = node({
34
+ name: "lookup",
35
+ args: [{ name: "ref", type: "string", required: true }] as const,
36
+ input: (ctx) => ({ ref: ctx.values.question }),
37
+ run: ({ ref }) => contracts.find(ref),
38
+ });
39
+
40
+ const answer = node({
41
+ name: "answer",
42
+ run: (values) => llm.call([{ role: "user", content: String(values.question) }]),
43
+ });
44
+
45
+ const flow = graph({
46
+ entry: "classify",
47
+ nodes: [classify, lookup, answer],
48
+ edges: [
49
+ edge("classify", "lookup", (ctx) => ctx.outputs.classify === "billing"),
50
+ edge("classify", "answer"),
51
+ edge("lookup", "answer"),
52
+ end("answer"),
53
+ ],
54
+ });
55
+
56
+ const result = await flow.execute({ question: "Why was I charged twice?" });
57
+ ```
58
+
59
+ ## What you get back
60
+
61
+ ```ts
62
+ result.status; // "completed" | "failed"
63
+ result.output; // the last node's return, stringified; "" on a failure
64
+ result.steps; // every node that ran, in order
65
+ result.outputs; // each node's return, by name
66
+ result.latencyMs;
67
+ result.inputTokens; // summed across every step, nested ones included
68
+ result.outputTokens;
69
+ ```
70
+
71
+ A graph run **is** a rollout with several steps, so it hands straight to `@spendgraph/prompt` without being translated first:
72
+
73
+ ```ts
74
+ await prompt.call(values, () => flow.execute(values));
75
+ ```
76
+
77
+ ## Three things worth knowing up front
78
+
79
+ **Compilation is the one place this package throws.** An edge to a node that does not exist, an entry that is not a node, two nodes with the same name, a node nothing reaches, a node whose edges are all conditional — all of it is checked before anything runs. See [wiring](/docs/graph/wiring).
80
+
81
+ **A run never throws.** A node that fails halts the run and comes back as a result with the steps that did run and the failed one last. A thrown exception cannot tell you which three nodes ran; a result can. See [running a graph](/docs/graph/running).
82
+
83
+ **`outputs` is the only channel between nodes.** No shared mutable bag, so node four cannot come to depend on a key node two happens to set — a dependency the graph does not declare and compilation cannot check.
84
+
85
+ <Callout tone="trap" title="Compilation is the only place mistakes are cheap">
86
+ An edge pointing at a node that does not exist, an entry that is not a node, a node nothing reaches — all refused by `graph()`, before a single model call. The same mistakes found at run time are found on the branch that reaches them, which may be the one that only runs at month end.
87
+ </Callout>
88
+
89
+ ## Where to go next
90
+
91
+ | | |
92
+ | --- | --- |
93
+ | [Nodes](/docs/graph/nodes) | declaring one, and the types you get for free |
94
+ | [Wiring](/docs/graph/wiring) | edges, branching, and what compilation refuses |
95
+ | [Running a graph](/docs/graph/running) | failure, the step ceiling, and what comes back |
96
+ | [Streaming](/docs/graph/streaming) | the same run, narrated |
@@ -0,0 +1,109 @@
1
+ export const meta = {
2
+ title: "Pausing a graph — spendgraph docs",
3
+ description:
4
+ "A node that runs something needing a person stops the graph rather than walking past the question. The pause arrives as an event, and `entry` picks the run back up where it stopped.",
5
+ };
6
+
7
+ # Stopping to ask
8
+
9
+ Some work needs a person: an approval, a choice between plans, a number nobody
10
+ can infer. A node that runs something like that comes back with a **question
11
+ rather than an answer**, and the graph stops there.
12
+
13
+ ```ts
14
+ const stopped = await flow.execute({ expression: "12 / 0" });
15
+
16
+ stopped.waitingOn; // { question: "12 / 0 — what should this return?", detail }
17
+ stopped.waitingFor; // the nested run to resume, where it named one
18
+ ```
19
+
20
+ Nothing downstream runs. Walking on would put every later node to work against a
21
+ value nobody has supplied, and report the run as done while it is parked.
22
+
23
+ ## Recognised by shape, not by a marker
24
+
25
+ A node returning a value that carries `waitingOn` **is** a pause. There is no
26
+ wrapper to remember.
27
+
28
+ That is the same argument as reading a node's outcome structurally: a node may
29
+ return anything, and a rule to wrap a paused run is one you learn by having the
30
+ graph walk past the question and call it success. Every node's return goes
31
+ through it, so a `waitingOn` that is data rather than a question stops the run
32
+ too — nothing else in a reply has that shape, and the alternative fails the
33
+ other way, silently.
34
+
35
+ The check itself is exported, so your own code can ask the same question the
36
+ graph asks:
37
+
38
+ ```ts
39
+ import { pausedInside } from "@spendgraph/graph";
40
+
41
+ const paused = pausedInside(result); // Paused, or null
42
+ paused?.waitingOn.question;
43
+ paused?.runId; // the inner run to resume, where there is one
44
+ ```
45
+
46
+ Reach for it inside a node that runs a nested graph, or in a harness of your
47
+ own. `Wait` is declared here rather than imported for the same reason — harness
48
+ owns durability and depends on this package, so the shape lives on this side and
49
+ harness's own `Wait` satisfies it.
50
+
51
+ ## Streaming, it is an event
52
+
53
+ ```ts
54
+ for await (const event of flow.stream(values)) {
55
+ if (event.type === "paused") ask(event.waitingOn, event.node);
56
+ }
57
+ ```
58
+
59
+ The event carries the `Wait` whole — the question, and whatever answering it
60
+ needs. A thrown error would carry only its message, which is why this is an
61
+ event and not an exception.
62
+
63
+ <Callout tone="trap" title="Whatever a node returned has to survive JSON">
64
+ A paused run is stored and handed back to running code. A `Date` comes back a string, a `Map` comes back `{}`, and neither throws — so the refusal happens at the pause, naming the node whose return would not survive, rather than three days later in whatever read it.
65
+ </Callout>
66
+
67
+ ## The graph owns no durability
68
+
69
+ It reports the pause and stops. Storing the run, holding it while somebody
70
+ thinks, and handing the answer back is `@spendgraph/harness`'s job — it has the
71
+ store, the expiry and the resume.
72
+
73
+ ## Picking it back up
74
+
75
+ `entry` starts the walk somewhere other than the compiled entry, and `outputs`
76
+ seeds what already ran. The `paused` event says which node stopped; this says
77
+ where to start again.
78
+
79
+ ```ts
80
+ await flow.execute(values, {
81
+ entry: "divide", // the node that asked
82
+ outputs: stopped.outputs, // what already ran, so nothing runs twice
83
+ steps: stopped.steps, // the run so far, so it comes back as one rollout
84
+ });
85
+ ```
86
+
87
+ `steps` is the history rather than the values. Leave it out and the resumed run
88
+ numbers its steps from 0 and shows a node an empty `ctx.steps`, so an edge like
89
+ `(ctx) => ctx.steps.length < 3` is true all over again and two halves of one run
90
+ cannot be stitched without renumbering them.
91
+
92
+ Only existence is checked. Reachability was settled from the compiled entry at
93
+ build time, and whatever a resume entry leaves unreachable has already run.
94
+
95
+ ```ts
96
+ result.status; // "failed"
97
+ result.error; // 'No node is called "divid", so there is nowhere to start. Known: parse, divide, format.'
98
+ ```
99
+
100
+ **`maxSteps` counts one run**, so a resumed one starts its budget over. A caller
101
+ resuming a cyclic graph again and again is the one holding the ceiling.
102
+
103
+ ## A run that paused still ran
104
+
105
+ `status` says whether the graph itself ran, and a paused run ran correctly as
106
+ far as it got — so it comes back `completed`, carrying the question.
107
+
108
+ Every workflow in this repo signals a pause the same way: not with a status, but
109
+ by carrying what it stopped to ask.
@@ -0,0 +1,71 @@
1
+ export const meta = {
2
+ title: "Running a graph — spendgraph docs",
3
+ description:
4
+ "A run never throws: a failed node halts it and comes back as a result with the steps that did run. The step ceiling, the context between nodes, and what a rollout carries.",
5
+ };
6
+
7
+ # Running a graph
8
+
9
+ ```ts
10
+ const result = await flow.execute({ question: "Why was I charged twice?" });
11
+ ```
12
+
13
+ ## A run never throws
14
+
15
+ A node that throws, or is handed an argument that does not validate, **halts the run and comes back as a result**:
16
+
17
+ ```ts
18
+ result.status; // "failed"
19
+ result.error; // '"lookup" failed: contract MSA 2.4 not found'
20
+ result.steps; // the steps that did run, with the failed one last
21
+ ```
22
+
23
+ Continuing would leave every later node reading an output that was never written. Stopping with the steps intact is what lets a failed run say which three nodes ran and where it stopped — which a thrown exception cannot.
24
+
25
+ ## The step ceiling
26
+
27
+ `maxSteps` bounds the run and defaults to **25**. It counts the nodes a run visits: a node that returns a nested run folds that run's steps into the rollout, and none of those count against the ceiling.
28
+
29
+ A backwards edge is a feature and also how a graph hangs, and nothing tells the two apart statically. So the guard is a count and a clear failure rather than a hung process.
30
+
31
+ ```ts
32
+ graph({ entry: "draft", nodes, edges, maxSteps: 50 });
33
+ ```
34
+
35
+ A ceiling that is not finite is **refused at compile time**. `NaN` would make the count never fire, and `Number(process.env.MAX_STEPS)` on an unset variable is `NaN` — which is how an unbounded run arrives without anyone typing one.
36
+
37
+ <Callout tone="trap" title="The ceiling counts nodes, and defaults to 25">
38
+ `maxSteps` bounds how many nodes a run may visit, not how many tokens it may spend — a cycle between two cheap nodes hits it, an expensive straight line never does. It defaults to 25, and a run that reaches it comes back as a *failed* result rather than a truncated success.
39
+ </Callout>
40
+
41
+ ## What comes back
42
+
43
+ ```ts
44
+ result.status; // "completed" | "failed"
45
+ result.output; // the last node's return, stringified; "" on a failure
46
+ result.steps; // every node that ran, in order
47
+ result.outputs; // each node's return, by name
48
+ result.latencyMs;
49
+ result.inputTokens; // summed across every step, nested ones included
50
+ result.outputTokens;
51
+ result.waitingOn; // the question a node stopped to ask, where one did
52
+ result.waitingFor; // the nested run to resume, where it named one
53
+ ```
54
+
55
+ A graph run is a rollout with several steps, so `GraphResult` hands straight to `@spendgraph/prompt`'s `report` and `call` without being translated:
56
+
57
+ ```ts
58
+ await prompt.call(values, () => flow.execute(values));
59
+ ```
60
+
61
+ ## Context
62
+
63
+ `outputs` is the **only** channel between nodes. A shared mutable bag would let node four depend on a key node two happens to set — a dependency the graph does not declare and compilation cannot check.
64
+
65
+ ```ts
66
+ ctx.values; // what execute() was called with
67
+ ctx.outputs; // what each finished node returned, by name
68
+ ctx.steps; // the steps so far
69
+ ```
70
+
71
+ Both halves of `steps` are sealed: the array is a copy, so pushing to it changes nothing, and each step is frozen, so a node cannot rewrite what an earlier one produced or what it cost.
@@ -0,0 +1,62 @@
1
+ export const meta = {
2
+ title: "Streaming a graph — spendgraph docs",
3
+ description:
4
+ "The same run, narrated: each node's start and end, whatever its nodes emit while running, and the finished rollout last.",
5
+ };
6
+
7
+ # Streaming
8
+
9
+ `stream` is the same run as `execute`, narrated — a node's start and end, whatever its nodes emit while running, and the finished result last.
10
+
11
+ ```ts
12
+ const running = flow.stream({ question });
13
+
14
+ for await (const event of running) {
15
+ if (event.type === "token") process.stdout.write(event.text);
16
+ }
17
+
18
+ const result = await running.result;
19
+ ```
20
+
21
+ ## A node emits without knowing what a token is
22
+
23
+ A node is handed an `emit`, and writes into the stream through it. A node wrapping a streaming model call forwards deltas that way, and the graph never has to know what it is forwarding.
24
+
25
+ ```ts
26
+ const answer = node({
27
+ name: "answer",
28
+ run: (values, ctx) => llm.stream(messages, { onText: ctx.emit }),
29
+ });
30
+ ```
31
+
32
+ `emit` takes the text and nothing else — the graph tags it with the node it came
33
+ from, because a stream carrying tokens from four nodes is unreadable without it.
34
+ It is always present and does nothing when nobody is listening, so a node never
35
+ has to ask whether anyone is.
36
+
37
+ ## The run starts on the call, not the first read
38
+
39
+ ```ts
40
+ const running = flow.stream(values);
41
+ const result = await running.result; // no iteration, and it still ran
42
+ ```
43
+
44
+ A caller who wants the rollout and not the commentary gets it. Nothing is deferred until somebody reads the stream, so a run cannot sit there un-started because a `for await` was never reached.
45
+
46
+ <Callout tone="trap" title="One stream has one reader">
47
+ Two `for await` loops over the same `graph.stream()` take from the same queue, so each sees roughly half the run — which reads as a graph dropping tokens rather than as the mistake it is. A second reader is refused. More than one watcher wants one reader writing into something they can both read.
48
+ </Callout>
49
+
50
+ ## What the events are
51
+
52
+ | | |
53
+ | --- | --- |
54
+ | a node starting | its name, and the step it is |
55
+ | a node finishing | what it returned, what it cost, how long it took |
56
+ | whatever a node emitted | passed through untouched — tokens, progress, anything |
57
+ | a node stopping to ask | the question, and the nested run to resume — see [pausing](/docs/graph/pausing) |
58
+ | the result | last, and the same object `execute` would have returned |
59
+
60
+ One reader. Events are handed out and dropped, so a second `for await` over the
61
+ same stream takes half of them rather than seeing the run twice — fan out
62
+ downstream of it, not by iterating it twice.
@@ -0,0 +1,76 @@
1
+ export const meta = {
2
+ title: "Wiring — spendgraph docs",
3
+ description:
4
+ "Edges say what runs next. Branching is several edges tried in order, not one clever edge — and compilation refuses the wiring mistakes before anything runs.",
5
+ };
6
+
7
+ # Wiring
8
+
9
+ An edge says what runs **next**. A node's `input` says on **what**. Keeping those separate is what lets the same node sit in two graphs.
10
+
11
+ ```ts
12
+ const flow = graph({
13
+ entry: "classify",
14
+ nodes: [classify, lookup, answer],
15
+ edges: [
16
+ edge("classify", "lookup", (ctx) => ctx.outputs.classify === "billing"),
17
+ edge("classify", "answer"),
18
+ edge("lookup", "answer"),
19
+ end("answer"),
20
+ ],
21
+ });
22
+ ```
23
+
24
+ ## Branching is several edges, not one clever edge
25
+
26
+ `edge(from, to, when)` takes a predicate. The edges leaving a node are tried **in declaration order**, and the first match wins — so an unconditional edge is the default branch and belongs last.
27
+
28
+ ```ts
29
+ edge("triage", "refund", (ctx) => ctx.outputs.triage === "billing"),
30
+ edge("triage", "escalate", (ctx) => ctx.outputs.triage === "abuse"),
31
+ edge("triage", "answer"), // the default, and it goes last
32
+ ```
33
+
34
+ `end(from)` is `edge(from, null)` said out loud: the run finishes here.
35
+
36
+ ## What compilation refuses
37
+
38
+ Compilation happens once, when you call `graph()`, and it is the one place this package throws. Everything it catches is a wiring mistake that would otherwise show up as a run that quietly did the wrong thing.
39
+
40
+ | | |
41
+ | --- | --- |
42
+ | an edge to a node that does not exist | usually a typo, so the message lists the names that do |
43
+ | an entry that is not a node | the run would have nowhere to start |
44
+ | two nodes with the same name | one of them silently shadows the other |
45
+ | a node nothing reaches | a branch that never runs, and nothing would say so |
46
+ | a node whose edges are **all conditional** | the half-specified case, below |
47
+
48
+ <Callout tone="trap" title="A second default edge is refused, not ignored">
49
+ Edges are tried in order and a default matches everything, so a second one after it can never be taken. That reads as a branch you wired and never see fire — compilation refuses it instead, naming the node.
50
+ </Callout>
51
+
52
+ ## The half-specified node
53
+
54
+ A node with **no** edges is the end of the run: the author wrote none and meant it.
55
+
56
+ A node whose edges are *all* conditional is different. A run arriving there when no condition matches has nowhere to go — so it would stop, and report **success**, having skipped every node after it. Saying half of it is the mistake, and compilation refuses it:
57
+
58
+ ```ts
59
+ edges: [
60
+ edge("triage", "refund", (ctx) => ctx.outputs.triage === "billing"),
61
+ // and nothing else leaving "triage" — refused
62
+ ];
63
+ ```
64
+
65
+ Add the default edge, or add `end("triage")` if stopping there is what you meant. Either is a decision; the missing one was not.
66
+
67
+ ## Inspecting a compiled graph
68
+
69
+ A graph answers questions about itself, which is what the workflows in `@spendgraph/harness` are built on.
70
+
71
+ ```ts
72
+ flow.entry; // where a run starts
73
+ flow.maxSteps;
74
+ flow.nodes(); // every node, in declaration order
75
+ flow.edgesFrom("classify"); // the edges leaving it, in the order they are tried
76
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spendgraph/graph",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Wire nodes into a graph, run it, and get back a rollout with every step.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -27,19 +27,16 @@
27
27
  "types": "./dist/index.d.ts",
28
28
  "import": "./dist/index.js"
29
29
  },
30
+ "./docs/*": "./docs/*",
30
31
  "./package.json": "./package.json"
31
32
  },
32
33
  "files": [
33
34
  "dist",
35
+ "docs",
34
36
  "README.md"
35
37
  ],
36
- "scripts": {
37
- "build": "rm -rf dist && tsc -p tsconfig.json --emitDeclarationOnly && tsc -p tsconfig.json --declaration false --removeComments && node ../../scripts/minify.mjs dist",
38
- "test": "npm run build && vitest run",
39
- "typecheck": "tsc -p tsconfig.tests.json"
40
- },
41
38
  "dependencies": {
42
- "@spendgraph/sdk": "^0.6.0"
39
+ "@spendgraph/sdk": "^0.7.0"
43
40
  },
44
41
  "devDependencies": {
45
42
  "typescript": "^5"
@@ -49,5 +46,10 @@
49
46
  },
50
47
  "publishConfig": {
51
48
  "access": "public"
49
+ },
50
+ "scripts": {
51
+ "build": "rm -rf dist && tsc -p tsconfig.json --emitDeclarationOnly && tsc -p tsconfig.json --declaration false --removeComments && node ../../scripts/minify.mjs dist",
52
+ "test": "vitest run",
53
+ "typecheck": "tsc -p tsconfig.tests.json"
52
54
  }
53
- }
55
+ }