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