@spendgraph/tools 0.2.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 CHANGED
@@ -172,9 +172,9 @@ const bus = toolbus([
172
172
  | `finish()` | the model says it is done, and what the answer is |
173
173
  | `currentTime()` | the clock a model does not have |
174
174
  | `escalate()` | hand back to a person; `pinned` by default |
175
- | `httpRequest()` | a GET or POST to hosts you named |
176
175
  | `dateMath()` | add, subtract and difference, in whole units |
177
176
  | `jsonQuery()` | one value out of a JSON document, by path |
177
+ | `httpRequest()` | a GET or POST to hosts you named |
178
178
  | `writeDocument()` | a document agent that returns a rendered file |
179
179
 
180
180
  **`calculate`** parses the expression — numbers, `+ - * / % ^`, brackets, and
@@ -189,6 +189,11 @@ a multi-step sequence uses the tools better.
189
189
  model goes quiet, which is indistinguishable from it losing the thread. Read the
190
190
  result with `isFinished(result)` and `answerOf(result)`.
191
191
 
192
+ **`current_time`** is the clock a model does not have. Asked for today's date it
193
+ will otherwise produce its training cutoff, confidently. A wrong IANA zone throws
194
+ rather than falling back, because a confidently wrong time is the failure this
195
+ exists to stop.
196
+
192
197
  **`escalate`** is `pinned: true`, because a refusal route lost to a similarity
193
198
  score is exactly the failure `pinned` exists for — the one turn that needs it is
194
199
  the turn where nothing else fit. Without an `onEscalate` it records the request
@@ -216,16 +221,6 @@ model can redirect. `"api.example.com"` matches that host; `".example.com"`
216
221
  matches it and any subdomain. If the guard feels heavy, that is the correct
217
222
  amount of heavy: this is the one builtin that can exfiltrate.
218
223
 
219
- ## `current_time`
220
-
221
- ```ts
222
- toolbus([currentTime({ defaultTimeZone: "Europe/London" })]);
223
- ```
224
-
225
- A model has no clock, and asked for today's date it will confidently produce its
226
- training cutoff. A wrong IANA zone throws rather than falling back, because a
227
- confidently wrong time is the failure this exists to stop.
228
-
229
224
  ## `write_document`
230
225
 
231
226
  ```ts
@@ -281,3 +276,7 @@ node examples/02-select/01-shortlist.mjs
281
276
  | `@spendgraph/tools/internals` | `selectTools` · `score` · `invokeTool` · `newTrace` · `toStep` · `warnOnOverlap` |
282
277
 
283
278
  A bus already wires all of `internals`; reach for it to build your own.
279
+
280
+ ## License
281
+
282
+ MIT
@@ -1,11 +1,5 @@
1
1
  import { tool } from "../../tool/index.js";
2
2
  import { evaluate } from "./parse.js";
3
- /**
4
- * Arithmetic, because a model does it from memory and is confidently wrong.
5
- *
6
- * The expression is parsed, never evaluated — `eval` on model output is
7
- * arbitrary code execution with extra steps.
8
- */
9
3
  export function calculate() {
10
4
  return tool({
11
5
  name: "calculate",
@@ -125,13 +125,6 @@ function additive(r) {
125
125
  return value;
126
126
  }
127
127
  }
128
- /**
129
- * Arithmetic, parsed rather than evaluated.
130
- *
131
- * `eval` on model output is arbitrary code execution with extra steps. This
132
- * reads numbers, the five operators, brackets and a short list of functions,
133
- * and refuses everything else.
134
- */
135
128
  export function evaluate(expression) {
136
129
  const reader = new Reader(tokenize(expression));
137
130
  const value = additive(reader);
@@ -1,13 +1,4 @@
1
1
  import { tool } from "../../tool/index.js";
2
- /**
3
- * What time it is.
4
- *
5
- * The one thing every model is confidently wrong about: weights are frozen at
6
- * training time, so "today" is answered fluently from a stale date.
7
- *
8
- * Built in because every agent needs it and the common mistake — a formatted
9
- * string with no zone — is a different instant depending on where it ran.
10
- */
11
2
  export function currentTime(opts = {}) {
12
3
  const now = opts.now ?? (() => new Date());
13
4
  return tool({
@@ -35,17 +26,12 @@ export function currentTime(opts = {}) {
35
26
  run: ({ timezone, format = "iso" }) => {
36
27
  const at = now();
37
28
  const zone = timezone ?? opts.defaultTimeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
38
- // Thrown rather than silently falling back: a wrong zone gives a
39
- // confidently wrong time, which is the failure this tool exists to stop.
40
29
  try {
41
30
  new Intl.DateTimeFormat("en-GB", { timeZone: zone });
42
31
  }
43
32
  catch {
44
33
  throw new Error(`"${zone}" is not a known IANA time zone.`);
45
34
  }
46
- // `iso` is a UTC instant, so the whole answer is UTC. Formatting the
47
- // weekday in the requested zone while reporting UTC put Saturday and
48
- // Sunday in one object near midnight.
49
35
  const answerZone = format === "iso" ? "UTC" : zone;
50
36
  const parts = (options) => new Intl.DateTimeFormat("en-GB", {
51
37
  timeZone: answerZone,
@@ -63,7 +49,6 @@ export function currentTime(opts = {}) {
63
49
  hour12: false,
64
50
  })
65
51
  : at.toISOString();
66
- // The zone travels with the answer, and every field uses the one named.
67
52
  return {
68
53
  now: formatted,
69
54
  iso: at.toISOString(),
@@ -1,12 +1,6 @@
1
1
  import { tool } from "../../tool/index.js";
2
2
  import { between, parseDate, shift } from "./shift.js";
3
3
  const UNITS = ["seconds", "minutes", "hours", "days", "weeks", "months", "years"];
4
- /**
5
- * Date arithmetic, because a model does it in its head and drops a leap year.
6
- *
7
- * `current_time` says when now is; this works out what follows from it —
8
- * deadlines, ages, notice periods, how long ago something was.
9
- */
10
4
  export function dateMath() {
11
5
  return tool({
12
6
  name: "date_math",
@@ -5,7 +5,6 @@ const MS = {
5
5
  days: 86_400_000,
6
6
  weeks: 604_800_000,
7
7
  };
8
- /** Parses an ISO instant, or says which argument was not one. */
9
8
  export function parseDate(value, label) {
10
9
  const at = new Date(value);
11
10
  if (!Number.isFinite(at.getTime())) {
@@ -13,13 +12,6 @@ export function parseDate(value, label) {
13
12
  }
14
13
  return at;
15
14
  }
16
- /**
17
- * Adds calendar months, clamping rather than overflowing.
18
- *
19
- * `setUTCMonth` turns 31 January plus one month into 3 March, which is nobody's
20
- * idea of a month later. The last day of a short month is the answer people
21
- * mean, and the answer every billing system gives.
22
- */
23
15
  function addMonths(from, months) {
24
16
  const day = from.getUTCDate();
25
17
  const shifted = new Date(from.getTime());
@@ -29,7 +21,6 @@ function addMonths(from, months) {
29
21
  shifted.setUTCDate(Math.min(day, lastOfMonth));
30
22
  return shifted;
31
23
  }
32
- /** Moves an instant by a whole number of units. Negative goes backwards. */
33
24
  export function shift(from, amount, unit) {
34
25
  if (!Number.isInteger(amount))
35
26
  throw new Error("amount must be a whole number.");
@@ -42,12 +33,6 @@ export function shift(from, amount, unit) {
42
33
  throw new Error(`"${unit}" is not a unit this tool knows.`);
43
34
  return new Date(from.getTime() + amount * ms);
44
35
  }
45
- /**
46
- * How far apart two instants are, in whole units.
47
- *
48
- * Calendar units count boundaries crossed rather than dividing elapsed
49
- * milliseconds, because a month is not 30 days and a year is not 365.
50
- */
51
36
  export function between(from, to, unit) {
52
37
  if (unit === "months" || unit === "years") {
53
38
  const months = (to.getUTCFullYear() - from.getUTCFullYear()) * 12 +
@@ -1,11 +1,5 @@
1
1
  import { tool } from "../../tool/index.js";
2
2
  const DEFAULT_MAX_FACTS = 20;
3
- /**
4
- * Your own knowledge base, answered with cited evidence.
5
- *
6
- * Cost comes back as micro-USD because deep recall runs model calls of its own,
7
- * and that spend is invisible to whatever loop called this.
8
- */
9
3
  export function deepRecall(opts) {
10
4
  const maxFacts = opts.maxFacts ?? DEFAULT_MAX_FACTS;
11
5
  return tool({
@@ -1,13 +1,4 @@
1
1
  import { tool } from "../../tool/index.js";
2
- /**
3
- * Hand back to a person rather than guess.
4
- *
5
- * An agent with no way to stop answers anyway, and a confident wrong answer to
6
- * something it could not do is worse than a handover.
7
- *
8
- * Its `effect` is left unset on purpose: `onEscalate` is yours, and paging
9
- * someone twice is not the same as paging them once.
10
- */
11
2
  export function escalate(opts = {}) {
12
3
  return tool({
13
4
  name: "escalate",
@@ -1,10 +1,4 @@
1
1
  import { tool } from "../../tool/index.js";
2
- /**
3
- * True when this result is the model saying it is done.
4
- *
5
- * A loop that ends because the model stopped asking for tools cannot tell
6
- * finished from confused. This makes the difference explicit.
7
- */
8
2
  export function isFinished(result) {
9
3
  if (result.name !== "finish" || result.status !== "completed")
10
4
  return false;
@@ -15,18 +9,11 @@ export function isFinished(result) {
15
9
  return false;
16
10
  }
17
11
  }
18
- /** Reads the answer out of a finished result, or null if it is not one. */
19
12
  export function answerOf(result) {
20
13
  if (!isFinished(result))
21
14
  return null;
22
15
  return JSON.parse(result.output).answer;
23
16
  }
24
- /**
25
- * The model says it is done, and says what the answer is.
26
- *
27
- * Without it a loop ends by the model going quiet, which is indistinguishable
28
- * from it losing the thread.
29
- */
30
17
  export function finish() {
31
18
  return tool({
32
19
  name: "finish",
@@ -11,19 +11,11 @@ function isPrivateHost(host) {
11
11
  return true;
12
12
  return PRIVATE_V4.test(name);
13
13
  }
14
- /** Matches a host exactly, or any subdomain when the rule starts with a dot. */
15
14
  function matches(host, rule) {
16
15
  const h = host.toLowerCase();
17
16
  const r = rule.toLowerCase();
18
17
  return r.startsWith(".") ? h === r.slice(1) || h.endsWith(r) : h === r;
19
18
  }
20
- /**
21
- * The URL a request may go to, or the reason it may not.
22
- *
23
- * An allowlist rather than a blocklist, and https only. A tool that fetches
24
- * whatever a model names is how an agent reads a cloud metadata endpoint and
25
- * hands back the credentials it finds.
26
- */
27
19
  export function checkUrl(raw, allow) {
28
20
  let url;
29
21
  try {
@@ -2,16 +2,6 @@ import { tool } from "../../tool/index.js";
2
2
  import { checkUrl } from "./allow.js";
3
3
  const DEFAULT_TIMEOUT_MS = 10_000;
4
4
  const DEFAULT_MAX_CHARS = 100_000;
5
- /**
6
- * An HTTP GET or POST, to hosts you named.
7
- *
8
- * The allowlist is required because the alternative is a tool that fetches
9
- * whatever a model is talked into naming. Headers are set here, not by the
10
- * model, so a credential is never something it can redirect.
11
- *
12
- * Its `effect` is left unset on purpose: a GET is readonly and a POST to your
13
- * allowlist may be anything at all, and one label cannot be true of both.
14
- */
15
5
  export function httpRequest(opts) {
16
6
  if (!opts.allow?.length) {
17
7
  throw new Error("httpRequest needs an allow list of hosts; there is no safe default.");
@@ -1,13 +1,6 @@
1
1
  import { tool } from "../../tool/index.js";
2
2
  import { readPath } from "./path.js";
3
3
  const DEFAULT_MAX_CHARS = 20_000;
4
- /**
5
- * Pulls one value out of a JSON document.
6
- *
7
- * The alternative is putting the whole reply in the context and asking the model
8
- * to read it, which costs tokens on every turn and gets the field wrong when the
9
- * document is long.
10
- */
11
4
  export function jsonQuery(opts = {}) {
12
5
  const maxChars = opts.maxChars ?? DEFAULT_MAX_CHARS;
13
6
  return tool({
@@ -36,13 +36,6 @@ function step(value, segment) {
36
36
  }
37
37
  return value;
38
38
  }
39
- /**
40
- * Reads a value out of parsed JSON by path.
41
- *
42
- * `a.b`, `a[0].b`, and `a[*].b` for every element — enough to pull a field out
43
- * of an API reply, and small enough that the syntax is guessable from one
44
- * example, which matters when the thing writing the path is a model.
45
- */
46
39
  export function readPath(document, path) {
47
40
  let cursor = document;
48
41
  let spread = false;
@@ -1,11 +1,4 @@
1
1
  import { tool } from "../../tool/index.js";
2
- /**
3
- * Somewhere to reason without it becoming the answer.
4
- *
5
- * It does nothing, which is the point: the thought lands in the steps rather
6
- * than in the reply, and a model given room to plan before a multi-step tool
7
- * sequence uses the tools better.
8
- */
9
2
  export function think() {
10
3
  return tool({
11
4
  name: "think",
@@ -5,14 +5,6 @@ function sources(raw) {
5
5
  }
6
6
  return (raw.citations ?? []).map((url) => ({ url }));
7
7
  }
8
- /**
9
- * The provider's reply as the tool hands it back.
10
- *
11
- * Sources come from `search_results` where the account returns them and from
12
- * `citations` otherwise, so one tool works across both reply shapes. Citation
13
- * and reasoning tokens are their own line items on a grounded model, and
14
- * `computeCostMicros` takes both — dropping them prices a paid call at zero.
15
- */
16
8
  export function readReply(body, fallbackModel, maxChars, maxResults) {
17
9
  const raw = body;
18
10
  const answer = raw.choices?.[0]?.message?.content ?? "";
@@ -22,14 +22,6 @@ function describe(maxDepth, domains) {
22
22
  : " deep is not enabled here.") +
23
23
  (domains?.length ? ` Results come only from ${domains.join(", ")}.` : ""));
24
24
  }
25
- /**
26
- * The live web, through Perplexity's Sonar models.
27
- *
28
- * The tier is the model's to choose and yours to cap: `sonar-deep-research` is
29
- * minutes and orders of magnitude, and a model reaching for it unprompted is
30
- * the failure `maxDepth` exists to prevent. Tokens come back rather than a
31
- * price, because what a search costs is the pricing table's business.
32
- */
33
25
  export function webSearch(opts) {
34
26
  if (!opts.apiKey?.trim()) {
35
27
  throw new Error("webSearch needs a Perplexity apiKey; it will not read one from the process.");
@@ -46,13 +46,6 @@ function taskText(input) {
46
46
  task += deliverablesBlock(input.artifacts);
47
47
  return task;
48
48
  }
49
- /**
50
- * HTTP {@link CommissionClient} over Moa's task API (`POST /tasks` → `GET /tasks/:id`).
51
- *
52
- * The request body carries the ask as prose — Moa's artifact pipeline is
53
- * prompt-driven and `POST /tasks` strips unknown fields, so context, design and
54
- * deliverables are blocks on the task rather than parameters beside it.
55
- */
56
49
  export function createCommissionClient(config = {}) {
57
50
  const request = createRequest(config);
58
51
  return {
@@ -92,12 +85,10 @@ export function createCommissionClient(config = {}) {
92
85
  },
93
86
  };
94
87
  }
95
- /** The sane ceiling for a chat-bound call. A rendered document wants minutes, not this. */
96
88
  export const INTERACTIVE_TIMEOUT_MS = 30_000;
97
89
  const DEFAULT_POLL_INTERVAL_MS = 2_000;
98
90
  const SETTLE_ATTEMPTS = 3;
99
91
  const LONGPOLL_WINDOW_MS = 25_000;
100
- /** Thrown by {@link pollUntilDone} when the caller's abort signal fires. */
101
92
  export class CommissionAbortedError extends Error {
102
93
  commissionId;
103
94
  constructor(commissionId) {
@@ -106,7 +97,6 @@ export class CommissionAbortedError extends Error {
106
97
  this.commissionId = commissionId;
107
98
  }
108
99
  }
109
- /** Thrown when the wait ran out. The task itself is still running server-side. */
110
100
  export class CommissionTimeoutError extends Error {
111
101
  commissionId;
112
102
  timeoutMs;
@@ -126,7 +116,6 @@ const sleep = (ms, signal) => new Promise((resolve) => {
126
116
  resolve();
127
117
  }, ms);
128
118
  });
129
- /** Wait for a commission to settle, long-polling rather than tight-polling. */
130
119
  export async function pollUntilDone(client, id, opts = {}) {
131
120
  const timeoutMs = opts.timeoutMs ?? INTERACTIVE_TIMEOUT_MS;
132
121
  const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
@@ -1,9 +1,3 @@
1
- /**
2
- * What a document looks like when nobody has said.
3
- *
4
- * Chosen to be defensible rather than distinctive: one typeface, one accent,
5
- * real whitespace, and figures that carry a single message.
6
- */
7
1
  export const DEFAULT_DESIGN = {
8
2
  name: "Spendgraph house style",
9
3
  fonts: { heading: "Inter", body: "Inter", mono: "JetBrains Mono" },
@@ -46,12 +40,6 @@ function mergeGroup(base, over) {
46
40
  return base;
47
41
  return { ...base, ...over };
48
42
  }
49
- /**
50
- * A caller's guide over the defaults: named fields win, `rules` accumulate.
51
- *
52
- * Rules append because they are constraints — a brand adding "never use red"
53
- * means it as well as the defaults, not instead of them.
54
- */
55
43
  export function mergeDesign(base, over) {
56
44
  if (!over)
57
45
  return base;
@@ -64,7 +52,6 @@ export function mergeDesign(base, over) {
64
52
  };
65
53
  }
66
54
  const line = (label, value) => (value ? [`${label}: ${value}`] : []);
67
- /** The guide as the block Moa reads, since its artifact pipeline is prompt-driven. */
68
55
  export function renderDesign(guide, format, notes) {
69
56
  const { fonts, palette } = guide;
70
57
  const parts = [
@@ -1,8 +1,6 @@
1
- /** Where Moa lives, unless a config says otherwise. */
2
1
  export const MOA_BASE_URL = "https://api.fnmoa.com";
3
2
  const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
4
3
  const ERROR_DETAIL_CHARS = 500;
5
- /** A non-2xx from Moa, carrying the status so a caller can tell 4xx from 5xx. */
6
4
  export class MoaApiError extends Error {
7
5
  status;
8
6
  detail;
@@ -37,7 +35,6 @@ function deadlineSignal(timeoutMs, caller) {
37
35
  },
38
36
  };
39
37
  }
40
- /** One `request<T>(path, opts)` against Moa, with auth, timeout and error shape settled. */
41
38
  export function createRequest(config = {}) {
42
39
  const doFetch = config.fetch ?? globalThis.fetch;
43
40
  const base = (config.baseUrl ?? MOA_BASE_URL).replace(/\/+$/, "");
@@ -48,14 +48,6 @@ function stillRendering(id, style, err) {
48
48
  "say so rather than commissioning it again.",
49
49
  };
50
50
  }
51
- /**
52
- * A document agent, briefed in your house style.
53
- *
54
- * Style is the deployment's business and content is the model's: the guide
55
- * comes from `options.design`, and `design_notes` may bend it for one document
56
- * rather than replace it. `effect` is `destructive` because a second call is a
57
- * second bill and a second set of files — never something to retry blindly.
58
- */
59
51
  export function writeDocument(opts) {
60
52
  if (!opts.client && !opts.apiKey?.trim()) {
61
53
  throw new Error("writeDocument needs a Moa apiKey; it will not read one from the process.");
package/dist/bus/bus.js CHANGED
@@ -4,16 +4,6 @@ import { warnOnOverlap } from "./overlap.js";
4
4
  import { selectTools } from "./select.js";
5
5
  import { toStep } from "./step.js";
6
6
  import { newTrace } from "./trace.js";
7
- /**
8
- * The tools an agent can reach, and how it reaches them.
9
- *
10
- * Holding them in one place is what makes selection possible. Fifty tools at
11
- * roughly 150 tokens of declaration each is 7.5k tokens on every call before the
12
- * user has said anything, so past a point you stop sending all of them.
13
- *
14
- * Selection is keyword and usage, deliberately: no embedding provider, no extra
15
- * call, no latency. It is also the baseline that says what embeddings would buy.
16
- */
17
7
  export function toolbus(tools, opts = {}) {
18
8
  const now = opts.now ?? (() => Date.now());
19
9
  const ceiling = Math.max(1, opts.limit ?? 6);
@@ -23,7 +13,6 @@ export function toolbus(tools, opts = {}) {
23
13
  throw new Error(`Two tools are called "${t.name}".`);
24
14
  byName.set(t.name, t);
25
15
  }
26
- /** How often each has been called, so the useful ones stay offered. */
27
16
  const calls = new Map();
28
17
  if (opts.warnOnOverlap ?? true)
29
18
  warnOnOverlap([...byName.values()]);
@@ -35,68 +24,25 @@ export function toolbus(tools, opts = {}) {
35
24
  });
36
25
  const all = () => [...byName.values()].map(declare);
37
26
  const bus = {
38
- /** Every tool, in declaration order. */
39
27
  all: () => [...byName.values()],
40
- /**
41
- * Names something expects that this bus does not have.
42
- *
43
- * The failure it catches is silent: a prompt edited to expect a `refund`
44
- * tool, deployed against a bus without one, does not error. The agent is
45
- * never offered it and answers anyway.
46
- */
47
28
  missing: (names) => names.filter((n) => !byName.has(n)),
48
29
  get: (name) => byName.get(name),
49
- /**
50
- * What one tool declares calling it does. `undefined` when it never said.
51
- *
52
- * A gate reading this must decide what unknown means for it. It is not
53
- * `readonly` — nothing has claimed that.
54
- */
55
30
  effectOf: (name) => byName.get(name)?.effect,
56
- /**
57
- * Names of the tools declared `destructive`.
58
- *
59
- * Only the ones that said so. Pair it with `unannotated` before treating
60
- * the rest as safe, or a tool nobody got round to labelling passes a gate
61
- * built on this.
62
- */
63
31
  destructive: () => [...byName.values()].filter((t) => t.effect === "destructive").map((t) => t.name),
64
- /** Names of the tools with no effect declared, so a gate can refuse to guess. */
65
32
  unannotated: () => [...byName.values()].filter((t) => t.effect === undefined).map((t) => t.name),
66
- /** Declarations for the model. Neutral shape; adapt per provider. */
67
33
  declarations: (names) => names
68
34
  ? names
69
35
  .map((n) => byName.get(n))
70
36
  .filter(Boolean)
71
37
  .map((t) => declare(t))
72
38
  : all(),
73
- /**
74
- * Everything, in the shape the Anthropic Messages API takes.
75
- *
76
- * Selection is not applied for you — pass a shortlist to narrow it. The
77
- * `tools` array sits ahead of the messages in the cached prefix, so a list
78
- * that changes every turn invalidates the system prompt with it, and hiding
79
- * the `select` call in here would hide that.
80
- */
81
39
  anthropic: (decls) => toAnthropic(decls ?? all()),
82
- /** Everything, in the shape the OpenAI chat completions API takes. */
83
40
  openai: (decls) => toOpenAI(decls ?? all()),
84
- /**
85
- * Everything as markdown, to append to a rendered system prompt.
86
- *
87
- * For a model with no tools API. Where there is one, prefer it: this is
88
- * cheaper in tokens and worse in every other way.
89
- */
90
41
  markdown: (decls, heading) => toMarkdown(decls ?? all(), heading),
91
- /** The working set for one request. */
92
42
  select: (query = "", limit) => selectTools([...byName.values()], calls, declare, query, Math.max(1, limit ?? ceiling)),
93
- /** Runs one, validating its arguments first. Never throws. */
94
43
  invoke: (name, args) => invokeTool(byName, calls, now, name, args),
95
- /** How many times each has been called on this bus. */
96
44
  usage: () => Object.fromEntries(calls),
97
- /** A result as a rollout step, for `report({ steps })`. */
98
45
  step: (result, index) => toStep(result, index),
99
- /** One turn's tool use, selected once and recorded for the rollout. */
100
46
  trace: (query = "", limit) => newTrace(bus.select(query, limit), (name, args) => bus.invoke(name, args)),
101
47
  };
102
48
  return bus;
@@ -1,11 +1,4 @@
1
1
  import { FieldValidationError, validateFields } from "@spendgraph/sdk";
2
- /**
3
- * Runs one tool, validating its arguments first.
4
- *
5
- * Never throws. A tool that fails is an outcome the agent can react to and a
6
- * step worth recording, not an exception that ends the run — the same argument
7
- * `status` and `error` exist on a rollout for.
8
- */
9
2
  export async function invokeTool(byName, calls, now, name, args) {
10
3
  const started = now();
11
4
  const found = byName.get(name);
@@ -1,22 +1,10 @@
1
- /** Words long enough to carry meaning, for comparing two descriptions. */
2
1
  function words(tool) {
3
2
  return new Set(tool.description
4
3
  .toLowerCase()
5
4
  .split(/[^a-z0-9]+/)
6
5
  .filter((w) => w.length > 3));
7
6
  }
8
- /**
9
- * Warns once about tools that describe themselves the same way.
10
- *
11
- * Two near-identical descriptions are worse than one tool: the model cannot tell
12
- * them apart and picks wrong more often, with nothing failing. Said at
13
- * construction because it is invisible at runtime.
14
- *
15
- * One line however many pairs. A line each buries whatever else the process said
16
- * at startup and teaches people to switch the check off.
17
- */
18
7
  export function warnOnOverlap(tools) {
19
- // Built once per tool, not once per pair — sixty tools is 1,770 pairs.
20
8
  const sets = tools.map(words);
21
9
  const named = [];
22
10
  let pairs = 0;
package/dist/bus/score.js CHANGED
@@ -1,10 +1,3 @@
1
- /**
2
- * Overlap between the query and a tool's own words.
3
- *
4
- * The name counts for more than the description: "refund" in a question is a
5
- * stronger signal about the `refund` tool than the same word buried in a
6
- * paragraph about it.
7
- */
8
1
  export function score(tool, terms) {
9
2
  const name = tool.name.toLowerCase().replace(/_/g, " ");
10
3
  const body = `${tool.description} ${tool.args.map((a) => `${a.name} ${a.description ?? ""}`).join(" ")}`.toLowerCase();
@@ -1,20 +1,4 @@
1
1
  import { score } from "./score.js";
2
- /**
3
- * The working set for one request: pinned, then what the query matches, then
4
- * what actually gets used.
5
- *
6
- * Relevance before usage, which is the opposite of what this did first. At a
7
- * shortlist of twelve the order was harmless; at six, three previously-used
8
- * tools take half the slots and push out the tool the query needs.
9
- *
10
- * Usage still earns its place as the tiebreaker among equal matches, and as the
11
- * fallback when nothing matches — the case where an agent would otherwise be
12
- * handed nothing.
13
- *
14
- * How many come back depends on how clearly the query matched. A single strong
15
- * match needs no company; a flat spread means the ranking does not know, and a
16
- * wider net is worth the distraction.
17
- */
18
2
  export function selectTools(tools, calls, declare, query, ceiling) {
19
3
  const terms = query
20
4
  .toLowerCase()
@@ -25,8 +9,6 @@ export function selectTools(tools, calls, declare, query, ceiling) {
25
9
  if (!chosen.includes(t) && chosen.length < ceiling)
26
10
  chosen.push(t);
27
11
  };
28
- // Counted against the ceiling like everything else: what is in front of the
29
- // model is what costs accuracy, whoever put it there.
30
12
  for (const t of tools)
31
13
  if (t.pinned)
32
14
  take(t);
@@ -35,10 +17,6 @@ export function selectTools(tools, calls, declare, query, ceiling) {
35
17
  .map((t) => ({ t, n: score(t, terms) }))
36
18
  .filter((x) => x.n > 0)
37
19
  .sort((a, b) => b.n - a.n || (calls.get(b.t.name) ?? 0) - (calls.get(a.t.name) ?? 0));
38
- // The depth applies to the slots selection is free to fill, not the whole
39
- // list. Measured against the whole list it was a bug: four pinned tools
40
- // already exceed a shortened target of three, so the loop below broke on its
41
- // first iteration and dropped an exact name match with slots still free.
42
20
  const room = ceiling - chosen.length;
43
21
  const decisive = ranked.length > 1 && ranked[0].n >= ranked[1].n * 2;
44
22
  const budget = chosen.length + (decisive ? Math.min(3, room) : room);
@@ -47,8 +25,6 @@ export function selectTools(tools, calls, declare, query, ceiling) {
47
25
  break;
48
26
  take(t);
49
27
  }
50
- // Up to the budget, not the ceiling — padding a decisive match back out to
51
- // the full limit would undo the shortening entirely.
52
28
  const byUse = [...rest].sort((a, b) => (calls.get(b.name) ?? 0) - (calls.get(a.name) ?? 0));
53
29
  for (const t of byUse) {
54
30
  if (chosen.length >= budget)
@@ -57,9 +33,6 @@ export function selectTools(tools, calls, declare, query, ceiling) {
57
33
  break;
58
34
  take(t);
59
35
  }
60
- // Only when the query singled out nothing. An agent handed no tools cannot
61
- // act; an agent handed four unrelated ones alongside the obvious answer just
62
- // has more to get wrong.
63
36
  if (ranked.length === 0) {
64
37
  for (const t of rest) {
65
38
  if (chosen.length >= ceiling)
package/dist/bus/step.js CHANGED
@@ -1,4 +1,3 @@
1
- /** A result as a rollout step, for `report({ steps })`. */
2
1
  export function toStep(result, index) {
3
2
  return {
4
3
  index,
package/dist/bus/trace.js CHANGED
@@ -1,17 +1,5 @@
1
1
  import { toAnthropic, toMarkdown, toOpenAI, } from "../wire/index.js";
2
2
  import { toStep } from "./step.js";
3
- /**
4
- * One turn's worth of tool use, holding the bookkeeping a rollout needs.
5
- *
6
- * Two things must survive from the moment a shortlist is chosen to the moment
7
- * the run is reported: which tools were offered, and what each call did in
8
- * order. Losing the first is worse than it sounds — a rollout that says the
9
- * agent never called `refund` cannot distinguish "chose not to" from "was never
10
- * offered it", which are opposite bugs.
11
- *
12
- * Selection happens once, here, and the same list is what gets reported. A trace
13
- * cannot disagree with itself about what the model saw.
14
- */
15
3
  export function newTrace(offered, invoke) {
16
4
  const steps = [];
17
5
  return {
package/dist/internals.js CHANGED
@@ -1,9 +1,3 @@
1
- /**
2
- * The pieces `toolbus` is built from.
3
- *
4
- * Not in the main entry: a bus already wires all of it, and a caller reaching
5
- * here is building their own. Kept exported so that stays possible.
6
- */
7
1
  export { evaluate } from "./builtin/calculate/index.js";
8
2
  export { between, parseDate, shift } from "./builtin/date/index.js";
9
3
  export { checkUrl } from "./builtin/http/index.js";
package/dist/tool/bind.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import { tool } from "./tool.js";
2
- /** The stored declaration disagrees with the one this code was written against. */
3
2
  export class ToolDriftError extends Error {
4
3
  toolName;
5
4
  differences;
@@ -10,7 +9,6 @@ export class ToolDriftError extends Error {
10
9
  this.name = "ToolDriftError";
11
10
  }
12
11
  }
13
- /** Nothing is stored under that name, so there is no wording to bind to. */
14
12
  export class ToolNotStoredError extends Error {
15
13
  toolName;
16
14
  constructor(toolName) {
@@ -19,14 +17,6 @@ export class ToolNotStoredError extends Error {
19
17
  this.name = "ToolNotStoredError";
20
18
  }
21
19
  }
22
- /**
23
- * Every way the stored arguments differ from the declared ones.
24
- *
25
- * Compared by name, type and requiredness — the three a handler is written
26
- * against. A description or a bound that differs is the dashboard being edited,
27
- * which is the point; a renamed argument is a handler about to be passed
28
- * `undefined` halfway through an agent loop.
29
- */
30
20
  function drift(declared, stored) {
31
21
  const out = [];
32
22
  const byName = new Map(stored.map((a) => [a.name, a]));
@@ -49,27 +39,12 @@ function drift(declared, stored) {
49
39
  }
50
40
  return out;
51
41
  }
52
- /**
53
- * A stored declaration bound to the function that runs it.
54
- *
55
- * The dashboard owns the description, the effect and the pin — the parts you
56
- * iterate on and want live without a release. This code owns the arguments and
57
- * the handler, and the two are checked against each other on the way through:
58
- * a rename in the dashboard fails here, at bind time, rather than as an
59
- * `undefined` mid-loop.
60
- *
61
- * Declare `args` with `as const` as you would for `tool()`, or inference falls
62
- * back to `Record<string, unknown>` and the check above is the only thing left
63
- * catching a rename.
64
- */
65
42
  export async function bindTool(sg, spec, query = {}) {
66
43
  let stored;
67
44
  try {
68
45
  ({ tool: stored } = await sg.tools.get(spec.name, query));
69
46
  }
70
47
  catch (err) {
71
- // A 404 is the ordinary case of "you have not written this one yet", and
72
- // saying so beats a status code from three layers down.
73
48
  if (err && typeof err === "object" && "status" in err && err.status === 404) {
74
49
  throw new ToolNotStoredError(spec.name);
75
50
  }
package/dist/tool/tool.js CHANGED
@@ -1,4 +1,3 @@
1
- /** Names a provider will accept: letters, digits and underscores. */
2
1
  const VALID_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
3
2
  const EFFECTS = ["readonly", "idempotent", "destructive"];
4
3
  function checkEnum(toolName, arg) {
@@ -30,16 +29,6 @@ function checkArgs(toolName, args) {
30
29
  checkEnum(toolName, arg);
31
30
  }
32
31
  }
33
- /**
34
- * Declares a tool, checking what is a silent failure otherwise.
35
- *
36
- * Every check here is something a provider either rejects opaquely or accepts
37
- * and behaves oddly about. Caught at import, not on the first loop in prod.
38
- *
39
- * Declare `args` with `as const` and `run` is typed from them — rename an
40
- * argument and the handler stops compiling, rather than being handed
41
- * `undefined` halfway through an agent loop.
42
- */
43
32
  export function tool(spec) {
44
33
  if (!VALID_NAME.test(spec.name)) {
45
34
  throw new Error(`Tool name "${spec.name}" must be letters, digits and underscores, starting with a letter.`);
@@ -7,16 +7,6 @@ export const JSON_TYPE = {
7
7
  list: "array",
8
8
  json: "object",
9
9
  };
10
- /**
11
- * A field's default as a value of its own type.
12
- *
13
- * `FieldSpec.default` is already-rendered text, so `"5"` on a number field has
14
- * to come back as `5` — a schema saying `number` and defaulting to `"5"` is
15
- * something providers accept and models then copy.
16
- *
17
- * `list` and `json` defaults are joined and printed forms that cannot be parsed
18
- * back, so they are dropped rather than guessed at.
19
- */
20
10
  function coerceDefault(field) {
21
11
  const raw = field.default;
22
12
  if (raw === undefined)
@@ -43,12 +33,6 @@ function coerceDefault(field) {
43
33
  return raw;
44
34
  }
45
35
  }
46
- /**
47
- * `FieldSpec[]` as a JSON Schema object.
48
- *
49
- * `separator`, `trueText` and `falseText` are dropped: they interpolate a value
50
- * into a string, which a tool call does not do. So is `datasetKey`.
51
- */
52
36
  export function toJsonSchema(args) {
53
37
  const properties = {};
54
38
  for (const field of args) {
@@ -1,10 +1,4 @@
1
1
  import { JSON_TYPE } from "./json-schema.js";
2
- /**
3
- * Neutralises headings inside text about to become markdown.
4
- *
5
- * A `###` at the start of a line would close the tool's own section and open one
6
- * the prompt author never wrote.
7
- */
8
2
  function escapeHeadings(text) {
9
3
  return text.replace(/^(\s*)(#{1,6})(\s|$)/gm, "$1\\$2$3");
10
4
  }
@@ -24,13 +18,6 @@ function argLine(field) {
24
18
  const head = `- \`${field.name}\` (${type}${field.required ? ", required" : ""})`;
25
19
  return notes.length ? `${head} ${notes.join(". ")}.` : `${head}`;
26
20
  }
27
- /**
28
- * Declarations as markdown, for a model with no tools API.
29
- *
30
- * About a quarter cheaper than the JSON, since JSON Schema is mostly
31
- * scaffolding — but you give up structured tool calls and parse intent out of
32
- * prose instead. Only worth it where there is no native `tools` array.
33
- */
34
21
  export function toMarkdown(decls, heading = "## Tools") {
35
22
  if (decls.length === 0)
36
23
  return "";
@@ -1,5 +1,4 @@
1
1
  import { toJsonSchema } from "./json-schema.js";
2
- /** Declarations in the shape the Anthropic Messages API takes. */
3
2
  export function toAnthropic(decls) {
4
3
  return decls.map((d) => ({
5
4
  name: d.name,
@@ -7,7 +6,6 @@ export function toAnthropic(decls) {
7
6
  input_schema: toJsonSchema(d.args),
8
7
  }));
9
8
  }
10
- /** Declarations in the shape the OpenAI chat completions API takes. */
11
9
  export function toOpenAI(decls) {
12
10
  return decls.map((d) => ({
13
11
  type: "function",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spendgraph/tools",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Declare a tool once, offer the right few, and record what was called.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -43,16 +43,16 @@
43
43
  "README.md"
44
44
  ],
45
45
  "scripts": {
46
- "build": "tsc -p tsconfig.json",
46
+ "build": "tsc -p tsconfig.json --emitDeclarationOnly && tsc -p tsconfig.json --declaration false --removeComments",
47
47
  "test": "vitest run",
48
48
  "examples": "npm run build && node examples/02-select/01-shortlist.mjs"
49
49
  },
50
50
  "dependencies": {
51
51
  "@locusgraph/client": "^0.8.1",
52
- "@spendgraph/sdk": "^0.2.0"
52
+ "@spendgraph/sdk": "^0.2.1"
53
53
  },
54
54
  "devDependencies": {
55
- "@spendgraph/prompt": "^0.2.0",
55
+ "@spendgraph/prompt": "^0.2.1",
56
56
  "typescript": "^5"
57
57
  },
58
58
  "engines": {