toolnexus 0.27.0 → 0.29.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.
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Default LLM transport: a `fetch`-shaped function over `node:http`/`node:https` with a shared
3
+ * keep-alive agent.
4
+ *
5
+ * Why: Node's built-in `fetch` (undici + web streams) costs ~1.5 ms per local round-trip, against
6
+ * ~0.3 ms for `node:http` on the same keep-alive socket. With two LLM calls per tool-calling turn
7
+ * that was most of the client's per-request overhead (benchmarks/run_toolnexus_js.mjs).
8
+ *
9
+ * Behavior is kept identical to `fetch` for what the client uses (status, headers incl.
10
+ * Retry-After, json/text, a streamed `body` for SSE, abort via `signal` rejecting with its reason).
11
+ * Anything this path does not cover is handed to `globalThis.fetch` unchanged: a proxy env
12
+ * (`HTTP(S)_PROXY`, `NODE_USE_ENV_PROXY`), a replaced global `fetch`, a non-http(s) URL, a body
13
+ * that is not a string/bytes, and redirects.
14
+ */
15
+ import http from "node:http"
16
+ import https from "node:https"
17
+ import { Readable } from "node:stream"
18
+ import zlib from "node:zlib"
19
+
20
+ const nativeFetch = globalThis.fetch
21
+ const httpAgent = new http.Agent({ keepAlive: true })
22
+ const httpsAgent = new https.Agent({ keepAlive: true })
23
+
24
+ function proxyEnv(): boolean {
25
+ const e = process.env
26
+ return !!(e.HTTP_PROXY || e.HTTPS_PROXY || e.http_proxy || e.https_proxy || e.NODE_USE_ENV_PROXY)
27
+ }
28
+
29
+ function toHeaderRecord(h: RequestInit["headers"]): Record<string, string> {
30
+ const out: Record<string, string> = {}
31
+ if (!h) return out
32
+ if (h instanceof Headers || Array.isArray(h)) {
33
+ for (const [k, v] of h as Iterable<[string, string]>) out[k] = v
34
+ return out
35
+ }
36
+ return { ...(h as Record<string, string>) }
37
+ }
38
+
39
+ // undici's global dispatcher slot. Node fills it lazily with a default Agent on the first fetch;
40
+ // anything else there (a ProxyAgent, a mock, a tuned Agent) means the host routed fetch on purpose.
41
+ const DISPATCHER = Symbol.for("undici.globalDispatcher.1")
42
+ let knownDefaultDispatcher: unknown
43
+
44
+ function customDispatcher(): boolean {
45
+ const d = (globalThis as Record<symbol, unknown>)[DISPATCHER]
46
+ return d !== undefined && d !== knownDefaultDispatcher
47
+ }
48
+
49
+ /** Calls the platform fetch; if that call is what created Node's default dispatcher, remember it
50
+ * so later calls still take the fast path. */
51
+ function platformFetch(input: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response> {
52
+ const before = (globalThis as Record<symbol, unknown>)[DISPATCHER]
53
+ const p = globalThis.fetch(input, init)
54
+ if (before === undefined) {
55
+ const after = (globalThis as Record<symbol, unknown>)[DISPATCHER]
56
+ if (after !== undefined) knownDefaultDispatcher = after
57
+ }
58
+ return p
59
+ }
60
+
61
+ const REDIRECTS = new Set([301, 302, 303, 307, 308])
62
+
63
+ export const nodeFetch: typeof fetch = (input, init) => {
64
+ const fallback = () => platformFetch(input, init)
65
+ if (globalThis.fetch !== nativeFetch || proxyEnv() || customDispatcher()) return fallback()
66
+ if (init?.redirect && init.redirect !== "follow") return fallback()
67
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : undefined
68
+ const body = init?.body
69
+ if (url === undefined || (body != null && typeof body !== "string" && !(body instanceof Uint8Array))) return fallback()
70
+ const isHttps = url.startsWith("https:")
71
+ if (!isHttps && !url.startsWith("http:")) return fallback()
72
+ const signal = init?.signal ?? undefined
73
+ if (signal?.aborted) return Promise.reject(signal.reason)
74
+
75
+ return new Promise<Response>((resolve, reject) => {
76
+ const headers = toHeaderRecord(init?.headers)
77
+ if (body != null && !Object.keys(headers).some((k) => k.toLowerCase() === "content-length")) {
78
+ headers["content-length"] = String(typeof body === "string" ? Buffer.byteLength(body) : body.byteLength)
79
+ }
80
+ const mod = isHttps ? https : http
81
+ let settled = false
82
+ const onAbort = () => {
83
+ req.destroy(signal!.reason)
84
+ if (!settled) { settled = true; reject(signal!.reason) }
85
+ }
86
+ const req = mod.request(url, { method: init?.method ?? "GET", headers, agent: isHttps ? httpsAgent : httpAgent }, (res) => {
87
+ const status = res.statusCode ?? 0
88
+ if (REDIRECTS.has(status) && res.headers.location) {
89
+ // Follow it the way fetch does, from the redirect target: re-sending to the original URL
90
+ // would reach the server twice. 303, and 301/302 after a POST, become a bodiless GET;
91
+ // 307/308 repeat the method and body. Further hops are fetch's to follow.
92
+ res.resume()
93
+ signal?.removeEventListener("abort", onAbort)
94
+ settled = true
95
+ const target = new URL(res.headers.location, url).href
96
+ const method = (init?.method ?? "GET").toUpperCase()
97
+ const toGet = status === 303 ? method !== "HEAD" : (status === 301 || status === 302) && method === "POST"
98
+ let next: RequestInit | undefined = init
99
+ if (toGet) {
100
+ const h = toHeaderRecord(init?.headers)
101
+ for (const k of Object.keys(h)) {
102
+ if (/^content-(type|length|encoding|language|location)$/i.test(k)) delete h[k]
103
+ }
104
+ next = { ...init, method: "GET", body: undefined, headers: h }
105
+ }
106
+ platformFetch(target, next).then(resolve, reject)
107
+ return
108
+ }
109
+ const h = new Headers()
110
+ for (let i = 0; i < res.rawHeaders.length; i += 2) h.append(res.rawHeaders[i], res.rawHeaders[i + 1])
111
+ let stream: Readable = res
112
+ const enc = (res.headers["content-encoding"] ?? "").toLowerCase()
113
+ if (enc === "gzip" || enc === "x-gzip" || enc === "deflate") stream = res.pipe(zlib.createUnzip())
114
+ else if (enc === "br") stream = res.pipe(zlib.createBrotliDecompress())
115
+ const nullBody = status === 204 || status === 304 || init?.method === "HEAD"
116
+ const ctype = (res.headers["content-type"] ?? "").toLowerCase()
117
+ const done = (b: ConstructorParameters<typeof Response>[0]) => {
118
+ if (settled) return
119
+ settled = true
120
+ resolve(new Response(nullBody ? null : b, { status, statusText: res.statusMessage, headers: h }))
121
+ }
122
+ if (ctype.includes("text/event-stream")) {
123
+ // Streamed: the body is consumed incrementally (SSE). Abort still destroys the socket.
124
+ stream.once("end", () => signal?.removeEventListener("abort", onAbort))
125
+ done(Readable.toWeb(stream) as unknown as ReadableStream)
126
+ return
127
+ }
128
+ const chunks: Buffer[] = []
129
+ stream.on("data", (c: Buffer) => chunks.push(c))
130
+ stream.once("error", (e) => { if (!settled) { settled = true; reject(e) } })
131
+ stream.once("end", () => {
132
+ signal?.removeEventListener("abort", onAbort)
133
+ done(Buffer.concat(chunks))
134
+ })
135
+ })
136
+ req.once("error", (e) => {
137
+ signal?.removeEventListener("abort", onAbort)
138
+ if (!settled) { settled = true; reject(signal?.aborted ? signal.reason : new TypeError("fetch failed", { cause: e })) }
139
+ })
140
+ signal?.addEventListener("abort", onAbort, { once: true })
141
+ req.end(body ?? undefined)
142
+ })
143
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * `transcriptSearchTool` — read-only search over a {@link ConversationStore} (SPEC.md §7E
3
+ * "Memory hierarchy" → transcript search). The ground-truth tier of agent memory: a dreaming
4
+ * persona checks its notes against what actually happened, not against its own notes.
5
+ */
6
+ import { defineTool } from "./native.js"
7
+ import type { Tool } from "./types.js"
8
+ import type { ConversationEntry, ConversationStore } from "./client.js"
9
+
10
+ /** Options for {@link transcriptSearchTool}. */
11
+ export interface TranscriptSearchOptions {
12
+ /** Most hits returned per call (and the ceiling for the `limit` input). Default 20. */
13
+ maxResults?: number
14
+ /** Snippet length in characters (Unicode code points, not bytes). Default 200. */
15
+ snippetChars?: number
16
+ /** Tool name. Default `transcript_search`. */
17
+ name?: string
18
+ }
19
+
20
+ /** A store that can enumerate its conversations — what transcript search requires. */
21
+ export type ListableConversationStore = ConversationStore & { list(): Promise<ConversationEntry[]> }
22
+
23
+ export const TRANSCRIPT_SEARCH_DESCRIPTION =
24
+ "Search past conversation transcripts — the ground truth of what actually happened. " +
25
+ "Case-insensitive substring match over message text. Returns one line per matching " +
26
+ "message: <conversationId>#<index> <role>: <snippet>. Read-only."
27
+
28
+ const WS = /[ \t\n\r\f\v]+/g
29
+
30
+ /** Collapse every run of ASCII whitespace to one space and trim (§7E). */
31
+ function normalize(s: string): string {
32
+ return s.replace(WS, " ").replace(/^ | $/g, "")
33
+ }
34
+
35
+ /** ASCII-only case fold — never Unicode lowercasing (it changes lengths, differs per port). */
36
+ function fold(s: string): string {
37
+ return s.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32))
38
+ }
39
+
40
+ /** Code-point order (not UTF-16 code-unit order, which differs above U+FFFF). */
41
+ function byCodePoint(a: string, b: string): number {
42
+ const x = Array.from(a)
43
+ const y = Array.from(b)
44
+ for (let i = 0; i < x.length && i < y.length; i++) {
45
+ const d = x[i].codePointAt(0)! - y[i].codePointAt(0)!
46
+ if (d !== 0) return d
47
+ }
48
+ return x.length - y.length
49
+ }
50
+
51
+ /** A copy with every object's keys sorted by code point, recursively (§7E); arrays keep order. */
52
+ function sortKeys(v: unknown): unknown {
53
+ if (Array.isArray(v)) return v.map(sortKeys)
54
+ if (v && typeof v === "object" && Object.getPrototypeOf(v) === Object.prototype) {
55
+ const o = v as Record<string, unknown>
56
+ const out: Record<string, unknown> = {}
57
+ for (const k of Object.keys(o).sort(byCodePoint)) out[k] = sortKeys(o[k])
58
+ return out
59
+ }
60
+ return v
61
+ }
62
+
63
+ function partsText(content: unknown): string[] {
64
+ if (typeof content === "string") return [content]
65
+ if (!Array.isArray(content)) return []
66
+ const out: string[] = []
67
+ for (const p of content) {
68
+ if (!p || typeof p !== "object") continue
69
+ const part = p as Record<string, unknown>
70
+ if (part.type === "text" && typeof part.text === "string") out.push(part.text)
71
+ else if (part.type === "tool_use") out.push(JSON.stringify(sortKeys(part.input ?? {})))
72
+ else if (part.type === "tool_result") out.push(...partsText(part.content))
73
+ }
74
+ return out
75
+ }
76
+
77
+ /** The searchable text of one stored message (§7E): string content, text parts, tool_use
78
+ * input as compact JSON, tool_result content, then each OpenAI tool call's `arguments`
79
+ * string — pieces joined by "\n", then whitespace-normalized. Images are not text. */
80
+ export function messageText(msg: unknown): string {
81
+ if (!msg || typeof msg !== "object") return ""
82
+ const m = msg as Record<string, unknown>
83
+ const pieces = partsText(m.content)
84
+ if (Array.isArray(m.tool_calls)) {
85
+ for (const tc of m.tool_calls as any[]) {
86
+ const a = tc?.function?.arguments
87
+ if (typeof a === "string") pieces.push(a)
88
+ }
89
+ }
90
+ return normalize(pieces.join("\n"))
91
+ }
92
+
93
+ /** Snippet of `snippetChars` code points centred on the match; `…` marks elision. */
94
+ function snippet(cps: string[], at: number, qlen: number, size: number): string {
95
+ if (cps.length <= size) return cps.join("")
96
+ let start = at + Math.floor(qlen / 2) - Math.floor(size / 2)
97
+ start = Math.max(0, Math.min(start, cps.length - size))
98
+ const end = start + size
99
+ return (start > 0 ? "…" : "") + cps.slice(start, end).join("") + (end < cps.length ? "…" : "")
100
+ }
101
+
102
+ /**
103
+ * A read-only {@link Tool} over `store` (§7E). Construction throws unless the store has
104
+ * `list()`. Input `{query, conversationId?, limit?}`; output one line per hit,
105
+ * `<conversationId>#<index> <role>: <snippet>`, `no matches` when none, and a trailing
106
+ * `(+<k> more)` when hits exceed the limit. Never writes.
107
+ */
108
+ export function transcriptSearchTool(store: ConversationStore, opts: TranscriptSearchOptions = {}): Tool {
109
+ if (!store || typeof (store as Partial<ListableConversationStore>).list !== "function") {
110
+ throw new Error("transcriptSearchTool: the store must implement list()")
111
+ }
112
+ const listable = store as ListableConversationStore
113
+ const maxResults = opts.maxResults ?? 20
114
+ const snippetChars = opts.snippetChars ?? 200
115
+ if (!Number.isInteger(maxResults) || maxResults < 1) throw new Error("transcriptSearchTool: maxResults must be an integer >= 1")
116
+ if (!Number.isInteger(snippetChars) || snippetChars < 1) throw new Error("transcriptSearchTool: snippetChars must be an integer >= 1")
117
+ return defineTool({
118
+ name: opts.name ?? "transcript_search",
119
+ description: TRANSCRIPT_SEARCH_DESCRIPTION,
120
+ inputSchema: {
121
+ type: "object",
122
+ properties: {
123
+ query: { type: "string", description: "Text to find (case-insensitive substring)." },
124
+ conversationId: { type: "string", description: "Search only this conversation." },
125
+ limit: { type: "integer", description: `Most hits to return (default and max ${maxResults}).` },
126
+ },
127
+ required: ["query"],
128
+ },
129
+ run: async (args: Record<string, unknown>) => {
130
+ const query = typeof args.query === "string" ? normalize(args.query) : ""
131
+ if (query === "") return { output: "query is required", isError: true }
132
+ let limit = maxResults
133
+ if (args.limit !== undefined) {
134
+ if (!Number.isInteger(args.limit) || (args.limit as number) < 1) {
135
+ return { output: "limit must be an integer >= 1", isError: true }
136
+ }
137
+ limit = Math.min(args.limit as number, maxResults)
138
+ }
139
+ const ids =
140
+ typeof args.conversationId === "string" ? [args.conversationId] : (await listable.list()).map((e) => e.id)
141
+ const q = Array.from(fold(query))
142
+ const hits: string[] = []
143
+ let total = 0
144
+ for (const id of ids) {
145
+ const msgs = await listable.get(id)
146
+ if (!msgs) continue
147
+ msgs.forEach((msg, index) => {
148
+ const cps = Array.from(messageText(msg))
149
+ const hay = Array.from(fold(cps.join("")))
150
+ const at = indexOfCps(hay, q)
151
+ if (at < 0) return
152
+ total++
153
+ if (hits.length < limit) {
154
+ const role = typeof (msg as any)?.role === "string" ? (msg as any).role : "unknown"
155
+ hits.push(`${id}#${index} ${role}: ${snippet(cps, at, q.length, snippetChars)}`)
156
+ }
157
+ })
158
+ }
159
+ if (total === 0) return "no matches"
160
+ if (total > hits.length) hits.push(`(+${total - hits.length} more)`)
161
+ return hits.join("\n")
162
+ },
163
+ })
164
+ }
165
+
166
+ function indexOfCps(hay: string[], needle: string[]): number {
167
+ outer: for (let i = 0; i + needle.length <= hay.length; i++) {
168
+ for (let j = 0; j < needle.length; j++) if (hay[i + j] !== needle[j]) continue outer
169
+ return i
170
+ }
171
+ return -1
172
+ }
package/src/wire.ts CHANGED
@@ -72,7 +72,7 @@ export function toOpenAIWire(messages: any[], opts: WireOptions = {}): any[] {
72
72
  const parts = partsOf(m)
73
73
  const { parts: _drop, ...rest } = m ?? {}
74
74
  if (!parts) {
75
- out.push(m)
75
+ out.push(m && "parts" in m ? rest : m)
76
76
  continue
77
77
  }
78
78
  const { texts, others } = split(parts)
@@ -109,7 +109,12 @@ export function toAnthropicWire(messages: any[], opts: WireOptions = {}): any[]
109
109
  const content = m.content.map((block: any) => {
110
110
  if (block?.type !== "tool_result") return block
111
111
  const parts = partsOf(block)
112
- if (!parts) return block
112
+ if (!parts) {
113
+ if (!("parts" in block)) return block
114
+ touched = true
115
+ const { parts: _drop, ...rest } = block
116
+ return rest
117
+ }
113
118
  touched = true
114
119
  const { parts: _drop, ...rest } = block
115
120
  return {
@@ -124,6 +129,18 @@ export function toAnthropicWire(messages: any[], opts: WireOptions = {}): any[]
124
129
  })
125
130
  }
126
131
 
132
+ /**
133
+ * The parts of a tool result that reach the model (SPEC §7G.6). A media tool result
134
+ * (`metadata.source === "media"`) contributes only its **image** parts; its video (`file`) and
135
+ * `audio` parts stay in `toolCalls[].parts` / `RunResult.files` and the model sees just the
136
+ * output line. Every other tool result keeps all its parts.
137
+ */
138
+ export function modelParts(result: { parts?: ContentPart[]; metadata?: Record<string, unknown> }): ContentPart[] {
139
+ const parts = result.parts ?? []
140
+ if (result.metadata?.source !== "media") return parts
141
+ return parts.filter((p) => p.type === "image")
142
+ }
143
+
127
144
  /** Validate every attached part before assembly, so `maxPartBytes` cannot be bypassed. */
128
145
  export function checkPromptParts(prompt: unknown, opts: WireOptions): void {
129
146
  if (!Array.isArray(prompt)) return