@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.
- package/README.md +283 -0
- package/dist/builtin/calculate/calculate.d.ts +13 -0
- package/dist/builtin/calculate/calculate.js +27 -0
- package/dist/builtin/calculate/index.d.ts +2 -0
- package/dist/builtin/calculate/index.js +2 -0
- package/dist/builtin/calculate/parse.d.ts +8 -0
- package/dist/builtin/calculate/parse.js +144 -0
- package/dist/builtin/clock/clock.d.ts +27 -0
- package/dist/builtin/clock/clock.js +76 -0
- package/dist/builtin/clock/index.d.ts +1 -0
- package/dist/builtin/clock/index.js +1 -0
- package/dist/builtin/date/date.d.ts +32 -0
- package/dist/builtin/date/date.js +47 -0
- package/dist/builtin/date/index.d.ts +2 -0
- package/dist/builtin/date/index.js +2 -0
- package/dist/builtin/date/shift.d.ts +12 -0
- package/dist/builtin/date/shift.js +62 -0
- package/dist/builtin/deep-recall/deep-recall.d.ts +27 -0
- package/dist/builtin/deep-recall/deep-recall.js +51 -0
- package/dist/builtin/deep-recall/index.d.ts +1 -0
- package/dist/builtin/deep-recall/index.js +1 -0
- package/dist/builtin/escalate/escalate.d.ts +46 -0
- package/dist/builtin/escalate/escalate.js +29 -0
- package/dist/builtin/escalate/index.d.ts +1 -0
- package/dist/builtin/escalate/index.js +1 -0
- package/dist/builtin/finish/finish.d.ts +27 -0
- package/dist/builtin/finish/finish.js +39 -0
- package/dist/builtin/finish/index.d.ts +1 -0
- package/dist/builtin/finish/index.js +1 -0
- package/dist/builtin/http/allow.d.ts +8 -0
- package/dist/builtin/http/allow.js +45 -0
- package/dist/builtin/http/http.d.ts +43 -0
- package/dist/builtin/http/http.js +51 -0
- package/dist/builtin/http/index.d.ts +2 -0
- package/dist/builtin/http/index.js +2 -0
- package/dist/builtin/index.d.ts +11 -0
- package/dist/builtin/index.js +11 -0
- package/dist/builtin/json/index.d.ts +2 -0
- package/dist/builtin/json/index.js +2 -0
- package/dist/builtin/json/json.d.ts +22 -0
- package/dist/builtin/json/json.js +44 -0
- package/dist/builtin/json/path.d.ts +8 -0
- package/dist/builtin/json/path.js +63 -0
- package/dist/builtin/think/index.d.ts +1 -0
- package/dist/builtin/think/index.js +1 -0
- package/dist/builtin/think/think.d.ts +13 -0
- package/dist/builtin/think/think.js +19 -0
- package/dist/builtin/web-search/index.d.ts +1 -0
- package/dist/builtin/web-search/index.js +1 -0
- package/dist/builtin/web-search/reply.d.ts +23 -0
- package/dist/builtin/web-search/reply.js +35 -0
- package/dist/builtin/web-search/web-search.d.ts +50 -0
- package/dist/builtin/web-search/web-search.js +93 -0
- package/dist/builtin/write-document/commission.d.ts +56 -0
- package/dist/builtin/write-document/commission.js +163 -0
- package/dist/builtin/write-document/design.d.ts +49 -0
- package/dist/builtin/write-document/design.js +99 -0
- package/dist/builtin/write-document/http.d.ts +34 -0
- package/dist/builtin/write-document/http.js +74 -0
- package/dist/builtin/write-document/index.d.ts +6 -0
- package/dist/builtin/write-document/index.js +4 -0
- package/dist/builtin/write-document/types.d.ts +33 -0
- package/dist/builtin/write-document/types.js +1 -0
- package/dist/builtin/write-document/write-document.d.ts +69 -0
- package/dist/builtin/write-document/write-document.js +150 -0
- package/dist/bus/bus.d.ts +76 -0
- package/dist/bus/bus.js +103 -0
- package/dist/bus/index.d.ts +3 -0
- package/dist/bus/index.js +2 -0
- package/dist/bus/invoke.d.ts +9 -0
- package/dist/bus/invoke.js +50 -0
- package/dist/bus/options.d.ts +22 -0
- package/dist/bus/options.js +1 -0
- package/dist/bus/overlap.d.ts +12 -0
- package/dist/bus/overlap.js +43 -0
- package/dist/bus/score.d.ts +9 -0
- package/dist/bus/score.js +19 -0
- package/dist/bus/select.d.ts +18 -0
- package/dist/bus/select.js +71 -0
- package/dist/bus/step.d.ts +4 -0
- package/dist/bus/step.js +12 -0
- package/dist/bus/trace.d.ts +42 -0
- package/dist/bus/trace.js +34 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/internals.d.ts +16 -0
- package/dist/internals.js +16 -0
- package/dist/tool/bind.d.ts +42 -0
- package/dist/tool/bind.js +89 -0
- package/dist/tool/index.d.ts +4 -0
- package/dist/tool/index.js +2 -0
- package/dist/tool/tool.d.ts +20 -0
- package/dist/tool/tool.js +62 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +1 -0
- package/dist/types/result.d.ts +10 -0
- package/dist/types/result.js +1 -0
- package/dist/types/tool.d.ts +105 -0
- package/dist/types/tool.js +1 -0
- package/dist/wire/index.d.ts +3 -0
- package/dist/wire/index.js +3 -0
- package/dist/wire/json-schema.d.ts +26 -0
- package/dist/wire/json-schema.js +81 -0
- package/dist/wire/markdown.d.ts +9 -0
- package/dist/wire/markdown.js +42 -0
- package/dist/wire/providers.d.ts +26 -0
- package/dist/wire/providers.js +20 -0
- 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
|
+
}
|
package/dist/bus/step.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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,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 @@
|
|
|
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 {};
|