@spendgraph/graph 0.8.1 → 0.8.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spendgraph/graph",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
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,17 @@
27
27
  "types": "./dist/index.d.ts",
28
28
  "import": "./dist/index.js"
29
29
  },
30
- "./docs/*": "./docs/*",
31
30
  "./package.json": "./package.json"
32
31
  },
33
32
  "files": [
34
33
  "dist",
35
- "docs",
36
34
  "README.md"
37
35
  ],
38
36
  "dependencies": {
39
- "@spendgraph/sdk": "^0.8.1"
37
+ "@spendgraph/sdk": "^0.8.3"
40
38
  },
41
39
  "devDependencies": {
42
- "@spendgraph/config": "0.8.1",
40
+ "@spendgraph/config": "0.8.3",
43
41
  "typescript": "^5"
44
42
  },
45
43
  "engines": {
package/docs/nodes.mdx DELETED
@@ -1,59 +0,0 @@
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.
package/docs/overview.mdx DELETED
@@ -1,98 +0,0 @@
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
- <GraphToRollout />
18
-
19
- Four things are exported. Everything else hangs off what they return.
20
-
21
- ```ts
22
- import { graph, node, edge, end } from "@spendgraph/graph";
23
- ```
24
-
25
- ## The whole thing
26
-
27
- ```ts
28
- const classify = node({
29
- name: "classify",
30
- args: [{ name: "text", type: "string", required: true }] as const,
31
- input: (ctx) => ({ text: ctx.values.question }),
32
- run: ({ text }) => model.classify(text),
33
- });
34
-
35
- const lookup = node({
36
- name: "lookup",
37
- args: [{ name: "ref", type: "string", required: true }] as const,
38
- input: (ctx) => ({ ref: ctx.values.question }),
39
- run: ({ ref }) => contracts.find(ref),
40
- });
41
-
42
- const answer = node({
43
- name: "answer",
44
- run: (values) => llm.call([{ role: "user", content: String(values.question) }]),
45
- });
46
-
47
- const flow = graph({
48
- entry: "classify",
49
- nodes: [classify, lookup, answer],
50
- edges: [
51
- edge("classify", "lookup", (ctx) => ctx.outputs.classify === "billing"),
52
- edge("classify", "answer"),
53
- edge("lookup", "answer"),
54
- end("answer"),
55
- ],
56
- });
57
-
58
- const result = await flow.execute({ question: "Why was I charged twice?" });
59
- ```
60
-
61
- ## What you get back
62
-
63
- ```ts
64
- result.status; // "completed" | "failed"
65
- result.output; // the last node's return, stringified; "" on a failure
66
- result.steps; // every node that ran, in order
67
- result.outputs; // each node's return, by name
68
- result.latencyMs;
69
- result.inputTokens; // summed across every step, nested ones included
70
- result.outputTokens;
71
- ```
72
-
73
- A graph run **is** a rollout with several steps, so it hands straight to `@spendgraph/prompt` without being translated first:
74
-
75
- ```ts
76
- await prompt.call(values, () => flow.execute(values));
77
- ```
78
-
79
- ## Three things worth knowing up front
80
-
81
- **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](/spendgraph/graph/wiring).
82
-
83
- **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](/spendgraph/graph/running).
84
-
85
- **`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.
86
-
87
- <Callout tone="trap" title="Compilation is the only place mistakes are cheap">
88
- 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.
89
- </Callout>
90
-
91
- ## Where to go next
92
-
93
- | | |
94
- | --- | --- |
95
- | [Nodes](/spendgraph/graph/nodes) | declaring one, and the types you get for free |
96
- | [Wiring](/spendgraph/graph/wiring) | edges, branching, and what compilation refuses |
97
- | [Running a graph](/spendgraph/graph/running) | failure, the step ceiling, and what comes back |
98
- | [Streaming](/spendgraph/graph/streaming) | the same run, narrated |
package/docs/pausing.mdx DELETED
@@ -1,109 +0,0 @@
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.
package/docs/running.mdx DELETED
@@ -1,71 +0,0 @@
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.
@@ -1,62 +0,0 @@
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](/spendgraph/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.
package/docs/wiring.mdx DELETED
@@ -1,76 +0,0 @@
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
- ```