@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.
Files changed (119) hide show
  1. package/README.md +141 -111
  2. package/dist/cascade/cascade.d.ts +13 -0
  3. package/dist/cascade/cascade.js +80 -0
  4. package/dist/cascade/index.d.ts +3 -0
  5. package/dist/cascade/index.js +2 -0
  6. package/dist/cascade/tier.d.ts +31 -0
  7. package/dist/cascade/tier.js +32 -0
  8. package/dist/cascade/types.d.ts +98 -0
  9. package/dist/chain/chain.d.ts +9 -0
  10. package/dist/chain/chain.js +50 -0
  11. package/dist/chain/gate.d.ts +16 -0
  12. package/dist/chain/gate.js +25 -0
  13. package/dist/chain/index.d.ts +3 -0
  14. package/dist/chain/index.js +2 -0
  15. package/dist/chain/step.d.ts +9 -0
  16. package/dist/chain/step.js +7 -0
  17. package/dist/chain/types.d.ts +50 -0
  18. package/dist/index.d.ts +16 -9
  19. package/dist/index.js +8 -7
  20. package/dist/loop/act.d.ts +21 -0
  21. package/dist/loop/act.js +29 -0
  22. package/dist/loop/compact.d.ts +49 -0
  23. package/dist/loop/compact.js +41 -0
  24. package/dist/loop/hooks.d.ts +43 -0
  25. package/dist/loop/hooks.js +31 -0
  26. package/dist/loop/index.d.ts +6 -0
  27. package/dist/loop/index.js +5 -0
  28. package/dist/loop/loop.d.ts +13 -0
  29. package/dist/loop/loop.js +99 -0
  30. package/dist/loop/turn.d.ts +6 -0
  31. package/dist/loop/turn.js +15 -0
  32. package/dist/loop/types.d.ts +140 -0
  33. package/dist/loop/types.js +1 -0
  34. package/dist/orchestrate/index.d.ts +4 -0
  35. package/dist/orchestrate/index.js +3 -0
  36. package/dist/orchestrate/orchestrate.d.ts +12 -0
  37. package/dist/orchestrate/orchestrate.js +56 -0
  38. package/dist/orchestrate/plan.d.ts +18 -0
  39. package/dist/orchestrate/plan.js +27 -0
  40. package/dist/orchestrate/types.d.ts +75 -0
  41. package/dist/orchestrate/types.js +1 -0
  42. package/dist/orchestrate/work.d.ts +13 -0
  43. package/dist/orchestrate/work.js +25 -0
  44. package/dist/parallel/index.d.ts +4 -0
  45. package/dist/parallel/index.js +3 -0
  46. package/dist/parallel/merge.d.ts +26 -0
  47. package/dist/parallel/merge.js +35 -0
  48. package/dist/parallel/parallel.d.ts +12 -0
  49. package/dist/parallel/parallel.js +56 -0
  50. package/dist/parallel/task.d.ts +13 -0
  51. package/dist/parallel/task.js +37 -0
  52. package/dist/parallel/types.d.ts +61 -0
  53. package/dist/parallel/types.js +1 -0
  54. package/dist/refine/attempt.d.ts +22 -0
  55. package/dist/refine/attempt.js +24 -0
  56. package/dist/refine/index.d.ts +4 -0
  57. package/dist/refine/index.js +3 -0
  58. package/dist/refine/judge.d.ts +15 -0
  59. package/dist/refine/judge.js +14 -0
  60. package/dist/refine/refine.d.ts +10 -0
  61. package/dist/refine/refine.js +59 -0
  62. package/dist/refine/types.d.ts +90 -0
  63. package/dist/refine/types.js +1 -0
  64. package/dist/route/classify.d.ts +52 -0
  65. package/dist/route/classify.js +33 -0
  66. package/dist/route/dispatch.d.ts +12 -0
  67. package/dist/route/dispatch.js +25 -0
  68. package/dist/route/index.d.ts +3 -0
  69. package/dist/route/index.js +2 -0
  70. package/dist/route/route.d.ts +11 -0
  71. package/dist/route/route.js +22 -0
  72. package/dist/route/types.d.ts +53 -0
  73. package/dist/route/types.js +1 -0
  74. package/dist/stream/graph.d.ts +9 -0
  75. package/dist/stream/graph.js +10 -0
  76. package/dist/stream/index.d.ts +2 -0
  77. package/dist/stream/index.js +1 -0
  78. package/dist/stream/stream.d.ts +19 -0
  79. package/dist/stream/stream.js +48 -0
  80. package/dist/stream/types.d.ts +31 -0
  81. package/dist/stream/types.js +1 -0
  82. package/docs/README.md +41 -0
  83. package/docs/cascade.md +165 -0
  84. package/docs/chain.md +134 -0
  85. package/docs/loop.md +318 -0
  86. package/docs/orchestrate.md +128 -0
  87. package/docs/parallel.md +139 -0
  88. package/docs/refine.md +206 -0
  89. package/docs/route.md +212 -0
  90. package/package.json +19 -15
  91. package/bin/harness.mjs +0 -76
  92. package/dist/core/budget.d.ts +0 -46
  93. package/dist/core/budget.js +0 -72
  94. package/dist/core/cache.d.ts +0 -119
  95. package/dist/core/cache.js +0 -206
  96. package/dist/core/client.d.ts +0 -33
  97. package/dist/core/client.js +0 -98
  98. package/dist/core/errors.d.ts +0 -22
  99. package/dist/core/errors.js +0 -28
  100. package/dist/core/index.d.ts +0 -5
  101. package/dist/core/index.js +0 -5
  102. package/dist/core/pull.d.ts +0 -17
  103. package/dist/core/pull.js +0 -38
  104. package/dist/core/schema/serialize.d.ts +0 -12
  105. package/dist/core/schema/serialize.js +0 -42
  106. package/dist/core/schema/types.d.ts +0 -58
  107. package/dist/core/schema/validate.d.ts +0 -28
  108. package/dist/core/schema/validate.js +0 -98
  109. package/dist/harness.d.ts +0 -128
  110. package/dist/harness.js +0 -187
  111. package/dist/prompt/codegen.d.ts +0 -37
  112. package/dist/prompt/codegen.js +0 -97
  113. package/dist/prompt/render.d.ts +0 -35
  114. package/dist/prompt/render.js +0 -66
  115. package/dist/prompt/run.d.ts +0 -38
  116. package/dist/prompt/run.js +0 -96
  117. package/dist/prompt/types.d.ts +0 -156
  118. /package/dist/{core/schema → cascade}/types.js +0 -0
  119. /package/dist/{prompt → chain}/types.js +0 -0
@@ -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`.