@spendgraph/harness 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +141 -111
- package/dist/cascade/cascade.d.ts +13 -0
- package/dist/cascade/cascade.js +80 -0
- package/dist/cascade/index.d.ts +3 -0
- package/dist/cascade/index.js +2 -0
- package/dist/cascade/tier.d.ts +31 -0
- package/dist/cascade/tier.js +32 -0
- package/dist/cascade/types.d.ts +98 -0
- package/dist/chain/chain.d.ts +9 -0
- package/dist/chain/chain.js +50 -0
- package/dist/chain/gate.d.ts +16 -0
- package/dist/chain/gate.js +25 -0
- package/dist/chain/index.d.ts +3 -0
- package/dist/chain/index.js +2 -0
- package/dist/chain/step.d.ts +9 -0
- package/dist/chain/step.js +7 -0
- package/dist/chain/types.d.ts +50 -0
- package/dist/index.d.ts +16 -9
- package/dist/index.js +8 -7
- package/dist/loop/act.d.ts +21 -0
- package/dist/loop/act.js +29 -0
- package/dist/loop/compact.d.ts +49 -0
- package/dist/loop/compact.js +41 -0
- package/dist/loop/hooks.d.ts +43 -0
- package/dist/loop/hooks.js +31 -0
- package/dist/loop/index.d.ts +6 -0
- package/dist/loop/index.js +5 -0
- package/dist/loop/loop.d.ts +13 -0
- package/dist/loop/loop.js +99 -0
- package/dist/loop/turn.d.ts +6 -0
- package/dist/loop/turn.js +15 -0
- package/dist/loop/types.d.ts +140 -0
- package/dist/loop/types.js +1 -0
- package/dist/orchestrate/index.d.ts +4 -0
- package/dist/orchestrate/index.js +3 -0
- package/dist/orchestrate/orchestrate.d.ts +12 -0
- package/dist/orchestrate/orchestrate.js +56 -0
- package/dist/orchestrate/plan.d.ts +18 -0
- package/dist/orchestrate/plan.js +27 -0
- package/dist/orchestrate/types.d.ts +75 -0
- package/dist/orchestrate/types.js +1 -0
- package/dist/orchestrate/work.d.ts +13 -0
- package/dist/orchestrate/work.js +25 -0
- package/dist/parallel/index.d.ts +4 -0
- package/dist/parallel/index.js +3 -0
- package/dist/parallel/merge.d.ts +26 -0
- package/dist/parallel/merge.js +35 -0
- package/dist/parallel/parallel.d.ts +12 -0
- package/dist/parallel/parallel.js +56 -0
- package/dist/parallel/task.d.ts +13 -0
- package/dist/parallel/task.js +37 -0
- package/dist/parallel/types.d.ts +61 -0
- package/dist/parallel/types.js +1 -0
- package/dist/refine/attempt.d.ts +22 -0
- package/dist/refine/attempt.js +24 -0
- package/dist/refine/index.d.ts +4 -0
- package/dist/refine/index.js +3 -0
- package/dist/refine/judge.d.ts +15 -0
- package/dist/refine/judge.js +14 -0
- package/dist/refine/refine.d.ts +10 -0
- package/dist/refine/refine.js +59 -0
- package/dist/refine/types.d.ts +90 -0
- package/dist/refine/types.js +1 -0
- package/dist/route/classify.d.ts +52 -0
- package/dist/route/classify.js +33 -0
- package/dist/route/dispatch.d.ts +12 -0
- package/dist/route/dispatch.js +25 -0
- package/dist/route/index.d.ts +3 -0
- package/dist/route/index.js +2 -0
- package/dist/route/route.d.ts +11 -0
- package/dist/route/route.js +22 -0
- package/dist/route/types.d.ts +53 -0
- package/dist/route/types.js +1 -0
- package/dist/stream/graph.d.ts +9 -0
- package/dist/stream/graph.js +10 -0
- package/dist/stream/index.d.ts +2 -0
- package/dist/stream/index.js +1 -0
- package/dist/stream/stream.d.ts +19 -0
- package/dist/stream/stream.js +48 -0
- package/dist/stream/types.d.ts +31 -0
- package/dist/stream/types.js +1 -0
- package/docs/README.md +41 -0
- package/docs/cascade.md +165 -0
- package/docs/chain.md +134 -0
- package/docs/loop.md +318 -0
- package/docs/orchestrate.md +128 -0
- package/docs/parallel.md +139 -0
- package/docs/refine.md +206 -0
- package/docs/route.md +212 -0
- package/package.json +19 -15
- package/bin/harness.mjs +0 -76
- package/dist/core/budget.d.ts +0 -46
- package/dist/core/budget.js +0 -72
- package/dist/core/cache.d.ts +0 -119
- package/dist/core/cache.js +0 -206
- package/dist/core/client.d.ts +0 -33
- package/dist/core/client.js +0 -98
- package/dist/core/errors.d.ts +0 -22
- package/dist/core/errors.js +0 -28
- package/dist/core/index.d.ts +0 -5
- package/dist/core/index.js +0 -5
- package/dist/core/pull.d.ts +0 -17
- package/dist/core/pull.js +0 -38
- package/dist/core/schema/serialize.d.ts +0 -12
- package/dist/core/schema/serialize.js +0 -42
- package/dist/core/schema/types.d.ts +0 -58
- package/dist/core/schema/validate.d.ts +0 -28
- package/dist/core/schema/validate.js +0 -98
- package/dist/harness.d.ts +0 -128
- package/dist/harness.js +0 -187
- package/dist/prompt/codegen.d.ts +0 -37
- package/dist/prompt/codegen.js +0 -97
- package/dist/prompt/render.d.ts +0 -35
- package/dist/prompt/render.js +0 -66
- package/dist/prompt/run.d.ts +0 -38
- package/dist/prompt/run.js +0 -96
- package/dist/prompt/types.d.ts +0 -156
- /package/dist/{core/schema → cascade}/types.js +0 -0
- /package/dist/{prompt → chain}/types.js +0 -0
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.
|
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`.
|