toolnexus 0.20.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.
Files changed (49) hide show
  1. package/README.md +40 -0
  2. package/dist/agents/agent.d.ts +12 -1
  3. package/dist/agents/agent.js +43 -7
  4. package/dist/agents/compaction.d.ts +19 -0
  5. package/dist/agents/compaction.js +26 -4
  6. package/dist/agents/runtime.d.ts +5 -0
  7. package/dist/agents/runtime.js +13 -2
  8. package/dist/batteries.d.ts +223 -0
  9. package/dist/batteries.js +488 -0
  10. package/dist/builtin.d.ts +16 -0
  11. package/dist/builtin.js +315 -17
  12. package/dist/builtin_extras.d.ts +80 -0
  13. package/dist/builtin_extras.js +248 -0
  14. package/dist/classifier.d.ts +14 -0
  15. package/dist/classifier.js +34 -1
  16. package/dist/client.d.ts +91 -15
  17. package/dist/client.js +377 -110
  18. package/dist/index.d.ts +8 -0
  19. package/dist/index.js +11 -0
  20. package/dist/judge.d.ts +98 -0
  21. package/dist/judge.js +199 -0
  22. package/dist/mcp.d.ts +32 -1
  23. package/dist/mcp.js +184 -6
  24. package/dist/models.d.ts +20 -0
  25. package/dist/models.js +46 -0
  26. package/dist/promptcache.d.ts +21 -0
  27. package/dist/promptcache.js +59 -0
  28. package/dist/store.d.ts +32 -0
  29. package/dist/store.js +131 -0
  30. package/dist/tooltiers.d.ts +49 -0
  31. package/dist/tooltiers.js +197 -0
  32. package/dist/types.d.ts +19 -0
  33. package/package.json +2 -2
  34. package/src/agents/agent.ts +39 -8
  35. package/src/agents/compaction.ts +45 -4
  36. package/src/agents/runtime.ts +16 -2
  37. package/src/batteries.ts +614 -0
  38. package/src/builtin.ts +333 -18
  39. package/src/builtin_extras.ts +285 -0
  40. package/src/classifier.ts +36 -1
  41. package/src/client.ts +415 -115
  42. package/src/index.ts +31 -0
  43. package/src/judge.ts +305 -0
  44. package/src/mcp.ts +204 -7
  45. package/src/models.ts +56 -0
  46. package/src/promptcache.ts +63 -0
  47. package/src/store.ts +142 -0
  48. package/src/tooltiers.ts +221 -0
  49. package/src/types.ts +20 -0
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Prompt caching (SPEC §8 "Prompt caching", change add-prompt-cache). The engine keeps the
3
+ * request prefix stable and marks it for the provider's cache.
4
+ */
5
+
6
+ /** `ClientOptions.promptCache`. */
7
+ export interface PromptCacheOptions {
8
+ /** Groups requests that share a prefix (one per session). Sent as openai `prompt_cache_key`.
9
+ * Ignored by anthropic, which caches by prefix. */
10
+ key?: string
11
+ /** Ask for the provider's longer cache lifetime where one exists (anthropic `ttl: "1h"`). */
12
+ longTTL?: boolean
13
+ }
14
+
15
+ function withCacheControl(v: unknown, mark: Record<string, unknown>): unknown {
16
+ if (!v || typeof v !== "object" || Array.isArray(v)) return v
17
+ return { ...(v as Record<string, unknown>), cache_control: mark }
18
+ }
19
+
20
+ /**
21
+ * Mark the cacheable prefix in an assembled body, in place. The transcript's own objects are
22
+ * never mutated: every touched entry is copied.
23
+ * - anthropic: `cache_control` on the last tool, the system prompt (a string becomes one text
24
+ * block) and the last content block of the latest message (3 of the 4 allowed breakpoints).
25
+ * - openai: `prompt_cache_key = key` when set; the prefix is cached automatically.
26
+ * - gemini: nothing yet (explicit cachedContent is deferred).
27
+ */
28
+ export function applyPromptCache(body: Record<string, any>, style: string, o: PromptCacheOptions): void {
29
+ if (style === "anthropic") {
30
+ const mark: Record<string, unknown> = { type: "ephemeral" }
31
+ if (o.longTTL) mark.ttl = "1h"
32
+ if (Array.isArray(body.tools) && body.tools.length > 0) {
33
+ const out = [...body.tools]
34
+ out[out.length - 1] = withCacheControl(out[out.length - 1], mark)
35
+ body.tools = out
36
+ }
37
+ if (typeof body.system === "string") {
38
+ if (body.system !== "") body.system = [{ type: "text", text: body.system, cache_control: mark }]
39
+ } else if (Array.isArray(body.system) && body.system.length > 0) {
40
+ const out = [...body.system]
41
+ out[out.length - 1] = withCacheControl(out[out.length - 1], mark)
42
+ body.system = out
43
+ }
44
+ if (Array.isArray(body.messages) && body.messages.length > 0) {
45
+ const out = [...body.messages]
46
+ const last = out[out.length - 1]
47
+ if (last && typeof last === "object" && !Array.isArray(last)) {
48
+ const m: Record<string, unknown> = { ...last }
49
+ if (typeof m.content === "string") {
50
+ m.content = [{ type: "text", text: m.content, cache_control: mark }]
51
+ } else if (Array.isArray(m.content) && m.content.length > 0) {
52
+ const blocks = [...m.content]
53
+ blocks[blocks.length - 1] = withCacheControl(blocks[blocks.length - 1], mark)
54
+ m.content = blocks
55
+ }
56
+ out[out.length - 1] = m
57
+ }
58
+ body.messages = out
59
+ }
60
+ } else if (style === "openai") {
61
+ if (o.key) body.prompt_cache_key = o.key
62
+ }
63
+ }
package/src/store.ts ADDED
@@ -0,0 +1,142 @@
1
+ import { promises as fsp } from "node:fs"
2
+ import path from "node:path"
3
+ import { randomBytes } from "node:crypto"
4
+ import { sortEntries, type ConversationEntry, type ConversationStore } from "./client.js"
5
+
6
+ const ID_RE = /^[A-Za-z0-9._-]{1,128}$/
7
+ const VERSION = 1
8
+
9
+ function checkId(id: string): void {
10
+ if (typeof id !== "string" || !ID_RE.test(id) || id === "." || id === "..") {
11
+ throw new Error(`invalid conversation id: ${JSON.stringify(id)}`)
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Durable {@link ConversationStore}: one file `<root>/<id>.json` per conversation holding
17
+ * `{"version":1,"messages":[...]}`. `save` writes a temp file (0600), fsyncs and renames it over
18
+ * the old one, so a reader sees the old or the new transcript, never a torn one (power-loss
19
+ * durability of the rename is best-effort). Same-id saves are serialized in-process; across
20
+ * processes the last rename wins.
21
+ */
22
+ /**
23
+ * Durability mode (SPEC §8 "Durability mode"): `full` (default) flushes the file then fsyncs the
24
+ * directory best-effort; `fsync` flushes the file only; `none` skips every flush. Temp-then-rename
25
+ * holds in every mode. (On macOS Node's `fh.sync()` is libuv's fsync, which already issues
26
+ * `F_FULLFSYNC`; Node exposes no plainer flush, so `fsync` differs from `full` only by the dir fsync.)
27
+ */
28
+ export type FileStoreSync = "full" | "fsync" | "none"
29
+
30
+ export interface FileStoreOptions {
31
+ sync?: FileStoreSync
32
+ }
33
+
34
+ export class FileConversationStore implements ConversationStore {
35
+ readonly root: string
36
+ readonly sync: FileStoreSync
37
+ private readonly chains = new Map<string, Promise<void>>()
38
+
39
+ constructor(root: string, opts: FileStoreOptions = {}) {
40
+ if (typeof root !== "string" || root === "") throw new Error("FileConversationStore: root is required")
41
+ const mode = opts.sync ?? "full"
42
+ if (mode !== "full" && mode !== "fsync" && mode !== "none") {
43
+ throw new Error(`FileConversationStore: unknown sync mode ${JSON.stringify(opts.sync)}`)
44
+ }
45
+ this.root = root
46
+ this.sync = mode
47
+ }
48
+
49
+ private file(id: string): string {
50
+ return path.join(this.root, id + ".json")
51
+ }
52
+
53
+ async get(id: string): Promise<any[] | undefined> {
54
+ checkId(id)
55
+ let text: string
56
+ try {
57
+ text = await fsp.readFile(this.file(id), "utf8")
58
+ } catch (e: any) {
59
+ if (e?.code === "ENOENT") return undefined
60
+ throw e
61
+ }
62
+ let body: any
63
+ try {
64
+ body = JSON.parse(text)
65
+ } catch (e) {
66
+ throw new Error(`conversation "${id}": corrupt file: ${e instanceof Error ? e.message : String(e)}`)
67
+ }
68
+ if (body === null || typeof body !== "object" || body.version !== VERSION) {
69
+ throw new Error(`conversation "${id}": unsupported version ${JSON.stringify(body?.version)}`)
70
+ }
71
+ if (!Array.isArray(body.messages)) throw new Error(`conversation "${id}": corrupt file: messages is not an array`)
72
+ return body.messages
73
+ }
74
+
75
+ async save(id: string, messages: any[]): Promise<void> {
76
+ checkId(id)
77
+ const prev = this.chains.get(id) ?? Promise.resolve()
78
+ const next = prev.catch(() => {}).then(() => this.write(id, messages))
79
+ this.chains.set(id, next)
80
+ try {
81
+ await next
82
+ } finally {
83
+ if (this.chains.get(id) === next) this.chains.delete(id)
84
+ }
85
+ }
86
+
87
+ private async write(id: string, messages: any[]): Promise<void> {
88
+ await fsp.mkdir(this.root, { recursive: true, mode: 0o700 })
89
+ const tmp = path.join(this.root, `.${id}.${randomBytes(6).toString("hex")}.tmp`)
90
+ const data = JSON.stringify({ version: VERSION, messages })
91
+ let fh: Awaited<ReturnType<typeof fsp.open>> | undefined
92
+ try {
93
+ fh = await fsp.open(tmp, "w", 0o600)
94
+ await fh.writeFile(data, "utf8")
95
+ if (this.sync !== "none") await fh.sync()
96
+ await fh.close()
97
+ fh = undefined
98
+ await fsp.rename(tmp, this.file(id))
99
+ } catch (e) {
100
+ await fh?.close().catch(() => {})
101
+ await fsp.rm(tmp, { force: true }).catch(() => {})
102
+ throw e
103
+ }
104
+ // Best-effort parent-dir fsync so the rename itself survives power loss where supported.
105
+ if (this.sync !== "full") return
106
+ try {
107
+ const dir = await fsp.open(this.root, "r")
108
+ await dir.sync().catch(() => {})
109
+ await dir.close()
110
+ } catch {}
111
+ }
112
+
113
+ /** Every stored conversation, newest first (mtime; ties by id ascending). Bodies are not parsed. */
114
+ async list(): Promise<ConversationEntry[]> {
115
+ let names: string[]
116
+ try {
117
+ names = await fsp.readdir(this.root)
118
+ } catch (e: any) {
119
+ if (e?.code === "ENOENT") return []
120
+ throw e
121
+ }
122
+ const out: ConversationEntry[] = []
123
+ for (const name of names) {
124
+ if (!name.endsWith(".json")) continue
125
+ const id = name.slice(0, -5)
126
+ if (!ID_RE.test(id) || id === "." || id === "..") continue
127
+ try {
128
+ const st = await fsp.stat(path.join(this.root, name))
129
+ if (st.isFile()) out.push({ id, updatedAt: st.mtime })
130
+ } catch (e: any) {
131
+ if (e?.code !== "ENOENT") throw e
132
+ }
133
+ }
134
+ return sortEntries(out)
135
+ }
136
+
137
+ /** Remove a conversation; absent ids are not an error. */
138
+ async delete(id: string): Promise<void> {
139
+ checkId(id)
140
+ await fsp.rm(this.file(id), { force: true })
141
+ }
142
+ }
@@ -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"