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/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
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export * from "./types.js"
2
+ export * from "./mcp.js"
3
+ export * from "./skill.js"
4
+ export * from "./native.js"
5
+ export * from "./http.js"
6
+ export * from "./adapters.js"
7
+ export * from "./toolkit.js"
8
+ export * from "./client.js"