@spendgraph/harness 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +141 -111
- package/dist/cascade/cascade.d.ts +13 -0
- package/dist/cascade/cascade.js +80 -0
- package/dist/cascade/index.d.ts +3 -0
- package/dist/cascade/index.js +2 -0
- package/dist/cascade/tier.d.ts +31 -0
- package/dist/cascade/tier.js +32 -0
- package/dist/cascade/types.d.ts +98 -0
- package/dist/chain/chain.d.ts +9 -0
- package/dist/chain/chain.js +50 -0
- package/dist/chain/gate.d.ts +16 -0
- package/dist/chain/gate.js +25 -0
- package/dist/chain/index.d.ts +3 -0
- package/dist/chain/index.js +2 -0
- package/dist/chain/step.d.ts +9 -0
- package/dist/chain/step.js +7 -0
- package/dist/chain/types.d.ts +50 -0
- package/dist/index.d.ts +16 -9
- package/dist/index.js +8 -7
- package/dist/loop/act.d.ts +21 -0
- package/dist/loop/act.js +29 -0
- package/dist/loop/compact.d.ts +49 -0
- package/dist/loop/compact.js +41 -0
- package/dist/loop/hooks.d.ts +43 -0
- package/dist/loop/hooks.js +31 -0
- package/dist/loop/index.d.ts +6 -0
- package/dist/loop/index.js +5 -0
- package/dist/loop/loop.d.ts +13 -0
- package/dist/loop/loop.js +99 -0
- package/dist/loop/turn.d.ts +6 -0
- package/dist/loop/turn.js +15 -0
- package/dist/loop/types.d.ts +140 -0
- package/dist/loop/types.js +1 -0
- package/dist/orchestrate/index.d.ts +4 -0
- package/dist/orchestrate/index.js +3 -0
- package/dist/orchestrate/orchestrate.d.ts +12 -0
- package/dist/orchestrate/orchestrate.js +56 -0
- package/dist/orchestrate/plan.d.ts +18 -0
- package/dist/orchestrate/plan.js +27 -0
- package/dist/orchestrate/types.d.ts +75 -0
- package/dist/orchestrate/types.js +1 -0
- package/dist/orchestrate/work.d.ts +13 -0
- package/dist/orchestrate/work.js +25 -0
- package/dist/parallel/index.d.ts +4 -0
- package/dist/parallel/index.js +3 -0
- package/dist/parallel/merge.d.ts +26 -0
- package/dist/parallel/merge.js +35 -0
- package/dist/parallel/parallel.d.ts +12 -0
- package/dist/parallel/parallel.js +56 -0
- package/dist/parallel/task.d.ts +13 -0
- package/dist/parallel/task.js +37 -0
- package/dist/parallel/types.d.ts +61 -0
- package/dist/parallel/types.js +1 -0
- package/dist/refine/attempt.d.ts +22 -0
- package/dist/refine/attempt.js +24 -0
- package/dist/refine/index.d.ts +4 -0
- package/dist/refine/index.js +3 -0
- package/dist/refine/judge.d.ts +15 -0
- package/dist/refine/judge.js +14 -0
- package/dist/refine/refine.d.ts +10 -0
- package/dist/refine/refine.js +59 -0
- package/dist/refine/types.d.ts +90 -0
- package/dist/refine/types.js +1 -0
- package/dist/route/classify.d.ts +52 -0
- package/dist/route/classify.js +33 -0
- package/dist/route/dispatch.d.ts +12 -0
- package/dist/route/dispatch.js +25 -0
- package/dist/route/index.d.ts +3 -0
- package/dist/route/index.js +2 -0
- package/dist/route/route.d.ts +11 -0
- package/dist/route/route.js +22 -0
- package/dist/route/types.d.ts +53 -0
- package/dist/route/types.js +1 -0
- package/dist/stream/graph.d.ts +9 -0
- package/dist/stream/graph.js +10 -0
- package/dist/stream/index.d.ts +2 -0
- package/dist/stream/index.js +1 -0
- package/dist/stream/stream.d.ts +19 -0
- package/dist/stream/stream.js +48 -0
- package/dist/stream/types.d.ts +31 -0
- package/dist/stream/types.js +1 -0
- package/docs/README.md +41 -0
- package/docs/cascade.md +165 -0
- package/docs/chain.md +134 -0
- package/docs/loop.md +318 -0
- package/docs/orchestrate.md +128 -0
- package/docs/parallel.md +139 -0
- package/docs/refine.md +206 -0
- package/docs/route.md +212 -0
- package/package.json +19 -15
- package/bin/harness.mjs +0 -76
- package/dist/core/budget.d.ts +0 -46
- package/dist/core/budget.js +0 -72
- package/dist/core/cache.d.ts +0 -119
- package/dist/core/cache.js +0 -206
- package/dist/core/client.d.ts +0 -33
- package/dist/core/client.js +0 -98
- package/dist/core/errors.d.ts +0 -22
- package/dist/core/errors.js +0 -28
- package/dist/core/index.d.ts +0 -5
- package/dist/core/index.js +0 -5
- package/dist/core/pull.d.ts +0 -17
- package/dist/core/pull.js +0 -38
- package/dist/core/schema/serialize.d.ts +0 -12
- package/dist/core/schema/serialize.js +0 -42
- package/dist/core/schema/types.d.ts +0 -58
- package/dist/core/schema/validate.d.ts +0 -28
- package/dist/core/schema/validate.js +0 -98
- package/dist/harness.d.ts +0 -128
- package/dist/harness.js +0 -187
- package/dist/prompt/codegen.d.ts +0 -37
- package/dist/prompt/codegen.js +0 -97
- package/dist/prompt/render.d.ts +0 -35
- package/dist/prompt/render.js +0 -66
- package/dist/prompt/run.d.ts +0 -38
- package/dist/prompt/run.js +0 -96
- package/dist/prompt/types.d.ts +0 -156
- /package/dist/{core/schema → cascade}/types.js +0 -0
- /package/dist/{prompt → chain}/types.js +0 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# orchestrate
|
|
2
|
+
|
|
3
|
+
A lead decomposes, workers do the pieces, the lead puts it back together.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { orchestrate } from "@spendgraph/harness";
|
|
7
|
+
|
|
8
|
+
const result = await orchestrate(
|
|
9
|
+
{ brief },
|
|
10
|
+
{
|
|
11
|
+
plan: async (v) => {
|
|
12
|
+
const reply = await lead.call(planPrompt(v), { schema: SUBTASKS });
|
|
13
|
+
return { ...reply.data, model: reply.model, inputTokens: reply.inputTokens, outputTokens: reply.outputTokens };
|
|
14
|
+
},
|
|
15
|
+
work: (subtask, v) => worker.call(sectionPrompt(subtask, v)),
|
|
16
|
+
synthesise: (results, v) => lead.call(assemblePrompt(results, v)),
|
|
17
|
+
maxWorkers: 5,
|
|
18
|
+
concurrency: 4,
|
|
19
|
+
maxTokens: 80_000,
|
|
20
|
+
}
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
result.subtasks; // what the lead asked for, after the cap
|
|
24
|
+
result.dropped; // what the cap refused
|
|
25
|
+
result.stoppedBy; // completed · planned-nothing · workers-failed · tokens · failed
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The workflow for work whose **shape is not known until the input is read** —
|
|
29
|
+
which is the whole difference from `chain`, where the stages are named in
|
|
30
|
+
advance.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## The cap is the point
|
|
35
|
+
|
|
36
|
+
This is the workflow that can spend the most, because the **lead** decides how
|
|
37
|
+
many calls to make. Asked to decompose a large input it will cheerfully invent
|
|
38
|
+
twelve subtasks, and twelve model calls leave before anyone has decided that was
|
|
39
|
+
reasonable.
|
|
40
|
+
|
|
41
|
+
`maxWorkers` defaults to 5. Anything past it is **dropped and said**, never
|
|
42
|
+
dropped quietly:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
result.subtasks ["a", "b", "c"]
|
|
46
|
+
result.dropped ["d", "e", "f", "g"]
|
|
47
|
+
steps[0].error "4 subtask(s) past the worker cap were dropped: d, e, f, g"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
It keeps the head and drops the tail: a lead asked for the most important piece
|
|
51
|
+
first usually gives it first, and truncating from the front would throw away
|
|
52
|
+
exactly what it thought mattered.
|
|
53
|
+
|
|
54
|
+
A duplicated name is made unique — `summary`, `summary_1` — rather than
|
|
55
|
+
rejected. A lead naming two sections the same thing is a wording problem, not a
|
|
56
|
+
reason to lose the run.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## When the lead finds nothing to do
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
{ stoppedBy: "planned-nothing", output: "There is nothing here to summarise." }
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The workers never run and `synthesise` is never called. Synthesising over an
|
|
67
|
+
empty list asks a model to write an answer from nothing, and it will — which is
|
|
68
|
+
worse than saying there was nothing.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## When a worker falls over
|
|
73
|
+
|
|
74
|
+
`minSuccess` decides, and it defaults to **all**.
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
minSuccess: 2 // three sections planned, two is enough
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Sectioning a document usually wants all of them: a hole in the middle is worse
|
|
81
|
+
than no answer. Research-style fan-out often does not — two sources out of three
|
|
82
|
+
still supports a conclusion. Without a `minSuccess` the run fails and
|
|
83
|
+
`synthesise` is not called, so the lead is never asked to write around a gap it
|
|
84
|
+
does not know about.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## The ceiling is checked twice
|
|
89
|
+
|
|
90
|
+
Once after planning and once before synthesising, because both are places where
|
|
91
|
+
the run can be abandoned before spending more:
|
|
92
|
+
|
|
93
|
+
- the plan alone reaching the ceiling means the workers never start
|
|
94
|
+
- the workers reaching it means the lead is never asked to assemble
|
|
95
|
+
|
|
96
|
+
Tokens rather than money, because this package cannot price a call — that
|
|
97
|
+
happens on the server, after the fact.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## When not to use it
|
|
102
|
+
|
|
103
|
+
**The stages are known in advance.** That is `chain`. Paying a lead to decide
|
|
104
|
+
what you already know is a call for nothing.
|
|
105
|
+
|
|
106
|
+
**The pieces are independent and uniform.** That is `parallel` — you do not need
|
|
107
|
+
a model to tell you a document has three sections if you already know it does.
|
|
108
|
+
|
|
109
|
+
**One call would do.** Three calls minimum here: plan, work, synthesise. On a
|
|
110
|
+
small input the lead's overhead outweighs the parallelism.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## How the files fit
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
types.ts the contract
|
|
118
|
+
▲
|
|
119
|
+
plan.ts the lead's decomposition, and the cap
|
|
120
|
+
work.ts the workers, delegated to `parallel`
|
|
121
|
+
▲
|
|
122
|
+
orchestrate.ts plan · work · synthesise
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`work.ts` does not fan out again — `parallel` already caps the concurrency, turns
|
|
126
|
+
a thrown worker into a result so the others land, and numbers the steps by
|
|
127
|
+
declaration so two runs can be compared. This is the workflow that most benefits
|
|
128
|
+
from the others existing first.
|
package/docs/parallel.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# parallel
|
|
2
|
+
|
|
3
|
+
Every task at once, then one answer out of what came back. Two shapes use it:
|
|
4
|
+
**sectioning** buys wall-clock, **voting** buys confidence.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Sectioning — split the work
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { parallel } from "@spendgraph/harness";
|
|
12
|
+
|
|
13
|
+
const result = await parallel(
|
|
14
|
+
{ brief },
|
|
15
|
+
{
|
|
16
|
+
tasks: [
|
|
17
|
+
{ name: "intro", run: (v) => llm.call(introPrompt(v)) },
|
|
18
|
+
{ name: "body", run: (v) => llm.call(bodyPrompt(v)) },
|
|
19
|
+
{ name: "close", run: (v) => llm.call(closePrompt(v)) },
|
|
20
|
+
],
|
|
21
|
+
merge: (results) => results.map((r) => r.value.output).join("\n\n"),
|
|
22
|
+
concurrency: 4,
|
|
23
|
+
}
|
|
24
|
+
);
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Three calls take as long as the slowest, not the sum. The trade against `chain`
|
|
28
|
+
is exact: chain buys accuracy with latency, parallel buys latency with tokens —
|
|
29
|
+
you pay for the same context three times over.
|
|
30
|
+
|
|
31
|
+
**A missing section fails the run by default.** A hole in the middle of an
|
|
32
|
+
answer is worse than no answer, and `merge` is never called with one. The tasks
|
|
33
|
+
that did land are still recorded, so the failure is legible.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Voting — ask N times, take the consensus
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { majority, parallel, repeat } from "@spendgraph/harness";
|
|
41
|
+
|
|
42
|
+
const result = await parallel(
|
|
43
|
+
{ question },
|
|
44
|
+
{
|
|
45
|
+
tasks: repeat("guess", 5, (v) => llm.call(prompt(v))),
|
|
46
|
+
merge: (results) => majority(results)?.value,
|
|
47
|
+
minSuccess: 3,
|
|
48
|
+
}
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
result.merged; // the answer most of them gave
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`repeat` **rounds an even count up.** Four voters tie, and a tie has no answer
|
|
55
|
+
that is not arbitrary.
|
|
56
|
+
|
|
57
|
+
`minSuccess: 3` is the point of running five: if one call is overloaded the other
|
|
58
|
+
four still land and still answer. Ties go to the first declared, so the same
|
|
59
|
+
inputs give the same result on every run.
|
|
60
|
+
|
|
61
|
+
**When voting is worth the tokens:** the model is right most of the time and
|
|
62
|
+
wrong *differently* each time — the wrong answers scatter, the right one repeats.
|
|
63
|
+
|
|
64
|
+
**When it buys nothing:** the model is confidently wrong the same way every
|
|
65
|
+
time. Five identical wrong answers vote unanimously for the wrong one, and you
|
|
66
|
+
have paid five times to be misled with more conviction. That is the failure it is
|
|
67
|
+
easiest to mistake this pattern for a fix for.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Why it is not a graph
|
|
72
|
+
|
|
73
|
+
`route` and `chain` compile down to `@spendgraph/graph`. This one cannot:
|
|
74
|
+
`graph` walks one node at a time and picks a single edge, so a fan-out is not a
|
|
75
|
+
shape it can express. The concurrency comes from `mapLimit`, which already exists
|
|
76
|
+
in `@spendgraph/prompt/internals`.
|
|
77
|
+
|
|
78
|
+
Same reasoning as `refine`, which is a loop rather than a DAG walk.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Steps are numbered by declaration, not by arrival
|
|
83
|
+
|
|
84
|
+
A fan-out that numbered steps by completion order would record a different run
|
|
85
|
+
every time for identical work, and two runs could not be compared. The slow task
|
|
86
|
+
declared first is still step 0.
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
steps: slow (index 0) ← declared first, landed last
|
|
90
|
+
quick (index 1)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Failure is a result, not an exception
|
|
96
|
+
|
|
97
|
+
A task that throws comes back as a failed `TaskResult`. The whole point of
|
|
98
|
+
running five is that the other four still land, so one bad call cannot take the
|
|
99
|
+
run down with it.
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
result.succeeded; // ["a", "b"]
|
|
103
|
+
result.failed; // ["c"]
|
|
104
|
+
result.results; // every one, in declaration order
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`minSuccess` decides whether that is fatal. Sectioning defaults to all —
|
|
108
|
+
anything less is a hole. Voting should set a majority.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## When not to use it
|
|
113
|
+
|
|
114
|
+
**The tasks need each other's output.** That is `chain`; a fan-out cannot pass
|
|
115
|
+
anything between branches.
|
|
116
|
+
|
|
117
|
+
**One call would do.** Voting on a task the model gets right every time is five
|
|
118
|
+
times the cost for the same answer.
|
|
119
|
+
|
|
120
|
+
**The context is enormous.** Sectioning resends it per task. Three sections of a
|
|
121
|
+
50k-token brief is 150k tokens of input, and the latency you bought may cost more
|
|
122
|
+
than it saved.
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## How the files fit
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
types.ts the contract
|
|
130
|
+
▲
|
|
131
|
+
task.ts run one, record one
|
|
132
|
+
merge.ts repeat · majority
|
|
133
|
+
▲
|
|
134
|
+
parallel.ts fan out · gather · merge
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`task.ts` and `merge.ts` are siblings. `majority` and `repeat` are pure and
|
|
138
|
+
tested on their own — the tie-break and the even-count rounding are the parts
|
|
139
|
+
worth pinning.
|
package/docs/refine.md
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# refine
|
|
2
|
+
|
|
3
|
+
Draft, judge, revise. Stop when it is good enough, or when the budget is out.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { refine } from "@spendgraph/harness";
|
|
7
|
+
|
|
8
|
+
const result = await refine({
|
|
9
|
+
rounds: 3,
|
|
10
|
+
maxTokens: 20_000,
|
|
11
|
+
attempt: (feedback) =>
|
|
12
|
+
llm.call([{ role: "user", content: feedback ? `${task}\n\nFix this: ${feedback}` : task }]),
|
|
13
|
+
judge: (attempt) =>
|
|
14
|
+
attempt.output.length <= 160
|
|
15
|
+
? { accepted: true }
|
|
16
|
+
: { accepted: false, feedback: `That is ${attempt.output.length} characters. The limit is 160.` },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
result.output; // the winning draft
|
|
20
|
+
result.accepted; // false when the rounds or the tokens ran out
|
|
21
|
+
result.stoppedBy; // accepted · rounds · tokens · failed
|
|
22
|
+
result.steps; // every attempt and every judgement
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
One `attempt` callback rather than separate generate and revise, because in
|
|
26
|
+
practice they are the same prompt with one extra paragraph — `feedback` is null
|
|
27
|
+
on the first round and the judge's note after.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Example: an invoice that has to add up
|
|
32
|
+
|
|
33
|
+
The judge is plain code, which is the case worth reaching for. It costs nothing,
|
|
34
|
+
it is deterministic, and it cannot be talked into agreeing.
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
const result = await refine({
|
|
38
|
+
rounds: 3,
|
|
39
|
+
|
|
40
|
+
attempt: (feedback) =>
|
|
41
|
+
llm.call([{ role: "user", content: `${email}${feedback ?? ""}` }], { schema: INVOICE }),
|
|
42
|
+
|
|
43
|
+
judge: (attempt) => {
|
|
44
|
+
const invoice = JSON.parse(attempt.output);
|
|
45
|
+
const summed = invoice.lines.reduce((n, l) => n + l.amount, 0);
|
|
46
|
+
|
|
47
|
+
return summed === invoice.total
|
|
48
|
+
? { accepted: true }
|
|
49
|
+
: {
|
|
50
|
+
accepted: false,
|
|
51
|
+
feedback: `\n\nThe lines add up to ${summed} but the total says ${invoice.total}. A line is missing.`,
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
What happens:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
round 0 → { lines: [1200, 50], total: 1550 } ✗ lines sum to 1250
|
|
61
|
+
feedback: "The lines add up to 1250 but the total says 1550…"
|
|
62
|
+
round 1 → { lines: [1200, 50, 300], total: 1550 } ✓ accepted
|
|
63
|
+
|
|
64
|
+
accepted: true rounds: 2 steps: 4
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The model dropped the shipping line. Told exactly what was inconsistent, it
|
|
68
|
+
found it. A **retry** would have run the same prompt and often made the same
|
|
69
|
+
mistake; the feedback is the difference.
|
|
70
|
+
|
|
71
|
+
A model judge asked "is this extracted correctly?" would have said yes, because
|
|
72
|
+
the JSON looks fine. Arithmetic does not have that problem.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## More judges that cost nothing
|
|
77
|
+
|
|
78
|
+
**SQL that has to run.** `EXPLAIN` is free and the database's own error is the
|
|
79
|
+
best feedback there is.
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
judge: async (attempt) => {
|
|
83
|
+
try {
|
|
84
|
+
await db.prepare(`EXPLAIN ${attempt.output}`).run();
|
|
85
|
+
return { accepted: true };
|
|
86
|
+
} catch (err) {
|
|
87
|
+
return { accepted: false, feedback: String(err.message) };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Hand the error back verbatim. Rewriting it into something friendlier throws away
|
|
93
|
+
the column name that tells the model what to fix.
|
|
94
|
+
|
|
95
|
+
**Code that has to pass its tests.** The strongest judge there is, because it is
|
|
96
|
+
the same bar a person would apply. Slow, so keep `rounds` low — three failed
|
|
97
|
+
attempts at a minute each is three minutes before you learn the prompt is wrong.
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
judge: async (attempt) => {
|
|
101
|
+
const { failed, output } = await runTests(attempt.output);
|
|
102
|
+
return failed === 0 ? { accepted: true } : { accepted: false, feedback: output };
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**Placeholders that must survive a translation.** Models translate `{count}`
|
|
107
|
+
about one time in fifty, and it fails at runtime for whoever speaks that
|
|
108
|
+
language.
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
judge: (attempt) => {
|
|
112
|
+
const wanted = source.match(/\{[a-z_]+\}/g) ?? [];
|
|
113
|
+
const missing = wanted.filter((token) => !attempt.output.includes(token));
|
|
114
|
+
return missing.length === 0
|
|
115
|
+
? { accepted: true }
|
|
116
|
+
: { accepted: false, feedback: `Keep these exactly as they are: ${missing.join(", ")}.` };
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## When the judge has to be a model
|
|
123
|
+
|
|
124
|
+
Only when no code can decide — tone, policy, "would a person find this rude".
|
|
125
|
+
It is a second model call every round, so the loop is now at least twice the
|
|
126
|
+
price of the answer.
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
judge: async (attempt) => {
|
|
130
|
+
const verdict = await judgeLlm.call([{ role: "user", content: `${RUBRIC}\n\n${attempt.output}` }], {
|
|
131
|
+
schema: {
|
|
132
|
+
type: "object",
|
|
133
|
+
properties: { score: { type: "number" }, fix: { type: "string" } },
|
|
134
|
+
required: ["score"],
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const { score, fix } = verdict.data;
|
|
139
|
+
return score >= 8 ? { accepted: true, score } : { accepted: false, score, feedback: fix };
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Always return `score`, even on acceptance. Without it, a run that never passes
|
|
144
|
+
hands back the **last** attempt rather than the best one.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## When not to use it
|
|
149
|
+
|
|
150
|
+
**You cannot state the bar.** If the judge accepts everything you have tripled
|
|
151
|
+
the cost to learn nothing. Write the check first; if you cannot, the pattern does
|
|
152
|
+
not apply.
|
|
153
|
+
|
|
154
|
+
**Round one already passes almost always.** At 99% you are paying a judge on
|
|
155
|
+
every request to catch the hundredth. Retry-on-failure is cheaper than a loop by
|
|
156
|
+
default.
|
|
157
|
+
|
|
158
|
+
**The failure is not fixable by trying again.** A missing document, a permission
|
|
159
|
+
the agent does not have, a question with no answer — feedback cannot help, and
|
|
160
|
+
three rounds is three ways of saying so. Escalate instead.
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## Reading the result
|
|
165
|
+
|
|
166
|
+
`stoppedBy` is the field to watch in production.
|
|
167
|
+
|
|
168
|
+
| | What it means |
|
|
169
|
+
| --- | --- |
|
|
170
|
+
| `accepted` | working as intended |
|
|
171
|
+
| `rounds` | the judge is too strict, or the prompt cannot get there |
|
|
172
|
+
| `tokens` | the ceiling is too low, or the attempts are too big |
|
|
173
|
+
| `failed` | the provider failed; not a quality problem |
|
|
174
|
+
|
|
175
|
+
A rising share of `rounds` is the signal to fix the prompt rather than raise the
|
|
176
|
+
limit. `history` carries every attempt with its verdict and token count, so "does
|
|
177
|
+
refining pay on this task?" is a query — round-one acceptance rate against the
|
|
178
|
+
cost of the extra calls — rather than a hunch.
|
|
179
|
+
|
|
180
|
+
**A failed attempt ends the run** rather than being judged. Asking a judge what
|
|
181
|
+
it thinks of an error spends a call to be told it is bad.
|
|
182
|
+
|
|
183
|
+
**The ceiling is in tokens, not money**, because this package cannot price a
|
|
184
|
+
call — that happens on the server, after the fact. It is checked *before* each
|
|
185
|
+
round, so it is never crossed rather than merely noticed once it has been.
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## How the files fit
|
|
190
|
+
|
|
191
|
+
```
|
|
192
|
+
types.ts the contract
|
|
193
|
+
▲
|
|
194
|
+
attempt.ts what a draft cost, and how it is recorded
|
|
195
|
+
judge.ts how a verdict is recorded, and which draft wins
|
|
196
|
+
▲
|
|
197
|
+
refine.ts the loop: draft · judge · stop
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`attempt.ts` and `judge.ts` are siblings — neither knows about the other, and
|
|
201
|
+
both import only the contract. The loop is the only file that knows the order
|
|
202
|
+
they run in.
|
|
203
|
+
|
|
204
|
+
`best` lives with the judge rather than the loop because the choice is the
|
|
205
|
+
judge's: it is made on the verdicts, and a judge that never scores gets the
|
|
206
|
+
fallback to the last attempt.
|
package/docs/route.md
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# route
|
|
2
|
+
|
|
3
|
+
Classify once, then hand the work to the branch built for it.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { route } from "@spendgraph/harness";
|
|
7
|
+
|
|
8
|
+
const result = await route(
|
|
9
|
+
{ subject, body },
|
|
10
|
+
{
|
|
11
|
+
classify: ({ subject }) => (/refund|invoice/i.test(subject) ? "billing" : "technical"),
|
|
12
|
+
routes: {
|
|
13
|
+
billing: (input) => haiku.bind(billingPrompt).invoke(input),
|
|
14
|
+
technical: (input) => opus.bind(docsPrompt).invoke(input),
|
|
15
|
+
},
|
|
16
|
+
fallback: (input) => opus.bind(generalPrompt).invoke(input),
|
|
17
|
+
}
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
result.route; // "billing", or null when the fallback ran
|
|
21
|
+
result.classified; // what the classifier said, used or not
|
|
22
|
+
result.inputTokens; // the classifier's tokens plus the branch's
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Example: support triage, routed for cost
|
|
28
|
+
|
|
29
|
+
Most tickets are simple. Only a few need the expensive model.
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
const result = await route(
|
|
33
|
+
{ subject, body },
|
|
34
|
+
{
|
|
35
|
+
classify: ({ subject, body }) => {
|
|
36
|
+
const text = `${subject} ${body}`.toLowerCase();
|
|
37
|
+
if (/refund|charged|invoice/.test(text)) return "billing";
|
|
38
|
+
if (/error|crash|not working/.test(text)) return "technical";
|
|
39
|
+
return "general";
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
routes: {
|
|
43
|
+
billing: (input) => haiku.bind(billingPrompt).invoke(input),
|
|
44
|
+
technical: (input) => opus.bind(docsPrompt).invoke(input),
|
|
45
|
+
general: () => ({ output: "Thanks — a human will reply within a day.", model: "none" }),
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
fallback: (input) => opus.bind(generalPrompt).invoke(input),
|
|
49
|
+
}
|
|
50
|
+
);
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
result.route "billing"
|
|
55
|
+
result.output "Your 12 March charge was a duplicate; it's been refunded."
|
|
56
|
+
result.inputTokens 340 classifier (0) + branch
|
|
57
|
+
result.steps [ classify, billing ]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Three things this is doing:
|
|
61
|
+
|
|
62
|
+
The **classifier is a regex** — free, instant, and it cannot invent a category.
|
|
63
|
+
That is the common case.
|
|
64
|
+
|
|
65
|
+
The **`general` branch calls no model at all.** It returns a canned line at zero
|
|
66
|
+
cost. If a third of your tickets land there, that third is now free, which no
|
|
67
|
+
amount of prompt tuning gets you.
|
|
68
|
+
|
|
69
|
+
**`billing` uses the small model, `technical` the big one.** That is the whole
|
|
70
|
+
cost argument: you stop paying opus rates for "where is my invoice".
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Example: a classifier that is a model
|
|
75
|
+
|
|
76
|
+
When no regex will ever be right — sentiment, intent, "is this actually a
|
|
77
|
+
complaint" — use a model, and constrain it with an enum so it can only answer
|
|
78
|
+
with a route you have.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
classify: async ({ subject, body }) => {
|
|
82
|
+
const reply = await haiku.call(
|
|
83
|
+
[{ role: "user", content: `Classify this ticket.\n\n${subject}\n${body}` }],
|
|
84
|
+
{
|
|
85
|
+
schema: {
|
|
86
|
+
type: "object",
|
|
87
|
+
properties: {
|
|
88
|
+
route: { type: "string", enum: ["billing", "technical", "general"] },
|
|
89
|
+
confidence: { type: "number" },
|
|
90
|
+
reason: { type: "string" },
|
|
91
|
+
},
|
|
92
|
+
required: ["route", "confidence"],
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
...reply.data,
|
|
99
|
+
model: reply.model,
|
|
100
|
+
inputTokens: reply.inputTokens,
|
|
101
|
+
outputTokens: reply.outputTokens,
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
minConfidence: 0.7,
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The `enum` is doing real work. A free-text classifier eventually answers
|
|
108
|
+
`"Billing"` or `"billing question"` or a whole sentence, and every one of those
|
|
109
|
+
falls through to the fallback silently. Constrained, it can only say one of
|
|
110
|
+
three things — and on Anthropic that is a forced tool call, which is a guarantee
|
|
111
|
+
rather than a request.
|
|
112
|
+
|
|
113
|
+
Returning `model` and the token counts is what keeps the cost honest: a
|
|
114
|
+
classifier running on every request is not free, and without them
|
|
115
|
+
`result.inputTokens` reports only the branch.
|
|
116
|
+
|
|
117
|
+
**Self-reported `confidence` is weak.** Models are overconfident and the number
|
|
118
|
+
is not calibrated. `minConfidence` is a blunt safety net for the genuinely
|
|
119
|
+
ambiguous cases, not a dial to tune to three decimal places.
|
|
120
|
+
|
|
121
|
+
**Check the arithmetic before reaching for it.** A haiku classify might be 300
|
|
122
|
+
in, 20 out. Routing 80% of traffic from opus to haiku pays for itself many times
|
|
123
|
+
over. If every branch is a cheap model anyway, you have added a call to every
|
|
124
|
+
request to save nothing.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## What else it is for
|
|
129
|
+
|
|
130
|
+
**Capability.** A refund branch gets a bus with `escalate` pinned and a payments
|
|
131
|
+
tool; a docs branch gets retrieval and no write access. You cannot express that
|
|
132
|
+
in one call, because the tools are chosen before you know what was asked.
|
|
133
|
+
|
|
134
|
+
**Quality.** One prompt covering billing, technical and legal has to carry
|
|
135
|
+
instructions for all three, and every instruction is a chance to apply the wrong
|
|
136
|
+
one. Three prompts each do one thing.
|
|
137
|
+
|
|
138
|
+
**Safety.** Classify first; if it is out of scope or harmful, route to a branch
|
|
139
|
+
that declines and hands off. More reliable than hoping one prompt's guardrails
|
|
140
|
+
hold, because the decision is a separate call you can test.
|
|
141
|
+
|
|
142
|
+
Beyond support, the same shape: document type → invoice, contract or receipt,
|
|
143
|
+
each with its own extraction schema. Language detection → a localised prompt.
|
|
144
|
+
Complexity → a template for the trivial, an agent loop for the rest.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## When not to use it
|
|
149
|
+
|
|
150
|
+
**The branches would share 90% of their prompt.** You have split one thing into
|
|
151
|
+
three that now drift apart.
|
|
152
|
+
|
|
153
|
+
**The classification is genuinely hard.** You have moved the failure earlier
|
|
154
|
+
without removing it — a confidently wrong route sends the question to a
|
|
155
|
+
specialist with no idea what to do with it. `minConfidence` and a required
|
|
156
|
+
`fallback` exist for exactly this, but they are a net, not a fix.
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## Reading the result
|
|
161
|
+
|
|
162
|
+
**Watch `route === null`.** A rising share of fallbacks means either the
|
|
163
|
+
classifier is drifting or there is a kind of request you never built a branch
|
|
164
|
+
for — and since `classified` records what the classifier *said* separately from
|
|
165
|
+
what ran, you can tell which.
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
classifier says "legal", no such branch → classified "legal" route null
|
|
169
|
+
classifier says "billing" at 0.4, bar 0.7 → classified "billing" route null
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Same `route`, different `classified`: the first is a **missing branch**, the
|
|
173
|
+
second a **miscalibrated** one, and they want opposite fixes.
|
|
174
|
+
|
|
175
|
+
**`fallback` is required.** A router that can fail to route fails in production
|
|
176
|
+
at 3am, and "unknown" is a class every classifier eventually returns.
|
|
177
|
+
|
|
178
|
+
`route` and `refine` compose: a branch can be a refine loop, and its rounds
|
|
179
|
+
appear as `billing.attempt_0`, `billing.judge_0` rather than being flattened
|
|
180
|
+
into one step.
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## How the files fit
|
|
185
|
+
|
|
186
|
+
```
|
|
187
|
+
types.ts the contract
|
|
188
|
+
▲
|
|
189
|
+
classify.ts what the classifier said, and what runs because of it
|
|
190
|
+
▲
|
|
191
|
+
dispatch.ts where the work goes: a node per branch, and the edges to them
|
|
192
|
+
▲
|
|
193
|
+
route.ts compile · run · shape the result
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Each file imports only downwards, which is why `decide` can be tested on its own
|
|
197
|
+
with no graph in sight.
|
|
198
|
+
|
|
199
|
+
It compiles to a graph rather than looping here. `@spendgraph/graph` already
|
|
200
|
+
validates that every branch is reachable and that no default sits in front of
|
|
201
|
+
one, already records a step per node with its tokens, and already returns the
|
|
202
|
+
rollout shape. A second execution engine in this package would be a second thing
|
|
203
|
+
to keep right.
|
|
204
|
+
|
|
205
|
+
**The decision travels in the graph, not in a closure.** The classifier
|
|
206
|
+
*returns* its decision, so it lands in `outputs.classify` like any node's return
|
|
207
|
+
and the edges read it from the context they are already handed. A finished run
|
|
208
|
+
can be read apart from `outputs.classify` alone.
|
|
209
|
+
|
|
210
|
+
**The edge order matters.** Conditional edges first, the unconditional fallback
|
|
211
|
+
last — `graph` refuses the other order, and is right to: an unconditional edge
|
|
212
|
+
ahead of a conditional one means the branch behind it can never be reached.
|