toolnexus 0.1.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/LICENSE +21 -0
- package/README.md +116 -0
- package/dist/adapters.d.ts +26 -0
- package/dist/adapters.js +24 -0
- package/dist/client.d.ts +169 -0
- package/dist/client.js +447 -0
- package/dist/http.d.ts +21 -0
- package/dist/http.js +90 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/mcp.d.ts +35 -0
- package/dist/mcp.js +169 -0
- package/dist/native.d.ts +21 -0
- package/dist/native.js +57 -0
- package/dist/skill.d.ts +16 -0
- package/dist/skill.js +184 -0
- package/dist/toolkit.d.ts +47 -0
- package/dist/toolkit.js +71 -0
- package/dist/types.d.ts +30 -0
- package/dist/types.js +5 -0
- package/package.json +29 -0
- package/src/adapters.ts +33 -0
- package/src/client.ts +495 -0
- package/src/http.ts +110 -0
- package/src/index.ts +8 -0
- package/src/mcp.ts +210 -0
- package/src/native.ts +72 -0
- package/src/skill.ts +195 -0
- package/src/toolkit.ts +93 -0
- package/src/types.ts +37 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified LLM client — the host loop. Give it a plain base URL + a style
|
|
3
|
+
* ("openai" | "anthropic"), and it runs the tool-calling agent loop against a
|
|
4
|
+
* Toolkit. See ../../SPEC.md §8.
|
|
5
|
+
*/
|
|
6
|
+
import type { Toolkit } from "./toolkit.js"
|
|
7
|
+
import type { ToolResult } from "./types.js"
|
|
8
|
+
|
|
9
|
+
export type ClientStyle = "openai" | "anthropic"
|
|
10
|
+
|
|
11
|
+
export interface ClientOptions {
|
|
12
|
+
baseUrl: string
|
|
13
|
+
style: ClientStyle
|
|
14
|
+
model: string
|
|
15
|
+
apiKey?: string
|
|
16
|
+
headers?: Record<string, string>
|
|
17
|
+
systemPrompt?: string
|
|
18
|
+
maxTurns?: number
|
|
19
|
+
hooks?: Hooks
|
|
20
|
+
/** Retries on transient LLM errors (429/5xx/network). Default 2. */
|
|
21
|
+
retries?: number
|
|
22
|
+
/** Base backoff in ms (exponential + jitter). Default 500. */
|
|
23
|
+
retryBaseMs?: number
|
|
24
|
+
/** Whole-run deadline in ms; aborts the run (and its in-flight request) when exceeded. */
|
|
25
|
+
timeoutMs?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const RETRYABLE = new Set([429, 500, 502, 503, 504])
|
|
29
|
+
|
|
30
|
+
function delay(ms: number, signal: AbortSignal): Promise<void> {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const t = setTimeout(resolve, ms)
|
|
33
|
+
signal.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason ?? new Error("aborted")) }, { once: true })
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Lifecycle hooks around the agent loop. Each may observe, and (where noted)
|
|
39
|
+
* mutate or short-circuit. All may be async.
|
|
40
|
+
*/
|
|
41
|
+
export interface Hooks {
|
|
42
|
+
/** Before each model call. Return { messages?, tools? } to replace them. */
|
|
43
|
+
beforeLLM?(ev: { messages: any[]; tools: any[]; model: string; turn: number }): void | { messages?: any[]; tools?: any[] } | Promise<void | { messages?: any[]; tools?: any[] }>
|
|
44
|
+
/** After each model call (observe: logging, cost, tracing). */
|
|
45
|
+
afterLLM?(ev: { response: any; model: string; turn: number }): void | Promise<void>
|
|
46
|
+
/** Before a tool runs. Return { result } to SHORT-CIRCUIT (deny/cache), { args } to rewrite. */
|
|
47
|
+
beforeTool?(ev: { name: string; args: Record<string, unknown>; id?: string; turn: number }): void | { args?: Record<string, unknown>; result?: ToolResult } | Promise<void | { args?: Record<string, unknown>; result?: ToolResult }>
|
|
48
|
+
/** After a tool runs. Return { result } to replace the result. */
|
|
49
|
+
afterTool?(ev: { name: string; args: Record<string, unknown>; result: ToolResult; id?: string; turn: number }): void | { result?: ToolResult } | Promise<void | { result?: ToolResult }>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A tool call the model made, plus its result + metadata. */
|
|
53
|
+
export interface ToolCallRecord {
|
|
54
|
+
name: string
|
|
55
|
+
args: Record<string, unknown>
|
|
56
|
+
output: string
|
|
57
|
+
isError: boolean
|
|
58
|
+
metadata?: Record<string, unknown>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Token usage, summed across every LLM round trip in the run. */
|
|
62
|
+
export interface Usage {
|
|
63
|
+
promptTokens: number
|
|
64
|
+
completionTokens: number
|
|
65
|
+
totalTokens: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface RunResult {
|
|
69
|
+
text: string
|
|
70
|
+
messages: any[]
|
|
71
|
+
/** Every tool call made, with its output, error flag, and metadata. */
|
|
72
|
+
toolCalls: ToolCallRecord[]
|
|
73
|
+
/** Total number of tool calls (= toolCalls.length). */
|
|
74
|
+
toolCallCount: number
|
|
75
|
+
/** Number of LLM round trips. */
|
|
76
|
+
turns: number
|
|
77
|
+
/** Aggregated token usage across all turns. */
|
|
78
|
+
usage: Usage
|
|
79
|
+
/** The model used. */
|
|
80
|
+
model: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Events yielded by client.stream(). */
|
|
84
|
+
export type StreamEvent =
|
|
85
|
+
| { type: "text"; delta: string }
|
|
86
|
+
| { type: "tool_call"; id: string; name: string; args: Record<string, unknown> }
|
|
87
|
+
| { type: "tool_result"; id: string; name: string; output: string; isError: boolean }
|
|
88
|
+
| { type: "usage"; usage: Usage }
|
|
89
|
+
| { type: "done"; result: RunResult }
|
|
90
|
+
|
|
91
|
+
function addUsage(acc: Usage, raw: any, style: ClientStyle): void {
|
|
92
|
+
if (!raw) return
|
|
93
|
+
if (style === "anthropic") {
|
|
94
|
+
acc.promptTokens += raw.input_tokens ?? 0
|
|
95
|
+
acc.completionTokens += raw.output_tokens ?? 0
|
|
96
|
+
acc.totalTokens += (raw.input_tokens ?? 0) + (raw.output_tokens ?? 0)
|
|
97
|
+
} else {
|
|
98
|
+
acc.promptTokens += raw.prompt_tokens ?? 0
|
|
99
|
+
acc.completionTokens += raw.completion_tokens ?? 0
|
|
100
|
+
acc.totalTokens += raw.total_tokens ?? (raw.prompt_tokens ?? 0) + (raw.completion_tokens ?? 0)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function resolveKey(opts: ClientOptions): string {
|
|
105
|
+
if (opts.apiKey) return opts.apiKey
|
|
106
|
+
const env = process.env
|
|
107
|
+
const key =
|
|
108
|
+
env.OPENROUTER_API_KEY ||
|
|
109
|
+
(opts.style === "anthropic" ? env.ANTHROPIC_API_KEY : env.OPENAI_API_KEY) ||
|
|
110
|
+
env.OPENAI_API_KEY ||
|
|
111
|
+
env.ANTHROPIC_API_KEY
|
|
112
|
+
if (!key) throw new Error("No API key: set apiKey or OPENROUTER_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY")
|
|
113
|
+
return key
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export class Client {
|
|
117
|
+
constructor(private readonly opts: ClientOptions) {}
|
|
118
|
+
|
|
119
|
+
async run(prompt: string, ctx: { toolkit: Toolkit; signal?: AbortSignal; history?: any[] }): Promise<RunResult> {
|
|
120
|
+
return this.opts.style === "anthropic"
|
|
121
|
+
? this.runAnthropic(prompt, ctx.toolkit, ctx.signal, ctx.history)
|
|
122
|
+
: this.runOpenAI(prompt, ctx.toolkit, ctx.signal, ctx.history)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A stateful multi-turn conversation that retains history across sends. */
|
|
126
|
+
conversation(ctx: { toolkit: Toolkit; signal?: AbortSignal }): Conversation {
|
|
127
|
+
return new Conversation(this, ctx.toolkit, ctx.signal)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Streaming variant: async-iterate live events (text deltas, tool calls/results, usage, done). */
|
|
131
|
+
stream(prompt: string, ctx: { toolkit: Toolkit; signal?: AbortSignal }): AsyncGenerator<StreamEvent, void, unknown> {
|
|
132
|
+
return this.opts.style === "anthropic"
|
|
133
|
+
? this.streamAnthropic(prompt, ctx.toolkit, ctx.signal)
|
|
134
|
+
: this.streamOpenAI(prompt, ctx.toolkit, ctx.signal)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private system(toolkit: Toolkit): string {
|
|
138
|
+
return [this.opts.systemPrompt ?? "", toolkit.skillsPrompt()].filter(Boolean).join("\n\n")
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Build a run-scoped abort signal from the optional run-level timeout + an external signal. */
|
|
142
|
+
private makeSignal(external?: AbortSignal): AbortSignal {
|
|
143
|
+
const ctrl = new AbortController()
|
|
144
|
+
if (this.opts.timeoutMs) {
|
|
145
|
+
const t = setTimeout(() => ctrl.abort(new Error(`run timeout after ${this.opts.timeoutMs}ms`)), this.opts.timeoutMs)
|
|
146
|
+
;(t as any).unref?.()
|
|
147
|
+
}
|
|
148
|
+
if (external) {
|
|
149
|
+
if (external.aborted) ctrl.abort(external.reason)
|
|
150
|
+
else external.addEventListener("abort", () => ctrl.abort(external.reason), { once: true })
|
|
151
|
+
}
|
|
152
|
+
return ctrl.signal
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** fetch with retry + exponential backoff on 429/5xx/network, honoring Retry-After; aborts via signal. */
|
|
156
|
+
private async llmFetch(url: string, init: RequestInit, signal: AbortSignal): Promise<Response> {
|
|
157
|
+
const retries = this.opts.retries ?? 2
|
|
158
|
+
const base = this.opts.retryBaseMs ?? 500
|
|
159
|
+
let lastErr: unknown
|
|
160
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
161
|
+
try {
|
|
162
|
+
const res = await fetch(url, { ...init, signal })
|
|
163
|
+
if (res.ok || !RETRYABLE.has(res.status) || attempt === retries) return res
|
|
164
|
+
const ra = Number(res.headers.get("retry-after"))
|
|
165
|
+
await delay(ra ? ra * 1000 : base * 2 ** attempt + Math.random() * 100, signal)
|
|
166
|
+
} catch (e) {
|
|
167
|
+
if (signal.aborted) throw e // abort/timeout: don't retry
|
|
168
|
+
lastErr = e
|
|
169
|
+
if (attempt === retries) throw e
|
|
170
|
+
await delay(base * 2 ** attempt + Math.random() * 100, signal)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
throw lastErr
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Run one tool through beforeTool/afterTool hooks (mutate args, short-circuit, transform result). */
|
|
177
|
+
private async runTool(toolkit: Toolkit, name: string, args: Record<string, unknown>, id: string | undefined, turn: number) {
|
|
178
|
+
const h = this.opts.hooks
|
|
179
|
+
let a = args
|
|
180
|
+
if (h?.beforeTool) {
|
|
181
|
+
const ov = await h.beforeTool({ name, args: a, id, turn })
|
|
182
|
+
if (ov?.result) return { args: a, result: ov.result } // short-circuit (deny/cache/dry-run)
|
|
183
|
+
if (ov?.args) a = ov.args
|
|
184
|
+
}
|
|
185
|
+
let result = await toolkit.execute(name, a)
|
|
186
|
+
if (h?.afterTool) {
|
|
187
|
+
const ov = await h.afterTool({ name, args: a, result, id, turn })
|
|
188
|
+
if (ov?.result) result = ov.result
|
|
189
|
+
}
|
|
190
|
+
return { args: a, result }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---- OpenAI-style: POST {baseUrl}/chat/completions ----
|
|
194
|
+
private async runOpenAI(prompt: string, toolkit: Toolkit, external?: AbortSignal, history?: any[]): Promise<RunResult> {
|
|
195
|
+
const key = resolveKey(this.opts)
|
|
196
|
+
const signal = this.makeSignal(external)
|
|
197
|
+
let messages: any[] = history && history.length ? [...history] : []
|
|
198
|
+
if (!messages.length) {
|
|
199
|
+
const system = this.system(toolkit)
|
|
200
|
+
if (system) messages.push({ role: "system", content: system })
|
|
201
|
+
}
|
|
202
|
+
messages.push({ role: "user", content: prompt })
|
|
203
|
+
let tools = toolkit.toOpenAI()
|
|
204
|
+
const toolCalls: ToolCallRecord[] = []
|
|
205
|
+
const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
|
|
206
|
+
let turns = 0
|
|
207
|
+
|
|
208
|
+
for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
|
|
209
|
+
turns++
|
|
210
|
+
if (this.opts.hooks?.beforeLLM) {
|
|
211
|
+
const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn })
|
|
212
|
+
if (ov?.messages) messages = ov.messages
|
|
213
|
+
if (ov?.tools) tools = ov.tools
|
|
214
|
+
}
|
|
215
|
+
const res = await this.llmFetch(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
|
|
216
|
+
method: "POST",
|
|
217
|
+
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
|
|
218
|
+
body: JSON.stringify({ model: this.opts.model, messages, tools, tool_choice: "auto" }),
|
|
219
|
+
}, signal)
|
|
220
|
+
if (!res.ok) throw new Error(`LLM ${res.status}: ${await res.text()}`)
|
|
221
|
+
const data: any = await res.json()
|
|
222
|
+
addUsage(usage, data.usage, "openai")
|
|
223
|
+
if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn })
|
|
224
|
+
const msg = data.choices[0].message
|
|
225
|
+
messages.push(msg)
|
|
226
|
+
const calls = msg.tool_calls ?? []
|
|
227
|
+
if (calls.length === 0) return this.result(msg.content ?? "", messages, toolCalls, turns, usage)
|
|
228
|
+
// execute all tool calls in this turn concurrently (true parallel tool calling)
|
|
229
|
+
const results = await Promise.all(
|
|
230
|
+
calls.map(async (call: any) => {
|
|
231
|
+
const { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn)
|
|
232
|
+
toolCalls.push({ name: call.function.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
|
|
233
|
+
return { role: "tool", tool_call_id: call.id, content: result.output }
|
|
234
|
+
}),
|
|
235
|
+
)
|
|
236
|
+
messages.push(...results)
|
|
237
|
+
}
|
|
238
|
+
return this.result(lastText(messages), messages, toolCalls, turns, usage)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private result(text: string, messages: any[], toolCalls: ToolCallRecord[], turns: number, usage: Usage): RunResult {
|
|
242
|
+
return { text, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---- Anthropic-style: POST {baseUrl}/messages ----
|
|
246
|
+
private async runAnthropic(prompt: string, toolkit: Toolkit, external?: AbortSignal, history?: any[]): Promise<RunResult> {
|
|
247
|
+
const key = resolveKey(this.opts)
|
|
248
|
+
const signal = this.makeSignal(external)
|
|
249
|
+
const base = this.opts.baseUrl.replace(/\/$/, "")
|
|
250
|
+
const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`
|
|
251
|
+
const system = this.system(toolkit)
|
|
252
|
+
let messages: any[] = history && history.length ? [...history, { role: "user", content: prompt }] : [{ role: "user", content: prompt }]
|
|
253
|
+
let tools = toolkit.toAnthropic()
|
|
254
|
+
const toolCalls: ToolCallRecord[] = []
|
|
255
|
+
const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
|
|
256
|
+
let turns = 0
|
|
257
|
+
|
|
258
|
+
for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
|
|
259
|
+
turns++
|
|
260
|
+
if (this.opts.hooks?.beforeLLM) {
|
|
261
|
+
const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn })
|
|
262
|
+
if (ov?.messages) messages = ov.messages
|
|
263
|
+
if (ov?.tools) tools = ov.tools
|
|
264
|
+
}
|
|
265
|
+
const res = await this.llmFetch(endpoint, {
|
|
266
|
+
method: "POST",
|
|
267
|
+
headers: {
|
|
268
|
+
"x-api-key": key,
|
|
269
|
+
"anthropic-version": "2023-06-01",
|
|
270
|
+
"Content-Type": "application/json",
|
|
271
|
+
...this.opts.headers,
|
|
272
|
+
},
|
|
273
|
+
body: JSON.stringify({ model: this.opts.model, max_tokens: 4096, system, messages, tools }),
|
|
274
|
+
}, signal)
|
|
275
|
+
if (!res.ok) throw new Error(`LLM ${res.status}: ${await res.text()}`)
|
|
276
|
+
const data: any = await res.json()
|
|
277
|
+
addUsage(usage, data.usage, "anthropic")
|
|
278
|
+
if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn })
|
|
279
|
+
messages.push({ role: "assistant", content: data.content })
|
|
280
|
+
const uses = (data.content ?? []).filter((b: any) => b.type === "tool_use")
|
|
281
|
+
if (uses.length === 0) {
|
|
282
|
+
const text = (data.content ?? []).filter((b: any) => b.type === "text").map((b: any) => b.text).join("")
|
|
283
|
+
return this.result(text, messages, toolCalls, turns, usage)
|
|
284
|
+
}
|
|
285
|
+
// execute all tool_use blocks in this turn concurrently (true parallel tool calling)
|
|
286
|
+
const results = await Promise.all(
|
|
287
|
+
uses.map(async (use: any) => {
|
|
288
|
+
const { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn)
|
|
289
|
+
toolCalls.push({ name: use.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
|
|
290
|
+
return { type: "tool_result", tool_use_id: use.id, content: result.output, is_error: result.isError }
|
|
291
|
+
}),
|
|
292
|
+
)
|
|
293
|
+
messages.push({ role: "user", content: results })
|
|
294
|
+
}
|
|
295
|
+
return this.result("", messages, toolCalls, turns, usage)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---- Streaming: OpenAI-style ----
|
|
299
|
+
private async *streamOpenAI(prompt: string, toolkit: Toolkit, external?: AbortSignal): AsyncGenerator<StreamEvent, void, unknown> {
|
|
300
|
+
const key = resolveKey(this.opts)
|
|
301
|
+
const signal = this.makeSignal(external)
|
|
302
|
+
let messages: any[] = []
|
|
303
|
+
const system = this.system(toolkit)
|
|
304
|
+
if (system) messages.push({ role: "system", content: system })
|
|
305
|
+
messages.push({ role: "user", content: prompt })
|
|
306
|
+
let tools = toolkit.toOpenAI()
|
|
307
|
+
const toolCalls: ToolCallRecord[] = []
|
|
308
|
+
const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
|
|
309
|
+
let turns = 0
|
|
310
|
+
|
|
311
|
+
for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
|
|
312
|
+
turns++
|
|
313
|
+
if (this.opts.hooks?.beforeLLM) {
|
|
314
|
+
const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn })
|
|
315
|
+
if (ov?.messages) messages = ov.messages
|
|
316
|
+
if (ov?.tools) tools = ov.tools
|
|
317
|
+
}
|
|
318
|
+
const res = await this.llmFetch(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
|
|
319
|
+
method: "POST",
|
|
320
|
+
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
|
|
321
|
+
body: JSON.stringify({ model: this.opts.model, messages, tools, tool_choice: "auto", stream: true, stream_options: { include_usage: true } }),
|
|
322
|
+
}, signal)
|
|
323
|
+
if (!res.ok || !res.body) throw new Error(`LLM ${res.status}: ${await res.text()}`)
|
|
324
|
+
|
|
325
|
+
let content = ""
|
|
326
|
+
const acc = new Map<number, { id: string; name: string; args: string }>()
|
|
327
|
+
for await (const line of sseLines(res.body)) {
|
|
328
|
+
if (!line.startsWith("data:")) continue
|
|
329
|
+
const payload = line.slice(5).trim()
|
|
330
|
+
if (payload === "[DONE]") break
|
|
331
|
+
const j = safeParse(payload)
|
|
332
|
+
if (j?.usage) addUsage(usage, j.usage, "openai")
|
|
333
|
+
const choice = j?.choices?.[0]
|
|
334
|
+
const d = choice?.delta
|
|
335
|
+
if (d?.content) { content += d.content; yield { type: "text", delta: d.content } }
|
|
336
|
+
for (const tc of d?.tool_calls ?? []) {
|
|
337
|
+
const slot = acc.get(tc.index) ?? { id: "", name: "", args: "" }
|
|
338
|
+
if (tc.id) slot.id = tc.id
|
|
339
|
+
if (tc.function?.name) slot.name += tc.function.name
|
|
340
|
+
if (tc.function?.arguments) slot.args += tc.function.arguments
|
|
341
|
+
acc.set(tc.index, slot)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: { streamed: true, usage }, model: this.opts.model, turn })
|
|
345
|
+
|
|
346
|
+
const calls = [...acc.values()]
|
|
347
|
+
if (calls.length === 0) {
|
|
348
|
+
messages.push({ role: "assistant", content })
|
|
349
|
+
yield { type: "usage", usage }
|
|
350
|
+
yield { type: "done", result: this.result(content, messages, toolCalls, turns, usage) }
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
messages.push({ role: "assistant", content: content || null, tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.args } })) })
|
|
354
|
+
for (const c of calls) yield { type: "tool_call", id: c.id, name: c.name, args: safeJson(c.args) }
|
|
355
|
+
const settled = await Promise.all(calls.map((c) => this.runTool(toolkit, c.name, safeJson(c.args), c.id, turn)))
|
|
356
|
+
for (let i = 0; i < calls.length; i++) {
|
|
357
|
+
const c = calls[i], { args, result } = settled[i]
|
|
358
|
+
toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
|
|
359
|
+
messages.push({ role: "tool", tool_call_id: c.id, content: result.output })
|
|
360
|
+
yield { type: "tool_result", id: c.id, name: c.name, output: result.output, isError: result.isError }
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
yield { type: "done", result: this.result(lastText(messages), messages, toolCalls, turns, usage) }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---- Streaming: Anthropic-style ----
|
|
367
|
+
private async *streamAnthropic(prompt: string, toolkit: Toolkit, external?: AbortSignal): AsyncGenerator<StreamEvent, void, unknown> {
|
|
368
|
+
const key = resolveKey(this.opts)
|
|
369
|
+
const signal = this.makeSignal(external)
|
|
370
|
+
const base = this.opts.baseUrl.replace(/\/$/, "")
|
|
371
|
+
const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`
|
|
372
|
+
const system = this.system(toolkit)
|
|
373
|
+
let messages: any[] = [{ role: "user", content: prompt }]
|
|
374
|
+
let tools = toolkit.toAnthropic()
|
|
375
|
+
const toolCalls: ToolCallRecord[] = []
|
|
376
|
+
const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
|
|
377
|
+
let turns = 0
|
|
378
|
+
|
|
379
|
+
for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
|
|
380
|
+
turns++
|
|
381
|
+
if (this.opts.hooks?.beforeLLM) {
|
|
382
|
+
const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn })
|
|
383
|
+
if (ov?.messages) messages = ov.messages
|
|
384
|
+
if (ov?.tools) tools = ov.tools
|
|
385
|
+
}
|
|
386
|
+
const res = await this.llmFetch(endpoint, {
|
|
387
|
+
method: "POST",
|
|
388
|
+
headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "Content-Type": "application/json", ...this.opts.headers },
|
|
389
|
+
body: JSON.stringify({ model: this.opts.model, max_tokens: 4096, system, messages, tools, stream: true }),
|
|
390
|
+
}, signal)
|
|
391
|
+
if (!res.ok || !res.body) throw new Error(`LLM ${res.status}: ${await res.text()}`)
|
|
392
|
+
|
|
393
|
+
const blocks = new Map<number, { type: string; text?: string; id?: string; name?: string; json?: string }>()
|
|
394
|
+
let stopReason = ""
|
|
395
|
+
for await (const line of sseLines(res.body)) {
|
|
396
|
+
if (!line.startsWith("data:")) continue
|
|
397
|
+
const j = safeParse(line.slice(5).trim())
|
|
398
|
+
if (!j) continue
|
|
399
|
+
if (j.type === "message_start") addUsage(usage, j.message?.usage, "anthropic")
|
|
400
|
+
else if (j.type === "content_block_start") blocks.set(j.index, { type: j.content_block.type, id: j.content_block.id, name: j.content_block.name, text: "", json: "" })
|
|
401
|
+
else if (j.type === "content_block_delta") {
|
|
402
|
+
const b = blocks.get(j.index)!
|
|
403
|
+
if (j.delta.type === "text_delta") { b.text += j.delta.text; yield { type: "text", delta: j.delta.text } }
|
|
404
|
+
else if (j.delta.type === "input_json_delta") b.json += j.delta.partial_json
|
|
405
|
+
} else if (j.type === "message_delta") { stopReason = j.delta?.stop_reason ?? stopReason; addUsage(usage, j.usage, "anthropic") }
|
|
406
|
+
}
|
|
407
|
+
if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: { streamed: true, usage }, model: this.opts.model, turn })
|
|
408
|
+
|
|
409
|
+
const content = [...blocks.values()].map((b) => b.type === "tool_use" ? { type: "tool_use", id: b.id, name: b.name, input: safeJson(b.json || "{}") } : { type: "text", text: b.text })
|
|
410
|
+
messages.push({ role: "assistant", content })
|
|
411
|
+
const uses = content.filter((b: any) => b.type === "tool_use")
|
|
412
|
+
if (stopReason !== "tool_use" || uses.length === 0) {
|
|
413
|
+
const text = content.filter((b: any) => b.type === "text").map((b: any) => b.text).join("")
|
|
414
|
+
yield { type: "usage", usage }
|
|
415
|
+
yield { type: "done", result: this.result(text, messages, toolCalls, turns, usage) }
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
for (const u of uses as any[]) yield { type: "tool_call", id: u.id, name: u.name, args: u.input }
|
|
419
|
+
const settled = await Promise.all((uses as any[]).map((u) => this.runTool(toolkit, u.name, u.input ?? {}, u.id, turn)))
|
|
420
|
+
const results: any[] = []
|
|
421
|
+
for (let i = 0; i < uses.length; i++) {
|
|
422
|
+
const u = uses[i] as any, { args, result } = settled[i]
|
|
423
|
+
toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
|
|
424
|
+
results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError })
|
|
425
|
+
yield { type: "tool_result", id: u.id, name: u.name, output: result.output, isError: result.isError }
|
|
426
|
+
}
|
|
427
|
+
messages.push({ role: "user", content: results })
|
|
428
|
+
}
|
|
429
|
+
yield { type: "done", result: this.result("", messages, toolCalls, turns, usage) }
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export function createClient(opts: ClientOptions): Client {
|
|
434
|
+
return new Client(opts)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Stateful multi-turn conversation: each send() continues the same transcript (memory). */
|
|
438
|
+
export class Conversation {
|
|
439
|
+
/** Full running transcript (system + user + assistant + tool messages). */
|
|
440
|
+
messages: any[] = []
|
|
441
|
+
constructor(private readonly client: Client, private readonly toolkit: Toolkit, private readonly signal?: AbortSignal) {}
|
|
442
|
+
|
|
443
|
+
/** Send the next user turn; prior history is retained automatically. */
|
|
444
|
+
async send(prompt: string): Promise<RunResult> {
|
|
445
|
+
const result = await this.client.run(prompt, { toolkit: this.toolkit, signal: this.signal, history: this.messages })
|
|
446
|
+
this.messages = result.messages
|
|
447
|
+
return result
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Reset the conversation memory. */
|
|
451
|
+
reset(): void {
|
|
452
|
+
this.messages = []
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Read an SSE stream line-by-line from a fetch body (web ReadableStream). */
|
|
457
|
+
async function* sseLines(body: ReadableStream<Uint8Array>): AsyncGenerator<string> {
|
|
458
|
+
const reader = body.getReader()
|
|
459
|
+
const decoder = new TextDecoder()
|
|
460
|
+
let buf = ""
|
|
461
|
+
while (true) {
|
|
462
|
+
const { done, value } = await reader.read()
|
|
463
|
+
if (done) break
|
|
464
|
+
buf += decoder.decode(value, { stream: true })
|
|
465
|
+
let idx: number
|
|
466
|
+
while ((idx = buf.indexOf("\n")) >= 0) {
|
|
467
|
+
yield buf.slice(0, idx).replace(/\r$/, "")
|
|
468
|
+
buf = buf.slice(idx + 1)
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (buf) yield buf
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function safeParse(s: string): any {
|
|
475
|
+
try {
|
|
476
|
+
return JSON.parse(s)
|
|
477
|
+
} catch {
|
|
478
|
+
return undefined
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function safeJson(s: string): Record<string, unknown> {
|
|
483
|
+
try {
|
|
484
|
+
return JSON.parse(s || "{}")
|
|
485
|
+
} catch {
|
|
486
|
+
return {}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function lastText(messages: any[]): string {
|
|
491
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
492
|
+
if (messages[i].role === "assistant" && typeof messages[i].content === "string") return messages[i].content
|
|
493
|
+
}
|
|
494
|
+
return ""
|
|
495
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP / REST tools — declare a remote endpoint as a uniform Tool.
|
|
3
|
+
* See ../../SPEC.md §7.
|
|
4
|
+
*/
|
|
5
|
+
import type { JSONSchema, Tool, ToolContext, ToolResult } from "./types.js"
|
|
6
|
+
|
|
7
|
+
export interface HttpToolOptions {
|
|
8
|
+
name: string
|
|
9
|
+
description: string
|
|
10
|
+
method: string
|
|
11
|
+
url: string
|
|
12
|
+
headers?: Record<string, string>
|
|
13
|
+
/** Arg names sent as querystring instead of in the body. */
|
|
14
|
+
query?: string[]
|
|
15
|
+
/** Body encoding for non-GET requests. */
|
|
16
|
+
body?: "json" | "form" | "raw"
|
|
17
|
+
inputSchema?: JSONSchema
|
|
18
|
+
timeout?: number
|
|
19
|
+
resultMode?: "text" | "json" | "status+text"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_TIMEOUT = 30_000
|
|
23
|
+
const EMPTY_SCHEMA: JSONSchema = { type: "object", properties: {}, additionalProperties: false }
|
|
24
|
+
|
|
25
|
+
/** Expand ${ENV_VAR} in header values from process.env (never logged). */
|
|
26
|
+
function expandHeaders(headers: Record<string, string> = {}): Record<string, string> {
|
|
27
|
+
const out: Record<string, string> = {}
|
|
28
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
29
|
+
out[k] = v.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name) => process.env[name] ?? "")
|
|
30
|
+
}
|
|
31
|
+
return out
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Wrap an HTTP endpoint as a Tool. */
|
|
35
|
+
export function httpTool(opts: HttpToolOptions): Tool {
|
|
36
|
+
const method = opts.method.toUpperCase()
|
|
37
|
+
const querySet = new Set(opts.query ?? [])
|
|
38
|
+
return {
|
|
39
|
+
name: opts.name,
|
|
40
|
+
description: opts.description,
|
|
41
|
+
inputSchema: opts.inputSchema ?? EMPTY_SCHEMA,
|
|
42
|
+
source: "http",
|
|
43
|
+
async execute(args: Record<string, unknown>, ctx?: ToolContext): Promise<ToolResult> {
|
|
44
|
+
const a = { ...(args ?? {}) }
|
|
45
|
+
// 1. substitute {placeholders} in the URL from args (consumed afterwards)
|
|
46
|
+
let url = opts.url.replace(/\{(\w+)\}/g, (_, key) => {
|
|
47
|
+
const val = a[key]
|
|
48
|
+
delete a[key]
|
|
49
|
+
return encodeURIComponent(String(val ?? ""))
|
|
50
|
+
})
|
|
51
|
+
// 2. querystring args
|
|
52
|
+
const params = new URLSearchParams()
|
|
53
|
+
for (const key of Object.keys(a)) {
|
|
54
|
+
if (querySet.has(key) || method === "GET") {
|
|
55
|
+
params.append(key, String(a[key]))
|
|
56
|
+
delete a[key]
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const qs = params.toString()
|
|
60
|
+
if (qs) url += (url.includes("?") ? "&" : "?") + qs
|
|
61
|
+
|
|
62
|
+
// 3. body
|
|
63
|
+
const headers = expandHeaders(opts.headers)
|
|
64
|
+
let bodyInit: string | undefined
|
|
65
|
+
if (method !== "GET" && method !== "HEAD" && Object.keys(a).length > 0) {
|
|
66
|
+
const mode = opts.body ?? "json"
|
|
67
|
+
if (mode === "json") {
|
|
68
|
+
headers["Content-Type"] ??= "application/json"
|
|
69
|
+
bodyInit = JSON.stringify(a)
|
|
70
|
+
} else if (mode === "form") {
|
|
71
|
+
headers["Content-Type"] ??= "application/x-www-form-urlencoded"
|
|
72
|
+
bodyInit = new URLSearchParams(a as Record<string, string>).toString()
|
|
73
|
+
} else {
|
|
74
|
+
bodyInit = String((a as any).body ?? "")
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const timeout = ctx?.timeout ?? opts.timeout ?? DEFAULT_TIMEOUT
|
|
79
|
+
const controller = new AbortController()
|
|
80
|
+
const timer = setTimeout(() => controller.abort(), timeout)
|
|
81
|
+
if (ctx?.signal) ctx.signal.addEventListener("abort", () => controller.abort(), { once: true })
|
|
82
|
+
try {
|
|
83
|
+
const res = await fetch(url, { method, headers, body: bodyInit, signal: controller.signal })
|
|
84
|
+
const text = await res.text()
|
|
85
|
+
if (!res.ok) {
|
|
86
|
+
return { output: `HTTP ${res.status}: ${text}`, isError: true, metadata: { status: res.status } }
|
|
87
|
+
}
|
|
88
|
+
const output =
|
|
89
|
+
opts.resultMode === "status+text"
|
|
90
|
+
? `${res.status}\n${text}`
|
|
91
|
+
: opts.resultMode === "json"
|
|
92
|
+
? JSON.stringify(safeParse(text))
|
|
93
|
+
: text
|
|
94
|
+
return { output, isError: false, metadata: { status: res.status } }
|
|
95
|
+
} catch (e) {
|
|
96
|
+
return { output: e instanceof Error ? e.message : String(e), isError: true }
|
|
97
|
+
} finally {
|
|
98
|
+
clearTimeout(timer)
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function safeParse(text: string): unknown {
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(text)
|
|
107
|
+
} catch {
|
|
108
|
+
return text
|
|
109
|
+
}
|
|
110
|
+
}
|