@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.
- package/README.md +87 -160
- package/dist/cascade/cascade.d.ts +13 -0
- package/dist/cascade/cascade.js +91 -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 +53 -0
- package/dist/cascade/types.d.ts +98 -0
- package/dist/chain/chain.d.ts +9 -0
- package/dist/chain/chain.js +58 -0
- package/dist/chain/gate.d.ts +16 -0
- package/dist/chain/gate.js +33 -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 +14 -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 +43 -0
- package/dist/loop/compact.d.ts +49 -0
- package/dist/loop/compact.js +59 -0
- package/dist/loop/hooks.d.ts +43 -0
- package/dist/loop/hooks.js +41 -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 +120 -0
- package/dist/loop/turn.d.ts +6 -0
- package/dist/loop/turn.js +17 -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 +68 -0
- package/dist/orchestrate/plan.d.ts +18 -0
- package/dist/orchestrate/plan.js +38 -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 +35 -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 +52 -0
- package/dist/parallel/parallel.d.ts +12 -0
- package/dist/parallel/parallel.js +66 -0
- package/dist/parallel/task.d.ts +13 -0
- package/dist/parallel/task.js +45 -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 +38 -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 +25 -0
- package/dist/refine/refine.d.ts +10 -0
- package/dist/refine/refine.js +67 -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 +57 -0
- package/dist/route/dispatch.d.ts +12 -0
- package/dist/route/dispatch.js +33 -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 +31 -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 +16 -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 +65 -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 +18 -14
- 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
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.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spendgraph/harness",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Workflows: the shapes an LLM app takes, built on prompts, tools and graphs.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -10,10 +10,11 @@
|
|
|
10
10
|
},
|
|
11
11
|
"keywords": [
|
|
12
12
|
"llm",
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
13
|
+
"agent",
|
|
14
|
+
"workflow",
|
|
15
|
+
"orchestration",
|
|
16
|
+
"routing",
|
|
17
|
+
"evaluator"
|
|
17
18
|
],
|
|
18
19
|
"type": "module",
|
|
19
20
|
"main": "./dist/index.js",
|
|
@@ -23,22 +24,22 @@
|
|
|
23
24
|
"types": "./dist/index.d.ts",
|
|
24
25
|
"import": "./dist/index.js"
|
|
25
26
|
},
|
|
26
|
-
"./core": {
|
|
27
|
-
"types": "./dist/core/index.d.ts",
|
|
28
|
-
"import": "./dist/core/index.js"
|
|
29
|
-
},
|
|
30
27
|
"./package.json": "./package.json"
|
|
31
28
|
},
|
|
32
29
|
"files": [
|
|
33
|
-
"bin",
|
|
34
30
|
"dist",
|
|
31
|
+
"docs",
|
|
35
32
|
"README.md"
|
|
36
33
|
],
|
|
37
34
|
"scripts": {
|
|
38
35
|
"build": "tsc -p tsconfig.json",
|
|
39
|
-
"
|
|
36
|
+
"prebuild": "npm run build --workspace @spendgraph/graph --workspace @spendgraph/tools --workspace @spendgraph/prompt",
|
|
37
|
+
"test": "vitest run",
|
|
38
|
+
"pretest": "npm run build --workspace @spendgraph/graph --workspace @spendgraph/prompt --workspace @spendgraph/tools --workspace @spendgraph/llms",
|
|
39
|
+
"examples": "npm run build && node examples/run-all.mjs"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
|
+
"@spendgraph/llms": "^0.2.0",
|
|
42
43
|
"typescript": "^5"
|
|
43
44
|
},
|
|
44
45
|
"engines": {
|
|
@@ -47,7 +48,10 @@
|
|
|
47
48
|
"publishConfig": {
|
|
48
49
|
"access": "public"
|
|
49
50
|
},
|
|
50
|
-
"
|
|
51
|
-
"
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@spendgraph/graph": "^0.2.0",
|
|
53
|
+
"@spendgraph/prompt": "^0.2.0",
|
|
54
|
+
"@spendgraph/sdk": "^0.2.0",
|
|
55
|
+
"@spendgraph/tools": "^0.2.0"
|
|
52
56
|
}
|
|
53
57
|
}
|
package/bin/harness.mjs
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* harness codegen — writes a .d.ts describing every stored prompt's fields.
|
|
4
|
-
*
|
|
5
|
-
* SPENDGRAPH_API_KEY=sg_… SPENDGRAPH_URL=https://costs.example.com \
|
|
6
|
-
* npx harness codegen --out src/harness.generated.d.ts
|
|
7
|
-
*
|
|
8
|
-
* Reads through the paginated list endpoint, so a project with hundreds of
|
|
9
|
-
* prompts is many small requests rather than one enormous response.
|
|
10
|
-
*/
|
|
11
|
-
import { writeFile, mkdir } from "node:fs/promises";
|
|
12
|
-
import { dirname, resolve } from "node:path";
|
|
13
|
-
import { emitPromptTypes } from "../dist/index.js";
|
|
14
|
-
|
|
15
|
-
const args = process.argv.slice(2);
|
|
16
|
-
const command = args[0];
|
|
17
|
-
|
|
18
|
-
function flag(name, fallback) {
|
|
19
|
-
const i = args.indexOf(`--${name}`);
|
|
20
|
-
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function fail(message) {
|
|
24
|
-
console.error(`harness: ${message}`);
|
|
25
|
-
process.exit(1);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
if (command !== "codegen") {
|
|
29
|
-
console.log("usage: harness codegen [--out <file>] [--project <id>] [--module <specifier>]");
|
|
30
|
-
process.exit(command ? 1 : 0);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const apiKey = process.env.SPENDGRAPH_API_KEY;
|
|
34
|
-
const baseUrl = (process.env.SPENDGRAPH_URL ?? flag("url", "")).replace(/\/+$/, "");
|
|
35
|
-
if (!apiKey) fail("set SPENDGRAPH_API_KEY");
|
|
36
|
-
if (!baseUrl) fail("set SPENDGRAPH_URL, or pass --url");
|
|
37
|
-
|
|
38
|
-
const out = resolve(flag("out", "src/harness.generated.d.ts"));
|
|
39
|
-
const project = flag("project", "");
|
|
40
|
-
const moduleName = flag("module", "@spendgraph/harness");
|
|
41
|
-
|
|
42
|
-
const prompts = [];
|
|
43
|
-
let cursor = null;
|
|
44
|
-
|
|
45
|
-
// Bounded: a server that kept handing back a cursor must not spin forever.
|
|
46
|
-
for (let page = 0; page < 200; page++) {
|
|
47
|
-
// Archived prompts included on purpose. The dashboard hides them, but a
|
|
48
|
-
// generated type disappearing because somebody tidied a list would break the
|
|
49
|
-
// build of code that still pulls the prompt — archiving does not stop it
|
|
50
|
-
// serving, so it must not stop it type-checking either.
|
|
51
|
-
const params = new URLSearchParams({ limit: "200", archived: "all" });
|
|
52
|
-
if (project) params.set("project", project);
|
|
53
|
-
if (cursor) params.set("cursor", cursor);
|
|
54
|
-
|
|
55
|
-
const res = await fetch(`${baseUrl}/api/v1/prompts?${params}`, {
|
|
56
|
-
headers: { "x-api-key": apiKey },
|
|
57
|
-
});
|
|
58
|
-
if (!res.ok) {
|
|
59
|
-
const body = await res.text().catch(() => "");
|
|
60
|
-
fail(`${res.status} listing prompts — ${body.slice(0, 200)}`);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const body = await res.json();
|
|
64
|
-
prompts.push(...(body.prompts ?? []));
|
|
65
|
-
cursor = body.nextCursor;
|
|
66
|
-
if (!cursor) break;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
await mkdir(dirname(out), { recursive: true });
|
|
70
|
-
await writeFile(out, emitPromptTypes(prompts, { module: moduleName }));
|
|
71
|
-
|
|
72
|
-
const named = prompts.filter((p) => (p.fieldSpec ?? []).some((f) => f.required)).length;
|
|
73
|
-
console.log(
|
|
74
|
-
`harness: wrote ${prompts.length} prompt${prompts.length === 1 ? "" : "s"} to ${out}` +
|
|
75
|
-
(named ? ` (${named} with required fields)` : "")
|
|
76
|
-
);
|
package/dist/core/budget.d.ts
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Thrown when a client has spent its ceiling.
|
|
3
|
-
*
|
|
4
|
-
* Deliberately not a `HarnessError`: nothing was refused by the server, so a
|
|
5
|
-
* caller inspecting `status` or `retryable` would be told a story about HTTP
|
|
6
|
-
* that never happened.
|
|
7
|
-
*/
|
|
8
|
-
export declare class BudgetExceededError extends Error {
|
|
9
|
-
readonly spentMicros: number;
|
|
10
|
-
readonly limitMicros: number;
|
|
11
|
-
constructor(spentMicros: number, limitMicros: number);
|
|
12
|
-
}
|
|
13
|
-
/**
|
|
14
|
-
* What this client has spent, and whether it may spend more.
|
|
15
|
-
*
|
|
16
|
-
* A loop that samples k rollouts across a dataset is the shape of an expensive
|
|
17
|
-
* accident: one bad `k`, one dataset larger than expected, and the bill arrives
|
|
18
|
-
* before anyone notices. The guard is a counter and a comparison, and a cost
|
|
19
|
-
* meter that shipped without one would be embarrassing.
|
|
20
|
-
*
|
|
21
|
-
* Governs spending this client *initiates* — `run`, `sample`, `runAll`. It does
|
|
22
|
-
* not gate `report`, which records money the caller already spent elsewhere;
|
|
23
|
-
* counting that would make the ceiling mean two different things at once.
|
|
24
|
-
*/
|
|
25
|
-
export declare class Budget {
|
|
26
|
-
private used;
|
|
27
|
-
private readonly limit;
|
|
28
|
-
/**
|
|
29
|
-
* `undefined` means no ceiling and warns once — an unbounded client in a
|
|
30
|
-
* scheduled script is how a surprise bill happens. `null` means the same
|
|
31
|
-
* thing deliberately, and says nothing.
|
|
32
|
-
*/
|
|
33
|
-
constructor(maxCostMicros?: number | null);
|
|
34
|
-
spent(): number;
|
|
35
|
-
remaining(): number;
|
|
36
|
-
add(costMicros: number): void;
|
|
37
|
-
/**
|
|
38
|
-
* Throws if the ceiling is already reached.
|
|
39
|
-
*
|
|
40
|
-
* Checked before a request rather than after: stopping once the bill has been
|
|
41
|
-
* incurred is a report, not a limit.
|
|
42
|
-
*/
|
|
43
|
-
assertAffordable(): void;
|
|
44
|
-
}
|
|
45
|
-
/** Test seam: lets the once-only warning be exercised more than once. */
|
|
46
|
-
export declare function resetBudgetWarning(): void;
|
package/dist/core/budget.js
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Thrown when a client has spent its ceiling.
|
|
3
|
-
*
|
|
4
|
-
* Deliberately not a `HarnessError`: nothing was refused by the server, so a
|
|
5
|
-
* caller inspecting `status` or `retryable` would be told a story about HTTP
|
|
6
|
-
* that never happened.
|
|
7
|
-
*/
|
|
8
|
-
export class BudgetExceededError extends Error {
|
|
9
|
-
spentMicros;
|
|
10
|
-
limitMicros;
|
|
11
|
-
constructor(spentMicros, limitMicros) {
|
|
12
|
-
super(`Cost ceiling reached: spent $${(spentMicros / 1e6).toFixed(4)} of ` +
|
|
13
|
-
`$${(limitMicros / 1e6).toFixed(4)}. Raise maxCostMicros or start a new client.`);
|
|
14
|
-
this.name = "BudgetExceededError";
|
|
15
|
-
this.spentMicros = spentMicros;
|
|
16
|
-
this.limitMicros = limitMicros;
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
let warned = false;
|
|
20
|
-
/**
|
|
21
|
-
* What this client has spent, and whether it may spend more.
|
|
22
|
-
*
|
|
23
|
-
* A loop that samples k rollouts across a dataset is the shape of an expensive
|
|
24
|
-
* accident: one bad `k`, one dataset larger than expected, and the bill arrives
|
|
25
|
-
* before anyone notices. The guard is a counter and a comparison, and a cost
|
|
26
|
-
* meter that shipped without one would be embarrassing.
|
|
27
|
-
*
|
|
28
|
-
* Governs spending this client *initiates* — `run`, `sample`, `runAll`. It does
|
|
29
|
-
* not gate `report`, which records money the caller already spent elsewhere;
|
|
30
|
-
* counting that would make the ceiling mean two different things at once.
|
|
31
|
-
*/
|
|
32
|
-
export class Budget {
|
|
33
|
-
used = 0;
|
|
34
|
-
limit;
|
|
35
|
-
/**
|
|
36
|
-
* `undefined` means no ceiling and warns once — an unbounded client in a
|
|
37
|
-
* scheduled script is how a surprise bill happens. `null` means the same
|
|
38
|
-
* thing deliberately, and says nothing.
|
|
39
|
-
*/
|
|
40
|
-
constructor(maxCostMicros) {
|
|
41
|
-
this.limit = maxCostMicros ?? Infinity;
|
|
42
|
-
if (maxCostMicros === undefined && !warned) {
|
|
43
|
-
warned = true;
|
|
44
|
-
console.warn("[harness] No maxCostMicros set — this client can spend without limit. " +
|
|
45
|
-
"Pass maxCostMicros, or null to say you meant it.");
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
spent() {
|
|
49
|
-
return this.used;
|
|
50
|
-
}
|
|
51
|
-
remaining() {
|
|
52
|
-
return this.limit === Infinity ? Infinity : Math.max(0, this.limit - this.used);
|
|
53
|
-
}
|
|
54
|
-
add(costMicros) {
|
|
55
|
-
if (Number.isFinite(costMicros) && costMicros > 0)
|
|
56
|
-
this.used += costMicros;
|
|
57
|
-
}
|
|
58
|
-
/**
|
|
59
|
-
* Throws if the ceiling is already reached.
|
|
60
|
-
*
|
|
61
|
-
* Checked before a request rather than after: stopping once the bill has been
|
|
62
|
-
* incurred is a report, not a limit.
|
|
63
|
-
*/
|
|
64
|
-
assertAffordable() {
|
|
65
|
-
if (this.used >= this.limit)
|
|
66
|
-
throw new BudgetExceededError(this.used, this.limit);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
/** Test seam: lets the once-only warning be exercised more than once. */
|
|
70
|
-
export function resetBudgetWarning() {
|
|
71
|
-
warned = false;
|
|
72
|
-
}
|