@spendgraph/harness 0.1.0 → 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 (119) hide show
  1. package/README.md +87 -160
  2. package/dist/cascade/cascade.d.ts +13 -0
  3. package/dist/cascade/cascade.js +91 -0
  4. package/dist/cascade/index.d.ts +3 -0
  5. package/dist/cascade/index.js +2 -0
  6. package/dist/cascade/tier.d.ts +31 -0
  7. package/dist/cascade/tier.js +53 -0
  8. package/dist/cascade/types.d.ts +98 -0
  9. package/dist/chain/chain.d.ts +9 -0
  10. package/dist/chain/chain.js +58 -0
  11. package/dist/chain/gate.d.ts +16 -0
  12. package/dist/chain/gate.js +33 -0
  13. package/dist/chain/index.d.ts +3 -0
  14. package/dist/chain/index.js +2 -0
  15. package/dist/chain/step.d.ts +9 -0
  16. package/dist/chain/step.js +14 -0
  17. package/dist/chain/types.d.ts +50 -0
  18. package/dist/index.d.ts +16 -9
  19. package/dist/index.js +8 -7
  20. package/dist/loop/act.d.ts +21 -0
  21. package/dist/loop/act.js +43 -0
  22. package/dist/loop/compact.d.ts +49 -0
  23. package/dist/loop/compact.js +59 -0
  24. package/dist/loop/hooks.d.ts +43 -0
  25. package/dist/loop/hooks.js +41 -0
  26. package/dist/loop/index.d.ts +6 -0
  27. package/dist/loop/index.js +5 -0
  28. package/dist/loop/loop.d.ts +13 -0
  29. package/dist/loop/loop.js +120 -0
  30. package/dist/loop/turn.d.ts +6 -0
  31. package/dist/loop/turn.js +17 -0
  32. package/dist/loop/types.d.ts +140 -0
  33. package/dist/loop/types.js +1 -0
  34. package/dist/orchestrate/index.d.ts +4 -0
  35. package/dist/orchestrate/index.js +3 -0
  36. package/dist/orchestrate/orchestrate.d.ts +12 -0
  37. package/dist/orchestrate/orchestrate.js +68 -0
  38. package/dist/orchestrate/plan.d.ts +18 -0
  39. package/dist/orchestrate/plan.js +38 -0
  40. package/dist/orchestrate/types.d.ts +75 -0
  41. package/dist/orchestrate/types.js +1 -0
  42. package/dist/orchestrate/work.d.ts +13 -0
  43. package/dist/orchestrate/work.js +35 -0
  44. package/dist/parallel/index.d.ts +4 -0
  45. package/dist/parallel/index.js +3 -0
  46. package/dist/parallel/merge.d.ts +26 -0
  47. package/dist/parallel/merge.js +52 -0
  48. package/dist/parallel/parallel.d.ts +12 -0
  49. package/dist/parallel/parallel.js +66 -0
  50. package/dist/parallel/task.d.ts +13 -0
  51. package/dist/parallel/task.js +45 -0
  52. package/dist/parallel/types.d.ts +61 -0
  53. package/dist/parallel/types.js +1 -0
  54. package/dist/refine/attempt.d.ts +22 -0
  55. package/dist/refine/attempt.js +38 -0
  56. package/dist/refine/index.d.ts +4 -0
  57. package/dist/refine/index.js +3 -0
  58. package/dist/refine/judge.d.ts +15 -0
  59. package/dist/refine/judge.js +25 -0
  60. package/dist/refine/refine.d.ts +10 -0
  61. package/dist/refine/refine.js +67 -0
  62. package/dist/refine/types.d.ts +90 -0
  63. package/dist/refine/types.js +1 -0
  64. package/dist/route/classify.d.ts +52 -0
  65. package/dist/route/classify.js +57 -0
  66. package/dist/route/dispatch.d.ts +12 -0
  67. package/dist/route/dispatch.js +33 -0
  68. package/dist/route/index.d.ts +3 -0
  69. package/dist/route/index.js +2 -0
  70. package/dist/route/route.d.ts +11 -0
  71. package/dist/route/route.js +31 -0
  72. package/dist/route/types.d.ts +53 -0
  73. package/dist/route/types.js +1 -0
  74. package/dist/stream/graph.d.ts +9 -0
  75. package/dist/stream/graph.js +16 -0
  76. package/dist/stream/index.d.ts +2 -0
  77. package/dist/stream/index.js +1 -0
  78. package/dist/stream/stream.d.ts +19 -0
  79. package/dist/stream/stream.js +65 -0
  80. package/dist/stream/types.d.ts +31 -0
  81. package/dist/stream/types.js +1 -0
  82. package/docs/README.md +41 -0
  83. package/docs/cascade.md +165 -0
  84. package/docs/chain.md +134 -0
  85. package/docs/loop.md +318 -0
  86. package/docs/orchestrate.md +128 -0
  87. package/docs/parallel.md +139 -0
  88. package/docs/refine.md +206 -0
  89. package/docs/route.md +212 -0
  90. package/package.json +18 -14
  91. package/bin/harness.mjs +0 -76
  92. package/dist/core/budget.d.ts +0 -46
  93. package/dist/core/budget.js +0 -72
  94. package/dist/core/cache.d.ts +0 -119
  95. package/dist/core/cache.js +0 -206
  96. package/dist/core/client.d.ts +0 -33
  97. package/dist/core/client.js +0 -98
  98. package/dist/core/errors.d.ts +0 -22
  99. package/dist/core/errors.js +0 -28
  100. package/dist/core/index.d.ts +0 -5
  101. package/dist/core/index.js +0 -5
  102. package/dist/core/pull.d.ts +0 -17
  103. package/dist/core/pull.js +0 -38
  104. package/dist/core/schema/serialize.d.ts +0 -12
  105. package/dist/core/schema/serialize.js +0 -42
  106. package/dist/core/schema/types.d.ts +0 -58
  107. package/dist/core/schema/validate.d.ts +0 -28
  108. package/dist/core/schema/validate.js +0 -98
  109. package/dist/harness.d.ts +0 -128
  110. package/dist/harness.js +0 -187
  111. package/dist/prompt/codegen.d.ts +0 -37
  112. package/dist/prompt/codegen.js +0 -97
  113. package/dist/prompt/render.d.ts +0 -35
  114. package/dist/prompt/render.js +0 -66
  115. package/dist/prompt/run.d.ts +0 -38
  116. package/dist/prompt/run.js +0 -96
  117. package/dist/prompt/types.d.ts +0 -156
  118. /package/dist/{core/schema → cascade}/types.js +0 -0
  119. /package/dist/{prompt → chain}/types.js +0 -0
@@ -0,0 +1,19 @@
1
+ import type { Emit, HarnessStream } from "./types.js";
2
+ /**
3
+ * Any workflow, watched while it runs.
4
+ *
5
+ * One helper rather than a streaming variant of each of the seven: every
6
+ * workflow already takes an `emit`, so wrapping the call is all it takes, and a
7
+ * `stream()` per pattern would be seven places for the same queue to go wrong.
8
+ *
9
+ * const run = streamed((emit, note) =>
10
+ * cascade(values, { ...opts, emit, onTier: note })
11
+ * );
12
+ * for await (const event of run) { ... }
13
+ * const result = await run.result;
14
+ *
15
+ * The run starts on the call, not on the first read, so a caller who wants only
16
+ * the result never has to iterate, and a client that connects late still sees
17
+ * what already happened.
18
+ */
19
+ export declare function streamed<T>(run: (emit: Emit, note: (data: unknown) => void) => Promise<T>): HarnessStream<T>;
@@ -0,0 +1,65 @@
1
+ class Events {
2
+ waiting = [];
3
+ wake = null;
4
+ closed = false;
5
+ push(event) {
6
+ this.waiting.push(event);
7
+ this.release();
8
+ }
9
+ close() {
10
+ this.closed = true;
11
+ this.release();
12
+ }
13
+ release() {
14
+ const wake = this.wake;
15
+ this.wake = null;
16
+ wake?.();
17
+ }
18
+ async *drain() {
19
+ while (true) {
20
+ while (this.waiting.length > 0) {
21
+ yield this.waiting.shift();
22
+ }
23
+ if (this.closed)
24
+ return;
25
+ await new Promise((resolve) => {
26
+ this.wake = resolve;
27
+ });
28
+ }
29
+ }
30
+ }
31
+ /**
32
+ * Any workflow, watched while it runs.
33
+ *
34
+ * One helper rather than a streaming variant of each of the seven: every
35
+ * workflow already takes an `emit`, so wrapping the call is all it takes, and a
36
+ * `stream()` per pattern would be seven places for the same queue to go wrong.
37
+ *
38
+ * const run = streamed((emit, note) =>
39
+ * cascade(values, { ...opts, emit, onTier: note })
40
+ * );
41
+ * for await (const event of run) { ... }
42
+ * const result = await run.result;
43
+ *
44
+ * The run starts on the call, not on the first read, so a caller who wants only
45
+ * the result never has to iterate, and a client that connects late still sees
46
+ * what already happened.
47
+ */
48
+ export function streamed(run) {
49
+ const events = new Events();
50
+ const result = (async () => {
51
+ try {
52
+ const finished = await run((text, stage) => events.push({ type: "token", text, stage }), (data) => events.push({ type: "note", data }));
53
+ events.push({ type: "result", result: finished });
54
+ return finished;
55
+ }
56
+ finally {
57
+ events.close();
58
+ }
59
+ })();
60
+ void result.catch(() => { });
61
+ return {
62
+ result,
63
+ [Symbol.asyncIterator]: () => events.drain(),
64
+ };
65
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Where a workflow's tokens go.
3
+ *
4
+ * `stage` is which part of the workflow produced them — a tier, a task, a node.
5
+ * A fan-out interleaves, and without it a stream of four workers reads as one
6
+ * confused voice. Assignable to a model client's `onText` either way, so a
7
+ * callback can pass it straight down.
8
+ */
9
+ export type Emit = (text: string, stage?: string) => void;
10
+ /** What a streamed workflow says while it runs, and what it ends with. */
11
+ export type HarnessEvent<T> = {
12
+ type: "token";
13
+ text: string;
14
+ stage?: string;
15
+ } | {
16
+ type: "note";
17
+ data: unknown;
18
+ } | {
19
+ type: "result";
20
+ result: T;
21
+ };
22
+ /** A workflow as it happens, and the same run once it is over. */
23
+ export interface HarnessStream<T> extends AsyncIterable<HarnessEvent<T>> {
24
+ /**
25
+ * The finished run.
26
+ *
27
+ * Resolves whether or not anyone iterated, and rejects where the workflow
28
+ * itself would have — streaming changes what you can watch, not what happens.
29
+ */
30
+ result: Promise<T>;
31
+ }
@@ -0,0 +1 @@
1
+ export {};
package/docs/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # Workflows
2
+
3
+ A workflow is a wiring pattern that keeps being rediscovered. Each one is a
4
+ graph you would otherwise hand-roll, with the pricing and the rollout already
5
+ attached, so a run is recorded rather than reconstructed afterwards.
6
+
7
+ | | | Built |
8
+ | --- | --- | --- |
9
+ | [`refine`](./refine.md) | draft, judge, revise until a bar is met | ✓ |
10
+ | [`route`](./route.md) | classify first, then dispatch to a specialist | ✓ |
11
+ | [`chain`](./chain.md) | steps in sequence, each on the last's output, with a gate between | ✓ |
12
+ | [`parallel`](./parallel.md) | the same call N times, or N sections at once | ✓ |
13
+ | [`orchestrate`](./orchestrate.md) | a lead decomposes and delegates to workers | ✓ |
14
+ | [`loop`](./loop.md) | tools in a loop until the model stops asking | ✓ |
15
+ | [`cascade`](./cascade.md) | try the cheap model, escalate only when it will not do | ✓ |
16
+
17
+ ## What every one of them owes you
18
+
19
+ **A rollout shape.** The result hands to `prompt.report()` without being
20
+ translated, because a workflow run *is* a rollout with several steps.
21
+
22
+ **Every step, including the rejected ones.** A refine loop that keeps only the
23
+ winner cannot say what the answer cost. A router that records only the branch it
24
+ ran cannot tell a missing branch from a miscalibrated one.
25
+
26
+ **A ceiling.** Each of these spends more than one call, and the interesting ones
27
+ spend an unbounded amount. The ceiling is in **tokens**, not money: this package
28
+ cannot price a call — that happens on the server, after the fact — so a budget in
29
+ micro-dollars here would be a guess.
30
+
31
+ ## Why they are not in `graph`
32
+
33
+ `graph` is the mechanism: nodes, edges, a run. These are the arrangements of it
34
+ worth naming, each with its own failure mode — a chain needs a gate or one bad
35
+ step poisons the rest, voting needs an odd N, a loop needs a ceiling. Putting
36
+ them beside the mechanism would suggest the mechanism is opinionated. It is not.
37
+
38
+ `route` compiles down to a graph and adds nothing to it. `refine` does not,
39
+ because it is a **loop**: `graph` is a DAG whose `maxSteps` error reads "a
40
+ conditional edge never turns false", so a cycle there is a runaway to be stopped
41
+ rather than a shape to express.
@@ -0,0 +1,165 @@
1
+ # `cascade`
2
+
3
+ Try the cheap model. Escalate only when its answer will not do.
4
+
5
+ ```ts
6
+ const result = await cascade(values, {
7
+ tiers: [
8
+ { name: "haiku", run: (v) => haiku.bind(prompt).invoke(v) },
9
+ { name: "opus", run: (v) => opus.bind(prompt).invoke(v) },
10
+ ],
11
+ accept: (answer) => (answer.data as Reply).confidence >= 0.8,
12
+ });
13
+
14
+ result.tier; // "haiku" — who answered
15
+ result.escalated; // [] — who did not
16
+ result.accepted; // true
17
+ ```
18
+
19
+ ---
20
+
21
+ ## The flow
22
+
23
+ ```
24
+ values
25
+
26
+
27
+ ┌─────────────────┐
28
+ │ tier 1 haiku │ cheap, tried first
29
+ └────────┬────────┘
30
+
31
+ ┌──────┴──────┐
32
+ │ accept()? │
33
+ └──────┬──────┘
34
+ yes ─────────┼───────── no, or it threw
35
+ │ │ │
36
+ │ │ ▼
37
+ │ │ ┌─────────────────┐
38
+ │ │ │ tier 2 opus │ the fallback
39
+ │ │ └────────┬────────┘
40
+ │ │ │
41
+ │ │ ┌──────┴──────┐
42
+ │ │ │ accept()? │
43
+ │ │ └──────┬──────┘
44
+ ▼ ▼ ▼ no → returned anyway,
45
+ ┌──────────────────────────────────┐ accepted: false
46
+ │ tier · escalated · accepted │
47
+ └──────────────────────────────────┘
48
+ ```
49
+
50
+ ---
51
+
52
+ ## What it is not
53
+
54
+ **Not `route`.** A router classifies *before* it has seen an answer, and commits.
55
+ A cascade judges the answer itself, which is the only evidence that actually
56
+ says whether the cheap model coped.
57
+
58
+ **Not `refine`.** Refining revises with the same model and feeds back what was
59
+ wrong. Cascading changes the model and asks again from scratch. Use `refine`
60
+ when the draft is nearly right; use `cascade` when the model is out of its depth.
61
+
62
+ ---
63
+
64
+ ## `accept` is required
65
+
66
+ There is no default and there cannot be one. Without `accept` this is a list of
67
+ models with no reason to stop at any of them — the first would always answer, and
68
+ the rest would be decoration.
69
+
70
+ It runs on the answer, so it can read anything the tier returned:
71
+
72
+ ```ts
73
+ accept: (answer) => (answer.data as Reply).confidence >= 0.8 // a self-report
74
+ accept: (answer) => answer.output.length > 200 // a floor
75
+ accept: (answer) => schema.safeParse(answer.data).success // a shape
76
+ accept: async (answer) => (await judge(answer)).ok // another model
77
+ ```
78
+
79
+ The last one costs a call per tier and is usually worth it only when the tiers
80
+ are far apart in price.
81
+
82
+ ---
83
+
84
+ ## A tier that throws escalates
85
+
86
+ An overloaded cheap model is the case this exists for, so a throw hands on to the
87
+ next rung rather than ending the run. So does a reply with `status: "failed"`.
88
+
89
+ Both are recorded **distinguishably**:
90
+
91
+ ```ts
92
+ result.history.map((r) => r.escalatedBy);
93
+ // ["failed", "rejected", undefined]
94
+ ```
95
+
96
+ `failed` is the call falling over. `rejected` is `accept` saying no. They want
97
+ opposite fixes — a model that is down against a bar set too high — and a record
98
+ that collapsed them into "escalated" could not tell you which you have.
99
+
100
+ ---
101
+
102
+ ## When nothing is accepted
103
+
104
+ The **last tier that produced an answer** comes back, with `accepted: false`. The
105
+ tiers are ordered by capability, so the most capable answer is the closest thing
106
+ to a best effort, and throwing it away to return an error would waste the
107
+ expensive call you just paid for.
108
+
109
+ A tier that threw produced nothing, so it cannot be the fallback however late it
110
+ ran:
111
+
112
+ ```
113
+ haiku → "weak" rejected
114
+ opus → threw failed
115
+ ────────────────────────────────
116
+ tier: "haiku", accepted: false
117
+ ```
118
+
119
+ Only when **no** tier produced anything at all does the run fail, with
120
+ `stoppedBy: "failed"` and the last error.
121
+
122
+ ---
123
+
124
+ ## The honest caveat
125
+
126
+ **A cascade can cost more than not having one.** If the cheap tier is rejected
127
+ most of the time, every run pays for it and then pays for the expensive model
128
+ anyway.
129
+
130
+ Which is why the totals count every tier tried:
131
+
132
+ ```ts
133
+ result.inputTokens; // haiku's and opus's, not the winner's
134
+ ```
135
+
136
+ and why the thing to watch in production is the distribution of `result.tier`:
137
+
138
+ ```
139
+ tier=haiku 91% ← paying off
140
+ tier=haiku 22% ← paying for two calls to do one model's work
141
+ ```
142
+
143
+ At the second number, delete the cascade and call the expensive model directly.
144
+ There is no threshold that is right for everyone: it depends on the price gap
145
+ between the tiers and on how much a rejected cheap answer costs relative to the
146
+ expensive one.
147
+
148
+ ---
149
+
150
+ ## The ceiling
151
+
152
+ `maxTokens` is checked before each tier, so it is never crossed rather than
153
+ merely noticed. A cascade that has spent its budget stops with
154
+ `stoppedBy: "tokens"` and returns the best answer it has — which is the fallback
155
+ rule again, not a special case.
156
+
157
+ ---
158
+
159
+ ## How the files fit
160
+
161
+ | | |
162
+ | --- | --- |
163
+ | `cascade.ts` | the walk down the tiers, and the result |
164
+ | `tier.ts` | running one tier, turning a throw into an escalation, choosing the fallback |
165
+ | `types.ts` | `Tier`, `Answer`, `TierRecord`, the options and the result |
package/docs/chain.md ADDED
@@ -0,0 +1,134 @@
1
+ # chain
2
+
3
+ Steps in sequence, each on the last one's output, with a gate between.
4
+
5
+ ```ts
6
+ import { chain } from "@spendgraph/harness";
7
+
8
+ const result = await chain(
9
+ { transcript },
10
+ {
11
+ steps: [
12
+ {
13
+ name: "extract",
14
+ run: ({ transcript }) => llm.call(extractPrompt(transcript), { schema: CHANGES }),
15
+ gate: (output) =>
16
+ JSON.parse(output.output).items.length > 0
17
+ ? { ok: true }
18
+ : { ok: false, reason: "found no changes to write about" },
19
+ },
20
+ {
21
+ name: "write",
22
+ run: (previous) => llm.call(writePrompt(previous)),
23
+ gate: (output) =>
24
+ output.output.length <= 1200
25
+ ? { ok: true }
26
+ : { ok: false, reason: `${output.output.length} characters, limit is 1200` },
27
+ },
28
+ { name: "translate", run: (previous) => llm.call(translatePrompt(previous)) },
29
+ ],
30
+ }
31
+ );
32
+
33
+ result.ran; // ["extract", "write", "translate"]
34
+ result.stoppedAt; // null, or the step whose gate refused
35
+ result.reason; // what the gate said
36
+ ```
37
+
38
+ `run` is handed whatever the step before it returned — the values the chain was
39
+ called with, for the first one.
40
+
41
+ ---
42
+
43
+ ## The gate is the point
44
+
45
+ Without one, a stage that produced nothing useful is passed to the next stage,
46
+ which confidently works on it, and the stage after that works on *that*. You get
47
+ a polished paragraph three calls later that is about nothing, and no indication
48
+ which stage went wrong.
49
+
50
+ ```
51
+ extract ──► gate ──► write ──► gate ──► translate ──► output
52
+ │ │
53
+ └── refuses ───────┴──► stops, and says which
54
+ ```
55
+
56
+ A refused gate stops the chain and names the step. The tests pin the thing that
57
+ matters: the next step **is not called**.
58
+
59
+ **The output the gate refused is kept.** It is recorded as a completed step with
60
+ whatever it produced, and the gate is recorded separately as the failure. The
61
+ rejected output is the evidence for why the gate fired at all — losing it leaves
62
+ you with "extract failed" and nothing to look at.
63
+
64
+ ```
65
+ steps: extract (completed, "nothing found")
66
+ gate_extract (failed, "no items")
67
+ ```
68
+
69
+ ---
70
+
71
+ ## Why not one prompt
72
+
73
+ Each call gets simpler instructions and does its one job better. Splitting trades
74
+ latency for accuracy — and you find out *which* stage went wrong instead of
75
+ receiving one bad paragraph with no explanation.
76
+
77
+ The cost is real: three sequential calls is three times the wall-clock, and they
78
+ cannot be parallelised because each needs the last. `chain` buys accuracy with
79
+ latency, where `parallel` buys latency with tokens.
80
+
81
+ ---
82
+
83
+ ## When it wins
84
+
85
+ The task decomposes into stages you can name **in advance**, and each stage's
86
+ output is checkable.
87
+
88
+ - extract → validate → transform
89
+ - outline → check it covers the brief → draft
90
+ - draft → translate → verify the placeholders survived
91
+
92
+ ## When it does not
93
+
94
+ **The stages are only separable on paper.** If each one needs the full context
95
+ anyway, you have paid for three calls to do one call's work.
96
+
97
+ **You do not know the stages until you have read the input.** That is
98
+ `orchestrate`, not `chain`.
99
+
100
+ **Nothing between the stages is checkable.** Without gates this is a `graph` with
101
+ extra ceremony — use `graph` directly.
102
+
103
+ ---
104
+
105
+ ## How it differs from its neighbours
106
+
107
+ `refine` loops on the **same** step until it is good enough. `chain` moves
108
+ **forward** through different steps. Refine asks "is this good yet?"; chain asks
109
+ "is this good enough to continue?"
110
+
111
+ `graph` can already express a chain — it is a line of nodes. `chain` is the named
112
+ case with the gate built in, the way `route` is the named case for branching. The
113
+ value is not the mechanism; it is that the gate is on by default rather than
114
+ something you remember to add.
115
+
116
+ ---
117
+
118
+ ## How the files fit
119
+
120
+ ```
121
+ types.ts the contract
122
+
123
+ step.ts a stage as a node
124
+ gate.ts a gate as its own node, and the refusal it throws
125
+
126
+ chain.ts compile · run · shape
127
+ ```
128
+
129
+ `step.ts` and `gate.ts` are siblings — neither knows about the other. `chain.ts`
130
+ is the only file that knows they alternate.
131
+
132
+ A stage reads its input from `ctx.outputs` rather than having it threaded in,
133
+ because a gate may sit between two stages and the gate's return is not what the
134
+ next stage wants — the stage before it is.