@spendgraph/tools 0.8.0 → 0.8.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spendgraph/tools",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Declare a tool once, offer the right few, and record what was called.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -28,7 +28,6 @@
28
28
  "types": "./dist/index.d.ts",
29
29
  "import": "./dist/index.js"
30
30
  },
31
- "./docs/*": "./docs/*",
32
31
  "./bogus": {
33
32
  "types": "./dist/bogus/index.d.ts",
34
33
  "import": "./dist/bogus/index.js"
@@ -57,18 +56,17 @@
57
56
  },
58
57
  "files": [
59
58
  "dist",
60
- "docs",
61
59
  "README.md"
62
60
  ],
63
61
  "dependencies": {
64
- "@spendgraph/sdk": "^0.8.0",
62
+ "@spendgraph/sdk": "^0.8.2",
65
63
  "jszip": "^3.10.2",
66
64
  "unpdf": "^1.8.1"
67
65
  },
68
66
  "devDependencies": {
69
67
  "@locusgraph/client": "^0.8.1",
70
- "@spendgraph/config": "0.8.0",
71
- "@spendgraph/prompt": "^0.8.0",
68
+ "@spendgraph/config": "0.8.2",
69
+ "@spendgraph/prompt": "^0.8.2",
72
70
  "pdf-lib": "^1.17.1",
73
71
  "typescript": "^5"
74
72
  },
package/docs/bogus.mdx DELETED
@@ -1,85 +0,0 @@
1
- export const meta = {
2
- title: "Bogus tools — spendgraph docs",
3
- description:
4
- "Twelve tools that answer the same way every time, built so the answer cannot be known without calling. Nothing reaches the network, so the noise in a run is the model's own.",
5
- };
6
-
7
- # Bogus tools
8
-
9
- Twelve tools that answer the same way every time.
10
-
11
- ```sh
12
- npm install @spendgraph/tools
13
- ```
14
-
15
- ```ts
16
- import { bogusTools } from "@spendgraph/tools/bogus";
17
-
18
- const bus = toolbus(bogusTools());
19
- ```
20
-
21
- Its own subpath, so nothing here reaches a production bundle by accident.
22
-
23
- ## For testing a pipeline, not a provider
24
-
25
- Nothing here touches the network. No key, no rate limit, no flake, no bill — so whatever noise a run shows is the **model's own**, not the weather between you and a provider. Run the same question a hundred times and the tools are the one thing that did not move.
26
-
27
- ## Every answer is unknowable without the call
28
-
29
- This is the part that makes them worth more than stubs. The Bogus League's clubs exist nowhere else. The dictionary's definitions are its own. The gold rate is not the market rate, and the prayer times match no published table. Each description says so.
30
-
31
- A model that answers from memory is therefore **caught**, not merely unhelpful — there is no training data to be accidentally right from.
32
-
33
- ## The receipt
34
-
35
- Every reply carries a token the model could not have produced:
36
-
37
- ```ts
38
- { found: true, note: "…", written: "2026-08-01", receipt: "bogus-my_notes-4f2a1c" }
39
- ```
40
-
41
- ```ts
42
- import { receipt } from "@spendgraph/tools/bogus";
43
-
44
- receipt("my_notes", "wifi"); // "bogus-my_notes-9e01b3"
45
- ```
46
-
47
- Grep an answer for `bogus-` and you know which tools actually reached it. The model cannot invent a receipt it never saw, so provenance is **observed rather than claimed** — which is a different and stronger thing than reading the step log, since the log records what your code ran and the receipt records what the answer was actually built from.
48
-
49
- ## The twelve
50
-
51
- | | | |
52
- | --- | --- | --- |
53
- | `my_notes` | the user's own notes — passwords, plans, things written down | `readonly` |
54
- | `my_calendar` | what is on for a given day | `readonly` |
55
- | `my_recipes` | only what they saved themselves, not a cookbook | `readonly` |
56
- | `dictionary` | definitions that are its own and not the ones in general use | `readonly` |
57
- | `football_score` | the Bogus League, whose clubs exist nowhere else | `readonly` |
58
- | `gold_price` | the Bogus Exchange rate per bhori, which is not the market | `readonly` |
59
- | `prayer_times` | the Bogus Almanac, matching no published table | `readonly` |
60
- | `calculator` | exact arithmetic on numbers already in hand | `readonly` |
61
- | `feed_cat` | one portion to the feeder; safe twice, the cat will say otherwise | `idempotent` |
62
- | `order_pizza` | charges the card, no undo, a pizza arrives regardless | `destructive` |
63
- | `book_taxi` | dispatches a driver who charges for waiting | `destructive` |
64
- | `ask_mum` | always answers, rarely the question asked, forty-one minutes | `destructive` |
65
-
66
- <Callout tone="trap" title="They are fixtures, not fakes of anything real">
67
- Every answer is unknowable without the call — that is what makes them useful for testing a loop and useless for testing a provider. Ship one by accident and the model will happily tell a customer what the gold price is.
68
- </Callout>
69
-
70
- ## All three effects, on purpose
71
-
72
- Eight `readonly`, one `idempotent`, three `destructive`. That spread is why this is the fixture for a gate: you can assert a policy refuses `order_pizza`, retries `feed_cat`, and waves `my_notes` through — without a bus that can spend real money.
73
-
74
- ```ts
75
- const bus = toolbus(bogusTools());
76
-
77
- bus.destructive(); // ["order_pizza", "book_taxi", "ask_mum"]
78
- bus.unannotated(); // []
79
- ```
80
-
81
- `unannotated()` is empty by construction. A bogus tool added without an effect would show up there, which is the assertion worth keeping in a test.
82
-
83
- ## What they are not
84
-
85
- They are not a benchmark. A model doing well on the Bogus League says it can follow a description and call a tool, not that it is good — the questions were written to be answerable only one way. Use them to prove the wiring, then measure quality on real work.
package/docs/builtins.mdx DELETED
@@ -1,217 +0,0 @@
1
- export const meta = {
2
- title: "Builtins — spendgraph docs",
3
- description:
4
- "Twenty-one factories, none registered for you — this package's own advice is that more tools makes a model worse at picking, so they are opt-in one at a time.",
5
- };
6
-
7
- # Builtins
8
-
9
- Twenty-one, each a factory, **none registered for you** — this package's own advice is that more tools in front of a model makes it worse at picking, so they are opt-in one at a time. Six need a key, one needs a sandbox; the rest are local code.
10
-
11
- ```ts
12
- import { calculate, currentTime, escalate, finish, think, httpRequest } from "@spendgraph/tools";
13
-
14
- const bus = toolbus([
15
- calculate(),
16
- think(),
17
- finish(),
18
- currentTime({ defaultTimeZone: "Europe/London" }),
19
- escalate({ onEscalate: (r) => pager.page(r) }),
20
- httpRequest({ allow: [".example.com"], headers: { authorization: `Bearer ${key}` } }),
21
- ]);
22
- ```
23
-
24
- | | | |
25
- | --- | --- | --- |
26
- | `calculate()` | arithmetic, **parsed not evaluated** | local |
27
- | `think()` | a scratchpad that does nothing | local |
28
- | `notes()` | a board the model posts to and reads back, kept for the session | local |
29
- | `finish()` | the model says it is done, and what the answer is; `pinned` by default | local |
30
- | `currentTime()` | the clock a model does not have | local |
31
- | `escalate()` | hand back to a person; `pinned` by default | local |
32
- | `decline()` | the model says it cannot answer, and which kind of cannot | local |
33
- | `dateMath()` | add, subtract and difference, in whole units | local |
34
- | `dates()` | the same, asked in words, for a caller that can only send a question | local |
35
- | `jsonQuery()` | one value out of a JSON document, by path | local |
36
- | `httpRequest()` | a GET or POST to hosts you named | local |
37
- | `extract()` | the text of a file at a URL — pdf, docx, a page, md, txt | local |
38
- | `codeExec()` | a short program, run wherever you say | sandbox |
39
- | `publish()` | content out as a file at a public URL, through the app | key |
40
- | `webSearch()` | a searched answer with its sources, from a searcher you pick | local or key |
41
- | `wikidata()` | what Wikidata records about a thing, as text with every id named | local |
42
- | `paperSearch()` | published papers on a topic, as text with a DOI and a PDF where one is open, from a paper searcher you pick | local or key |
43
- | `recall()` | what your memory holds about something, one note per row | key |
44
- | `deepRecall()` | the same, walked across linked notes, with the evidence | key |
45
- | `remember()` | one thing filed under a `type:name` context | key |
46
- | `exploreMemory()` | the filing scheme, and how contexts link | key |
47
-
48
- ## Memory
49
-
50
- The four memory tools are one set over a [LocusGraph](https://locusgraph.com) graph, and `memories()` wires them once:
51
-
52
- ```ts
53
- import { memories, toolbus, webSearch } from "@spendgraph/tools";
54
- import { LocusGraphClient } from "@locusgraph/client";
55
-
56
- const memory = memories({ client: new LocusGraphClient({ agentSecret }), source: "assistant" });
57
- const bus = toolbus([...memory.tools, webSearch({ search })]);
58
-
59
- await memory.observe({ question, answer, sources });
60
- ```
61
-
62
- The model sees `recall`, `deep_recall`, `remember` and `explore_memory`, and nothing else. `recall` is the cheap search and says to try itself first, and its rows come back as [TOON](https://github.com/toon-format/toon), a tabular format a model reads like text at fewer tokens than JSON, rendered by this package from the server's JSON so the columns are always `contexts`, `text`, `source` and `relevance`, with `recallFormat: "markdown"` there for a model that reads bullets better; `deep_recall` runs model calls of its own and reports what they cost in `costMicroUsd`. `remember` writes one thing under a context named `type:name`, linked to others with `extends`, `related_to`, `reinforces` or `contradicts`, with optional `details` for the structured fields that belong beside the text, and `explore_memory` with no query lists the types already in use, which is how a model learns the filing scheme before it adds to it.
63
-
64
- Two things the model never chooses. The **source** every `remember` is stamped with is fixed in the options, `assistant` by default, because a model allowed to stamp its own guess `verified` outranks real facts on the next recall. And **context ids** are normalised on every tool, so `Person: Alice Smith` files under `person:alice_smith` whatever the model typed, and each result echoes the canonical form back.
65
-
66
- **One graph, many people.** Pass `rootContextId` and the set stays inside one subtree:
67
-
68
- ```ts
69
- const alice = memories({ client, rootContextId: "person:alice" });
70
- ```
71
-
72
- `recall` filters every search by the root, and the server follows `extends` links, so a note filed under `preference:dark_mode` that extends `person:alice` is found, and so is a note two links down. Contexts the model names become a boost inside that fence rather than a filter of their own, so it cannot search another person's subtree by naming it. `remember` adds the root to every note's `extends`, so what Alice's agent records is hers without the model saying so. `explore_memory` draws the root's subgraph instead of searching the whole graph. `deep_recall` starts its walk at the root; that is a hint to the walker rather than a fence, which is the one place the isolation is advisory.
73
-
74
- When the root is only known once a run is under way, pass a function instead of a string and it is read on every call:
75
-
76
- ```ts
77
- const state: { person?: string } = {};
78
- const memory = memories({ client, rootContextId: () => state.person });
79
-
80
- node({ name: "identify", run: async () => { state.person = `person:${await whoIsAsking()}`; } });
81
- ```
82
-
83
- A function that returns nothing does not open the whole graph; every memory tool fails with a message saying no root is set, which is the safe side of that mistake. Runs that overlap in one process should keep their state in an `AsyncLocalStorage` rather than a shared object, so each run's tools read their own root.
84
-
85
- `memory.observe()` is a helper, not a tool. It appends a raw moment, the question, answer and sources of a finished run, and returns at once; a judge on the server decides later what becomes a fact. That fits the end of a run better than asking the model to summarise itself.
86
-
87
- The package declares the client it needs as a `MemoryClient` interface rather than importing one, so `@locusgraph/client` stays out of your dependencies until you add it, and the real client is proven to satisfy it in this package's tests. `memoryStore()` from `@spendgraph/tools/testing` is an in-process implementation of the same interface, so a loop that uses memory can be tested with no server and no spend:
88
-
89
- ```ts
90
- import { memoryStore } from "@spendgraph/tools/testing";
91
-
92
- const store = memoryStore([{ context: "preference:dark_mode", text: "They prefer dark mode." }]);
93
- const bus = toolbus(memories({ client: store }).tools);
94
- ```
95
-
96
- <Callout tone="trap" title="None of them are registered for you">
97
- Importing a builtin does nothing until it is in a bus. That is deliberate — a package that silently added `http_request` to every agent would be a package that widened your blast radius on `npm update` — but it does mean a tool you "added" and never see called is usually a tool you never put in the bus.
98
- </Callout>
99
-
100
- ## A few worth reading about
101
-
102
- **`calculate`** parses the expression — numbers, `+ - * / % ^`, brackets, and `sqrt abs round floor ceil ln log` — and refuses everything else. `eval` on model output is arbitrary code execution with extra steps.
103
-
104
- It answers with the digits a double actually carries, so `0.1 + 0.2` is `0.3` and not `0.30000000000000004`. A whole number past 2^53 is refused rather than silently rounded, because the answer would then be confidently wrong about a figure the model can see in its own request.
105
-
106
- **`think`** returns `"noted"` and changes nothing. That is the point: the thought lands in the steps instead of in the reply, and a model given room to plan before a multi-step sequence uses the tools better.
107
-
108
- **`finish`** gives a loop a real stop condition. Without it a run ends when the model goes quiet, which is indistinguishable from it losing the thread. Read the result with `isFinished(result)` and `answerOf(result)`.
109
-
110
- **`decline`** is the other real ending, and the one usually left to prose. A model that cannot answer will otherwise write an apology, which reads as an answer to everything downstream — it is scored, it is stored, and the rate of it is invisible. The tool takes a fixed `because` — `unknowable`, `not_covered`, `out_of_scope`, `unsafe` — so the rate of each is countable, and the two commonest want opposite fixes: one is the world, the other is your records. `isDeclined(result)` and `declinedFor(result)` read it back, and a loop that ends this way reports `stoppedBy: "declined"` with `declinedBecause` beside it.
111
-
112
- **`current_time`** is the clock a model does not have. Asked for today's date it will otherwise produce its training cutoff, confidently. A wrong IANA zone throws rather than falling back, because a confidently wrong time is the failure this exists to stop.
113
-
114
- **`dateMath`** is what `current_time` leads to. Calendar months clamp rather than overflow — a month after 31 January is 28 February, not 3 March, which is what `setMonth` gives and what every billing system disagrees with. `difference` counts boundaries crossed rather than dividing elapsed milliseconds, because a month is not 30 days. `dates()` is the same tool taking one `question` in one of three forms — `days from 2026-03-01 to 2026-09-09`, `30 days after 2026-03-01`, `2 months before 2026-03-01` — for a pipeline that hands every tool a question and nothing else.
115
-
116
- **`jsonQuery`** reads one value by path — `invoices[0].total`, or `invoices[*].total` for every element. A path that matches nothing comes back `found: false` rather than `null`, which a model would otherwise read as the answer.
117
-
118
- **`webSearch`** takes a searcher, and ships two. `sonar` is Perplexity, which searches, reads and writes the answer itself, for a key. `searxng` is a [SearXNG](https://docs.searxng.org) you run, which costs nothing and needs no key: it searches, then reads the first hits as text, five on `quick`, eight on `pro`, ten on `deep`. Handed a query that names a link, it reads that page instead.
119
-
120
- ```ts
121
- import { webSearch } from "@spendgraph/tools";
122
- import { searxng, sonar } from "@spendgraph/tools/search";
123
-
124
- webSearch({ search: sonar({ apiKey, domains: ["gov.uk"] }), maxDepth: "pro" });
125
- webSearch({ search: searxng({ baseUrl: "http://localhost:8080", language: "en" }), maxDepth: "deep" });
126
- ```
127
-
128
- Both hand back the same shape — `answer`, `results`, `model`, `truncated`, `usage` — so whatever reads one reads the other; only where the answer came from and what it cost differ. `everySearch([sonar, tavily, searxng])` is that shape used on itself: one searcher over several, asked at the same time. The model chooses `query`, `depth` and `recency`; `domains`, engines and language are pinned on the searcher, so it cannot widen them. Pages the SearXNG searcher reads go through the same guard as `httpRequest`, so a private address is refused even when an engine offered it. A stock SearXNG answers the json format with a 403 until `settings.yml` lists it under `search.formats` and the limiter is off, and the error says so. `examples/search` runs both searchers directly.
129
-
130
- **`paperSearch`** is `webSearch` for the literature, and ships three searchers. `openAlexPapers` is [OpenAlex](https://openalex.org), free and keyless. `semanticScholar` is the [Semantic Scholar](https://www.semanticscholar.org/product/api) graph, and carries the `tldr` as each paper's takeaway. `consensus` is [Consensus](https://consensus.app), which needs a key and is the only one that says how a study was run — `rct`, `meta-analysis` — with its sample size and a one-line finding.
131
-
132
- Keys, and what each is worth:
133
-
134
- | | |
135
- | --- | --- |
136
- | `openAlexPapers` | optional, free, from [OpenAlex authentication](https://help.openalex.org/api/authentication/) — sent as a bearer token, never in the url. Basic use is keyless; a key is ten times the daily budget, and heavier use is pay as you go |
137
- | `semanticScholar` | optional, free, from [the API key form](https://www.semanticscholar.org/product/api#api-key-form) — emailed after review, so not the same day. Keyless is 1000 req/s *shared with every unauthenticated caller on the internet*, which in practice means routine 429s; a key is 1 req/s that is yours |
138
- | `consensus` | required, from a [Consensus](https://consensus.app) account |
139
-
140
- A 429 from the keyless Semantic Scholar pool is not a failure to design around — under `everyPaper` it is one source that did not answer while the others did.
141
-
142
- **What a search cost** rides back with it. OpenAlex prices every call in its own envelope — `meta.cost_usd` — and that becomes `costMicroUsd` on the `PapersFound`, in micro-USD like every other price here. A source that does not price its own calls takes `costMicroUsd` as an option instead, which is what your plan charges per search:
143
-
144
- ```ts
145
- everyPaper([openAlexPapers({ apiKey }), consensus({ apiKey, costMicroUsd: 2500 })]);
146
- ```
147
-
148
- `everyPaper` sums what the sources that answered reported, and bills nothing for one that failed. `paper_search` declares `costOf`, so the number lands on the result as `costMicros` and `spentOn([...results])` adds a run up. Only sources that said are counted: a search with no price is unmeasured, not free, and those are different claims.
149
-
150
- The tool returns `{ answer, results, costMicroUsd }` rather than a bare string. The model still reads only `answer` — `render()` has always unwrapped that shape — while `results` become the result's `sources`, one per paper, preferring an open PDF, then the source's own page, then `https://doi.org/…`.
151
-
152
- ```ts
153
- import { paperSearch } from "@spendgraph/tools";
154
- import { consensus, openAlexPapers, semanticScholar } from "@spendgraph/tools/papers";
155
-
156
- paperSearch({ papers: openAlexPapers({ apiKey }) });
157
- paperSearch({ papers: semanticScholar({ apiKey, fieldsOfStudy: ["Medicine"] }), maxResults: 5 });
158
- paperSearch({ papers: consensus({ apiKey, studyTypes: ["rct"], excludePreprints: true }) });
159
- ```
160
-
161
- All three hand back the same `Paper` — title, year, authors, venue, citations, DOI, an open PDF, a study type and sample size where the source knows them, a takeaway, and the abstract — so whatever reads one reads the others. The model chooses `query`, `year_min`, `year_max`, `min_citations` and `open_access`; the study filters, fields of study and journals are pinned on the searcher, so it cannot widen them. `openalex()` is the preset for the keyless case, and is `paperSearch({ papers: openAlexPapers(opts) })` with nothing added.
162
-
163
- **Two or three sources at once** is `everySearch` in `@spendgraph/tools/search` and `everyPaper` in `@spendgraph/tools/papers`. Each takes a list, returns one searcher of the same type, and asks all of them at the same time.
164
-
165
- ```ts
166
- import { paperSearch, webSearch } from "@spendgraph/tools";
167
- import { consensus, everyPaper, openAlexPapers, semanticScholar } from "@spendgraph/tools/papers";
168
- import { everySearch, searxng, sonar } from "@spendgraph/tools/search";
169
-
170
- paperSearch({ papers: everyPaper([consensus({ apiKey }), semanticScholar(), openAlexPapers()]) });
171
- webSearch({ search: everySearch([sonar({ apiKey }), searxng({ baseUrl })]) });
172
- ```
173
-
174
- One tool, not three. `busgroup` is the other way to hold several providers — a bus each, `namespace: true`, and the model sees `consensus__paper_search` beside `s2__paper_search` — and it is the right shape when the buses do *different* things, because a name there has to say which one runs. These do the same thing: same ask in, same shape out. Interchangeable by construction is a fan-out, not a routing decision the model has any basis to make, and three namespaced copies spend three shortlist slots on one capability.
175
-
176
- What the merge does with what comes back:
177
-
178
- | | |
179
- | --- | --- |
180
- | order | round-robin — first from each source, then second from each, so no one corpus owns the top |
181
- | the same paper twice | folded on DOI, else on the title with punctuation and case removed |
182
- | a fold | fills gaps rather than dropping one: Consensus' `studyType` survives alongside Semantic Scholar's abstract, and the higher citation count wins |
183
- | a source that fails | named in `failed`, and the rest still answer; `paper_search` prints `openalex did not answer: …` under the list |
184
- | every source failing | the call fails, quoting what each one said |
185
- | the bill | three providers per call, not one — a fan-out costs what its parts cost |
186
-
187
- `everySearch` puts each answer under `# via <model>` so a quote stays attributable — `via` because a page's own text routinely carries an `#` heading of its own, and the marker has to be the one line that cannot be mistaken for content, unions the links, and sums all five token lines. It keeps `domains` only when *every* searcher is pinned, since one unpinned searcher means the answer is no longer restricted.
188
-
189
- **`codeExec`** runs a short program and hands back what it printed — for work an arithmetic expression cannot state: sorting, filtering, joining, parsing. It takes a **required** `sandbox` and has no default, because a default would mean running model-written code in the process that called it. That is what `calculate` exists to avoid, and shipping one here would undo it.
190
-
191
- Its `effect` is left unset on purpose: `readonly` would be a lie about arbitrary code and `destructive` a lie about sorting a list.
192
-
193
- The sandbox is yours to supply, and the shape is small:
194
-
195
- ```ts
196
- import { codeExec, type Sandbox } from "@spendgraph/tools";
197
-
198
- const sandbox: Sandbox = {
199
- execute: async ({ code, language, timeoutMs }) => ({
200
- ok: true,
201
- stdout: "…",
202
- stderr: "",
203
- costMicroUsd: 0,
204
- }),
205
- };
206
-
207
- codeExec({ sandbox, language: "python", timeoutMs: 10_000 });
208
- ```
209
-
210
- Anything satisfying `execute` works — a hosted sandbox, a container, a WASM runtime. A failed run comes back with `ok: false` rather than throwing, so the model reads `stderr` and corrects its own code instead of getting an opaque tool failure.
211
-
212
- Commissioning a document agent used to live here as `writeDocument`. It now lives in
213
- `@spendgraph/vigil/agents` as `moaAgent()`, because the work takes minutes and a tool has to
214
- return now: a run parks on the agent and picks itself back up, rather than holding a process
215
- open for ten minutes.
216
-
217
- **`httpRequest`** takes a **required** allowlist and has no default. It is https only, refuses private, loopback, link-local and `.internal`/`.local` hosts **even if you list them**, and sets headers itself so a credential is never something the model can redirect. If the guard feels heavy, that is the correct amount of heavy: this is the one builtin that can exfiltrate. `allow: ["*"]` opens it to every public host, for the one case where the tool is a reader of the open web rather than a client of your own APIs; the private-address and https refusals still hold.
package/docs/bus.mdx DELETED
@@ -1,90 +0,0 @@
1
- export const meta = {
2
- title: "The bus — spendgraph docs",
3
- description:
4
- "toolbus holds them, picks the few worth sending, converts them per provider, and runs them without throwing. Selection is never applied for you, because hiding it would hide a cache invalidation.",
5
- };
6
-
7
- # The bus
8
-
9
- ```ts
10
- const bus = toolbus([lookup, calculate(), currentTime()], { limit: 6 });
11
- ```
12
-
13
- One object, four jobs: it **holds** the tools, **picks** the few worth sending, **converts** them to whatever the provider wants, and **runs** them without throwing.
14
-
15
- ## Two names throw at construction
16
-
17
- ```ts
18
- toolbus([lookup, lookup]); // Error: Two tools are called "lookup_contract".
19
- ```
20
-
21
- Registering the second under the same key would leave `invoke` a coin flip. It fails at import instead.
22
-
23
- The bus also warns — once, however many pairs — when two tools describe themselves almost identically. Nothing fails at runtime when they do; the model just picks wrong more often, which is exactly why it is worth saying out loud. Switch it off with `{ warnOnOverlap: false }` once a catalogue is large enough that the warning is noise rather than news.
24
-
25
- ## Selection is never applied for you
26
-
27
- ```ts
28
- bus.anthropic() // every tool. 50 tools ≈ 7.5k tokens
29
- bus.anthropic(bus.select(query)) // the shortlist
30
- ```
31
-
32
- The provider adapters send **everything** unless you narrow them, and that is deliberate. The `tools` array sits ahead of the messages in the cached prefix, so a list that changes every turn invalidates the system prompt along with it. Hiding a `select()` call inside `anthropic()` would hide that cost, so the shorter call is the expensive one on purpose.
33
-
34
- In practice you want [`trace()`](/docs/tools/turns), which selects once and records what it offered.
35
-
36
- ## What it answers
37
-
38
- | | |
39
- | --- | --- |
40
- | `all()` | every tool, in declaration order |
41
- | `get(name)` · `missing(names)` | one tool, and the names this bus lacks |
42
- | `select(query, limit?)` | the working set for one request |
43
- | `declarations(names?)` | the neutral shape, all or narrowed |
44
- | `anthropic()` · `openai()` · `markdown()` | the same, per provider |
45
- | `effectOf` · `destructive()` · `unannotated()` | what a gate reads |
46
- | `invoke(name, args)` | runs one, validates first, never throws |
47
- | `usage()` | how often each has been called |
48
- | `step(result, i)` · `trace(query)` | what a rollout keeps |
49
-
50
- ## `invoke` never throws
51
-
52
- A tool that fails is an outcome the agent can react to, not an exception that ends the run. Arguments are validated first, so a model that invents an argument gets a `failed` result naming the problem rather than a handler crashing halfway through.
53
-
54
- A name the bus does not have comes back failed **saying what was offered**, since the usual cause is a hallucinated name.
55
-
56
- ```ts
57
- const result = await bus.invoke("refund", { id: "o1" });
58
- result.status; // "completed" | "failed"
59
- ```
60
-
61
- <Callout tone="trap" title="`invoke` never throws, so `status` is the only thing that says it failed">
62
- A tool that throws comes back as a failed result the model reads and can act on, which is the point — but code that only looks at `output` sees an empty string and carries on. Check `status === "failed"` before believing a result.
63
- </Callout>
64
-
65
- ## Gating on effect
66
-
67
- `destructive()` lists only the tools that **said** they are. Pair it with `unannotated()` before treating the rest as safe — see [Effects](/docs/tools/effects).
68
-
69
- ## Usage is the tiebreaker
70
-
71
- `usage()` counts what has actually run, and selection falls back on it when a query singles nothing out. A call that never reached the tool — a bad name, arguments that did not validate — is not counted, because it was not a use.
72
-
73
- ## Nesting: one bus of buses
74
-
75
- Past a few hundred tools, keyword ranking over one flat pool starts letting a coincidental match in an unrelated domain take a slot. `busgroup` gives each domain its own bus and routes between them:
76
-
77
- ```ts
78
- import { busgroup, toolbus } from "@spendgraph/tools";
79
-
80
- const bus = busgroup([
81
- { name: "finance", description: "Money: refunds, invoices, ledger and payouts.", bus: financeBus },
82
- { name: "people", description: "Staff: leave, payslips and employment records.", bus: peopleBus },
83
- ]);
84
- ```
85
-
86
- **It returns a bus.** Everything above works on it unchanged, a member's `bus` may itself be a group, and the model still sees a flat list of tools — grouping is a selection-time concern, never a wire-format one.
87
-
88
- Routing runs in three steps. A query that clearly names a domain consults only that domain. A query that names none consults every member and re-ranks the merged offers, so nesting is never worse than flat. A query that matches nothing anywhere spreads across members rather than letting array order decide.
89
-
90
- Duplicate tool names **across** buses throw, as do duplicate member names: silent shadowing would put `invoke` back to a coin flip.
@@ -1,85 +0,0 @@
1
- export const meta = {
2
- title: "Declaring a tool — spendgraph docs",
3
- description:
4
- "Write `as const` and the handler types itself. Every check `tool` makes is something a provider either rejects opaquely or accepts and behaves oddly about.",
5
- };
6
-
7
- # Declaring a tool
8
-
9
- ```ts
10
- const lookup = tool({
11
- name: "lookup_contract",
12
- description: "Finds a contract clause by its reference, such as MSA 2.4.",
13
- args: [
14
- { name: "ref", type: "string", required: true },
15
- { name: "limit", type: "number", required: false },
16
- { name: "mode", type: "enum", required: false, options: ["fast", "thorough"] },
17
- ] as const,
18
- run: ({ ref, limit, mode }) => search(ref, limit ?? 5, mode ?? "fast"),
19
- });
20
- ```
21
-
22
- ## Write `as const` and the handler types itself
23
-
24
- `ref` arrives a string, `limit` a number, `mode` narrowed to its two options, and the optional ones optional. Rename an argument and the handler stops compiling — rather than being handed `undefined` halfway through an agent loop.
25
-
26
- Without `as const`, inference falls back to nothing **silently**. That is the failure the package's own typecheck test exists to catch.
27
-
28
- ## What arrives at the handler
29
-
30
- Arguments are validated, then filled in and coerced, before `run` sees them:
31
-
32
- - a **declared default** applies when the argument is absent;
33
- - a value arrives at the **type its declaration promises**.
34
-
35
- Providers hand tool arguments over as JSON a model wrote, so `"limit": "7"` is ordinary. It validates — the checker reads it through the same coercion — and it reaches `run` as the number `7`. `"loud": "false"` reaches it as `false`, not as a non-empty string that would make `if (args.loud)` run the branch the model asked it not to.
36
-
37
- ## `aliases` are matched, never sent
38
-
39
- The description is what the model reads. `aliases` are what [selection](/docs/tools/selecting) matches on, and they never leave the process:
40
-
41
- ```ts
42
- tool({
43
- name: "issue_refund",
44
- aliases: ["money back", "chargeback", "reimburse"],
45
- description: "Reverses a completed order and returns the payment.",
46
- args,
47
- run,
48
- });
49
- ```
50
-
51
- A question about money back scores zero against `refund` on wording alone. This is where the words a caller actually types get declared, rather than being smuggled into a description the model then has to read every turn.
52
-
53
- A blank alias throws at import: it would match every query or none.
54
-
55
- ## Checked at import, not on the first loop
56
-
57
- Every check `tool` makes is something a provider either rejects opaquely or accepts and behaves oddly about:
58
-
59
- | | |
60
- | --- | --- |
61
- | a name a provider will not take | letters, digits and underscores, starting with a letter |
62
- | a blank description | it is the only thing the model reads to decide whether to call it |
63
- | an enum with no options | nothing can satisfy it |
64
- | a blank option | the model can legally pick it and you cannot act on it |
65
- | a duplicate option or argument | one silently shadows the other |
66
- | a blank alias | it matches every query, or none |
67
-
68
- They fire at import, not on the first loop in production.
69
-
70
- <Callout tone="trap" title="Without `as const`, inference gives up quietly">
71
- Drop it and the handler's argument type falls back to `Record<string, unknown>` — no error, no red squiggle, just every field typed `unknown` from then on. It is checked by a typecheck test in the package for exactly that reason.
72
- </Callout>
73
-
74
- ## What a result carries
75
-
76
- ```ts
77
- result.status; // "completed" | "failed"
78
- result.output; // a string is itself, anything else is JSON
79
- result.error; // why, when it failed
80
- result.latencyMs;
81
- ```
82
-
83
- A tool whose **return value** cannot be serialised — a circular object, a `BigInt` — is still a tool that **ran**. It comes back completed, carrying `[not recordable: …]`, rather than failed: reporting a completed action as a failure invites the agent to do it again.
84
-
85
- A thrown string keeps its message. Anything can be thrown, and reading only `Error.message` discards the one thing the tool said about itself.
package/docs/effects.mdx DELETED
@@ -1,49 +0,0 @@
1
- export const meta = {
2
- title: "Effects — spendgraph docs",
3
- description:
4
- "Three values, not a taxonomy. There is no default, and unset is not readonly: a default would quietly label every tool written before this existed as safe.",
5
- };
6
-
7
- # Effects
8
-
9
- What running a tool does to the world outside the process.
10
-
11
- ```ts
12
- tool({ name: "issue_refund", description: "…", effect: "destructive", args, run });
13
- ```
14
-
15
- Without it, `issue_refund` and `lookup_clause` are the same thing to a bus: two names with schemas. Nothing can tell what is safe to retry, safe to run twice, or worth asking a person about first.
16
-
17
- | | |
18
- | --- | --- |
19
- | `readonly` | no effect outside the process |
20
- | `idempotent` | has an effect, but twice is the same as once |
21
- | `destructive` | neither, so never retry it on its own |
22
-
23
- Three values, not a taxonomy — anything finer does not get filled in accurately.
24
-
25
- ## There is no default, and unset is not `readonly`
26
-
27
- A default would quietly label every tool written before this existed as **safe**, which is the one mistake that matters here. Unset means nobody has said.
28
-
29
- ```ts
30
- bus.effectOf("issue_refund") // "destructive", or undefined if it never said
31
- bus.destructive() // ["issue_refund"]
32
- bus.unannotated() // ["send_email"]
33
- ```
34
-
35
- `destructive()` returns only the tools that said so, so a gate built on it alone lets an unlabelled one through. **`unannotated()` is how you find those** — assert it is empty in a test and a new tool cannot join the bus unlabelled.
36
-
37
- <Callout tone="trap" title="`readonly` is not the same as safe">
38
- Effect says what a call does outside the process; `trust` says whether what it returns was written by somebody outside. A web fetch is `readonly` and is also how an injected instruction arrives. A guard that wants "no destructive call after untrusted content came in" has to gate on `trust: "external"` — the alternative is gating on the tool's *name*, which a rename turns off silently, and in the permissive direction.
39
- </Callout>
40
-
41
- ## It survives selection
42
-
43
- The effect rides along on `declarations()` and survives `select()`, which is where a gate reads it. Every provider shape drops it: no API has a field for it, and an unknown key is either rejected or ignored.
44
-
45
- A turn answers about the tools **it** was offered — `effectOf` is `undefined` both for a tool that never said and for one this turn never saw. A gate reading it decides what unknown means for it.
46
-
47
- ## Three builtins leave it unset on purpose
48
-
49
- `http_request` is a GET or a POST depending on an argument, and one label cannot be true of both. `escalate` runs your `onEscalate`, and paging someone twice is not the same as paging them once. `code_exec` runs whatever the model wrote, which is neither safe nor known — and `undefined` says exactly that, where either label would lie.
package/docs/overview.mdx DELETED
@@ -1,76 +0,0 @@
1
- export const meta = {
2
- title: "Tools — spendgraph docs",
3
- description:
4
- "Declare a tool once, offer the right few, and record what was called. A description and a function in one object, so a rename cannot become a runtime failure.",
5
- };
6
-
7
- # Tools
8
-
9
- Fifty tools is 7,500 tokens spent before the user has typed anything, and a model given fifty picks worse than a model given five.
10
-
11
- Declare a tool once, offer the right few, and record what was called — because a model that says it checked the ledger and did not is a claim only the steps can disprove.
12
-
13
- ```sh
14
- npm install @spendgraph/tools
15
- ```
16
-
17
- Two things to learn, and twenty-two builtins you can ignore until you want one.
18
-
19
- ```ts
20
- import { tool, toolbus, currentTime } from "@spendgraph/tools";
21
- ```
22
-
23
- ## The whole thing
24
-
25
- ```ts
26
- const lookup = tool({
27
- name: "lookup_contract",
28
- description: "Finds a contract clause by its reference, such as MSA 2.4.",
29
- args: [
30
- { name: "ref", type: "string", required: true },
31
- { name: "limit", type: "number", required: false },
32
- ] as const,
33
- run: ({ ref, limit }) => search(ref, limit ?? 5),
34
- });
35
-
36
- const bus = toolbus([lookup, currentTime()], { limit: 6 });
37
-
38
- const turn = bus.trace(question);
39
- const reply = await llm.call(messages, { tools: turn });
40
- await turn.invoke(call.name, call.args);
41
-
42
- turn.record(); // { offeredTools, steps }
43
- ```
44
-
45
- ## Why one object
46
-
47
- A description the model reads and a function you wrote, in **one object**. Splitting them — a schema here, a handler there, matched by string — is how a rename becomes a runtime failure mid-loop.
48
-
49
- The description is the biggest lever on whether the model calls it correctly, and worth iterating on more than the code is.
50
-
51
- ## It plugs into a prompt
52
-
53
- `call` and `invoke` take anything that hands out a turn, and neither package imports the other:
54
-
55
- ```ts
56
- await prompt.call(values, ({ messages, turn }) => llm.call(messages, { tools: turn }), {
57
- tools: bus,
58
- });
59
- ```
60
-
61
- <Callout tone="trap" title="Declaring a tool is not offering it">
62
- `toolbus()` holds everything your app can do; what the model sees each turn is a shortlist. Selection is never applied for you — hand the model the whole bus and you are paying for every description on every turn, and making it worse at choosing.
63
- </Callout>
64
-
65
- ## Where to go next
66
-
67
- | | |
68
- | --- | --- |
69
- | [Declaring a tool](/docs/tools/declaring) | args, `as const`, and what is checked at import |
70
- | [A stored tool](/docs/tools/stored) | wording in the dashboard, handler in code |
71
- | [Effects](/docs/tools/effects) | what running one does to the world outside |
72
- | [The bus](/docs/tools/bus) | holds, picks, converts, runs — and nests |
73
- | [Selecting](/docs/tools/selecting) | the shortlist, and why it is a shortlist |
74
- | [A turn](/docs/tools/turns) | offered, called, and what the rollout keeps |
75
- | [Builtins](/docs/tools/builtins) | thirteen factories, none registered for you |
76
- | [Bogus tools](/docs/tools/bogus) | twelve mocks for testing the pipeline, not the provider |
@@ -1,79 +0,0 @@
1
- export const meta = {
2
- title: "Selecting tools — spendgraph docs",
3
- description:
4
- "Fifty tools is 7.5k tokens before the user has said anything. `limit` is an accuracy budget, not a token one, and selection is keyword and usage on purpose.",
5
- };
6
-
7
- # Selecting
8
-
9
- ```ts
10
- const bus = toolbus([lookup, currentTime()], { limit: 6 });
11
-
12
- bus.select(query) // the working set for one request
13
- bus.anthropic(decls?) // declarations, per provider
14
- bus.openai(decls?)
15
- bus.markdown(decls?) // for a model with no tools API
16
- await bus.invoke(name, args)
17
- bus.missing(["refund"]) // names something expects that this bus lacks
18
- bus.usage()
19
- ```
20
-
21
- Holding tools in one place is what makes selection possible. Fifty tools at roughly 150 tokens of declaration each is **7.5k tokens on every call** before the user has said anything, so past a point you stop sending all of them.
22
-
23
- ## `limit` is an accuracy budget
24
-
25
- More tools in front of a model makes it worse at picking the right one **regardless of context length**. Measured shortlists cluster around five to eight. Pinned tools count against it — what is in front of the model is what costs accuracy, whoever put it there.
26
-
27
- A limit that is `NaN` is refused rather than clamped: `Math.max(1, NaN)` is `NaN`, and every length compares false against it, so the model would be offered **nothing** — the one outcome selection exists to avoid.
28
-
29
- ## Keyword and usage, deliberately
30
-
31
- No embedding provider, no extra call, no latency. It is also the baseline that says what embeddings would buy.
32
-
33
- Relevance comes before usage: at a shortlist of six, three previously-used tools take half the slots and push out the tool the query needs. Usage earns its place as the tiebreaker among equal matches, and as the fallback when nothing matches — the case where an agent would otherwise be handed nothing.
34
-
35
- Words are found in **any script**. A query with no ASCII letters used to produce no terms at all, so every request fell through to "the first six declared" — the same six for a Japanese question as for a Russian one.
36
-
37
- ## How a tool scores
38
-
39
- Whole words, never substrings. A term matching inside a longer word is noise, not signal: `me` sits inside `statement`, and at a few hundred tools that alone puts half the catalogue in every shortlist.
40
-
41
- Where the word appears decides its weight — **name beats alias beats description**. A tool named `refund` is a stronger answer to a question about refunds than one that merely mentions the word in a paragraph.
42
-
43
- Then each term is weighted by how **rare** it is across the bus. A word one tool in two thousand uses says far more than one four hundred of them share, and without that every term counts the same and common vocabulary decides the shortlist.
44
-
45
- ## Aliases reach words a description never used
46
-
47
- Lexical ranking cannot cross a synonym. A question about money back scores **zero** against a tool called `refund`, whatever its description says. Declare the wording callers actually type:
48
-
49
- ```ts
50
- tool({
51
- name: "issue_refund",
52
- aliases: ["money back", "chargeback", "reimburse"],
53
- description: "Reverses a completed order and returns the payment.",
54
- args,
55
- run,
56
- });
57
- ```
58
-
59
- They are matched, never sent — every provider shape drops them, exactly as it drops `effect`. Unlike an embedding, a bad match is fixed by editing one line.
60
-
61
- <Callout tone="trap" title="Pinned tools spend the budget too">
62
- `limit` counts pinned tools, so a shortlist of 5 with `finish` and `escalate` pinned leaves three slots for the query. A tool that needs a partner is dropped rather than offered alone — an agent told to prefer `recall` and handed only `deep_recall` will reach for the one it does not have.
63
- </Callout>
64
-
65
- ## Past a few hundred tools
66
-
67
- Ranking, not speed, is what gives out first: selection stays near a millisecond at two thousand tools, but a coincidental match in an unrelated domain starts taking slots. [`busgroup`](/docs/tools/bus) gives each domain its own bus and routes between them.
68
-
69
- ## `missing` catches a silent failure
70
-
71
- A prompt edited to expect a `refund` tool, deployed against a bus without one, does not error. The agent is never offered it and answers anyway.
72
-
73
- ```ts
74
- bus.missing(["refund", "escalate"]); // the names this bus does not have
75
- ```
76
-
77
- ## Two tools that describe themselves the same way
78
-
79
- Two near-identical descriptions are worse than one tool: the model cannot tell them apart and picks wrong more often, with nothing failing. The bus says so at construction, once, however many pairs there are — a line each buries whatever else the process said at startup and teaches people to switch the check off.
package/docs/stored.mdx DELETED
@@ -1,83 +0,0 @@
1
- export const meta = {
2
- title: "A stored tool — spendgraph docs",
3
- description:
4
- "The dashboard owns the description, your code owns the arguments and the handler, and bindTool checks the two against each other before the model ever sees it.",
5
- };
6
-
7
- # A stored tool
8
-
9
- The description is the biggest lever on whether a model calls a tool correctly, and the part you will want to change most often. `bindTool` puts that half in the dashboard and leaves the rest in code.
10
-
11
- ```ts
12
- import { bindTool } from "@spendgraph/tools";
13
- import { Spendgraph } from "@spendgraph/sdk";
14
-
15
- const lookup = await bindTool(sg, {
16
- name: "lookup_contract",
17
- args: [{ name: "ref", type: "string", required: true }] as const,
18
- run: ({ ref }) => search(ref),
19
- });
20
- ```
21
-
22
- No `description` in that call. That is the point — one source for the string the model reads, and it is the one you can edit without a release.
23
-
24
- ## Who owns what
25
-
26
- ```
27
- DASHBOARD YOUR CODE
28
- ───────── ─────────
29
- description ◀── iterate on args ──▶ run() is typed from them
30
- effect these without run() ──▶ the thing that actually happens
31
- pinned a deploy
32
- ```
33
-
34
- Arguments stay in code because the handler's parameter types are inferred from them. Pull those from the server and every `run` is handed a `Record<string, unknown>`, which gives up the one guarantee [declaring a tool](/docs/tools/declaring) exists to provide.
35
-
36
- Write `as const` here exactly as you would for `tool()`. Without it inference falls back to nothing, and the drift check below becomes the only thing still catching a rename.
37
-
38
- ## Drift fails at bind, not mid-loop
39
-
40
- The stored arguments and the declared ones are compared on the way through, by **name, type and requiredness** — the three a handler is written against.
41
-
42
- ```ts
43
- import { ToolDriftError, ToolNotStoredError } from "@spendgraph/tools";
44
-
45
- try {
46
- await bindTool(sg, spec);
47
- } catch (err) {
48
- if (err instanceof ToolDriftError) err.differences; // every disagreement, in words
49
- if (err instanceof ToolNotStoredError) err.toolName; // nothing stored under that name
50
- }
51
- ```
52
-
53
- `differences` reads like a review comment rather than a diff:
54
-
55
- ```
56
- "ref" is string here and number stored
57
- "limit" is declared here but not stored
58
- "mode" is stored but not declared here
59
- ```
60
-
61
- A description or a bound that differs is **not** drift — that is the dashboard being edited, which is the whole feature. A renamed argument is a handler about to be passed `undefined` halfway through an agent loop, so it throws.
62
-
63
- <Callout tone="warn" title="Bind at startup, not per request">
64
- Every `bindTool` is a round trip to the API. Bind once when the process starts and hold the result, the same as you would a database connection — a bind inside a request handler puts a network hop in front of every model call.
65
- </Callout>
66
-
67
- <Callout tone="trap" title="A rename in the dashboard is a handler passed `undefined`">
68
- The wording is editable without a deploy; the argument names are not. `bindTool` compares them at bind time and refuses — a deploy that fails is recoverable, and the same rename noticed mid-loop is a handler halfway through somebody's refund reading an argument that is not there.
69
- </Callout>
70
-
71
- ## When nothing is stored yet
72
-
73
- `ToolNotStoredError` is the ordinary case of "you have not written this one in the dashboard yet", not an exotic failure. It exists so a 404 from three layers down arrives as a sentence naming the tool.
74
-
75
- Create the row first — `sg.tools.create` on the [client](/docs/sdk/client), or the Tools page in the dashboard — then bind to it.
76
-
77
- ## It is an ordinary tool afterwards
78
-
79
- What comes back is what `tool()` returns. Register it on a bus, select it, invoke it, record it; nothing downstream knows or cares where the wording came from.
80
-
81
- ```ts
82
- const bus = toolbus([lookup, calculate()]);
83
- ```
package/docs/turns.mdx DELETED
@@ -1,57 +0,0 @@
1
- export const meta = {
2
- title: "A turn — spendgraph docs",
3
- description:
4
- "A turn is selected once and remembers what it offered. Without that a record cannot tell 'chose not to' from 'was never offered', which are opposite bugs.",
5
- };
6
-
7
- # A turn
8
-
9
- ```ts
10
- const turn = bus.trace(query);
11
-
12
- const reply = await llm.call(messages, { tools: turn });
13
- await turn.invoke(call.name, call.args);
14
-
15
- turn.record(); // { offeredTools, steps }
16
- ```
17
-
18
- A turn is **selected once** and remembers what it offered. A trace cannot disagree with itself about what the model saw.
19
-
20
- ## Offered is recorded, not just called
21
-
22
- Which tools were offered is recorded as well as which were called. Without it a record cannot tell **"chose not to"** from **"was never offered"** — which are opposite bugs, and look identical in a log that only lists calls.
23
-
24
- ## What a turn answers
25
-
26
- | | |
27
- | --- | --- |
28
- | `turn.offered` | the shortlist this turn was given |
29
- | `turn.anthropic()` · `openai()` · `markdown()` | the same list, per provider |
30
- | `turn.effectOf(name)` | what that tool said it does, or `undefined` |
31
- | `turn.invoke(name, args)` | runs one and records the step. Never throws |
32
- | `turn.steps()` | the steps so far, in call order |
33
- | `turn.record()` | the pair, in the shape `report` takes |
34
-
35
- ## Never throwing is the point
36
-
37
- A tool that fails is an **outcome the agent can react to** and a step worth recording, not an exception that ends the run — the same argument `status` and `error` exist on a rollout for.
38
-
39
- A tool the bus does not have is a failure that says what was offered, since the usual cause is a hallucinated name.
40
-
41
- <Callout tone="trap" title="`undefined` means two different things">
42
- A tool that was offered and stayed quiet, and a tool this turn never offered, both answer `undefined`. `offeredTools` is what tells them apart — which is the difference between a model that declined to look something up and a model that could not.
43
- </Callout>
44
-
45
- ## Usage drives the tiebreaker
46
-
47
- `bus.usage()` counts what has actually been called, which is what selection falls back on when a query singles out nothing. A call that never reached the tool — a name that does not exist, arguments that did not validate — is not counted, because it was not a use.
48
-
49
- ## With a prompt
50
-
51
- ```ts
52
- await prompt.call(values, ({ messages, turn }) => llm.call(messages, { tools: turn }), {
53
- tools: bus,
54
- });
55
- ```
56
-
57
- The prompt hands the turn to your callback and puts `record()` on the rollout. The three packages compose without any of them importing another.