@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/loop.md
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
# loop
|
|
2
|
+
|
|
3
|
+
Model, tools, model again, until it stops asking.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { loop } from "@spendgraph/harness";
|
|
7
|
+
import { finish, toolbus } from "@spendgraph/tools";
|
|
8
|
+
|
|
9
|
+
const bus = toolbus([lookupClause, issueRefund, finish()]);
|
|
10
|
+
|
|
11
|
+
const result = await loop({
|
|
12
|
+
tools: bus,
|
|
13
|
+
maxTurns: 8,
|
|
14
|
+
maxTokens: 40_000,
|
|
15
|
+
turn: ({ results, last }) =>
|
|
16
|
+
llm.call(messagesFrom(results, last), { tools: bus }),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
result.stoppedBy; // finished · quiet · turns · tokens · failed
|
|
20
|
+
result.answer; // set when the model called finish()
|
|
21
|
+
result.turns;
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## What this adds over a provider's own tool loop
|
|
27
|
+
|
|
28
|
+
`@spendgraph/llms` already loops: it sends the declarations, reads the calls
|
|
29
|
+
back, invokes them and goes round again. What is always hand-rolled around that,
|
|
30
|
+
and always wrong the same way, is the rest:
|
|
31
|
+
|
|
32
|
+
**A ceiling checked before the spend.** `maxTokens` is tested at the top of each
|
|
33
|
+
turn, so it is never crossed rather than merely noticed once it has been.
|
|
34
|
+
`maxTurns` defaults to 8.
|
|
35
|
+
|
|
36
|
+
**Every turn and every tool call recorded.** Including the tool calls that
|
|
37
|
+
failed — a run that dropped them cannot explain why the model changed course.
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
steps: turn_0 · lookup · lookup · turn_1
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**`finished` told apart from `quiet`.**
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## The distinction worth having
|
|
48
|
+
|
|
49
|
+
Two things end a tool loop and they look identical from outside:
|
|
50
|
+
|
|
51
|
+
| | |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `finished` | the model called `finish()` and gave an answer |
|
|
54
|
+
| `quiet` | the model said something and asked for nothing more |
|
|
55
|
+
|
|
56
|
+
The second is what being *done* looks like. It is also exactly what being
|
|
57
|
+
**lost** looks like — the model has run out of ideas and trailed off. Without
|
|
58
|
+
`finish` in the bus you cannot tell them apart, and a run that quietly gave up
|
|
59
|
+
is indistinguishable from one that succeeded.
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
toolbus([...yourTools, finish()]);
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`result.answer` is set only on `finished`, and carries what the model said the
|
|
66
|
+
answer was — not the surrounding prose.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## A failed tool does not end the run
|
|
71
|
+
|
|
72
|
+
`invoke` returns a failed result rather than throwing, so a tool that fell over
|
|
73
|
+
is something the model can read and work around. It is recorded as a failed step
|
|
74
|
+
and the loop carries on.
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
turn_0 "let me check"
|
|
78
|
+
nope failed — No tool called "nope". Offered: lookup, finish
|
|
79
|
+
turn_1 "that tool is not available; answering from what I have"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
A loop that crashed on a bad call would lose the whole turn, and with it the
|
|
83
|
+
work already done.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Hooks
|
|
88
|
+
|
|
89
|
+
Deterministic code at fixed points. A guardrail written into the prompt is a
|
|
90
|
+
request; this is enforcement — it runs whether or not the model read the
|
|
91
|
+
instruction, and next turn's wording cannot argue it away.
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
await loop({
|
|
95
|
+
tools: bus,
|
|
96
|
+
turn,
|
|
97
|
+
hooks: {
|
|
98
|
+
beforeTurn: () => (overDailyBudget() ? { block: "daily budget spent" } : undefined),
|
|
99
|
+
beforeTool: (call) =>
|
|
100
|
+
call.name === "issue_refund" && Number(call.args.amount) > 50_000
|
|
101
|
+
? { block: "over the approval limit" }
|
|
102
|
+
: undefined,
|
|
103
|
+
afterTool: (result) => audit.write(result),
|
|
104
|
+
afterTurn: (turn) => log.debug(turn),
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Gate on what a tool does, not on what it is called
|
|
110
|
+
|
|
111
|
+
`beforeTool` is handed the effect the tool declared, so a rule can be written
|
|
112
|
+
against the thing that matters:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
hooks: {
|
|
116
|
+
beforeTool: (call, effect) => {
|
|
117
|
+
if (effect === "destructive") return { block: "needs a person" };
|
|
118
|
+
if (effect === undefined) return { block: `${call.name} has not said what it does` };
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The version above it — `call.name === "issue_refund"` — is a rule that stops
|
|
124
|
+
firing the day somebody renames the tool. Nothing errors, no test goes red, and
|
|
125
|
+
the refund goes through. A rule written against `effect` survives the rename,
|
|
126
|
+
because it never knew the name.
|
|
127
|
+
|
|
128
|
+
`undefined` means the tool declared nothing, and it is deliberately not
|
|
129
|
+
`readonly`. Blocking it is the safe direction; letting it through means every
|
|
130
|
+
tool written before annotations existed is treated as harmless. Use
|
|
131
|
+
`bus.unannotated()` in a test to keep that list empty.
|
|
132
|
+
|
|
133
|
+
The effect comes from whatever you passed as `tools`: a `toolbus()` answers from
|
|
134
|
+
its registry, a `bus.trace()` from the shortlist it offered that turn. Anything
|
|
135
|
+
else with just an `invoke` is still a valid `tools`, and every effect reads
|
|
136
|
+
`undefined` — which is what it is.
|
|
137
|
+
|
|
138
|
+
### Blocking a tool is not an error
|
|
139
|
+
|
|
140
|
+
A refused call comes back as a **failed tool result**, in the shape the model
|
|
141
|
+
already knows how to read:
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
turn_0 "refunding"
|
|
145
|
+
issue_refund failed — blocked: over the approval limit
|
|
146
|
+
turn_1 "understood, escalating"
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
So the model reads the refusal and works around it. Throwing instead would lose
|
|
150
|
+
the turn and the work already done in it. The tool itself never runs — a
|
|
151
|
+
guardrail that refunds first and refuses afterwards is not a guardrail.
|
|
152
|
+
|
|
153
|
+
### Blocking a turn ends the run
|
|
154
|
+
|
|
155
|
+
There is no way around a blocked turn, so it stops with `stoppedBy: "blocked"`
|
|
156
|
+
and a `failed` status. That is the shape for a budget ceiling or a kill switch.
|
|
157
|
+
|
|
158
|
+
### A hook that throws is treated as permission
|
|
159
|
+
|
|
160
|
+
A bug in the guardrail must not take down the thing it was guarding. The call it
|
|
161
|
+
was checking has not happened yet, the next hook still gets its say, and the run
|
|
162
|
+
continues. The alternative — an unreachable policy service ending every run —
|
|
163
|
+
fails far more often than the case it was protecting against.
|
|
164
|
+
|
|
165
|
+
The observing hooks, `afterTool` and `afterTurn`, change nothing when they
|
|
166
|
+
throw. They were only watching.
|
|
167
|
+
|
|
168
|
+
### Why hooks rather than an `if` inside the tool
|
|
169
|
+
|
|
170
|
+
A blocked call is a **step**, so "what did this agent try to do and get refused"
|
|
171
|
+
is a query rather than a grep. `afterTool` sees blocked results as well as
|
|
172
|
+
successful ones, which is what an audit trail actually wants.
|
|
173
|
+
|
|
174
|
+
## Telling the model what is left
|
|
175
|
+
|
|
176
|
+
The ceilings stop a run. They do not stop the model planning as if there were no
|
|
177
|
+
ceilings, which is how turn seven of eight opens a five-step approach and gets
|
|
178
|
+
cut off after the first — paid for in full.
|
|
179
|
+
|
|
180
|
+
`remaining` is on the state your `turn` already receives:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
turn: ({ remaining }) =>
|
|
184
|
+
model.call([
|
|
185
|
+
{ role: "user", content: question },
|
|
186
|
+
{ role: "user", content: `${remaining.turns} turns and about ${remaining.tokens} tokens left.` },
|
|
187
|
+
], { tools: bus.trace(question) });
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
| | |
|
|
191
|
+
| --- | --- |
|
|
192
|
+
| `remaining.turns` | turns left, **this one included** — the last turn reads `1`, not `0` |
|
|
193
|
+
| `remaining.tokens` | what is left of `maxTokens`, floored at zero |
|
|
194
|
+
|
|
195
|
+
`beforeTurn` gets the same state, so a run can be stopped rather than started
|
|
196
|
+
with a budget that cannot finish it:
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
beforeTurn: ({ remaining }) =>
|
|
200
|
+
remaining.tokens < 30_000 ? { block: "not enough left to finish" } : undefined,
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Putting it in the prompt is your job
|
|
204
|
+
|
|
205
|
+
The loop hands you the number and stops there. Which wording works is
|
|
206
|
+
task-specific, and a library that quietly appends a sentence to your messages
|
|
207
|
+
invalidates your cached prefix without asking.
|
|
208
|
+
|
|
209
|
+
### No ceiling reads as `Infinity`, never zero
|
|
210
|
+
|
|
211
|
+
This is the whole reason the field needs a test. The obvious check —
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
if (remaining.tokens < 1000) return "wrap up now";
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
— would fire on the **first turn of every run that never set `maxTokens`** if a
|
|
218
|
+
missing budget read as zero. The agent would refuse to start work because it
|
|
219
|
+
believes it is already out, and it would look like a model problem rather than a
|
|
220
|
+
library one.
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## Compaction
|
|
225
|
+
|
|
226
|
+
A long run gets expensive because the transcript grows, not because the work got
|
|
227
|
+
harder. Every turn resends what came before, so the tool results are the bulk of
|
|
228
|
+
what you pay for on turn eight.
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
await loop({
|
|
232
|
+
tools: bus,
|
|
233
|
+
maxTokens: 40_000,
|
|
234
|
+
compact: {
|
|
235
|
+
at: 0.7, // fraction of the ceiling
|
|
236
|
+
keep: 4, // recent results, verbatim
|
|
237
|
+
summarise: (older) => lead.call(summarisePrompt(older)),
|
|
238
|
+
},
|
|
239
|
+
turn: ({ results, last }) => llm.call(messagesFrom(results, last), { tools: bus }),
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
result.compactions; // { count: 2, replaced: 11 }
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
The older results are replaced by **one synthetic result named `compacted`**,
|
|
246
|
+
which the next turn reads like any other. The most recent `keep` survive
|
|
247
|
+
verbatim: summarising what just happened is how a loop forgets what it was in
|
|
248
|
+
the middle of doing.
|
|
249
|
+
|
|
250
|
+
### It measures the context, not the spend
|
|
251
|
+
|
|
252
|
+
The threshold is on **the last turn's `inputTokens`** — the size of what is
|
|
253
|
+
actually being resent — and not on what the run has spent so far.
|
|
254
|
+
|
|
255
|
+
Spend only ever rises. A threshold on it fires once and then on every turn after,
|
|
256
|
+
compacting an already-compacted history again and again. The input count falls
|
|
257
|
+
the moment a compaction lands, so the next turn is under the line and it does not
|
|
258
|
+
fire again until the history has genuinely grown back.
|
|
259
|
+
|
|
260
|
+
Set `after: 12` instead to compact on a result count, which needs no ceiling.
|
|
261
|
+
|
|
262
|
+
### What it costs
|
|
263
|
+
|
|
264
|
+
The summary is a model call, recorded as a `compact` step with its tokens and
|
|
265
|
+
counted against `maxTokens`. A compaction that costs more than it saves still has
|
|
266
|
+
to be paid for, and the step is there so you can see whether it did.
|
|
267
|
+
|
|
268
|
+
**A failed summariser is survivable.** The run carries on with the history it
|
|
269
|
+
has rather than ending over a summary it could not write — losing the compaction
|
|
270
|
+
costs tokens, losing the run costs the work.
|
|
271
|
+
|
|
272
|
+
**The honest caveat:** compaction loses information, and the loss is silent. A
|
|
273
|
+
run that compacted and then got the answer wrong looks identical to one that was
|
|
274
|
+
simply wrong.
|
|
275
|
+
|
|
276
|
+
## Reading `stoppedBy` in production
|
|
277
|
+
|
|
278
|
+
| | What it means |
|
|
279
|
+
| --- | --- |
|
|
280
|
+
| `finished` | working as intended |
|
|
281
|
+
| `quiet` | offer it `finish()`, or the prompt is not telling it what done looks like |
|
|
282
|
+
| `turns` | the task needs more steps than you allowed, or it is going in circles |
|
|
283
|
+
| `tokens` | the ceiling is too low, or the history is growing faster than the work |
|
|
284
|
+
| `failed` | the provider failed; not an agent problem |
|
|
285
|
+
|
|
286
|
+
A rising share of `turns` is the signal to look at the tool descriptions: a model
|
|
287
|
+
that cannot tell two tools apart calls both and gets nowhere.
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## When not to use it
|
|
292
|
+
|
|
293
|
+
**The steps are known in advance.** That is `chain` — a loop pays for the model
|
|
294
|
+
to decide what to do next, and if you already know, you are paying for nothing.
|
|
295
|
+
|
|
296
|
+
**There are no tools.** A loop with an empty bus is one call with extra
|
|
297
|
+
machinery.
|
|
298
|
+
|
|
299
|
+
**The work is unbounded and the budget is not.** This is the workflow that can
|
|
300
|
+
spend the most, which is why both ceilings are here and why `maxTurns` has a
|
|
301
|
+
default when the others do not.
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
## How the files fit
|
|
306
|
+
|
|
307
|
+
```
|
|
308
|
+
types.ts the contract
|
|
309
|
+
▲
|
|
310
|
+
turn.ts one model call: what it cost, how it is recorded
|
|
311
|
+
act.ts running what it asked for, and reading `finish`
|
|
312
|
+
▲
|
|
313
|
+
loop.ts the cycle and its ceilings
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
Not compiled to a graph, unlike `route` and `chain`. This is a cycle, and
|
|
317
|
+
`@spendgraph/graph` is a DAG whose step ceiling exists to stop one — same
|
|
318
|
+
reasoning as `refine`.
|
|
@@ -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.
|