@spendgraph/tools 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +283 -0
  2. package/dist/builtin/calculate/calculate.d.ts +13 -0
  3. package/dist/builtin/calculate/calculate.js +27 -0
  4. package/dist/builtin/calculate/index.d.ts +2 -0
  5. package/dist/builtin/calculate/index.js +2 -0
  6. package/dist/builtin/calculate/parse.d.ts +8 -0
  7. package/dist/builtin/calculate/parse.js +144 -0
  8. package/dist/builtin/clock/clock.d.ts +27 -0
  9. package/dist/builtin/clock/clock.js +76 -0
  10. package/dist/builtin/clock/index.d.ts +1 -0
  11. package/dist/builtin/clock/index.js +1 -0
  12. package/dist/builtin/date/date.d.ts +32 -0
  13. package/dist/builtin/date/date.js +47 -0
  14. package/dist/builtin/date/index.d.ts +2 -0
  15. package/dist/builtin/date/index.js +2 -0
  16. package/dist/builtin/date/shift.d.ts +12 -0
  17. package/dist/builtin/date/shift.js +62 -0
  18. package/dist/builtin/deep-recall/deep-recall.d.ts +27 -0
  19. package/dist/builtin/deep-recall/deep-recall.js +51 -0
  20. package/dist/builtin/deep-recall/index.d.ts +1 -0
  21. package/dist/builtin/deep-recall/index.js +1 -0
  22. package/dist/builtin/escalate/escalate.d.ts +46 -0
  23. package/dist/builtin/escalate/escalate.js +29 -0
  24. package/dist/builtin/escalate/index.d.ts +1 -0
  25. package/dist/builtin/escalate/index.js +1 -0
  26. package/dist/builtin/finish/finish.d.ts +27 -0
  27. package/dist/builtin/finish/finish.js +39 -0
  28. package/dist/builtin/finish/index.d.ts +1 -0
  29. package/dist/builtin/finish/index.js +1 -0
  30. package/dist/builtin/http/allow.d.ts +8 -0
  31. package/dist/builtin/http/allow.js +45 -0
  32. package/dist/builtin/http/http.d.ts +43 -0
  33. package/dist/builtin/http/http.js +51 -0
  34. package/dist/builtin/http/index.d.ts +2 -0
  35. package/dist/builtin/http/index.js +2 -0
  36. package/dist/builtin/index.d.ts +11 -0
  37. package/dist/builtin/index.js +11 -0
  38. package/dist/builtin/json/index.d.ts +2 -0
  39. package/dist/builtin/json/index.js +2 -0
  40. package/dist/builtin/json/json.d.ts +22 -0
  41. package/dist/builtin/json/json.js +44 -0
  42. package/dist/builtin/json/path.d.ts +8 -0
  43. package/dist/builtin/json/path.js +63 -0
  44. package/dist/builtin/think/index.d.ts +1 -0
  45. package/dist/builtin/think/index.js +1 -0
  46. package/dist/builtin/think/think.d.ts +13 -0
  47. package/dist/builtin/think/think.js +19 -0
  48. package/dist/builtin/web-search/index.d.ts +1 -0
  49. package/dist/builtin/web-search/index.js +1 -0
  50. package/dist/builtin/web-search/reply.d.ts +23 -0
  51. package/dist/builtin/web-search/reply.js +35 -0
  52. package/dist/builtin/web-search/web-search.d.ts +50 -0
  53. package/dist/builtin/web-search/web-search.js +93 -0
  54. package/dist/builtin/write-document/commission.d.ts +56 -0
  55. package/dist/builtin/write-document/commission.js +163 -0
  56. package/dist/builtin/write-document/design.d.ts +49 -0
  57. package/dist/builtin/write-document/design.js +99 -0
  58. package/dist/builtin/write-document/http.d.ts +34 -0
  59. package/dist/builtin/write-document/http.js +74 -0
  60. package/dist/builtin/write-document/index.d.ts +6 -0
  61. package/dist/builtin/write-document/index.js +4 -0
  62. package/dist/builtin/write-document/types.d.ts +33 -0
  63. package/dist/builtin/write-document/types.js +1 -0
  64. package/dist/builtin/write-document/write-document.d.ts +69 -0
  65. package/dist/builtin/write-document/write-document.js +150 -0
  66. package/dist/bus/bus.d.ts +76 -0
  67. package/dist/bus/bus.js +103 -0
  68. package/dist/bus/index.d.ts +3 -0
  69. package/dist/bus/index.js +2 -0
  70. package/dist/bus/invoke.d.ts +9 -0
  71. package/dist/bus/invoke.js +50 -0
  72. package/dist/bus/options.d.ts +22 -0
  73. package/dist/bus/options.js +1 -0
  74. package/dist/bus/overlap.d.ts +12 -0
  75. package/dist/bus/overlap.js +43 -0
  76. package/dist/bus/score.d.ts +9 -0
  77. package/dist/bus/score.js +19 -0
  78. package/dist/bus/select.d.ts +18 -0
  79. package/dist/bus/select.js +71 -0
  80. package/dist/bus/step.d.ts +4 -0
  81. package/dist/bus/step.js +12 -0
  82. package/dist/bus/trace.d.ts +42 -0
  83. package/dist/bus/trace.js +34 -0
  84. package/dist/index.d.ts +6 -0
  85. package/dist/index.js +3 -0
  86. package/dist/internals.d.ts +16 -0
  87. package/dist/internals.js +16 -0
  88. package/dist/tool/bind.d.ts +42 -0
  89. package/dist/tool/bind.js +89 -0
  90. package/dist/tool/index.d.ts +4 -0
  91. package/dist/tool/index.js +2 -0
  92. package/dist/tool/tool.d.ts +20 -0
  93. package/dist/tool/tool.js +62 -0
  94. package/dist/types/index.d.ts +2 -0
  95. package/dist/types/index.js +1 -0
  96. package/dist/types/result.d.ts +10 -0
  97. package/dist/types/result.js +1 -0
  98. package/dist/types/tool.d.ts +105 -0
  99. package/dist/types/tool.js +1 -0
  100. package/dist/wire/index.d.ts +3 -0
  101. package/dist/wire/index.js +3 -0
  102. package/dist/wire/json-schema.d.ts +26 -0
  103. package/dist/wire/json-schema.js +81 -0
  104. package/dist/wire/markdown.d.ts +9 -0
  105. package/dist/wire/markdown.js +42 -0
  106. package/dist/wire/providers.d.ts +26 -0
  107. package/dist/wire/providers.js +20 -0
  108. package/package.json +64 -0
@@ -0,0 +1,43 @@
1
+ /** Words long enough to carry meaning, for comparing two descriptions. */
2
+ function words(tool) {
3
+ return new Set(tool.description
4
+ .toLowerCase()
5
+ .split(/[^a-z0-9]+/)
6
+ .filter((w) => w.length > 3));
7
+ }
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
+ export function warnOnOverlap(tools) {
19
+ // Built once per tool, not once per pair — sixty tools is 1,770 pairs.
20
+ const sets = tools.map(words);
21
+ const named = [];
22
+ let pairs = 0;
23
+ for (let i = 0; i < tools.length; i++) {
24
+ for (let j = i + 1; j < tools.length; j++) {
25
+ const a = sets[i];
26
+ const b = sets[j];
27
+ if (a.size === 0 || b.size === 0)
28
+ continue;
29
+ const shared = [...a].filter((w) => b.has(w)).length;
30
+ if (shared / Math.min(a.size, b.size) < 0.8)
31
+ continue;
32
+ pairs += 1;
33
+ if (named.length < 3)
34
+ named.push(`"${tools[i].name}" and "${tools[j].name}"`);
35
+ }
36
+ }
37
+ if (pairs === 0)
38
+ return;
39
+ const rest = pairs - named.length;
40
+ console.warn(`[toolbus] ${pairs} ${pairs === 1 ? "pair of tools describes" : "pairs of tools describe"} ` +
41
+ `themselves almost identically: ${named.join(", ")}${rest > 0 ? `, and ${rest} more` : ""}. ` +
42
+ `The model will struggle to choose between them — give them distinct descriptions, or merge them.`);
43
+ }
@@ -0,0 +1,9 @@
1
+ import type { Tool } from "../types/index.js";
2
+ /**
3
+ * Overlap between the query and a tool's own words.
4
+ *
5
+ * The name counts for more than the description: "refund" in a question is a
6
+ * stronger signal about the `refund` tool than the same word buried in a
7
+ * paragraph about it.
8
+ */
9
+ export declare function score(tool: Tool<never>, terms: string[]): number;
@@ -0,0 +1,19 @@
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
+ export function score(tool, terms) {
9
+ const name = tool.name.toLowerCase().replace(/_/g, " ");
10
+ const body = `${tool.description} ${tool.args.map((a) => `${a.name} ${a.description ?? ""}`).join(" ")}`.toLowerCase();
11
+ let n = 0;
12
+ for (const term of terms) {
13
+ if (name.includes(term))
14
+ n += 3;
15
+ else if (body.includes(term))
16
+ n += 1;
17
+ }
18
+ return n;
19
+ }
@@ -0,0 +1,18 @@
1
+ import type { Tool, ToolDeclaration } from "../types/index.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
+ export declare function selectTools(tools: Tool<never>[], calls: Map<string, number>, declare: (t: Tool<never>) => ToolDeclaration, query: string, ceiling: number): ToolDeclaration[];
@@ -0,0 +1,71 @@
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
+ export function selectTools(tools, calls, declare, query, ceiling) {
19
+ const terms = query
20
+ .toLowerCase()
21
+ .split(/[^a-z0-9]+/)
22
+ .filter(Boolean);
23
+ const chosen = [];
24
+ const take = (t) => {
25
+ if (!chosen.includes(t) && chosen.length < ceiling)
26
+ chosen.push(t);
27
+ };
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
+ for (const t of tools)
31
+ if (t.pinned)
32
+ take(t);
33
+ const rest = tools.filter((t) => !t.pinned);
34
+ const ranked = rest
35
+ .map((t) => ({ t, n: score(t, terms) }))
36
+ .filter((x) => x.n > 0)
37
+ .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
+ const room = ceiling - chosen.length;
43
+ const decisive = ranked.length > 1 && ranked[0].n >= ranked[1].n * 2;
44
+ const budget = chosen.length + (decisive ? Math.min(3, room) : room);
45
+ for (const { t } of ranked) {
46
+ if (chosen.length >= budget)
47
+ break;
48
+ take(t);
49
+ }
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
+ const byUse = [...rest].sort((a, b) => (calls.get(b.name) ?? 0) - (calls.get(a.name) ?? 0));
53
+ for (const t of byUse) {
54
+ if (chosen.length >= budget)
55
+ break;
56
+ if ((calls.get(t.name) ?? 0) === 0)
57
+ break;
58
+ take(t);
59
+ }
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
+ if (ranked.length === 0) {
64
+ for (const t of rest) {
65
+ if (chosen.length >= ceiling)
66
+ break;
67
+ take(t);
68
+ }
69
+ }
70
+ return chosen.map(declare);
71
+ }
@@ -0,0 +1,4 @@
1
+ import type { RolloutStep } from "@spendgraph/sdk";
2
+ import type { ToolResult } from "../types/index.js";
3
+ /** A result as a rollout step, for `report({ steps })`. */
4
+ export declare function toStep(result: ToolResult, index: number): RolloutStep;
@@ -0,0 +1,12 @@
1
+ /** A result as a rollout step, for `report({ steps })`. */
2
+ export function toStep(result, index) {
3
+ return {
4
+ index,
5
+ source: result.name,
6
+ input: result.args,
7
+ output: result.output,
8
+ status: result.status,
9
+ error: result.error,
10
+ latencyMs: result.latencyMs,
11
+ };
12
+ }
@@ -0,0 +1,42 @@
1
+ import type { RolloutStep } from "@spendgraph/sdk";
2
+ import type { Effect, ToolDeclaration, ToolResult } from "../types/index.js";
3
+ import { type AnthropicTool, type OpenAITool } from "../wire/index.js";
4
+ /**
5
+ * What one turn recorded: what was offered, and what was called.
6
+ *
7
+ * `record()` returns the pair in the shape `report()` takes, so the whole of the
8
+ * bookkeeping is `harness.report(id, { ...turn.record(), output, … })`.
9
+ */
10
+ export interface ToolTrace {
11
+ /** The shortlist this turn was given, chosen once. */
12
+ offered: ToolDeclaration[];
13
+ anthropic(): AnthropicTool[];
14
+ openai(): OpenAITool[];
15
+ markdown(heading?: string): string;
16
+ /**
17
+ * What a tool offered this turn declared it does, or `undefined` for one that
18
+ * never said — and for one that was never offered.
19
+ */
20
+ effectOf(name: string): Effect | undefined;
21
+ /** Runs a tool and records the step. Never throws, exactly as `invoke`. */
22
+ invoke(name: string, args: Record<string, unknown>): Promise<ToolResult>;
23
+ /** The steps so far, in call order. */
24
+ steps(): RolloutStep[];
25
+ record(): {
26
+ offeredTools: string[];
27
+ steps: RolloutStep[];
28
+ };
29
+ }
30
+ /**
31
+ * One turn's worth of tool use, holding the bookkeeping a rollout needs.
32
+ *
33
+ * Two things must survive from the moment a shortlist is chosen to the moment
34
+ * the run is reported: which tools were offered, and what each call did in
35
+ * order. Losing the first is worse than it sounds — a rollout that says the
36
+ * agent never called `refund` cannot distinguish "chose not to" from "was never
37
+ * offered it", which are opposite bugs.
38
+ *
39
+ * Selection happens once, here, and the same list is what gets reported. A trace
40
+ * cannot disagree with itself about what the model saw.
41
+ */
42
+ export declare function newTrace(offered: ToolDeclaration[], invoke: (name: string, args: Record<string, unknown>) => Promise<ToolResult>): ToolTrace;
@@ -0,0 +1,34 @@
1
+ import { toAnthropic, toMarkdown, toOpenAI, } from "../wire/index.js";
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
+ export function newTrace(offered, invoke) {
16
+ const steps = [];
17
+ return {
18
+ offered,
19
+ anthropic: () => toAnthropic(offered),
20
+ openai: () => toOpenAI(offered),
21
+ markdown: (heading) => toMarkdown(offered, heading),
22
+ effectOf: (name) => offered.find((d) => d.name === name)?.effect,
23
+ invoke: async (name, args) => {
24
+ const result = await invoke(name, args);
25
+ steps.push(toStep(result, steps.length));
26
+ return result;
27
+ },
28
+ steps: () => [...steps],
29
+ record: () => ({
30
+ offeredTools: offered.map((d) => d.name),
31
+ steps: [...steps],
32
+ }),
33
+ };
34
+ }
@@ -0,0 +1,6 @@
1
+ export { type ArtifactFormat, answerOf, type ClockOptions, CommissionAbortedError, type CommissionClient, CommissionTimeoutError, calculate, currentTime, DEFAULT_DESIGN, type DeepRecallOptions, type DesignGuide, dateMath, deepRecall, type EscalateOptions, type EscalationRequest, escalate, type Finished, finish, type HttpOptions, httpRequest, isFinished, type JsonQueryOptions, jsonQuery, mergeDesign, renderDesign, type SearchDepth, think, type WebSearchOptions, type WriteDocumentOptions, webSearch, writeDocument, } from "./builtin/index.js";
2
+ export type { ToolBus, ToolBusOptions, ToolTrace } from "./bus/index.js";
3
+ export { toolbus } from "./bus/index.js";
4
+ export type { BoundSpec, ToolSpec } from "./tool/index.js";
5
+ export { bindTool, ToolDriftError, ToolNotStoredError, tool } from "./tool/index.js";
6
+ export type { ArgSpec, ArgsOf, ArgValue, Effect, Tool, ToolDeclaration, ToolResult, } from "./types/index.js";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { answerOf, CommissionAbortedError, CommissionTimeoutError, calculate, currentTime, DEFAULT_DESIGN, dateMath, deepRecall, escalate, finish, httpRequest, isFinished, jsonQuery, mergeDesign, renderDesign, think, webSearch, writeDocument, } from "./builtin/index.js";
2
+ export { toolbus } from "./bus/index.js";
3
+ export { bindTool, ToolDriftError, ToolNotStoredError, tool } from "./tool/index.js";
@@ -0,0 +1,16 @@
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
+ export { evaluate } from "./builtin/calculate/index.js";
8
+ export { between, parseDate, shift, type Unit } from "./builtin/date/index.js";
9
+ export { checkUrl } from "./builtin/http/index.js";
10
+ export { readPath } from "./builtin/json/index.js";
11
+ export { invokeTool } from "./bus/invoke.js";
12
+ export { warnOnOverlap } from "./bus/overlap.js";
13
+ export { score } from "./bus/score.js";
14
+ export { selectTools } from "./bus/select.js";
15
+ export { toStep } from "./bus/step.js";
16
+ export { newTrace } from "./bus/trace.js";
@@ -0,0 +1,16 @@
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
+ export { evaluate } from "./builtin/calculate/index.js";
8
+ export { between, parseDate, shift } from "./builtin/date/index.js";
9
+ export { checkUrl } from "./builtin/http/index.js";
10
+ export { readPath } from "./builtin/json/index.js";
11
+ export { invokeTool } from "./bus/invoke.js";
12
+ export { warnOnOverlap } from "./bus/overlap.js";
13
+ export { score } from "./bus/score.js";
14
+ export { selectTools } from "./bus/select.js";
15
+ export { toStep } from "./bus/step.js";
16
+ export { newTrace } from "./bus/trace.js";
@@ -0,0 +1,42 @@
1
+ import type { Spendgraph } from "@spendgraph/sdk";
2
+ import type { ArgSpec, ArgsOf, Tool } from "../types/index.js";
3
+ /**
4
+ * A tool whose wording lives in the dashboard.
5
+ *
6
+ * No `description`: that is the half worth editing without a deploy, and having
7
+ * one here too would mean two sources for the string the model reads. The
8
+ * arguments stay in code because `run` is typed from them — pulling those would
9
+ * hand every handler a `Record<string, unknown>`.
10
+ */
11
+ export interface BoundSpec<T extends readonly ArgSpec[]> {
12
+ name: string;
13
+ args: T;
14
+ run(args: ArgsOf<T>): Promise<unknown> | unknown;
15
+ }
16
+ /** The stored declaration disagrees with the one this code was written against. */
17
+ export declare class ToolDriftError extends Error {
18
+ readonly toolName: string;
19
+ readonly differences: string[];
20
+ constructor(toolName: string, differences: string[]);
21
+ }
22
+ /** Nothing is stored under that name, so there is no wording to bind to. */
23
+ export declare class ToolNotStoredError extends Error {
24
+ readonly toolName: string;
25
+ constructor(toolName: string);
26
+ }
27
+ /**
28
+ * A stored declaration bound to the function that runs it.
29
+ *
30
+ * The dashboard owns the description, the effect and the pin — the parts you
31
+ * iterate on and want live without a release. This code owns the arguments and
32
+ * the handler, and the two are checked against each other on the way through:
33
+ * a rename in the dashboard fails here, at bind time, rather than as an
34
+ * `undefined` mid-loop.
35
+ *
36
+ * Declare `args` with `as const` as you would for `tool()`, or inference falls
37
+ * back to `Record<string, unknown>` and the check above is the only thing left
38
+ * catching a rename.
39
+ */
40
+ export declare function bindTool<const T extends readonly ArgSpec[]>(sg: Spendgraph, spec: BoundSpec<T>, query?: {
41
+ project?: string;
42
+ }): Promise<Tool<ArgsOf<T>>>;
@@ -0,0 +1,89 @@
1
+ import { tool } from "./tool.js";
2
+ /** The stored declaration disagrees with the one this code was written against. */
3
+ export class ToolDriftError extends Error {
4
+ toolName;
5
+ differences;
6
+ constructor(toolName, differences) {
7
+ super(`Tool "${toolName}" is stored differently from the code binding it: ${differences.join("; ")}.`);
8
+ this.toolName = toolName;
9
+ this.differences = differences;
10
+ this.name = "ToolDriftError";
11
+ }
12
+ }
13
+ /** Nothing is stored under that name, so there is no wording to bind to. */
14
+ export class ToolNotStoredError extends Error {
15
+ toolName;
16
+ constructor(toolName) {
17
+ super(`No stored tool named "${toolName}".`);
18
+ this.toolName = toolName;
19
+ this.name = "ToolNotStoredError";
20
+ }
21
+ }
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
+ function drift(declared, stored) {
31
+ const out = [];
32
+ const byName = new Map(stored.map((a) => [a.name, a]));
33
+ for (const arg of declared) {
34
+ const found = byName.get(arg.name);
35
+ if (!found) {
36
+ out.push(`"${arg.name}" is declared here but not stored`);
37
+ continue;
38
+ }
39
+ byName.delete(arg.name);
40
+ if (found.type !== arg.type) {
41
+ out.push(`"${arg.name}" is ${arg.type} here and ${found.type} stored`);
42
+ }
43
+ if (found.required !== arg.required) {
44
+ out.push(`"${arg.name}" is ${arg.required ? "required" : "optional"} here and the reverse stored`);
45
+ }
46
+ }
47
+ for (const name of byName.keys()) {
48
+ out.push(`"${name}" is stored but not declared here`);
49
+ }
50
+ return out;
51
+ }
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
+ export async function bindTool(sg, spec, query = {}) {
66
+ let stored;
67
+ try {
68
+ ({ tool: stored } = await sg.tools.get(spec.name, query));
69
+ }
70
+ 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
+ if (err && typeof err === "object" && "status" in err && err.status === 404) {
74
+ throw new ToolNotStoredError(spec.name);
75
+ }
76
+ throw err;
77
+ }
78
+ const differences = drift(spec.args, stored.args ?? []);
79
+ if (differences.length)
80
+ throw new ToolDriftError(spec.name, differences);
81
+ return tool({
82
+ name: stored.name,
83
+ description: stored.description,
84
+ args: spec.args,
85
+ effect: stored.effect ?? undefined,
86
+ pinned: stored.pinned,
87
+ run: spec.run,
88
+ });
89
+ }
@@ -0,0 +1,4 @@
1
+ export type { BoundSpec } from "./bind.js";
2
+ export { bindTool, ToolDriftError, ToolNotStoredError } from "./bind.js";
3
+ export type { ToolSpec } from "./tool.js";
4
+ export { tool } from "./tool.js";
@@ -0,0 +1,2 @@
1
+ export { bindTool, ToolDriftError, ToolNotStoredError } from "./bind.js";
2
+ export { tool } from "./tool.js";
@@ -0,0 +1,20 @@
1
+ import type { ArgSpec, ArgsOf, Effect, Tool } from "../types/index.js";
2
+ export interface ToolSpec<T extends readonly ArgSpec[]> {
3
+ name: string;
4
+ description: string;
5
+ args: T;
6
+ pinned?: boolean;
7
+ effect?: Effect;
8
+ run(args: ArgsOf<T>): Promise<unknown> | unknown;
9
+ }
10
+ /**
11
+ * Declares a tool, checking what is a silent failure otherwise.
12
+ *
13
+ * Every check here is something a provider either rejects opaquely or accepts
14
+ * and behaves oddly about. Caught at import, not on the first loop in prod.
15
+ *
16
+ * Declare `args` with `as const` and `run` is typed from them — rename an
17
+ * argument and the handler stops compiling, rather than being handed
18
+ * `undefined` halfway through an agent loop.
19
+ */
20
+ export declare function tool<const T extends readonly ArgSpec[]>(spec: ToolSpec<T>): Tool<ArgsOf<T>>;
@@ -0,0 +1,62 @@
1
+ /** Names a provider will accept: letters, digits and underscores. */
2
+ const VALID_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
3
+ const EFFECTS = ["readonly", "idempotent", "destructive"];
4
+ function checkEnum(toolName, arg) {
5
+ if (!arg.options?.length) {
6
+ throw new Error(`Tool "${toolName}" argument "${arg.name}" is an enum with no options, so nothing can satisfy it.`);
7
+ }
8
+ const choices = new Set();
9
+ for (const option of arg.options) {
10
+ if (!option.trim()) {
11
+ throw new Error(`Tool "${toolName}" argument "${arg.name}" has a blank option, which the model can pick and you cannot act on.`);
12
+ }
13
+ if (choices.has(option)) {
14
+ throw new Error(`Tool "${toolName}" argument "${arg.name}" lists "${option}" twice.`);
15
+ }
16
+ choices.add(option);
17
+ }
18
+ }
19
+ function checkArgs(toolName, args) {
20
+ const seen = new Set();
21
+ for (const arg of args) {
22
+ if (!VALID_NAME.test(arg.name)) {
23
+ throw new Error(`Tool "${toolName}" argument "${arg.name}" is not a usable name.`);
24
+ }
25
+ if (seen.has(arg.name)) {
26
+ throw new Error(`Tool "${toolName}" declares "${arg.name}" twice.`);
27
+ }
28
+ seen.add(arg.name);
29
+ if (arg.type === "enum")
30
+ checkEnum(toolName, arg);
31
+ }
32
+ }
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
+ export function tool(spec) {
44
+ if (!VALID_NAME.test(spec.name)) {
45
+ throw new Error(`Tool name "${spec.name}" must be letters, digits and underscores, starting with a letter.`);
46
+ }
47
+ if (!spec.description.trim()) {
48
+ throw new Error(`Tool "${spec.name}" has no description. It is the only thing the model reads to decide whether to call it.`);
49
+ }
50
+ checkArgs(spec.name, spec.args);
51
+ if (spec.effect !== undefined && !EFFECTS.includes(spec.effect)) {
52
+ throw new Error(`Tool "${spec.name}" declares effect "${spec.effect}", which is not one of ${EFFECTS.join(", ")}.`);
53
+ }
54
+ return {
55
+ name: spec.name,
56
+ description: spec.description,
57
+ args: spec.args.map((a) => ({ ...a, options: a.options ? [...a.options] : undefined })),
58
+ pinned: spec.pinned,
59
+ ...(spec.effect ? { effect: spec.effect } : {}),
60
+ run: spec.run,
61
+ };
62
+ }
@@ -0,0 +1,2 @@
1
+ export type { ToolResult } from "./result.js";
2
+ export type { ArgSpec, ArgsOf, ArgValue, Effect, Tool, ToolDeclaration } from "./tool.js";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
1
+ /** What happened when one was called. */
2
+ export interface ToolResult {
3
+ name: string;
4
+ args: Record<string, unknown>;
5
+ status: "completed" | "failed";
6
+ /** Whatever `run` returned, stringified for the record. */
7
+ output: string;
8
+ error?: string;
9
+ latencyMs: number;
10
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,105 @@
1
+ import type { FieldSpec, FieldType } from "@spendgraph/sdk";
2
+ /**
3
+ * A declared argument, as written.
4
+ *
5
+ * Structurally a `FieldSpec` with every field readonly, because that is what an
6
+ * `as const` array produces — and `FieldSpec.options` being a mutable
7
+ * `string[]` is enough to make the whole tuple fail the constraint and inference
8
+ * fall back to nothing, silently.
9
+ */
10
+ export interface ArgSpec {
11
+ readonly name: string;
12
+ readonly type: FieldType;
13
+ readonly required: boolean;
14
+ readonly default?: string;
15
+ readonly description?: string;
16
+ readonly options?: readonly string[];
17
+ readonly separator?: string;
18
+ readonly min?: number;
19
+ readonly max?: number;
20
+ readonly maxLength?: number;
21
+ readonly trueText?: string;
22
+ readonly falseText?: string;
23
+ readonly datasetKey?: boolean;
24
+ }
25
+ /** What one declared argument is worth at runtime. */
26
+ export type ArgValue<F> = F extends {
27
+ type: "number";
28
+ } ? number : F extends {
29
+ type: "boolean";
30
+ } ? boolean : F extends {
31
+ type: "list";
32
+ } ? string[] : F extends {
33
+ type: "json";
34
+ } ? unknown : F extends {
35
+ type: "enum";
36
+ options: readonly (infer O)[];
37
+ } ? O : string;
38
+ type Required<T> = T extends {
39
+ required: true;
40
+ } ? T : never;
41
+ type Optional<T> = T extends {
42
+ required: true;
43
+ } ? never : T;
44
+ /**
45
+ * The object `run` is handed, derived from the args you declared.
46
+ *
47
+ * Declare `args` with `as const` and the handler's parameter types follow —
48
+ * a `number` arg arrives as a number, a missing optional is optional, and a
49
+ * renamed arg is a compile error at the handler rather than a runtime one
50
+ * mid-loop.
51
+ */
52
+ export type ArgsOf<T extends readonly ArgSpec[]> = {
53
+ [F in Required<T[number]> as F["name"]]: ArgValue<F>;
54
+ } & {
55
+ [F in Optional<T[number]> as F["name"]]?: ArgValue<F>;
56
+ };
57
+ /**
58
+ * What running a tool does to the world outside the process.
59
+ *
60
+ * `readonly` has no external effect. `idempotent` has one, but running it twice
61
+ * is the same as running it once. `destructive` is neither, so it is never safe
62
+ * to retry on its own.
63
+ *
64
+ * Three values rather than a taxonomy: anything finer does not get filled in
65
+ * accurately. There is no default — unset means nobody has said, which is not
66
+ * the same as safe.
67
+ */
68
+ export type Effect = "readonly" | "idempotent" | "destructive";
69
+ /**
70
+ * A description the model reads and a function you wrote, in one object.
71
+ *
72
+ * Splitting them — a schema here, a handler there, matched by string — is how a
73
+ * rename becomes a runtime failure mid-loop. Arguments are `FieldSpec` because a
74
+ * tool's arguments and a prompt's placeholders are the same problem.
75
+ */
76
+ export interface Tool<Args = Record<string, unknown>> {
77
+ /** What the model calls it. Also the key it is registered under. */
78
+ name: string;
79
+ /**
80
+ * What the model reads to decide whether to call it, and the biggest lever on
81
+ * whether it calls it correctly. Worth iterating on more than the code is.
82
+ */
83
+ description: string;
84
+ args: FieldSpec[];
85
+ /**
86
+ * Offered whatever selection decides — an escalation path, a refusal, where
87
+ * losing a similarity search is not an acceptable outcome.
88
+ */
89
+ pinned?: boolean;
90
+ /** What calling it does outside the process. Unset means unknown. */
91
+ effect?: Effect;
92
+ run(args: Args): Promise<unknown> | unknown;
93
+ }
94
+ /** A tool as the model sees it: no implementation, no pin. */
95
+ export interface ToolDeclaration {
96
+ name: string;
97
+ description: string;
98
+ args: FieldSpec[];
99
+ /**
100
+ * Carried so a caller can gate on it. Dropped by every provider shape — no
101
+ * API has a field for it, and an unknown key is rejected or ignored.
102
+ */
103
+ effect?: Effect;
104
+ }
105
+ export {};