@spendgraph/tools 0.7.0 → 0.8.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/dist/builtin/index.d.ts +1 -0
- package/dist/builtin/index.js +1 -1
- package/dist/builtin/openalex/api.d.ts +8 -4
- package/dist/builtin/openalex/api.js +1 -1
- package/dist/builtin/openalex/index.d.ts +0 -1
- package/dist/builtin/openalex/index.js +1 -1
- package/dist/builtin/openalex/openalex.d.ts +30 -17
- package/dist/builtin/openalex/openalex.js +1 -1
- package/dist/builtin/paper-search/index.d.ts +1 -0
- package/dist/builtin/paper-search/index.js +1 -0
- package/dist/builtin/paper-search/paper-search.d.ts +38 -0
- package/dist/builtin/paper-search/paper-search.js +3 -0
- package/dist/builtin/polite.d.ts +1 -0
- package/dist/builtin/polite.js +1 -1
- package/dist/builtin/publish/publish.d.ts +1 -1
- package/dist/builtin/wikidata/wikidata.d.ts +3 -1
- package/dist/builtin/wikidata/wikidata.js +1 -1
- package/dist/bus/output.d.ts +0 -17
- package/dist/bus/output.js +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/papers/consensus/consensus.d.ts +12 -0
- package/dist/papers/consensus/consensus.js +1 -0
- package/dist/papers/consensus/index.d.ts +4 -0
- package/dist/papers/consensus/index.js +1 -0
- package/dist/papers/consensus/reply.d.ts +24 -0
- package/dist/papers/consensus/reply.js +1 -0
- package/dist/papers/every.d.ts +6 -0
- package/dist/papers/every.js +1 -0
- package/dist/papers/index.d.ts +11 -0
- package/dist/papers/index.js +1 -0
- package/dist/papers/openalex/index.d.ts +2 -0
- package/dist/papers/openalex/index.js +1 -0
- package/dist/papers/openalex/openalex.d.ts +8 -0
- package/dist/papers/openalex/openalex.js +1 -0
- package/dist/papers/papers.d.ts +54 -0
- package/dist/papers/papers.js +1 -0
- package/dist/{builtin/openalex → papers}/render.d.ts +1 -1
- package/dist/papers/render.js +4 -0
- package/dist/papers/semantic-scholar/index.d.ts +4 -0
- package/dist/papers/semantic-scholar/index.js +1 -0
- package/dist/papers/semantic-scholar/reply.d.ts +29 -0
- package/dist/papers/semantic-scholar/reply.js +1 -0
- package/dist/papers/semantic-scholar/semantic-scholar.d.ts +8 -0
- package/dist/papers/semantic-scholar/semantic-scholar.js +1 -0
- package/dist/search/every.d.ts +6 -0
- package/dist/search/every.js +4 -0
- package/dist/search/index.d.ts +4 -2
- package/dist/search/index.js +1 -1
- package/dist/search/search.d.ts +6 -0
- package/dist/search/searxng/searxng.js +3 -3
- package/dist/search/sonar/sonar.js +1 -1
- package/dist/search/tavily/tavily.js +1 -1
- package/dist/tool/tool.d.ts +4 -0
- package/dist/tool/tool.js +1 -1
- package/dist/types/tool.d.ts +13 -0
- package/docs/bogus.mdx +8 -8
- package/docs/builtins.mdx +76 -15
- package/docs/bus.mdx +7 -7
- package/docs/declaring.mdx +6 -6
- package/docs/effects.mdx +6 -6
- package/docs/overview.mdx +14 -12
- package/docs/selecting.mdx +10 -10
- package/docs/stored.mdx +8 -8
- package/docs/turns.mdx +5 -5
- package/package.json +10 -4
- package/dist/agents/index.d.ts +0 -1
- package/dist/agents/index.js +0 -1
- package/dist/agents/moa/design.d.ts +0 -49
- package/dist/agents/moa/design.js +0 -5
- package/dist/agents/moa/http.d.ts +0 -34
- package/dist/agents/moa/http.js +0 -1
- package/dist/agents/moa/index.d.ts +0 -74
- package/dist/agents/moa/index.js +0 -1
- package/dist/agents/moa/moa.d.ts +0 -56
- package/dist/agents/moa/moa.js +0 -13
- package/dist/agents/moa/types.d.ts +0 -33
- package/dist/agents/moa/types.js +0 -0
- package/dist/builtin/openalex/render.js +0 -4
package/dist/search/search.d.ts
CHANGED
|
@@ -14,6 +14,10 @@ export interface SearchUsage {
|
|
|
14
14
|
reasoningTokens: number;
|
|
15
15
|
searchQueries: number;
|
|
16
16
|
}
|
|
17
|
+
export interface SearchFailure {
|
|
18
|
+
source: string;
|
|
19
|
+
reason: string;
|
|
20
|
+
}
|
|
17
21
|
/** What every searcher hands back, whoever did the searching. */
|
|
18
22
|
export interface Searched {
|
|
19
23
|
answer: string;
|
|
@@ -21,6 +25,7 @@ export interface Searched {
|
|
|
21
25
|
model: string;
|
|
22
26
|
truncated: boolean;
|
|
23
27
|
usage: SearchUsage;
|
|
28
|
+
failed?: SearchFailure[];
|
|
24
29
|
}
|
|
25
30
|
export interface SearchAsk {
|
|
26
31
|
query: string;
|
|
@@ -29,6 +34,7 @@ export interface SearchAsk {
|
|
|
29
34
|
}
|
|
30
35
|
export type Searcher = ((ask: SearchAsk) => Promise<Searched>) & {
|
|
31
36
|
domains?: readonly string[];
|
|
37
|
+
source?: string;
|
|
32
38
|
};
|
|
33
39
|
/** What every searcher is configured with; each adds only where its answers come from. */
|
|
34
40
|
export interface SearcherOptions {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import{checkUrl as
|
|
1
|
+
import{checkUrl as x}from"../../builtin/http/allow.js";import{retrying as y}from"../retry.js";import{DEFAULT_MAX_CHARS as N,DEFAULT_MAX_RESULTS as F,NO_TOKENS as A}from"../search.js";import{textOf as T}from"./text.js";const f="searxng",U=["*"],G=2e4,X=6e3,L=4e5,H={quick:5,pro:8,deep:10},I=/https?:\/\/[^\s"'<>)\]]+/,Q=2;function W(t){return!t.url||!t.title?null:{url:t.url,title:t.title,...t.publishedDate?{date:t.publishedDate}:{},snippet:t.content??""}}function q({url:t,title:r,date:i}){return{url:t,title:r,...i?{date:i}:{}}}function K(t,r){return`## ${t.title}
|
|
2
2
|
${t.url}
|
|
3
|
-
${r}`}const Y=3,v=t=>t.status>=300&&t.status<400;function R(t,r){if(!r?.length)return!0;const i=new URL(t.url).hostname.toLowerCase();return r.some(u=>{const h=u.toLowerCase().replace(/^\./,"");return i===h||i.endsWith(`.${h}`)})}function z(t){const r=new Set;return t.flatMap(i=>i.hits).filter(i=>!r.has(i.url)&&r.add(i.url))}function tt(t){if(!t.baseUrl?.trim())throw new Error("searxng needs the baseUrl of a SearXNG instance, such as http://localhost:8080.");const r=t.fetch??globalThis.fetch.bind(globalThis),i=t.baseUrl.replace(/\/+$/,""),u=t.timeoutMs??G,h=Math.max(1,t.maxResults??F),g=t.maxChars??N,
|
|
3
|
+
${r}`}const Y=3,v=t=>t.status>=300&&t.status<400;function R(t,r){if(!r?.length)return!0;const i=new URL(t.url).hostname.toLowerCase();return r.some(u=>{const h=u.toLowerCase().replace(/^\./,"");return i===h||i.endsWith(`.${h}`)})}function z(t){const r=new Set;return t.flatMap(i=>i.hits).filter(i=>!r.has(i.url)&&r.add(i.url))}function tt(t){if(!t.baseUrl?.trim())throw new Error("searxng needs the baseUrl of a SearXNG instance, such as http://localhost:8080.");const r=t.fetch??globalThis.fetch.bind(globalThis),i=t.baseUrl.replace(/\/+$/,""),u=t.timeoutMs??G,h=Math.max(1,t.maxResults??F),g=t.maxChars??N,d=t.pageChars??X,b={...H,...t.pages},O=y(t);async function p(n,s){const e=new URL(`${i}/search`);e.searchParams.set("q",n),e.searchParams.set("format","json"),s&&e.searchParams.set("time_range",s),t.language&&e.searchParams.set("language",t.language),t.engines?.length&&e.searchParams.set("engines",t.engines.join(","));const a=await O(()=>r(e,{signal:AbortSignal.timeout(u),headers:{accept:"application/json"}}));if(!a.ok){const o=a.status===403?" SearXNG refuses the json format unless settings.yml lists it under search.formats and the limiter is off.":"";throw new Error(`SearXNG returned ${a.status}.${o}`)}const c=await a.json();return{hits:(c.results??[]).flatMap(o=>{const l=W(o);return l&&R(l,t.domains)?[l]:[]}),suggestions:c.suggestions??[]}}async function P(n,s){const e=n.suggestions.slice(0,Q);return[n,...await Promise.all(e.map(a=>p(a,s)))]}async function w(n){const s=AbortSignal.timeout(u);let e=x(n,U),a=await r(e,{signal:s,redirect:"manual"});for(let c=0;c<Y&&v(a);c++){const o=a.headers.get("location");if(!o)break;e=x(new URL(o,a.url||e).toString(),U),a=await r(e,{signal:s,redirect:"manual"})}return a}async function M(n){try{const s=await w(n.url);if(!s.ok)return n.snippet;const e=T((await s.text()).slice(0,L));return e?e.slice(0,d):n.snippet}catch{return n.snippet}}async function D(n){if(!R({url:n,snippet:""},t.domains))return null;try{const s=await w(n);return s.ok&&T((await s.text()).slice(0,L)).slice(0,d)||null}catch{return null}}const C=(n,s)=>({answer:s.slice(0,g),results:[{url:n}],model:f,truncated:s.length>g,usage:{...A,searchQueries:0}});return Object.assign(async({query:n,depth:s,recency:e})=>{const a=n.match(I)?.[0],c=a?await D(a):null;if(a&&c)return C(a,c);const o=await p(n,e),l=s==="deep"?await P(o,e):[o],_=z(l),m=_.slice(0,h),E=m.slice(0,b[s]),k=await Promise.all(E.map(M)),S=E.map(($,j)=>K($,k[j])).join(`
|
|
4
4
|
|
|
5
|
-
`);return{answer:
|
|
5
|
+
`);return{answer:S.slice(0,g),results:m.map(q),model:f,truncated:S.length>g||_.length>m.length,usage:{...A,searchQueries:l.length}}},{source:f,...t.domains?.length?{domains:[...t.domains]}:{}})}export{tt as searxng};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{retrying as m}from"../retry.js";import{DEFAULT_MAX_CHARS as d,DEFAULT_MAX_RESULTS as h}from"../search.js";import{readReply as _}from"./reply.js";const E={quick:"sonar",pro:"sonar-pro",deep:"sonar-deep-research"},f="https://api.perplexity.ai/chat/completions",
|
|
1
|
+
import{retrying as m}from"../retry.js";import{DEFAULT_MAX_CHARS as d,DEFAULT_MAX_RESULTS as h}from"../search.js";import{readReply as _}from"./reply.js";const E={quick:"sonar",pro:"sonar-pro",deep:"sonar-deep-research"},f="sonar",p="https://api.perplexity.ai/chat/completions",u=6e4,T=3e5,y=500;function O(e){if(!e.apiKey?.trim())throw new Error("sonar needs a Perplexity apiKey; it will not read one from the process.");const a=e.fetch??globalThis.fetch.bind(globalThis),i=m(e);return Object.assign(async({query:s,depth:o,recency:n})=>{const r=E[o],c=e.timeoutMs??(o==="deep"?T:u),t=await i(()=>a(p,{method:"POST",signal:AbortSignal.timeout(c),headers:{authorization:`Bearer ${e.apiKey}`,"content-type":"application/json"},body:JSON.stringify({model:r,messages:[{role:"user",content:s}],...n?{search_recency_filter:n}:{},...e.domains?.length?{search_domain_filter:e.domains}:{}})}));if(!t.ok){const l=(await t.text()).slice(0,y);throw new Error(`Perplexity returned ${t.status} for ${r}: ${l}`)}return _(await t.json(),r,e.maxChars??d,e.maxResults??h)},{source:f,...e.domains?.length?{domains:[...e.domains]}:{}})}export{O as sonar};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{retrying as
|
|
1
|
+
import{retrying as m}from"../retry.js";import{DEFAULT_MAX_CHARS as u,DEFAULT_MAX_RESULTS as n}from"../search.js";import{readReply as _}from"./reply.js";const y="https://api.tavily.com/search",i="tavily-search",g=6e4,p=500,T={quick:{search_depth:"basic",pages:3},pro:{search_depth:"advanced",pages:5},deep:{search_depth:"advanced",pages:10}},E={day:1,week:7,month:30,year:365};function S(e){if(!e.apiKey?.trim())throw new Error("tavily needs an apiKey; it will not read one from the process.");const r=e.fetch??globalThis.fetch.bind(globalThis),s=m(e);return Object.assign(async({query:o,depth:c,recency:t})=>{const{search_depth:d,pages:h}=T[c],a=await s(()=>r(y,{method:"POST",signal:AbortSignal.timeout(e.timeoutMs??g),headers:{authorization:`Bearer ${e.apiKey}`,"content-type":"application/json"},body:JSON.stringify({query:o,search_depth:d,include_answer:!0,include_raw_content:!0,max_results:Math.min(h,e.maxResults??n),...t?{days:E[t]}:{},...e.domains?.length?{include_domains:e.domains}:{}})}));if(!a.ok){const l=(await a.text()).slice(0,p);throw new Error(`Tavily returned ${a.status}: ${l}`)}return _(await a.json(),i,e.maxChars??u,e.maxResults??n)},{source:i,...e.domains?.length?{domains:[...e.domains]}:{}})}export{S as tavily};
|
package/dist/tool/tool.d.ts
CHANGED
|
@@ -9,6 +9,10 @@ export interface ToolSpec<T extends readonly ArgSpec[]> {
|
|
|
9
9
|
trust?: Trust;
|
|
10
10
|
costOf?(value: unknown): number | undefined;
|
|
11
11
|
needs?: readonly string[];
|
|
12
|
+
wants?: {
|
|
13
|
+
name: string;
|
|
14
|
+
description: string;
|
|
15
|
+
};
|
|
12
16
|
run(args: ArgsOf<T>): Promise<unknown> | unknown;
|
|
13
17
|
}
|
|
14
18
|
/**
|
package/dist/tool/tool.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
const r=/^[A-Za-z_][A-Za-z0-9_]*$/,i=["readonly","idempotent","destructive"],a=["own","external"];function s(n,t){if(!t.options?.length)throw new Error(`Tool "${n}" argument "${t.name}" is an enum with no options, so nothing can satisfy it.`);const e=new Set;for(const o of t.options){if(!o.trim())throw new Error(`Tool "${n}" argument "${t.name}" has a blank option, which the model can pick and you cannot act on.`);if(e.has(o))throw new Error(`Tool "${n}" argument "${t.name}" lists "${o}" twice.`);e.add(o)}}function f(n,t){if(t){for(const e of t)if(!e.trim())throw new Error(`Tool "${n}" has a blank alias, which matches every query or none.`)}}function h(n,t){const e=new Set;for(const o of t){if(!r.test(o.name))throw new Error(`Tool "${n}" argument "${o.name}" is not a usable name.`);if(e.has(o.name))throw new Error(`Tool "${n}" declares "${o.name}" twice.`);e.add(o.name),o.type==="enum"&&s(n,o)}}function d(n){if(!r.test(n.name))throw new Error(`Tool name "${n.name}" must be letters, digits and underscores, starting with a letter.`);if(!n.description.trim())throw new Error(`Tool "${n.name}" has no description. It is the only thing the model reads to decide whether to call it.`);if(h(n.name,n.args),f(n.name,n.aliases),n.effect!==void 0&&!i.includes(n.effect))throw new Error(`Tool "${n.name}" declares effect "${n.effect}", which is not one of ${i.join(", ")}.`);if(n.trust!==void 0&&!a.includes(n.trust))throw new Error(`Tool "${n.name}" declares trust "${n.trust}", which is not one of ${a.join(", ")}.`);return{name:n.name,description:n.description,args:n.args.map(t=>({...t,options:t.options?[...t.options]:void 0})),...n.aliases?.length?{aliases:[...n.aliases]}:{},pinned:n.pinned,...n.effect?{effect:n.effect}:{},...n.trust?{trust:n.trust}:{},...n.costOf?{costOf:n.costOf}:{},...n.needs?.length?{needs:[...n.needs]}:{},run:n.run}}export{d as tool};
|
|
1
|
+
const r=/^[A-Za-z_][A-Za-z0-9_]*$/,i=["readonly","idempotent","destructive"],a=["own","external"];function s(n,t){if(!t.options?.length)throw new Error(`Tool "${n}" argument "${t.name}" is an enum with no options, so nothing can satisfy it.`);const e=new Set;for(const o of t.options){if(!o.trim())throw new Error(`Tool "${n}" argument "${t.name}" has a blank option, which the model can pick and you cannot act on.`);if(e.has(o))throw new Error(`Tool "${n}" argument "${t.name}" lists "${o}" twice.`);e.add(o)}}function f(n,t){if(t){for(const e of t)if(!e.trim())throw new Error(`Tool "${n}" has a blank alias, which matches every query or none.`)}}function h(n,t){const e=new Set;for(const o of t){if(!r.test(o.name))throw new Error(`Tool "${n}" argument "${o.name}" is not a usable name.`);if(e.has(o.name))throw new Error(`Tool "${n}" declares "${o.name}" twice.`);e.add(o.name),o.type==="enum"&&s(n,o)}}function d(n){if(!r.test(n.name))throw new Error(`Tool name "${n.name}" must be letters, digits and underscores, starting with a letter.`);if(!n.description.trim())throw new Error(`Tool "${n.name}" has no description. It is the only thing the model reads to decide whether to call it.`);if(h(n.name,n.args),f(n.name,n.aliases),n.effect!==void 0&&!i.includes(n.effect))throw new Error(`Tool "${n.name}" declares effect "${n.effect}", which is not one of ${i.join(", ")}.`);if(n.trust!==void 0&&!a.includes(n.trust))throw new Error(`Tool "${n.name}" declares trust "${n.trust}", which is not one of ${a.join(", ")}.`);return{name:n.name,description:n.description,args:n.args.map(t=>({...t,options:t.options?[...t.options]:void 0})),...n.aliases?.length?{aliases:[...n.aliases]}:{},pinned:n.pinned,...n.effect?{effect:n.effect}:{},...n.trust?{trust:n.trust}:{},...n.costOf?{costOf:n.costOf}:{},...n.needs?.length?{needs:[...n.needs]}:{},...n.wants?{wants:{...n.wants}}:{},run:n.run}}export{d as tool};
|
package/dist/types/tool.d.ts
CHANGED
|
@@ -106,6 +106,19 @@ export interface Tool<Args = Record<string, unknown>> {
|
|
|
106
106
|
* where the words a caller actually types are declared.
|
|
107
107
|
*/
|
|
108
108
|
aliases?: string[];
|
|
109
|
+
/**
|
|
110
|
+
* A second, differently-shaped input the tool would rather be given, named.
|
|
111
|
+
*
|
|
112
|
+
* A stage hands every tool the one question it wrote. That suits a search
|
|
113
|
+
* engine and defeats a catalogue that matches on a title: measured against
|
|
114
|
+
* OpenAlex, a 280-character sub-question found nothing where the same ask as
|
|
115
|
+
* an 80-character phrase found ten papers. Declaring it here is what lets the
|
|
116
|
+
* stage be told to write one, without the stored wording naming any tool.
|
|
117
|
+
*/
|
|
118
|
+
wants?: {
|
|
119
|
+
name: string;
|
|
120
|
+
description: string;
|
|
121
|
+
};
|
|
109
122
|
/**
|
|
110
123
|
* Offered whatever selection decides — an escalation path, a refusal, where
|
|
111
124
|
* losing a similarity search is not an acceptable outcome.
|
package/docs/bogus.mdx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const meta = {
|
|
2
|
-
title: "Bogus tools
|
|
2
|
+
title: "Bogus tools: spendgraph docs",
|
|
3
3
|
description:
|
|
4
4
|
"Twelve tools that answer the same way every time, built so the answer cannot be known without calling. Nothing reaches the network, so the noise in a run is the model's own.",
|
|
5
5
|
};
|
|
@@ -22,13 +22,13 @@ Its own subpath, so nothing here reaches a production bundle by accident.
|
|
|
22
22
|
|
|
23
23
|
## For testing a pipeline, not a provider
|
|
24
24
|
|
|
25
|
-
Nothing here touches the network. No key, no rate limit, no flake, no bill
|
|
25
|
+
Nothing here touches the network. No key, no rate limit, no flake, no bill, so whatever noise a run shows is the **model's own**, not the weather between you and a provider. Run the same question a hundred times and the tools are the one thing that did not move.
|
|
26
26
|
|
|
27
27
|
## Every answer is unknowable without the call
|
|
28
28
|
|
|
29
29
|
This is the part that makes them worth more than stubs. The Bogus League's clubs exist nowhere else. The dictionary's definitions are its own. The gold rate is not the market rate, and the prayer times match no published table. Each description says so.
|
|
30
30
|
|
|
31
|
-
A model that answers from memory is therefore **caught**, not merely unhelpful
|
|
31
|
+
A model that answers from memory is therefore **caught**, not merely unhelpful: there is no training data to be accidentally right from.
|
|
32
32
|
|
|
33
33
|
## The receipt
|
|
34
34
|
|
|
@@ -44,13 +44,13 @@ import { receipt } from "@spendgraph/tools/bogus";
|
|
|
44
44
|
receipt("my_notes", "wifi"); // "bogus-my_notes-9e01b3"
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
Grep an answer for `bogus-` and you know which tools actually reached it. The model cannot invent a receipt it never saw, so provenance is **observed rather than claimed
|
|
47
|
+
Grep an answer for `bogus-` and you know which tools actually reached it. The model cannot invent a receipt it never saw, so provenance is **observed rather than claimed**, which is a different and stronger thing than reading the step log, since the log records what your code ran and the receipt records what the answer was actually built from.
|
|
48
48
|
|
|
49
49
|
## The twelve
|
|
50
50
|
|
|
51
51
|
| | | |
|
|
52
52
|
| --- | --- | --- |
|
|
53
|
-
| `my_notes` | the user's own notes
|
|
53
|
+
| `my_notes` | the user's own notes: passwords, plans, things written down | `readonly` |
|
|
54
54
|
| `my_calendar` | what is on for a given day | `readonly` |
|
|
55
55
|
| `my_recipes` | only what they saved themselves, not a cookbook | `readonly` |
|
|
56
56
|
| `dictionary` | definitions that are its own and not the ones in general use | `readonly` |
|
|
@@ -64,12 +64,12 @@ Grep an answer for `bogus-` and you know which tools actually reached it. The mo
|
|
|
64
64
|
| `ask_mum` | always answers, rarely the question asked, forty-one minutes | `destructive` |
|
|
65
65
|
|
|
66
66
|
<Callout tone="trap" title="They are fixtures, not fakes of anything real">
|
|
67
|
-
Every answer is unknowable without the call
|
|
67
|
+
Every answer is unknowable without the call: that is what makes them useful for testing a loop and useless for testing a provider. Ship one by accident and the model will happily tell a customer what the gold price is.
|
|
68
68
|
</Callout>
|
|
69
69
|
|
|
70
70
|
## All three effects, on purpose
|
|
71
71
|
|
|
72
|
-
Eight `readonly`, one `idempotent`, three `destructive`. That spread is why this is the fixture for a gate: you can assert a policy refuses `order_pizza`, retries `feed_cat`, and waves `my_notes` through
|
|
72
|
+
Eight `readonly`, one `idempotent`, three `destructive`. That spread is why this is the fixture for a gate: you can assert a policy refuses `order_pizza`, retries `feed_cat`, and waves `my_notes` through, without a bus that can spend real money.
|
|
73
73
|
|
|
74
74
|
```ts
|
|
75
75
|
const bus = toolbus(bogusTools());
|
|
@@ -82,4 +82,4 @@ bus.unannotated(); // []
|
|
|
82
82
|
|
|
83
83
|
## What they are not
|
|
84
84
|
|
|
85
|
-
They are not a benchmark. A model doing well on the Bogus League says it can follow a description and call a tool, not that it is good
|
|
85
|
+
They are not a benchmark. A model doing well on the Bogus League says it can follow a description and call a tool, not that it is good: the questions were written to be answerable only one way. Use them to prove the wiring, then measure quality on real work.
|
package/docs/builtins.mdx
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
export const meta = {
|
|
2
|
-
title: "Builtins
|
|
2
|
+
title: "Builtins: spendgraph docs",
|
|
3
3
|
description:
|
|
4
|
-
"Twenty-one factories, none registered for you
|
|
4
|
+
"Twenty-one factories, none registered for you: this package's own advice is that more tools makes a model worse at picking, so they are opt-in one at a time.",
|
|
5
5
|
};
|
|
6
6
|
|
|
7
7
|
# Builtins
|
|
8
8
|
|
|
9
|
-
Twenty-one, each a factory, **none registered for you
|
|
9
|
+
Twenty-one, each a factory, **none registered for you**: this package's own advice is that more tools in front of a model makes it worse at picking, so they are opt-in one at a time. Six need a key, one needs a sandbox; the rest are local code.
|
|
10
10
|
|
|
11
11
|
```ts
|
|
12
12
|
import { calculate, currentTime, escalate, finish, think, httpRequest } from "@spendgraph/tools";
|
|
@@ -34,13 +34,12 @@ const bus = toolbus([
|
|
|
34
34
|
| `dates()` | the same, asked in words, for a caller that can only send a question | local |
|
|
35
35
|
| `jsonQuery()` | one value out of a JSON document, by path | local |
|
|
36
36
|
| `httpRequest()` | a GET or POST to hosts you named | local |
|
|
37
|
-
| `extract()` | the text of a file at a URL
|
|
37
|
+
| `extract()` | the text of a file at a URL: pdf, docx, a page, md, txt | local |
|
|
38
38
|
| `codeExec()` | a short program, run wherever you say | sandbox |
|
|
39
|
-
| `writeDocument()` | a document agent that returns a rendered file | key |
|
|
40
39
|
| `publish()` | content out as a file at a public URL, through the app | key |
|
|
41
40
|
| `webSearch()` | a searched answer with its sources, from a searcher you pick | local or key |
|
|
42
41
|
| `wikidata()` | what Wikidata records about a thing, as text with every id named | local |
|
|
43
|
-
| `
|
|
42
|
+
| `paperSearch()` | published papers on a topic, as text with a DOI and a PDF where one is open, from a paper searcher you pick | local or key |
|
|
44
43
|
| `recall()` | what your memory holds about something, one note per row | key |
|
|
45
44
|
| `deepRecall()` | the same, walked across linked notes, with the evidence | key |
|
|
46
45
|
| `remember()` | one thing filed under a `type:name` context | key |
|
|
@@ -95,12 +94,12 @@ const bus = toolbus(memories({ client: store }).tools);
|
|
|
95
94
|
```
|
|
96
95
|
|
|
97
96
|
<Callout tone="trap" title="None of them are registered for you">
|
|
98
|
-
Importing a builtin does nothing until it is in a bus. That is deliberate
|
|
97
|
+
Importing a builtin does nothing until it is in a bus. That is deliberate, a package that silently added `http_request` to every agent would be a package that widened your blast radius on `npm update`, but it does mean a tool you "added" and never see called is usually a tool you never put in the bus.
|
|
99
98
|
</Callout>
|
|
100
99
|
|
|
101
100
|
## A few worth reading about
|
|
102
101
|
|
|
103
|
-
**`calculate`** parses the expression
|
|
102
|
+
**`calculate`** parses the expression, numbers, `+ - * / % ^`, brackets, and `sqrt abs round floor ceil ln log`, and refuses everything else. `eval` on model output is arbitrary code execution with extra steps.
|
|
104
103
|
|
|
105
104
|
It answers with the digits a double actually carries, so `0.1 + 0.2` is `0.3` and not `0.30000000000000004`. A whole number past 2^53 is refused rather than silently rounded, because the answer would then be confidently wrong about a figure the model can see in its own request.
|
|
106
105
|
|
|
@@ -108,13 +107,13 @@ It answers with the digits a double actually carries, so `0.1 + 0.2` is `0.3` an
|
|
|
108
107
|
|
|
109
108
|
**`finish`** gives a loop a real stop condition. Without it a run ends when the model goes quiet, which is indistinguishable from it losing the thread. Read the result with `isFinished(result)` and `answerOf(result)`.
|
|
110
109
|
|
|
111
|
-
**`decline`** is the other real ending, and the one usually left to prose. A model that cannot answer will otherwise write an apology, which reads as an answer to everything downstream
|
|
110
|
+
**`decline`** is the other real ending, and the one usually left to prose. A model that cannot answer will otherwise write an apology, which reads as an answer to everything downstream, it is scored, it is stored, and the rate of it is invisible. The tool takes a fixed `because`, `unknowable`, `not_covered`, `out_of_scope`, `unsafe`, so the rate of each is countable, and the two commonest want opposite fixes: one is the world, the other is your records. `isDeclined(result)` and `declinedFor(result)` read it back, and a loop that ends this way reports `stoppedBy: "declined"` with `declinedBecause` beside it.
|
|
112
111
|
|
|
113
112
|
**`current_time`** is the clock a model does not have. Asked for today's date it will otherwise produce its training cutoff, confidently. A wrong IANA zone throws rather than falling back, because a confidently wrong time is the failure this exists to stop.
|
|
114
113
|
|
|
115
|
-
**`dateMath`** is what `current_time` leads to. Calendar months clamp rather than overflow
|
|
114
|
+
**`dateMath`** is what `current_time` leads to. Calendar months clamp rather than overflow, a month after 31 January is 28 February, not 3 March, which is what `setMonth` gives and what every billing system disagrees with. `difference` counts boundaries crossed rather than dividing elapsed milliseconds, because a month is not 30 days. `dates()` is the same tool taking one `question` in one of three forms, `days from 2026-03-01 to 2026-09-09`, `30 days after 2026-03-01`, `2 months before 2026-03-01`, for a pipeline that hands every tool a question and nothing else.
|
|
116
115
|
|
|
117
|
-
**`jsonQuery`** reads one value by path
|
|
116
|
+
**`jsonQuery`** reads one value by path: `invoices[0].total`, or `invoices[*].total` for every element. A path that matches nothing comes back `found: false` rather than `null`, which a model would otherwise read as the answer.
|
|
118
117
|
|
|
119
118
|
**`webSearch`** takes a searcher, and ships two. `sonar` is Perplexity, which searches, reads and writes the answer itself, for a key. `searxng` is a [SearXNG](https://docs.searxng.org) you run, which costs nothing and needs no key: it searches, then reads the first hits as text, five on `quick`, eight on `pro`, ten on `deep`. Handed a query that names a link, it reads that page instead.
|
|
120
119
|
|
|
@@ -126,9 +125,68 @@ webSearch({ search: sonar({ apiKey, domains: ["gov.uk"] }), maxDepth: "pro" });
|
|
|
126
125
|
webSearch({ search: searxng({ baseUrl: "http://localhost:8080", language: "en" }), maxDepth: "deep" });
|
|
127
126
|
```
|
|
128
127
|
|
|
129
|
-
Both hand back the same shape
|
|
128
|
+
Both hand back the same shape, `answer`, `results`, `model`, `truncated`, `usage`, so whatever reads one reads the other; only where the answer came from and what it cost differ. `everySearch([sonar, tavily, searxng])` is that shape used on itself: one searcher over several, asked at the same time. The model chooses `query`, `depth` and `recency`; `domains`, engines and language are pinned on the searcher, so it cannot widen them. Pages the SearXNG searcher reads go through the same guard as `httpRequest`, so a private address is refused even when an engine offered it. A stock SearXNG answers the json format with a 403 until `settings.yml` lists it under `search.formats` and the limiter is off, and the error says so. `examples/search` runs both searchers directly.
|
|
130
129
|
|
|
131
|
-
**`
|
|
130
|
+
**`paperSearch`** is `webSearch` for the literature, and ships three searchers. `openAlexPapers` is [OpenAlex](https://openalex.org), free and keyless. `semanticScholar` is the [Semantic Scholar](https://www.semanticscholar.org/product/api) graph, and carries the `tldr` as each paper's takeaway. `consensus` is [Consensus](https://consensus.app), which needs a key and is the only one that says how a study was run, `rct`, `meta-analysis`, with its sample size and a one-line finding.
|
|
131
|
+
|
|
132
|
+
Keys, and what each is worth:
|
|
133
|
+
|
|
134
|
+
| | |
|
|
135
|
+
| --- | --- |
|
|
136
|
+
| `openAlexPapers` | optional, free, from [OpenAlex authentication](https://help.openalex.org/api/authentication/): sent as a bearer token, never in the url. Basic use is keyless; a key is ten times the daily budget, and heavier use is pay as you go |
|
|
137
|
+
| `semanticScholar` | optional, free, from [the API key form](https://www.semanticscholar.org/product/api#api-key-form): emailed after review, so not the same day. Keyless is 1000 req/s *shared with every unauthenticated caller on the internet*, which in practice means routine 429s; a key is 1 req/s that is yours |
|
|
138
|
+
| `consensus` | required, from a [Consensus](https://consensus.app) account |
|
|
139
|
+
|
|
140
|
+
A 429 from the keyless Semantic Scholar pool is not a failure to design around: under `everyPaper` it is one source that did not answer while the others did.
|
|
141
|
+
|
|
142
|
+
**What a search cost** rides back with it. OpenAlex prices every call in its own envelope, `meta.cost_usd`, and that becomes `costMicroUsd` on the `PapersFound`, in micro-USD like every other price here. A source that does not price its own calls takes `costMicroUsd` as an option instead, which is what your plan charges per search:
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
everyPaper([openAlexPapers({ apiKey }), consensus({ apiKey, costMicroUsd: 2500 })]);
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`everyPaper` sums what the sources that answered reported, and bills nothing for one that failed. `paper_search` declares `costOf`, so the number lands on the result as `costMicros` and `spentOn([...results])` adds a run up. Only sources that said are counted: a search with no price is unmeasured, not free, and those are different claims.
|
|
149
|
+
|
|
150
|
+
The tool returns `{ answer, results, costMicroUsd }` rather than a bare string. The model still reads only `answer`, `render()` has always unwrapped that shape, while `results` become the result's `sources`, one per paper, preferring an open PDF, then the source's own page, then `https://doi.org/…`.
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
import { paperSearch } from "@spendgraph/tools";
|
|
154
|
+
import { consensus, openAlexPapers, semanticScholar } from "@spendgraph/tools/papers";
|
|
155
|
+
|
|
156
|
+
paperSearch({ papers: openAlexPapers({ apiKey }) });
|
|
157
|
+
paperSearch({ papers: semanticScholar({ apiKey, fieldsOfStudy: ["Medicine"] }), maxResults: 5 });
|
|
158
|
+
paperSearch({ papers: consensus({ apiKey, studyTypes: ["rct"], excludePreprints: true }) });
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
All three hand back the same `Paper`, title, year, authors, venue, citations, DOI, an open PDF, a study type and sample size where the source knows them, a takeaway, and the abstract, so whatever reads one reads the others. The model chooses `query`, `year_min`, `year_max`, `min_citations` and `open_access`; the study filters, fields of study and journals are pinned on the searcher, so it cannot widen them. `openalex()` is the preset for the keyless case, and is `paperSearch({ papers: openAlexPapers(opts) })` with nothing added.
|
|
162
|
+
|
|
163
|
+
**Two or three sources at once** is `everySearch` in `@spendgraph/tools/search` and `everyPaper` in `@spendgraph/tools/papers`. Each takes a list, returns one searcher of the same type, and asks all of them at the same time.
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
import { paperSearch, webSearch } from "@spendgraph/tools";
|
|
167
|
+
import { consensus, everyPaper, openAlexPapers, semanticScholar } from "@spendgraph/tools/papers";
|
|
168
|
+
import { everySearch, searxng, sonar } from "@spendgraph/tools/search";
|
|
169
|
+
|
|
170
|
+
paperSearch({ papers: everyPaper([consensus({ apiKey }), semanticScholar(), openAlexPapers()]) });
|
|
171
|
+
webSearch({ search: everySearch([sonar({ apiKey }), searxng({ baseUrl })]) });
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
One tool, not three. `busgroup` is the other way to hold several providers, a bus each, `namespace: true`, and the model sees `consensus__paper_search` beside `s2__paper_search`, and it is the right shape when the buses do *different* things, because a name there has to say which one runs. These do the same thing: same ask in, same shape out. Interchangeable by construction is a fan-out, not a routing decision the model has any basis to make, and three namespaced copies spend three shortlist slots on one capability.
|
|
175
|
+
|
|
176
|
+
What the merge does with what comes back:
|
|
177
|
+
|
|
178
|
+
| | |
|
|
179
|
+
| --- | --- |
|
|
180
|
+
| order | round-robin: first from each source, then second from each, so no one corpus owns the top |
|
|
181
|
+
| the same paper twice | folded on DOI, else on the title with punctuation and case removed |
|
|
182
|
+
| a fold | fills gaps rather than dropping one: Consensus' `studyType` survives alongside Semantic Scholar's abstract, and the higher citation count wins |
|
|
183
|
+
| a source that fails | named in `failed`, and the rest still answer; `paper_search` prints `openalex did not answer: …` under the list |
|
|
184
|
+
| every source failing | the call fails, quoting what each one said |
|
|
185
|
+
| the bill | three providers per call, not one, a fan-out costs what its parts cost |
|
|
186
|
+
|
|
187
|
+
`everySearch` puts each answer under `# via <model>` so a quote stays attributable: `via` because a page's own text routinely carries an `#` heading of its own, and the marker has to be the one line that cannot be mistaken for content, unions the links, and sums all five token lines. It keeps `domains` only when *every* searcher is pinned, since one unpinned searcher means the answer is no longer restricted.
|
|
188
|
+
|
|
189
|
+
**`codeExec`** runs a short program and hands back what it printed, for work an arithmetic expression cannot state: sorting, filtering, joining, parsing. It takes a **required** `sandbox` and has no default, because a default would mean running model-written code in the process that called it. That is what `calculate` exists to avoid, and shipping one here would undo it.
|
|
132
190
|
|
|
133
191
|
Its `effect` is left unset on purpose: `readonly` would be a lie about arbitrary code and `destructive` a lie about sorting a list.
|
|
134
192
|
|
|
@@ -149,8 +207,11 @@ const sandbox: Sandbox = {
|
|
|
149
207
|
codeExec({ sandbox, language: "python", timeoutMs: 10_000 });
|
|
150
208
|
```
|
|
151
209
|
|
|
152
|
-
Anything satisfying `execute` works
|
|
210
|
+
Anything satisfying `execute` works, a hosted sandbox, a container, a WASM runtime. A failed run comes back with `ok: false` rather than throwing, so the model reads `stderr` and corrects its own code instead of getting an opaque tool failure.
|
|
153
211
|
|
|
154
|
-
|
|
212
|
+
Commissioning a document agent used to live here as `writeDocument`. It now lives in
|
|
213
|
+
`@spendgraph/vigil/agents` as `moaAgent()`, because the work takes minutes and a tool has to
|
|
214
|
+
return now: a run parks on the agent and picks itself back up, rather than holding a process
|
|
215
|
+
open for ten minutes.
|
|
155
216
|
|
|
156
217
|
**`httpRequest`** takes a **required** allowlist and has no default. It is https only, refuses private, loopback, link-local and `.internal`/`.local` hosts **even if you list them**, and sets headers itself so a credential is never something the model can redirect. If the guard feels heavy, that is the correct amount of heavy: this is the one builtin that can exfiltrate. `allow: ["*"]` opens it to every public host, for the one case where the tool is a reader of the open web rather than a client of your own APIs; the private-address and https refusals still hold.
|
package/docs/bus.mdx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const meta = {
|
|
2
|
-
title: "The bus
|
|
2
|
+
title: "The bus: spendgraph docs",
|
|
3
3
|
description:
|
|
4
4
|
"toolbus holds them, picks the few worth sending, converts them per provider, and runs them without throwing. Selection is never applied for you, because hiding it would hide a cache invalidation.",
|
|
5
5
|
};
|
|
@@ -20,7 +20,7 @@ toolbus([lookup, lookup]); // Error: Two tools are called "lookup_contract".
|
|
|
20
20
|
|
|
21
21
|
Registering the second under the same key would leave `invoke` a coin flip. It fails at import instead.
|
|
22
22
|
|
|
23
|
-
The bus also warns
|
|
23
|
+
The bus also warns, once, however many pairs, when two tools describe themselves almost identically. Nothing fails at runtime when they do; the model just picks wrong more often, which is exactly why it is worth saying out loud. Switch it off with `{ warnOnOverlap: false }` once a catalogue is large enough that the warning is noise rather than news.
|
|
24
24
|
|
|
25
25
|
## Selection is never applied for you
|
|
26
26
|
|
|
@@ -31,7 +31,7 @@ bus.anthropic(bus.select(query)) // the shortlist
|
|
|
31
31
|
|
|
32
32
|
The provider adapters send **everything** unless you narrow them, and that is deliberate. The `tools` array sits ahead of the messages in the cached prefix, so a list that changes every turn invalidates the system prompt along with it. Hiding a `select()` call inside `anthropic()` would hide that cost, so the shorter call is the expensive one on purpose.
|
|
33
33
|
|
|
34
|
-
In practice you want [`trace()`](/
|
|
34
|
+
In practice you want [`trace()`](/spendgraph/tools/turns), which selects once and records what it offered.
|
|
35
35
|
|
|
36
36
|
## What it answers
|
|
37
37
|
|
|
@@ -59,16 +59,16 @@ result.status; // "completed" | "failed"
|
|
|
59
59
|
```
|
|
60
60
|
|
|
61
61
|
<Callout tone="trap" title="`invoke` never throws, so `status` is the only thing that says it failed">
|
|
62
|
-
A tool that throws comes back as a failed result the model reads and can act on, which is the point
|
|
62
|
+
A tool that throws comes back as a failed result the model reads and can act on, which is the point, but code that only looks at `output` sees an empty string and carries on. Check `status === "failed"` before believing a result.
|
|
63
63
|
</Callout>
|
|
64
64
|
|
|
65
65
|
## Gating on effect
|
|
66
66
|
|
|
67
|
-
`destructive()` lists only the tools that **said** they are. Pair it with `unannotated()` before treating the rest as safe
|
|
67
|
+
`destructive()` lists only the tools that **said** they are. Pair it with `unannotated()` before treating the rest as safe. See [Effects](/spendgraph/tools/effects).
|
|
68
68
|
|
|
69
69
|
## Usage is the tiebreaker
|
|
70
70
|
|
|
71
|
-
`usage()` counts what has actually run, and selection falls back on it when a query singles nothing out. A call that never reached the tool
|
|
71
|
+
`usage()` counts what has actually run, and selection falls back on it when a query singles nothing out. A call that never reached the tool, a bad name, arguments that did not validate, is not counted, because it was not a use.
|
|
72
72
|
|
|
73
73
|
## Nesting: one bus of buses
|
|
74
74
|
|
|
@@ -83,7 +83,7 @@ const bus = busgroup([
|
|
|
83
83
|
]);
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
-
**It returns a bus.** Everything above works on it unchanged, a member's `bus` may itself be a group, and the model still sees a flat list of tools
|
|
86
|
+
**It returns a bus.** Everything above works on it unchanged, a member's `bus` may itself be a group, and the model still sees a flat list of tools: grouping is a selection-time concern, never a wire-format one.
|
|
87
87
|
|
|
88
88
|
Routing runs in three steps. A query that clearly names a domain consults only that domain. A query that names none consults every member and re-ranks the merged offers, so nesting is never worse than flat. A query that matches nothing anywhere spreads across members rather than letting array order decide.
|
|
89
89
|
|
package/docs/declaring.mdx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const meta = {
|
|
2
|
-
title: "Declaring a tool
|
|
2
|
+
title: "Declaring a tool: spendgraph docs",
|
|
3
3
|
description:
|
|
4
4
|
"Write `as const` and the handler types itself. Every check `tool` makes is something a provider either rejects opaquely or accepts and behaves oddly about.",
|
|
5
5
|
};
|
|
@@ -21,7 +21,7 @@ const lookup = tool({
|
|
|
21
21
|
|
|
22
22
|
## Write `as const` and the handler types itself
|
|
23
23
|
|
|
24
|
-
`ref` arrives a string, `limit` a number, `mode` narrowed to its two options, and the optional ones optional. Rename an argument and the handler stops compiling
|
|
24
|
+
`ref` arrives a string, `limit` a number, `mode` narrowed to its two options, and the optional ones optional. Rename an argument and the handler stops compiling: rather than being handed `undefined` halfway through an agent loop.
|
|
25
25
|
|
|
26
26
|
Without `as const`, inference falls back to nothing **silently**. That is the failure the package's own typecheck test exists to catch.
|
|
27
27
|
|
|
@@ -32,11 +32,11 @@ Arguments are validated, then filled in and coerced, before `run` sees them:
|
|
|
32
32
|
- a **declared default** applies when the argument is absent;
|
|
33
33
|
- a value arrives at the **type its declaration promises**.
|
|
34
34
|
|
|
35
|
-
Providers hand tool arguments over as JSON a model wrote, so `"limit": "7"` is ordinary. It validates
|
|
35
|
+
Providers hand tool arguments over as JSON a model wrote, so `"limit": "7"` is ordinary. It validates, the checker reads it through the same coercion, and it reaches `run` as the number `7`. `"loud": "false"` reaches it as `false`, not as a non-empty string that would make `if (args.loud)` run the branch the model asked it not to.
|
|
36
36
|
|
|
37
37
|
## `aliases` are matched, never sent
|
|
38
38
|
|
|
39
|
-
The description is what the model reads. `aliases` are what [selection](/
|
|
39
|
+
The description is what the model reads. `aliases` are what [selection](/spendgraph/tools/selecting) matches on, and they never leave the process:
|
|
40
40
|
|
|
41
41
|
```ts
|
|
42
42
|
tool({
|
|
@@ -68,7 +68,7 @@ Every check `tool` makes is something a provider either rejects opaquely or acce
|
|
|
68
68
|
They fire at import, not on the first loop in production.
|
|
69
69
|
|
|
70
70
|
<Callout tone="trap" title="Without `as const`, inference gives up quietly">
|
|
71
|
-
Drop it and the handler's argument type falls back to `Record<string, unknown
|
|
71
|
+
Drop it and the handler's argument type falls back to `Record<string, unknown>`, no error, no red squiggle, just every field typed `unknown` from then on. It is checked by a typecheck test in the package for exactly that reason.
|
|
72
72
|
</Callout>
|
|
73
73
|
|
|
74
74
|
## What a result carries
|
|
@@ -80,6 +80,6 @@ result.error; // why, when it failed
|
|
|
80
80
|
result.latencyMs;
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
A tool whose **return value** cannot be serialised
|
|
83
|
+
A tool whose **return value** cannot be serialised, a circular object, a `BigInt`, is still a tool that **ran**. It comes back completed, carrying `[not recordable: …]`, rather than failed: reporting a completed action as a failure invites the agent to do it again.
|
|
84
84
|
|
|
85
85
|
A thrown string keeps its message. Anything can be thrown, and reading only `Error.message` discards the one thing the tool said about itself.
|
package/docs/effects.mdx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const meta = {
|
|
2
|
-
title: "Effects
|
|
2
|
+
title: "Effects: spendgraph docs",
|
|
3
3
|
description:
|
|
4
4
|
"Three values, not a taxonomy. There is no default, and unset is not readonly: a default would quietly label every tool written before this existed as safe.",
|
|
5
5
|
};
|
|
@@ -20,7 +20,7 @@ Without it, `issue_refund` and `lookup_clause` are the same thing to a bus: two
|
|
|
20
20
|
| `idempotent` | has an effect, but twice is the same as once |
|
|
21
21
|
| `destructive` | neither, so never retry it on its own |
|
|
22
22
|
|
|
23
|
-
Three values, not a taxonomy
|
|
23
|
+
Three values, not a taxonomy: anything finer does not get filled in accurately.
|
|
24
24
|
|
|
25
25
|
## There is no default, and unset is not `readonly`
|
|
26
26
|
|
|
@@ -32,18 +32,18 @@ bus.destructive() // ["issue_refund"]
|
|
|
32
32
|
bus.unannotated() // ["send_email"]
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
`destructive()` returns only the tools that said so, so a gate built on it alone lets an unlabelled one through. **`unannotated()` is how you find those
|
|
35
|
+
`destructive()` returns only the tools that said so, so a gate built on it alone lets an unlabelled one through. **`unannotated()` is how you find those**: assert it is empty in a test and a new tool cannot join the bus unlabelled.
|
|
36
36
|
|
|
37
37
|
<Callout tone="trap" title="`readonly` is not the same as safe">
|
|
38
|
-
Effect says what a call does outside the process; `trust` says whether what it returns was written by somebody outside. A web fetch is `readonly` and is also how an injected instruction arrives. A guard that wants "no destructive call after untrusted content came in" has to gate on `trust: "external"
|
|
38
|
+
Effect says what a call does outside the process; `trust` says whether what it returns was written by somebody outside. A web fetch is `readonly` and is also how an injected instruction arrives. A guard that wants "no destructive call after untrusted content came in" has to gate on `trust: "external"`: the alternative is gating on the tool's *name*, which a rename turns off silently, and in the permissive direction.
|
|
39
39
|
</Callout>
|
|
40
40
|
|
|
41
41
|
## It survives selection
|
|
42
42
|
|
|
43
43
|
The effect rides along on `declarations()` and survives `select()`, which is where a gate reads it. Every provider shape drops it: no API has a field for it, and an unknown key is either rejected or ignored.
|
|
44
44
|
|
|
45
|
-
A turn answers about the tools **it** was offered
|
|
45
|
+
A turn answers about the tools **it** was offered: `effectOf` is `undefined` both for a tool that never said and for one this turn never saw. A gate reading it decides what unknown means for it.
|
|
46
46
|
|
|
47
47
|
## Three builtins leave it unset on purpose
|
|
48
48
|
|
|
49
|
-
`http_request` is a GET or a POST depending on an argument, and one label cannot be true of both. `escalate` runs your `onEscalate`, and paging someone twice is not the same as paging them once. `code_exec` runs whatever the model wrote, which is neither safe nor known
|
|
49
|
+
`http_request` is a GET or a POST depending on an argument, and one label cannot be true of both. `escalate` runs your `onEscalate`, and paging someone twice is not the same as paging them once. `code_exec` runs whatever the model wrote, which is neither safe nor known, and `undefined` says exactly that, where either label would lie.
|
package/docs/overview.mdx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const meta = {
|
|
2
|
-
title: "Tools
|
|
2
|
+
title: "Tools: spendgraph docs",
|
|
3
3
|
description:
|
|
4
4
|
"Declare a tool once, offer the right few, and record what was called. A description and a function in one object, so a rename cannot become a runtime failure.",
|
|
5
5
|
};
|
|
@@ -8,12 +8,14 @@ export const meta = {
|
|
|
8
8
|
|
|
9
9
|
Fifty tools is 7,500 tokens spent before the user has typed anything, and a model given fifty picks worse than a model given five.
|
|
10
10
|
|
|
11
|
-
Declare a tool once, offer the right few, and record what was called
|
|
11
|
+
Declare a tool once, offer the right few, and record what was called: because a model that says it checked the ledger and did not is a claim only the steps can disprove.
|
|
12
12
|
|
|
13
13
|
```sh
|
|
14
14
|
npm install @spendgraph/tools
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
+
<ToolShortlist />
|
|
18
|
+
|
|
17
19
|
Two things to learn, and twenty-two builtins you can ignore until you want one.
|
|
18
20
|
|
|
19
21
|
```ts
|
|
@@ -44,7 +46,7 @@ turn.record(); // { offeredTools, steps }
|
|
|
44
46
|
|
|
45
47
|
## Why one object
|
|
46
48
|
|
|
47
|
-
A description the model reads and a function you wrote, in **one object**. Splitting them
|
|
49
|
+
A description the model reads and a function you wrote, in **one object**. Splitting them, a schema here, a handler there, matched by string, is how a rename becomes a runtime failure mid-loop.
|
|
48
50
|
|
|
49
51
|
The description is the biggest lever on whether the model calls it correctly, and worth iterating on more than the code is.
|
|
50
52
|
|
|
@@ -59,18 +61,18 @@ await prompt.call(values, ({ messages, turn }) => llm.call(messages, { tools: tu
|
|
|
59
61
|
```
|
|
60
62
|
|
|
61
63
|
<Callout tone="trap" title="Declaring a tool is not offering it">
|
|
62
|
-
`toolbus()` holds everything your app can do; what the model sees each turn is a shortlist. Selection is never applied for you
|
|
64
|
+
`toolbus()` holds everything your app can do; what the model sees each turn is a shortlist. Selection is never applied for you: hand the model the whole bus and you are paying for every description on every turn, and making it worse at choosing.
|
|
63
65
|
</Callout>
|
|
64
66
|
|
|
65
67
|
## Where to go next
|
|
66
68
|
|
|
67
69
|
| | |
|
|
68
70
|
| --- | --- |
|
|
69
|
-
| [Declaring a tool](/
|
|
70
|
-
| [A stored tool](/
|
|
71
|
-
| [Effects](/
|
|
72
|
-
| [The bus](/
|
|
73
|
-
| [Selecting](/
|
|
74
|
-
| [A turn](/
|
|
75
|
-
| [Builtins](/
|
|
76
|
-
| [Bogus tools](/
|
|
71
|
+
| [Declaring a tool](/spendgraph/tools/declaring) | args, `as const`, and what is checked at import |
|
|
72
|
+
| [A stored tool](/spendgraph/tools/stored) | wording in the dashboard, handler in code |
|
|
73
|
+
| [Effects](/spendgraph/tools/effects) | what running one does to the world outside |
|
|
74
|
+
| [The bus](/spendgraph/tools/bus) | holds, picks, converts, runs, and nests |
|
|
75
|
+
| [Selecting](/spendgraph/tools/selecting) | the shortlist, and why it is a shortlist |
|
|
76
|
+
| [A turn](/spendgraph/tools/turns) | offered, called, and what the rollout keeps |
|
|
77
|
+
| [Builtins](/spendgraph/tools/builtins) | thirteen factories, none registered for you |
|
|
78
|
+
| [Bogus tools](/spendgraph/tools/bogus) | twelve mocks for testing the pipeline, not the provider |
|
package/docs/selecting.mdx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const meta = {
|
|
2
|
-
title: "Selecting tools
|
|
2
|
+
title: "Selecting tools: spendgraph docs",
|
|
3
3
|
description:
|
|
4
4
|
"Fifty tools is 7.5k tokens before the user has said anything. `limit` is an accuracy budget, not a token one, and selection is keyword and usage on purpose.",
|
|
5
5
|
};
|
|
@@ -22,23 +22,23 @@ Holding tools in one place is what makes selection possible. Fifty tools at roug
|
|
|
22
22
|
|
|
23
23
|
## `limit` is an accuracy budget
|
|
24
24
|
|
|
25
|
-
More tools in front of a model makes it worse at picking the right one **regardless of context length**. Measured shortlists cluster around five to eight. Pinned tools count against it
|
|
25
|
+
More tools in front of a model makes it worse at picking the right one **regardless of context length**. Measured shortlists cluster around five to eight. Pinned tools count against it: what is in front of the model is what costs accuracy, whoever put it there.
|
|
26
26
|
|
|
27
|
-
A limit that is `NaN` is refused rather than clamped: `Math.max(1, NaN)` is `NaN`, and every length compares false against it, so the model would be offered **nothing
|
|
27
|
+
A limit that is `NaN` is refused rather than clamped: `Math.max(1, NaN)` is `NaN`, and every length compares false against it, so the model would be offered **nothing**, the one outcome selection exists to avoid.
|
|
28
28
|
|
|
29
29
|
## Keyword and usage, deliberately
|
|
30
30
|
|
|
31
31
|
No embedding provider, no extra call, no latency. It is also the baseline that says what embeddings would buy.
|
|
32
32
|
|
|
33
|
-
Relevance comes before usage: at a shortlist of six, three previously-used tools take half the slots and push out the tool the query needs. Usage earns its place as the tiebreaker among equal matches, and as the fallback when nothing matches
|
|
33
|
+
Relevance comes before usage: at a shortlist of six, three previously-used tools take half the slots and push out the tool the query needs. Usage earns its place as the tiebreaker among equal matches, and as the fallback when nothing matches, the case where an agent would otherwise be handed nothing.
|
|
34
34
|
|
|
35
|
-
Words are found in **any script**. A query with no ASCII letters used to produce no terms at all, so every request fell through to "the first six declared"
|
|
35
|
+
Words are found in **any script**. A query with no ASCII letters used to produce no terms at all, so every request fell through to "the first six declared", the same six for a Japanese question as for a Russian one.
|
|
36
36
|
|
|
37
37
|
## How a tool scores
|
|
38
38
|
|
|
39
39
|
Whole words, never substrings. A term matching inside a longer word is noise, not signal: `me` sits inside `statement`, and at a few hundred tools that alone puts half the catalogue in every shortlist.
|
|
40
40
|
|
|
41
|
-
Where the word appears decides its weight
|
|
41
|
+
Where the word appears decides its weight: **name beats alias beats description**. A tool named `refund` is a stronger answer to a question about refunds than one that merely mentions the word in a paragraph.
|
|
42
42
|
|
|
43
43
|
Then each term is weighted by how **rare** it is across the bus. A word one tool in two thousand uses says far more than one four hundred of them share, and without that every term counts the same and common vocabulary decides the shortlist.
|
|
44
44
|
|
|
@@ -56,15 +56,15 @@ tool({
|
|
|
56
56
|
});
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
They are matched, never sent
|
|
59
|
+
They are matched, never sent: every provider shape drops them, exactly as it drops `effect`. Unlike an embedding, a bad match is fixed by editing one line.
|
|
60
60
|
|
|
61
61
|
<Callout tone="trap" title="Pinned tools spend the budget too">
|
|
62
|
-
`limit` counts pinned tools, so a shortlist of 5 with `finish` and `escalate` pinned leaves three slots for the query. A tool that needs a partner is dropped rather than offered alone
|
|
62
|
+
`limit` counts pinned tools, so a shortlist of 5 with `finish` and `escalate` pinned leaves three slots for the query. A tool that needs a partner is dropped rather than offered alone, an agent told to prefer `recall` and handed only `deep_recall` will reach for the one it does not have.
|
|
63
63
|
</Callout>
|
|
64
64
|
|
|
65
65
|
## Past a few hundred tools
|
|
66
66
|
|
|
67
|
-
Ranking, not speed, is what gives out first: selection stays near a millisecond at two thousand tools, but a coincidental match in an unrelated domain starts taking slots. [`busgroup`](/
|
|
67
|
+
Ranking, not speed, is what gives out first: selection stays near a millisecond at two thousand tools, but a coincidental match in an unrelated domain starts taking slots. [`busgroup`](/spendgraph/tools/bus) gives each domain its own bus and routes between them.
|
|
68
68
|
|
|
69
69
|
## `missing` catches a silent failure
|
|
70
70
|
|
|
@@ -76,4 +76,4 @@ bus.missing(["refund", "escalate"]); // the names this bus does not have
|
|
|
76
76
|
|
|
77
77
|
## Two tools that describe themselves the same way
|
|
78
78
|
|
|
79
|
-
Two near-identical descriptions are worse than one tool: the model cannot tell them apart and picks wrong more often, with nothing failing. The bus says so at construction, once, however many pairs there are
|
|
79
|
+
Two near-identical descriptions are worse than one tool: the model cannot tell them apart and picks wrong more often, with nothing failing. The bus says so at construction, once, however many pairs there are, a line each buries whatever else the process said at startup and teaches people to switch the check off.
|