@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/README.md
CHANGED
|
@@ -1,162 +1,89 @@
|
|
|
1
1
|
# @spendgraph/harness
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
`
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
`harness.invalidate(promptId)` drops it immediately — `promote()` does this for
|
|
93
|
-
you, including for entries cached under the other handle.
|
|
94
|
-
|
|
95
|
-
## The batch path
|
|
96
|
-
|
|
97
|
-
`run`, `sample` and `runAll` execute the prompt server-side and record the
|
|
98
|
-
rollout in one call. That adds a hop and makes spendgraph a dependency of your
|
|
99
|
-
uptime, which is the right trade for evaluation and the wrong one for a user
|
|
100
|
-
waiting on a response.
|
|
101
|
-
|
|
102
|
-
```ts
|
|
103
|
-
const result = await harness.run("billing-explainer-k3f9x2", values);
|
|
104
|
-
const k = await harness.sample(promptId, values, { k: 5 }); // pass^k
|
|
105
|
-
const across = await harness.runAll(promptId, values); // one per model
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
Cap what a client can spend:
|
|
109
|
-
|
|
110
|
-
```ts
|
|
111
|
-
new Harness({ apiKey, baseUrl, maxCostMicros: 5_000_000 }); // $5
|
|
112
|
-
harness.spent(); // micro-dollars used
|
|
113
|
-
harness.remaining(); // what is left
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
Omit `maxCostMicros` and it warns once that it is unbounded. Pass `null` to say
|
|
117
|
-
you meant it.
|
|
118
|
-
|
|
119
|
-
## Typed fields
|
|
120
|
-
|
|
121
|
-
A prompt declares its placeholders — `string`, `text`, `number`, `boolean`,
|
|
122
|
-
`enum`, `list`, `json` — and `render` validates before anything is sent, so a
|
|
123
|
-
missing required field is an exception rather than a `{placeholder}` reaching
|
|
124
|
-
the model.
|
|
125
|
-
|
|
126
|
-
`harness codegen` turns those declarations into types:
|
|
127
|
-
|
|
128
|
-
```sh
|
|
129
|
-
npx harness codegen --url https://your-spendgraph.example.com
|
|
130
|
-
# usage: harness codegen [--out <file>] [--project <id>] [--module <specifier>]
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
It reads `SPENDGRAPH_API_KEY` and `SPENDGRAPH_URL` from the environment and
|
|
134
|
-
writes `src/harness.generated.d.ts`. Include that file and the values argument
|
|
135
|
-
is checked per prompt, by id and by slug. Delete it and calls fall back to a
|
|
136
|
-
loose `Record<string, unknown>` — the runtime validation is unchanged either
|
|
137
|
-
way.
|
|
138
|
-
|
|
139
|
-
## Versions
|
|
140
|
-
|
|
141
|
-
```ts
|
|
142
|
-
const { versions, currentVersionId } = await harness.versions(promptId);
|
|
143
|
-
await harness.promote(promptId, versionId);
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
Promoting changes what every `pull` serves from that moment, and invalidates the
|
|
147
|
-
cache so the change is visible immediately rather than after a TTL.
|
|
148
|
-
|
|
149
|
-
## Datasets
|
|
150
|
-
|
|
151
|
-
```ts
|
|
152
|
-
const { cases, counts } = await harness.cases(promptId);
|
|
153
|
-
await harness.setCases(promptId, cases);
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
`setCases` replaces the whole set. A dataset answers "what am I evaluating
|
|
157
|
-
against" with one answer at a time, and a merge would leave no way to remove a
|
|
158
|
-
case.
|
|
159
|
-
|
|
160
|
-
## License
|
|
161
|
-
|
|
162
|
-
MIT
|
|
3
|
+
**Agent = Model + Harness.** The model reasons. The harness is everything around
|
|
4
|
+
it that turns text into work — choosing what runs next, calling tools, holding
|
|
5
|
+
the result together, and knowing what to do when a step fails.
|
|
6
|
+
|
|
7
|
+
This is the *outer* harness: the part you assemble, on top of whatever agent SDK
|
|
8
|
+
your provider ships. It is built on `@spendgraph/prompt`, `@spendgraph/tools`
|
|
9
|
+
and `@spendgraph/graph`, and none of it reasons — every workflow here directs a
|
|
10
|
+
model call you supply.
|
|
11
|
+
|
|
12
|
+
Six shapes, all built. Each is a graph you would otherwise hand-roll, with the
|
|
13
|
+
pricing and the rollout already attached, so a run is recorded rather than
|
|
14
|
+
reconstructed afterwards.
|
|
15
|
+
|
|
16
|
+
| Workflow | What it is | When it wins |
|
|
17
|
+
| --- | --- | --- |
|
|
18
|
+
| **`chain`** | steps in sequence, each on the last's output, with a gate between | a task that decomposes cleanly and reads better as stages |
|
|
19
|
+
| **`route`** | classify first, then dispatch to a specialist | inputs that fall into kinds a cheaper model can tell apart |
|
|
20
|
+
| **`parallel`** | the same call N times, or N sections at once | voting for confidence, sectioning for latency |
|
|
21
|
+
| **`orchestrate`** | a lead decomposes and delegates to workers | the subtasks are not known until the input is read |
|
|
22
|
+
| **`refine`** | generate, critique, revise until a bar is met | there is a clear criterion and a first draft rarely meets it |
|
|
23
|
+
| **`loop`** | tools in a loop until the model stops asking | open-ended work with a tool surface and a step ceiling |
|
|
24
|
+
| **`cascade`** | try the cheap model, escalate only when it will not do | most inputs are easy and a few are not, and you can tell which |
|
|
25
|
+
|
|
26
|
+
## Inner and outer
|
|
27
|
+
|
|
28
|
+
The provider's SDK is the **inner** harness: it drives one model call, and on
|
|
29
|
+
Anthropic or OpenAI it will loop over tool calls for you. `@spendgraph/llms`
|
|
30
|
+
wraps that.
|
|
31
|
+
|
|
32
|
+
What it does not do is decide *which* call to make, what to do with the answer,
|
|
33
|
+
when to stop, or what any of it cost. That is the **outer** harness, and it is
|
|
34
|
+
the part that is written again in every project — usually inline, usually
|
|
35
|
+
without a ceiling, and usually recording nothing.
|
|
36
|
+
|
|
37
|
+
| | |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| Inner | one call, tool loop, streaming — `@spendgraph/llms` |
|
|
40
|
+
| Outer | which call, what next, when to stop, what it cost — here |
|
|
41
|
+
|
|
42
|
+
## Why here and not in `graph`
|
|
43
|
+
|
|
44
|
+
`graph` is the mechanism: nodes, edges, a run. These are the seven arrangements of
|
|
45
|
+
it worth naming, each with its own failure mode — a chain needs a gate or a bad
|
|
46
|
+
step poisons the rest, voting needs an odd N, a loop needs a ceiling and every
|
|
47
|
+
one of them needs a budget. Putting them beside the mechanism would suggest the
|
|
48
|
+
mechanism is opinionated. It is not.
|
|
49
|
+
|
|
50
|
+
## What every one of them owes you
|
|
51
|
+
|
|
52
|
+
- A `GraphResult`, so the run reports as a rollout without translation.
|
|
53
|
+
- Every step recorded, including the ones that were rejected — a refine loop that
|
|
54
|
+
keeps only the winner cannot say what it cost.
|
|
55
|
+
- A budget ceiling, because each of these spends more than one call and the
|
|
56
|
+
interesting ones spend an unbounded amount.
|
|
57
|
+
- A `stoppedBy`, because "it finished" and "it gave up" must never look the same.
|
|
58
|
+
|
|
59
|
+
## What is missing
|
|
60
|
+
|
|
61
|
+
Every workflow here is **single-run**: it starts, it finishes, and it holds
|
|
62
|
+
nothing afterwards. A harness in the fuller sense also carries state across a
|
|
63
|
+
pause — a run that waits for a person, resumes tomorrow, or picks up where a
|
|
64
|
+
crash left it.
|
|
65
|
+
|
|
66
|
+
That needs somewhere to persist a run, which is a decision about the product and
|
|
67
|
+
not a wiring pattern. It is written up in [`PLAN.md`](./PLAN.md) rather than
|
|
68
|
+
quietly folded in.
|
|
69
|
+
|
|
70
|
+
## What is next
|
|
71
|
+
|
|
72
|
+
Phase one is done: the context manager (`compact`), lifecycle hooks, tool
|
|
73
|
+
annotations, budget signalling and `cascade`. What is left is **durable state** —
|
|
74
|
+
a run that can wait for a person and be resumed — which [`PLAN.md`](./PLAN.md)
|
|
75
|
+
plans with the decisions and the tests it needs.
|
|
76
|
+
|
|
77
|
+
## Documentation
|
|
78
|
+
|
|
79
|
+
Each built workflow has a page with worked examples, the cases it suits, and the
|
|
80
|
+
cases it does not.
|
|
81
|
+
|
|
82
|
+
- [`docs/refine.md`](./docs/refine.md) — draft, judge, revise
|
|
83
|
+
- [`docs/route.md`](./docs/route.md) — classify, then dispatch
|
|
84
|
+
- [`docs/chain.md`](./docs/chain.md) — steps in sequence, with a gate between
|
|
85
|
+
- [`docs/parallel.md`](./docs/parallel.md) — sectioning for latency, voting for confidence
|
|
86
|
+
- [`docs/loop.md`](./docs/loop.md) — tools until the model stops asking
|
|
87
|
+
- [`docs/orchestrate.md`](./docs/orchestrate.md) — a lead decomposes and delegates
|
|
88
|
+
- [`docs/cascade.md`](./docs/cascade.md) — cheap first, escalate on rejection
|
|
89
|
+
- [`docs/README.md`](./docs) — the seven patterns, and what each owes you
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CascadeOptions, CascadeResult } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Try the cheap model, and escalate only when its answer will not do.
|
|
4
|
+
*
|
|
5
|
+
* Not `route`, which chooses before it has seen an answer. Not `refine`, which
|
|
6
|
+
* revises with the same model. This is the one lever that spends less by
|
|
7
|
+
* default and more only on the inputs that turn out to need it.
|
|
8
|
+
*
|
|
9
|
+
* Whether it pays depends entirely on how often the first tier is accepted, so
|
|
10
|
+
* every tier tried is recorded and its tokens counted — a total that hid the
|
|
11
|
+
* rejected attempts would report a saving on every run.
|
|
12
|
+
*/
|
|
13
|
+
export declare function cascade(values: Record<string, unknown>, opts: CascadeOptions): Promise<CascadeResult>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { attempt, fallback, tierStep, tokensOf } from "./tier.js";
|
|
2
|
+
function checkTiers(tiers) {
|
|
3
|
+
if (tiers.length === 0)
|
|
4
|
+
throw new Error("cascade needs at least one tier.");
|
|
5
|
+
const seen = new Set();
|
|
6
|
+
for (const tier of tiers) {
|
|
7
|
+
if (!tier.name.trim())
|
|
8
|
+
throw new Error("Every cascade tier needs a name.");
|
|
9
|
+
if (seen.has(tier.name))
|
|
10
|
+
throw new Error(`Two cascade tiers are called "${tier.name}".`);
|
|
11
|
+
seen.add(tier.name);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Try the cheap model, and escalate only when its answer will not do.
|
|
16
|
+
*
|
|
17
|
+
* Not `route`, which chooses before it has seen an answer. Not `refine`, which
|
|
18
|
+
* revises with the same model. This is the one lever that spends less by
|
|
19
|
+
* default and more only on the inputs that turn out to need it.
|
|
20
|
+
*
|
|
21
|
+
* Whether it pays depends entirely on how often the first tier is accepted, so
|
|
22
|
+
* every tier tried is recorded and its tokens counted — a total that hid the
|
|
23
|
+
* rejected attempts would report a saving on every run.
|
|
24
|
+
*/
|
|
25
|
+
export async function cascade(values, opts) {
|
|
26
|
+
const startedAt = Date.now();
|
|
27
|
+
checkTiers(opts.tiers);
|
|
28
|
+
const steps = [];
|
|
29
|
+
const history = [];
|
|
30
|
+
let spent = 0;
|
|
31
|
+
let accepted;
|
|
32
|
+
let stoppedBy = "exhausted";
|
|
33
|
+
for (const [index, tier] of opts.tiers.entries()) {
|
|
34
|
+
if (opts.maxTokens !== undefined && spent >= opts.maxTokens) {
|
|
35
|
+
stoppedBy = "tokens";
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
const { answer, error } = await attempt(tier.run, values, (text, stage) => opts.emit?.(text, stage ?? tier.name));
|
|
39
|
+
const passed = answer !== null && !error && (await opts.accept(answer, index));
|
|
40
|
+
const record = {
|
|
41
|
+
index,
|
|
42
|
+
name: tier.name,
|
|
43
|
+
answer,
|
|
44
|
+
accepted: passed,
|
|
45
|
+
...(passed ? {} : { escalatedBy: error ? "failed" : "rejected" }),
|
|
46
|
+
error,
|
|
47
|
+
tokens: tokensOf(answer),
|
|
48
|
+
};
|
|
49
|
+
steps.push(tierStep(steps.length, record));
|
|
50
|
+
spent += record.tokens;
|
|
51
|
+
history.push(record);
|
|
52
|
+
opts.onTier?.(record);
|
|
53
|
+
if (passed) {
|
|
54
|
+
accepted = record;
|
|
55
|
+
stoppedBy = "accepted";
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const winner = accepted ?? fallback(history);
|
|
60
|
+
const totals = history.reduce((sum, record) => ({
|
|
61
|
+
inputTokens: sum.inputTokens + (record.answer?.inputTokens ?? 0),
|
|
62
|
+
outputTokens: sum.outputTokens + (record.answer?.outputTokens ?? 0),
|
|
63
|
+
}), { inputTokens: 0, outputTokens: 0 });
|
|
64
|
+
const shared = {
|
|
65
|
+
steps,
|
|
66
|
+
outputs: { history },
|
|
67
|
+
latencyMs: Date.now() - startedAt,
|
|
68
|
+
...totals,
|
|
69
|
+
accepted: accepted !== undefined,
|
|
70
|
+
escalated: history.filter((r) => r !== winner).map((r) => r.name),
|
|
71
|
+
history,
|
|
72
|
+
};
|
|
73
|
+
if (!winner) {
|
|
74
|
+
return {
|
|
75
|
+
...shared,
|
|
76
|
+
status: "failed",
|
|
77
|
+
output: "",
|
|
78
|
+
error: history.at(-1)?.error ?? "No tier produced an answer.",
|
|
79
|
+
tier: null,
|
|
80
|
+
stoppedBy: stoppedBy === "tokens" ? "tokens" : "failed",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
...shared,
|
|
85
|
+
status: "completed",
|
|
86
|
+
output: winner.answer?.output ?? "",
|
|
87
|
+
tier: winner.name,
|
|
88
|
+
stoppedBy,
|
|
89
|
+
...(winner.answer?.data === undefined ? {} : { data: winner.answer.data }),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { RolloutStep } from "@spendgraph/sdk";
|
|
2
|
+
import type { Emit } from "../stream/index.js";
|
|
3
|
+
import type { Answer, TierRecord } from "./types.js";
|
|
4
|
+
/** What one tier consumed, both halves, whether or not its answer was used. */
|
|
5
|
+
export declare function tokensOf(answer: Answer | null): number;
|
|
6
|
+
/**
|
|
7
|
+
* One tier as a step, named for the tier rather than its position.
|
|
8
|
+
*
|
|
9
|
+
* A rollout that read `tier_0` would need the config beside it to mean anything,
|
|
10
|
+
* and the config is the thing most likely to have changed since.
|
|
11
|
+
*/
|
|
12
|
+
export declare function tierStep(index: number, record: TierRecord): RolloutStep;
|
|
13
|
+
/**
|
|
14
|
+
* Runs one tier, turning a throw into an escalation.
|
|
15
|
+
*
|
|
16
|
+
* An overloaded cheap model is the case this workflow exists for, so a tier that
|
|
17
|
+
* falls over must hand on rather than end the run. The next rung is the whole
|
|
18
|
+
* point of having one.
|
|
19
|
+
*/
|
|
20
|
+
export declare function attempt(run: (values: Record<string, unknown>, emit: Emit) => Promise<Answer> | Answer, values: Record<string, unknown>, emit: Emit): Promise<{
|
|
21
|
+
answer: Answer | null;
|
|
22
|
+
error?: string;
|
|
23
|
+
}>;
|
|
24
|
+
/**
|
|
25
|
+
* The tier to fall back to when nothing was accepted.
|
|
26
|
+
*
|
|
27
|
+
* The last that produced anything, because the tiers are ordered by capability
|
|
28
|
+
* and the most capable answer is the closest thing to a best effort. A tier that
|
|
29
|
+
* threw produced nothing, so it cannot be the fallback however late it ran.
|
|
30
|
+
*/
|
|
31
|
+
export declare function fallback(history: TierRecord[]): TierRecord | undefined;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** What one tier consumed, both halves, whether or not its answer was used. */
|
|
2
|
+
export function tokensOf(answer) {
|
|
3
|
+
if (!answer)
|
|
4
|
+
return 0;
|
|
5
|
+
return (answer.inputTokens ?? 0) + (answer.outputTokens ?? 0);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* One tier as a step, named for the tier rather than its position.
|
|
9
|
+
*
|
|
10
|
+
* A rollout that read `tier_0` would need the config beside it to mean anything,
|
|
11
|
+
* and the config is the thing most likely to have changed since.
|
|
12
|
+
*/
|
|
13
|
+
export function tierStep(index, record) {
|
|
14
|
+
return {
|
|
15
|
+
index,
|
|
16
|
+
source: record.name,
|
|
17
|
+
model: record.answer?.model,
|
|
18
|
+
output: record.answer?.output ?? "",
|
|
19
|
+
status: record.escalatedBy === "failed" ? "failed" : "completed",
|
|
20
|
+
error: record.error,
|
|
21
|
+
inputTokens: record.answer?.inputTokens,
|
|
22
|
+
outputTokens: record.answer?.outputTokens,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Runs one tier, turning a throw into an escalation.
|
|
27
|
+
*
|
|
28
|
+
* An overloaded cheap model is the case this workflow exists for, so a tier that
|
|
29
|
+
* falls over must hand on rather than end the run. The next rung is the whole
|
|
30
|
+
* point of having one.
|
|
31
|
+
*/
|
|
32
|
+
export async function attempt(run, values, emit) {
|
|
33
|
+
try {
|
|
34
|
+
const answer = await run(values, emit);
|
|
35
|
+
if (answer.status === "failed") {
|
|
36
|
+
return { answer, error: answer.error ?? "The tier failed." };
|
|
37
|
+
}
|
|
38
|
+
return { answer };
|
|
39
|
+
}
|
|
40
|
+
catch (cause) {
|
|
41
|
+
return { answer: null, error: cause instanceof Error ? cause.message : String(cause) };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The tier to fall back to when nothing was accepted.
|
|
46
|
+
*
|
|
47
|
+
* The last that produced anything, because the tiers are ordered by capability
|
|
48
|
+
* and the most capable answer is the closest thing to a best effort. A tier that
|
|
49
|
+
* threw produced nothing, so it cannot be the fallback however late it ran.
|
|
50
|
+
*/
|
|
51
|
+
export function fallback(history) {
|
|
52
|
+
return [...history].reverse().find((record) => record.answer !== null);
|
|
53
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { RolloutStep } from "@spendgraph/sdk";
|
|
2
|
+
import type { Emit } from "../stream/index.js";
|
|
3
|
+
/** What one tier produced. Structurally the `LlmReply` a provider read gives back. */
|
|
4
|
+
export interface Answer {
|
|
5
|
+
output: string;
|
|
6
|
+
model: string;
|
|
7
|
+
status?: "completed" | "failed";
|
|
8
|
+
error?: string;
|
|
9
|
+
inputTokens?: number;
|
|
10
|
+
outputTokens?: number;
|
|
11
|
+
/** A parsed reply, where the tier asked for a shape. What `accept` usually reads. */
|
|
12
|
+
data?: unknown;
|
|
13
|
+
}
|
|
14
|
+
/** One rung: a name for the record, and the call it makes. */
|
|
15
|
+
export interface Tier {
|
|
16
|
+
/** How it appears in `result.tier`, `escalated` and the steps. Unique. */
|
|
17
|
+
name: string;
|
|
18
|
+
run(values: Record<string, unknown>, emit: Emit): Promise<Answer> | Answer;
|
|
19
|
+
}
|
|
20
|
+
/** Why a tier did not answer. */
|
|
21
|
+
export type Escalation = "rejected" | "failed";
|
|
22
|
+
/** What one tier did, whether it answered or not. */
|
|
23
|
+
export interface TierRecord {
|
|
24
|
+
index: number;
|
|
25
|
+
name: string;
|
|
26
|
+
/** What it produced, or null when the call threw. */
|
|
27
|
+
answer: Answer | null;
|
|
28
|
+
accepted: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Absent when it answered.
|
|
31
|
+
*
|
|
32
|
+
* `rejected` is `accept` saying no; `failed` is the call falling over. They
|
|
33
|
+
* want opposite fixes — a bar set too high against a model that is down — and
|
|
34
|
+
* a record that collapsed them could not tell you which you have.
|
|
35
|
+
*/
|
|
36
|
+
escalatedBy?: Escalation;
|
|
37
|
+
error?: string;
|
|
38
|
+
tokens: number;
|
|
39
|
+
}
|
|
40
|
+
export interface CascadeOptions {
|
|
41
|
+
/**
|
|
42
|
+
* Rungs in order, cheapest first.
|
|
43
|
+
*
|
|
44
|
+
* Ordered by capability rather than price where the two disagree: the last is
|
|
45
|
+
* what a run falls back to, so it should be the one most likely to cope.
|
|
46
|
+
*/
|
|
47
|
+
tiers: Tier[];
|
|
48
|
+
/**
|
|
49
|
+
* Whether that answer will do.
|
|
50
|
+
*
|
|
51
|
+
* Required. Without it there is no cascade — only a list of models and no
|
|
52
|
+
* reason to stop at any of them.
|
|
53
|
+
*/
|
|
54
|
+
accept(answer: Answer, index: number): Promise<boolean> | boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Where the tokens go while this runs.
|
|
57
|
+
*
|
|
58
|
+
* Passed down to whatever makes the model call; omit it and nothing is
|
|
59
|
+
* emitted. `streamed()` supplies one.
|
|
60
|
+
*/
|
|
61
|
+
emit?: Emit;
|
|
62
|
+
/**
|
|
63
|
+
* Stop once the tiers have consumed this many tokens.
|
|
64
|
+
*
|
|
65
|
+
* Checked before each tier, so the ceiling is never crossed rather than merely
|
|
66
|
+
* noticed. Tokens rather than money because this package cannot price a call.
|
|
67
|
+
*/
|
|
68
|
+
maxTokens?: number;
|
|
69
|
+
/** Called after each tier, so a long escalation is not silent. */
|
|
70
|
+
onTier?: (record: TierRecord) => void;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* What the cascade produced, in the shape of a rollout.
|
|
74
|
+
*
|
|
75
|
+
* `inputTokens` and `outputTokens` cover **every** tier tried, not the winner
|
|
76
|
+
* alone. The only question a cascade has to answer is whether it paid, and a
|
|
77
|
+
* total that hid the rejected attempts would always say yes.
|
|
78
|
+
*/
|
|
79
|
+
export interface CascadeResult {
|
|
80
|
+
status: "completed" | "failed";
|
|
81
|
+
output: string;
|
|
82
|
+
error?: string;
|
|
83
|
+
steps: RolloutStep[];
|
|
84
|
+
outputs: Record<string, unknown>;
|
|
85
|
+
latencyMs: number;
|
|
86
|
+
inputTokens: number;
|
|
87
|
+
outputTokens: number;
|
|
88
|
+
/** True when a tier passed `accept`. False when the answer is a fallback. */
|
|
89
|
+
accepted: boolean;
|
|
90
|
+
/** Whose answer this is: the one accepted, or the last that produced one. */
|
|
91
|
+
tier: string | null;
|
|
92
|
+
/** Tiers tried that did not supply the answer, in the order they were tried. */
|
|
93
|
+
escalated: string[];
|
|
94
|
+
stoppedBy: "accepted" | "exhausted" | "tokens" | "failed";
|
|
95
|
+
/** The winning tier's parsed reply, where it had one. */
|
|
96
|
+
data?: unknown;
|
|
97
|
+
history: TierRecord[];
|
|
98
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ChainOptions, ChainResult } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Steps in sequence, each on the last one's output, with a gate between.
|
|
4
|
+
*
|
|
5
|
+
* Compiled to a graph, which already stops at the node that failed and records
|
|
6
|
+
* every node that ran before it — which is the whole reason to gate a chain
|
|
7
|
+
* rather than let it run to the end and hand back something plausible.
|
|
8
|
+
*/
|
|
9
|
+
export declare function chain(values: Record<string, unknown>, opts: ChainOptions): Promise<ChainResult>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { edge, end, graph } from "@spendgraph/graph";
|
|
2
|
+
import { runGraph } from "../stream/graph.js";
|
|
3
|
+
import { gateName, gateNode } from "./gate.js";
|
|
4
|
+
import { stepNode } from "./step.js";
|
|
5
|
+
function assertNamed(steps) {
|
|
6
|
+
if (steps.length === 0)
|
|
7
|
+
throw new Error("A chain needs at least one step.");
|
|
8
|
+
const seen = new Set();
|
|
9
|
+
for (const step of steps) {
|
|
10
|
+
if (seen.has(step.name))
|
|
11
|
+
throw new Error(`Two steps are called "${step.name}".`);
|
|
12
|
+
seen.add(step.name);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** Every node in order: a stage, then its gate when it has one. */
|
|
16
|
+
function nodesFor(steps) {
|
|
17
|
+
const nodes = [];
|
|
18
|
+
const order = [];
|
|
19
|
+
let previous = null;
|
|
20
|
+
for (const step of steps) {
|
|
21
|
+
nodes.push(stepNode(step, previous));
|
|
22
|
+
order.push(step.name);
|
|
23
|
+
if (step.gate) {
|
|
24
|
+
nodes.push(gateNode(step));
|
|
25
|
+
order.push(gateName(step.name));
|
|
26
|
+
}
|
|
27
|
+
previous = step.name;
|
|
28
|
+
}
|
|
29
|
+
return { nodes, order };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Steps in sequence, each on the last one's output, with a gate between.
|
|
33
|
+
*
|
|
34
|
+
* Compiled to a graph, which already stops at the node that failed and records
|
|
35
|
+
* every node that ran before it — which is the whole reason to gate a chain
|
|
36
|
+
* rather than let it run to the end and hand back something plausible.
|
|
37
|
+
*/
|
|
38
|
+
export async function chain(values, opts) {
|
|
39
|
+
assertNamed(opts.steps);
|
|
40
|
+
const { nodes, order } = nodesFor(opts.steps);
|
|
41
|
+
const flow = graph({
|
|
42
|
+
entry: order[0],
|
|
43
|
+
maxSteps: opts.maxSteps,
|
|
44
|
+
nodes,
|
|
45
|
+
edges: [
|
|
46
|
+
...order.slice(0, -1).map((from, at) => edge(from, order[at + 1])),
|
|
47
|
+
end(order.at(-1)),
|
|
48
|
+
],
|
|
49
|
+
}, { now: opts.now });
|
|
50
|
+
const result = await runGraph(flow, values, opts.emit);
|
|
51
|
+
const stopped = result.status === "failed" ? result.steps.at(-1) : undefined;
|
|
52
|
+
return {
|
|
53
|
+
...result,
|
|
54
|
+
ran: result.steps.filter((s) => !s.source.startsWith("gate_")).map((s) => s.source),
|
|
55
|
+
stoppedAt: stopped ? stopped.source.replace(/^gate_/, "") : null,
|
|
56
|
+
reason: stopped?.error,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Step } from "./types.js";
|
|
2
|
+
/** The gate node's name for a stage, and the key its verdict is stored under. */
|
|
3
|
+
export declare function gateName(step: string): string;
|
|
4
|
+
export declare class GateRefused extends Error {
|
|
5
|
+
readonly step: string;
|
|
6
|
+
readonly reason: string;
|
|
7
|
+
constructor(step: string, reason: string);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* A gate as its own node, so a refusal is a step of its own.
|
|
11
|
+
*
|
|
12
|
+
* Folding the check into the stage would lose the output it refused — and the
|
|
13
|
+
* rejected output is the evidence for why the gate fired at all. This way the
|
|
14
|
+
* stage records what it produced and the gate records what was wrong with it.
|
|
15
|
+
*/
|
|
16
|
+
export declare function gateNode(step: Step): import("@spendgraph/graph").Node<import("@spendgraph/graph").ArgsOf<readonly import("@spendgraph/graph").ArgSpec[]>>;
|