toolnexus 0.21.0 → 0.22.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/dist/agents/agent.d.ts +12 -1
- package/dist/agents/agent.js +43 -7
- package/dist/agents/compaction.d.ts +19 -0
- package/dist/agents/compaction.js +26 -4
- package/dist/agents/runtime.d.ts +5 -0
- package/dist/agents/runtime.js +13 -2
- package/dist/builtin.d.ts +16 -0
- package/dist/builtin.js +315 -17
- package/dist/builtin_extras.d.ts +80 -0
- package/dist/builtin_extras.js +248 -0
- package/dist/client.d.ts +74 -2
- package/dist/client.js +292 -55
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/mcp.d.ts +32 -1
- package/dist/mcp.js +184 -6
- package/dist/models.d.ts +20 -0
- package/dist/models.js +46 -0
- package/dist/promptcache.d.ts +21 -0
- package/dist/promptcache.js +59 -0
- package/dist/store.d.ts +32 -0
- package/dist/store.js +131 -0
- package/dist/tooltiers.d.ts +49 -0
- package/dist/tooltiers.js +197 -0
- package/dist/types.d.ts +19 -0
- package/package.json +2 -2
- package/src/agents/agent.ts +39 -8
- package/src/agents/compaction.ts +45 -4
- package/src/agents/runtime.ts +16 -2
- package/src/builtin.ts +333 -18
- package/src/builtin_extras.ts +285 -0
- package/src/client.ts +317 -60
- package/src/index.ts +5 -0
- package/src/mcp.ts +204 -7
- package/src/models.ts +56 -0
- package/src/promptcache.ts +63 -0
- package/src/store.ts +142 -0
- package/src/tooltiers.ts +221 -0
- package/src/types.ts +20 -0
package/src/tooltiers.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool tiers (SPEC §8 "Prompt caching" → Tool tiers, change add-prompt-cache phase 2). With
|
|
3
|
+
* `ClientOptions.toolTiers` set, only CORE tools are sent with full schemas. LISTED tools appear
|
|
4
|
+
* as "name — one line" and DEFERRED tools by name only, both inside the description of a
|
|
5
|
+
* `tool_search` tool that returns full schemas on demand, as a tool result: the tools array
|
|
6
|
+
* never changes mid-session, so the cached prefix survives. A loaded tool is called by name, or
|
|
7
|
+
* through `call_tool {name, arguments}`, which the loop unwraps BEFORE hooks.
|
|
8
|
+
*/
|
|
9
|
+
import type { Tool, ToolResult } from "./types.js"
|
|
10
|
+
|
|
11
|
+
/** `ClientOptions.toolTiers`. */
|
|
12
|
+
export interface ToolTierOptions {
|
|
13
|
+
/** Always sent with their full schema. */
|
|
14
|
+
core?: string[]
|
|
15
|
+
/** Named only. Every other tool is listed (name + one line). A tool registered after the
|
|
16
|
+
* plan froze is deferred and found by search. */
|
|
17
|
+
deferred?: string[]
|
|
18
|
+
/** Caps the tokens sent up front (core schemas + tool_search + call_tool). Absent/0 ⇒ 5% of
|
|
19
|
+
* `contextWindow`; both absent ⇒ no budget. Over budget, the least-used core tools move to
|
|
20
|
+
* listed, then the least-used listed tools to deferred. */
|
|
21
|
+
budgetTokens?: number
|
|
22
|
+
contextWindow?: number
|
|
23
|
+
/** Host per-tool usage stat (higher = more used), read only when the plan is built. */
|
|
24
|
+
usageCount?: (name: string) => number
|
|
25
|
+
/** Token estimate of a JSON-encodable value; default ceil(bytes/4). */
|
|
26
|
+
countTokens?: (v: unknown) => number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type ToolTier = "core" | "listed" | "deferred"
|
|
30
|
+
|
|
31
|
+
/** The frozen tier assignment for one toolkit. */
|
|
32
|
+
export interface ToolPlan {
|
|
33
|
+
tier: Map<string, ToolTier>
|
|
34
|
+
core: string[]
|
|
35
|
+
listed: string[]
|
|
36
|
+
deferred: string[]
|
|
37
|
+
search: Tool
|
|
38
|
+
call: Tool
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
|
42
|
+
|
|
43
|
+
export function defaultCountTokens(v: unknown): number {
|
|
44
|
+
return Math.ceil(Buffer.byteLength(JSON.stringify(v) ?? "", "utf8") / 4)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function toolDef(t: Tool): Record<string, unknown> {
|
|
48
|
+
return { name: t.name, description: t.description, inputSchema: t.inputSchema }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function firstLine(s: string): string {
|
|
52
|
+
const i = s.indexOf("\n")
|
|
53
|
+
if (i >= 0) s = s.slice(0, i)
|
|
54
|
+
// Go cuts at 120 BYTES; cut at 120 UTF-8 bytes too, without splitting a character.
|
|
55
|
+
if (Buffer.byteLength(s, "utf8") > 120) {
|
|
56
|
+
let out = ""
|
|
57
|
+
for (const ch of s) {
|
|
58
|
+
if (Buffer.byteLength(out + ch, "utf8") > 120) break
|
|
59
|
+
out += ch
|
|
60
|
+
}
|
|
61
|
+
s = out + "…"
|
|
62
|
+
}
|
|
63
|
+
return s
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const notRun = async (): Promise<ToolResult> => ({ output: "engine-internal tool: run by the client loop", isError: true })
|
|
67
|
+
|
|
68
|
+
function rebuild(p: ToolPlan, byName: Map<string, Tool>): void {
|
|
69
|
+
p.core = []
|
|
70
|
+
p.listed = []
|
|
71
|
+
p.deferred = []
|
|
72
|
+
for (const [n, t] of p.tier) (t === "core" ? p.core : t === "listed" ? p.listed : p.deferred).push(n)
|
|
73
|
+
p.core.sort(cmp)
|
|
74
|
+
p.listed.sort(cmp)
|
|
75
|
+
p.deferred.sort(cmp)
|
|
76
|
+
let d =
|
|
77
|
+
'Load the full schema of tools that are not shown in full, then call them by name (or with call_tool). Query by keywords, or "select:a,b" for exact names.'
|
|
78
|
+
if (p.listed.length) {
|
|
79
|
+
d += "\n\nAvailable tools:"
|
|
80
|
+
for (const n of p.listed) {
|
|
81
|
+
d += "\n- " + n
|
|
82
|
+
const line = firstLine(byName.get(n)?.description ?? "")
|
|
83
|
+
if (line) d += " — " + line
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (p.deferred.length) d += "\n\nMore tools (name only): " + p.deferred.join(", ")
|
|
87
|
+
p.search = {
|
|
88
|
+
name: "tool_search",
|
|
89
|
+
description: d,
|
|
90
|
+
inputSchema: {
|
|
91
|
+
type: "object",
|
|
92
|
+
properties: {
|
|
93
|
+
query: { type: "string", description: "Keywords, or select:name1,name2" },
|
|
94
|
+
limit: { type: "number", description: "Maximum results for a keyword query (default 5)" },
|
|
95
|
+
},
|
|
96
|
+
required: ["query"],
|
|
97
|
+
additionalProperties: false,
|
|
98
|
+
},
|
|
99
|
+
source: "builtin",
|
|
100
|
+
hints: { readOnly: true, destructive: false, openWorld: false },
|
|
101
|
+
execute: notRun,
|
|
102
|
+
}
|
|
103
|
+
p.call = {
|
|
104
|
+
name: "call_tool",
|
|
105
|
+
description: "Call a tool loaded with tool_search, by name, with its arguments.",
|
|
106
|
+
inputSchema: {
|
|
107
|
+
type: "object",
|
|
108
|
+
properties: { name: { type: "string" }, arguments: { type: "object" } },
|
|
109
|
+
required: ["name"],
|
|
110
|
+
additionalProperties: false,
|
|
111
|
+
},
|
|
112
|
+
source: "builtin",
|
|
113
|
+
execute: notRun,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Build a tier plan over `tools` (exported for tests and host UIs). */
|
|
118
|
+
export function buildToolPlan(o: ToolTierOptions, tools: Tool[]): ToolPlan {
|
|
119
|
+
const count = o.countTokens ?? defaultCountTokens
|
|
120
|
+
const usage = (n: string) => (o.usageCount ? o.usageCount(n) : 0)
|
|
121
|
+
const byName = new Map(tools.map((t) => [t.name, t] as const))
|
|
122
|
+
const tier = new Map<string, ToolTier>()
|
|
123
|
+
for (const t of tools) tier.set(t.name, "listed")
|
|
124
|
+
for (const n of o.deferred ?? []) if (tier.has(n)) tier.set(n, "deferred")
|
|
125
|
+
for (const n of o.core ?? []) if (tier.has(n)) tier.set(n, "core")
|
|
126
|
+
const p = { tier } as ToolPlan
|
|
127
|
+
rebuild(p, byName)
|
|
128
|
+
|
|
129
|
+
let budget = o.budgetTokens ?? 0
|
|
130
|
+
if (!budget && o.contextWindow && o.contextWindow > 0) budget = Math.floor(o.contextWindow / 20)
|
|
131
|
+
if (budget > 0) {
|
|
132
|
+
const cost = () => {
|
|
133
|
+
let n = count(toolDef(p.search)) + count(toolDef(p.call))
|
|
134
|
+
for (const name of p.core) n += count(toolDef(byName.get(name)!))
|
|
135
|
+
return n
|
|
136
|
+
}
|
|
137
|
+
// least used first; ties: bigger schema first, then name
|
|
138
|
+
const order = (names: string[]) =>
|
|
139
|
+
[...names].sort((a, b) => {
|
|
140
|
+
const ua = usage(a), ub = usage(b)
|
|
141
|
+
if (ua !== ub) return ua - ub
|
|
142
|
+
const sa = count(toolDef(byName.get(a)!)), sb = count(toolDef(byName.get(b)!))
|
|
143
|
+
if (sa !== sb) return sb - sa
|
|
144
|
+
return cmp(a, b)
|
|
145
|
+
})
|
|
146
|
+
for (const n of order(p.core)) {
|
|
147
|
+
if (cost() <= budget) break
|
|
148
|
+
tier.set(n, "listed")
|
|
149
|
+
rebuild(p, byName)
|
|
150
|
+
}
|
|
151
|
+
for (const n of order(p.listed)) {
|
|
152
|
+
if (cost() <= budget) break
|
|
153
|
+
tier.set(n, "deferred")
|
|
154
|
+
rebuild(p, byName)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return p
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** `call_tool {name, arguments}` → the real call; `undefined` when `name` is not call_tool. */
|
|
161
|
+
export function unwrapCallTool(name: string, args: Record<string, unknown>): { name: string; args: Record<string, unknown> } | undefined {
|
|
162
|
+
if (name !== "call_tool") return undefined
|
|
163
|
+
const real = typeof args?.name === "string" ? args.name : ""
|
|
164
|
+
const inner = args?.arguments && typeof args.arguments === "object" && !Array.isArray(args.arguments) ? (args.arguments as Record<string, unknown>) : {}
|
|
165
|
+
return { name: real, args: inner }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Keys sorted at every level, so the loaded definitions print the same bytes as the Go port. */
|
|
169
|
+
function sortKeys(v: unknown): unknown {
|
|
170
|
+
if (Array.isArray(v)) return v.map(sortKeys)
|
|
171
|
+
if (v && typeof v === "object") {
|
|
172
|
+
const out: Record<string, unknown> = {}
|
|
173
|
+
for (const k of Object.keys(v as object).sort(cmp)) out[k] = sortKeys((v as Record<string, unknown>)[k])
|
|
174
|
+
return out
|
|
175
|
+
}
|
|
176
|
+
return v
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Go's json escapes `<`, `>`, `&`, U+2028 and U+2029 inside strings; they never occur outside one. */
|
|
180
|
+
export function goHtmlEscape(json: string): string {
|
|
181
|
+
return json.replace(/[<>&\u2028\u2029]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"))
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Run `tool_search` over every tool (any tier). */
|
|
185
|
+
export function toolSearch(all: Tool[], get: (name: string) => Tool | undefined, args: Record<string, unknown>): ToolResult {
|
|
186
|
+
const q = (typeof args.query === "string" ? args.query : "").trim()
|
|
187
|
+
if (!q) return { output: "tool_search: query is required", isError: true }
|
|
188
|
+
let picked: Tool[] = []
|
|
189
|
+
if (q.startsWith("select:")) {
|
|
190
|
+
const missing: string[] = []
|
|
191
|
+
for (const raw of q.slice("select:".length).split(",")) {
|
|
192
|
+
const n = raw.trim()
|
|
193
|
+
if (!n) continue
|
|
194
|
+
const t = get(n)
|
|
195
|
+
if (t) picked.push(t)
|
|
196
|
+
else missing.push(n)
|
|
197
|
+
}
|
|
198
|
+
if (missing.length && !picked.length) return { output: "tool_search: no such tool: " + missing.join(", "), isError: true }
|
|
199
|
+
} else {
|
|
200
|
+
const limit = typeof args.limit === "number" && args.limit >= 1 ? Math.trunc(args.limit) : 5
|
|
201
|
+
const terms = q.toLowerCase().split(/\s+/).filter(Boolean)
|
|
202
|
+
const hits: Array<{ t: Tool; score: number }> = []
|
|
203
|
+
for (const t of all) {
|
|
204
|
+
const name = t.name.toLowerCase(), desc = t.description.toLowerCase()
|
|
205
|
+
let s = 0
|
|
206
|
+
for (const term of terms) {
|
|
207
|
+
if (name.includes(term)) s += 3
|
|
208
|
+
if (desc.includes(term)) s += 1
|
|
209
|
+
}
|
|
210
|
+
if (s > 0) hits.push({ t, score: s })
|
|
211
|
+
}
|
|
212
|
+
hits.sort((a, b) => b.score - a.score || cmp(a.t.name, b.t.name))
|
|
213
|
+
picked = hits.slice(0, limit).map((h) => h.t)
|
|
214
|
+
}
|
|
215
|
+
if (!picked.length) return { output: "No matching tools.", isError: false, metadata: { count: 0 } }
|
|
216
|
+
return {
|
|
217
|
+
output: "Loaded tools (call by name, or with call_tool):\n" + goHtmlEscape(JSON.stringify(sortKeys(picked.map(toolDef)), null, 2)),
|
|
218
|
+
isError: false,
|
|
219
|
+
metadata: { count: picked.length, tools: picked.map((t) => t.name) },
|
|
220
|
+
}
|
|
221
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -32,6 +32,23 @@ export interface ToolContext {
|
|
|
32
32
|
timeout?: number
|
|
33
33
|
/** Present ONLY on a post-waitFor retry (§10): the resolution of a prior suspension. */
|
|
34
34
|
answer?: Answer
|
|
35
|
+
/** The model's id for this tool call, set by the client loop (§4C). A message's calls run
|
|
36
|
+
* concurrently, so this is how a tool tells its sibling calls apart. Absent outside the loop. */
|
|
37
|
+
callId?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Advisory behavior hints (named after MCP tool annotations, §4C), for host permission gates
|
|
42
|
+
* built on `hooks.beforeTool`. Absent on a tool = unknown; treat it as mutating. Never sent to
|
|
43
|
+
* the model: no adapter or wire output carries them.
|
|
44
|
+
*/
|
|
45
|
+
export interface ToolHints {
|
|
46
|
+
/** The tool does not change its environment. */
|
|
47
|
+
readOnly: boolean
|
|
48
|
+
/** The tool may overwrite or delete things (meaningful when not readOnly). */
|
|
49
|
+
destructive: boolean
|
|
50
|
+
/** The tool reaches outside the host (network, external systems). */
|
|
51
|
+
openWorld: boolean
|
|
35
52
|
}
|
|
36
53
|
|
|
37
54
|
/**
|
|
@@ -110,6 +127,9 @@ export interface Tool {
|
|
|
110
127
|
inputSchema: JSONSchema
|
|
111
128
|
source: ToolSource
|
|
112
129
|
execute(args: Record<string, unknown>, ctx?: ToolContext): Promise<ToolResult>
|
|
130
|
+
/** What the tool may do (§4C). Builtins always carry hints; MCP tools carry them when the
|
|
131
|
+
* server sends annotations. Advisory: a server can mislabel its tools. */
|
|
132
|
+
hints?: ToolHints
|
|
113
133
|
}
|
|
114
134
|
|
|
115
135
|
export type McpStatus = "connected" | "disabled" | "failed"
|