toolnexus 0.3.1 → 0.5.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 CHANGED
@@ -26,8 +26,21 @@ export interface ClientOptions {
26
26
  /** Conversation provider for `ask(prompt, { id })`. Default: in-memory (process lifetime).
27
27
  * Supply a file/db store to persist conversations across processes. */
28
28
  store?: ConversationStore
29
+ /** Observability sink — receives semantic metric events ({event:"llm"|"tool"|"run"…}) as the
30
+ * loop runs (§8 Observability). Forward to statsd/logs/OTel/anything. No cost when unset. */
31
+ onMetric?: (ev: MetricEvent) => void
29
32
  }
30
33
 
34
+ /**
35
+ * Semantic observability events (§8). NOT counter/histogram primitives — readable records the
36
+ * host can forward anywhere. The same events also feed the built-in Prometheus registry
37
+ * (`client.metrics()`).
38
+ */
39
+ export type MetricEvent =
40
+ | { event: "llm"; model: string; status: "ok" | "error"; ms: number; promptTokens: number; completionTokens: number }
41
+ | { event: "tool"; tool: string; source: string; isError: boolean; ms: number }
42
+ | { event: "run"; model: string; turns: number; toolCalls: number; totalTokens: number; ms: number; error?: string }
43
+
31
44
  /**
32
45
  * Where `ask()` conversations are remembered — two methods. Ship the in-memory
33
46
  * default; implement this for a file/db/redis provider to persist across processes.
@@ -127,6 +140,13 @@ function addUsage(acc: Usage, raw: any, style: ClientStyle): void {
127
140
  }
128
141
  }
129
142
 
143
+ /** Per-call token counts from one raw usage payload (not summed). */
144
+ function perCall(raw: any, style: ClientStyle): { prompt: number; completion: number } {
145
+ if (!raw) return { prompt: 0, completion: 0 }
146
+ if (style === "anthropic") return { prompt: raw.input_tokens ?? 0, completion: raw.output_tokens ?? 0 }
147
+ return { prompt: raw.prompt_tokens ?? 0, completion: raw.completion_tokens ?? 0 }
148
+ }
149
+
130
150
  function resolveKey(opts: ClientOptions): string {
131
151
  if (opts.apiKey) return opts.apiKey
132
152
  const env = process.env
@@ -139,13 +159,139 @@ function resolveKey(opts: ClientOptions): string {
139
159
  return key
140
160
  }
141
161
 
162
+ // ---- Prometheus metrics registry (zero-dependency; §8 Observability) ----
163
+
164
+ /** FIXED across all ports for byte-parity of `..._duration_seconds` histograms. Seconds. */
165
+ const DURATION_BUCKETS = [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60]
166
+
167
+ interface Hist {
168
+ counts: number[]
169
+ sum: number
170
+ count: number
171
+ }
172
+
173
+ /** Escape a Prometheus label value: backslash, double-quote, newline. */
174
+ function escapeLabel(v: string): string {
175
+ return v.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")
176
+ }
177
+
178
+ /** Render an ordered list of label pairs to `k="v",k="v"` (order is load-bearing for parity). */
179
+ function labelStr(pairs: [string, string][]): string {
180
+ return pairs.map(([k, v]) => `${k}="${escapeLabel(v)}"`).join(",")
181
+ }
182
+
183
+ function inc(m: Map<string, number>, key: string, by = 1): void {
184
+ m.set(key, (m.get(key) ?? 0) + by)
185
+ }
186
+
187
+ function observe(m: Map<string, Hist>, key: string, seconds: number): void {
188
+ let h = m.get(key)
189
+ if (!h) {
190
+ h = { counts: new Array(DURATION_BUCKETS.length).fill(0), sum: 0, count: 0 }
191
+ m.set(key, h)
192
+ }
193
+ h.sum += seconds
194
+ h.count++
195
+ for (let i = 0; i < DURATION_BUCKETS.length; i++) if (seconds <= DURATION_BUCKETS[i]) h.counts[i]++
196
+ }
197
+
198
+ /**
199
+ * In-memory cumulative registry that turns the same semantic events `onMetric` sees into the
200
+ * Prometheus counters/histograms of §8, and renders the text exposition format by hand (no dep).
201
+ */
202
+ class MetricsRegistry {
203
+ private readonly llmRequests = new Map<string, number>() // labels: model,status
204
+ private readonly llmTokens = new Map<string, number>() // labels: type
205
+ private readonly llmDuration = new Map<string, Hist>() // labels: model
206
+ private readonly toolCalls = new Map<string, number>() // labels: tool,source,is_error
207
+ private readonly toolDuration = new Map<string, Hist>() // labels: tool
208
+ private readonly runErrors = new Map<string, number>() // labels: model
209
+
210
+ record(ev: MetricEvent): void {
211
+ if (ev.event === "llm") {
212
+ inc(this.llmRequests, labelStr([["model", ev.model], ["status", ev.status]]))
213
+ if (ev.status === "ok") {
214
+ inc(this.llmTokens, labelStr([["type", "prompt"]]), ev.promptTokens)
215
+ inc(this.llmTokens, labelStr([["type", "completion"]]), ev.completionTokens)
216
+ }
217
+ observe(this.llmDuration, labelStr([["model", ev.model]]), ev.ms / 1000)
218
+ } else if (ev.event === "tool") {
219
+ inc(this.toolCalls, labelStr([["tool", ev.tool], ["source", ev.source], ["is_error", String(ev.isError)]]))
220
+ observe(this.toolDuration, labelStr([["tool", ev.tool]]), ev.ms / 1000)
221
+ } else if (ev.event === "run") {
222
+ if (ev.error) inc(this.runErrors, labelStr([["model", ev.model]]))
223
+ }
224
+ }
225
+
226
+ render(): string {
227
+ const out: string[] = []
228
+ this.renderCounter(out, "toolnexus_llm_requests_total", "Total LLM requests.", this.llmRequests)
229
+ this.renderCounter(out, "toolnexus_llm_tokens_total", "Total tokens, by type.", this.llmTokens)
230
+ this.renderHistogram(out, "toolnexus_llm_request_duration_seconds", "LLM request duration in seconds.", this.llmDuration)
231
+ this.renderCounter(out, "toolnexus_tool_calls_total", "Total tool calls.", this.toolCalls)
232
+ this.renderHistogram(out, "toolnexus_tool_duration_seconds", "Tool execution duration in seconds.", this.toolDuration)
233
+ this.renderCounter(out, "toolnexus_run_errors_total", "Total run errors.", this.runErrors)
234
+ return out.join("\n") + "\n"
235
+ }
236
+
237
+ private renderCounter(out: string[], name: string, help: string, m: Map<string, number>): void {
238
+ out.push(`# HELP ${name} ${help}`)
239
+ out.push(`# TYPE ${name} counter`)
240
+ for (const key of [...m.keys()].sort()) {
241
+ out.push(key ? `${name}{${key}} ${m.get(key)}` : `${name} ${m.get(key)}`)
242
+ }
243
+ }
244
+
245
+ private renderHistogram(out: string[], name: string, help: string, m: Map<string, Hist>): void {
246
+ out.push(`# HELP ${name} ${help}`)
247
+ out.push(`# TYPE ${name} histogram`)
248
+ for (const key of [...m.keys()].sort()) {
249
+ const h = m.get(key)!
250
+ for (let i = 0; i < DURATION_BUCKETS.length; i++) {
251
+ out.push(`${name}_bucket{${withLe(key, String(DURATION_BUCKETS[i]))}} ${h.counts[i]}`)
252
+ }
253
+ out.push(`${name}_bucket{${withLe(key, "+Inf")}} ${h.count}`)
254
+ out.push(key ? `${name}_sum{${key}} ${h.sum}` : `${name}_sum ${h.sum}`)
255
+ out.push(key ? `${name}_count{${key}} ${h.count}` : `${name}_count ${h.count}`)
256
+ }
257
+ }
258
+ }
259
+
260
+ function withLe(base: string, le: string): string {
261
+ return base ? `${base},le="${le}"` : `le="${le}"`
262
+ }
263
+
142
264
  export class Client {
143
265
  /** Conversation provider for `ask()` — from opts.store, else in-memory. */
144
266
  private readonly store: ConversationStore
267
+ /** Cumulative Prometheus registry fed by the same events `onMetric` sees. */
268
+ private readonly registry = new MetricsRegistry()
145
269
  constructor(private readonly opts: ClientOptions) {
146
270
  this.store = opts.store ?? new InMemoryConversationStore()
147
271
  }
148
272
 
273
+ /** Prometheus text exposition of cumulative metrics (§8). Always valid, empty-but-valid before activity. */
274
+ metrics(): string {
275
+ return this.registry.render()
276
+ }
277
+
278
+ /** Feed a semantic metric event to the built-in registry and the optional onMetric sink. */
279
+ private emit(ev: MetricEvent): void {
280
+ this.registry.record(ev)
281
+ this.opts.onMetric?.(ev)
282
+ }
283
+
284
+ /** Emit the terminal `run` metric event and build the RunResult. */
285
+ private endRun(runStart: number, text: string, messages: any[], toolCalls: ToolCallRecord[], turns: number, usage: Usage): RunResult {
286
+ this.emit({ event: "run", model: this.opts.model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart })
287
+ return this.result(text, messages, toolCalls, turns, usage)
288
+ }
289
+
290
+ /** Emit a `run` error metric event (once, on a thrown run). */
291
+ private emitRunError(runStart: number, toolCalls: ToolCallRecord[], turns: number, usage: Usage, e: unknown): void {
292
+ this.emit({ event: "run", model: this.opts.model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart, error: e instanceof Error ? e.message : String(e) })
293
+ }
294
+
149
295
  async run(prompt: string, ctx: { toolkit: Toolkit; signal?: AbortSignal; history?: any[] }): Promise<RunResult> {
150
296
  return this.opts.style === "anthropic"
151
297
  ? this.runAnthropic(prompt, ctx.toolkit, ctx.signal, ctx.history)
@@ -158,7 +304,17 @@ export class Client {
158
304
  * the answer — so the next `ask` with the same `id` continues it. Without an
159
305
  * `id` it is a stateless one-shot (identical to `run`).
160
306
  */
161
- async ask(prompt: string, ctx: { toolkit: Toolkit; id?: string; signal?: AbortSignal }): Promise<RunResult> {
307
+ async ask(prompt: string, ctx: { toolkit: Toolkit; id?: string; on_text?: (delta: string) => void; signal?: AbortSignal }): Promise<RunResult> {
308
+ // Block-style streaming: run the streaming loop, forward text deltas, still return the
309
+ // final RunResult. Memory (id load/save) is handled by stream() itself, so no duplication.
310
+ if (ctx.on_text) {
311
+ let result: RunResult | undefined
312
+ for await (const ev of this.stream(prompt, { toolkit: ctx.toolkit, id: ctx.id, signal: ctx.signal })) {
313
+ if (ev.type === "text") ctx.on_text(ev.delta)
314
+ else if (ev.type === "done") result = ev.result
315
+ }
316
+ return result!
317
+ }
162
318
  if (!ctx.id) return this.run(prompt, { toolkit: ctx.toolkit, signal: ctx.signal })
163
319
  const history = (await this.store.get(ctx.id)) ?? []
164
320
  const result = await this.run(prompt, { toolkit: ctx.toolkit, signal: ctx.signal, history })
@@ -171,11 +327,20 @@ export class Client {
171
327
  return new Conversation(this, ctx.toolkit, ctx.signal)
172
328
  }
173
329
 
174
- /** Streaming variant: async-iterate live events (text deltas, tool calls/results, usage, done). */
175
- stream(prompt: string, ctx: { toolkit: Toolkit; signal?: AbortSignal }): AsyncGenerator<StreamEvent, void, unknown> {
176
- return this.opts.style === "anthropic"
177
- ? this.streamAnthropic(prompt, ctx.toolkit, ctx.signal)
178
- : this.streamOpenAI(prompt, ctx.toolkit, ctx.signal)
330
+ /**
331
+ * Streaming variant: async-iterate live events (text deltas, tool calls/results, usage, done).
332
+ * With an `id`, it is stateful (like `ask`): the thread's transcript is loaded as history before
333
+ * streaming, and saved back to the ConversationStore on the terminal `done` event. No `id` ⇒ stateless.
334
+ */
335
+ async *stream(prompt: string, ctx: { toolkit: Toolkit; id?: string; signal?: AbortSignal }): AsyncGenerator<StreamEvent, void, unknown> {
336
+ const history = ctx.id ? (await this.store.get(ctx.id)) ?? [] : undefined
337
+ const gen = this.opts.style === "anthropic"
338
+ ? this.streamAnthropic(prompt, ctx.toolkit, ctx.signal, history)
339
+ : this.streamOpenAI(prompt, ctx.toolkit, ctx.signal, history)
340
+ for await (const ev of gen) {
341
+ if (ev.type === "done" && ctx.id) await this.store.save(ctx.id, ev.result.messages)
342
+ yield ev
343
+ }
179
344
  }
180
345
 
181
346
  private system(toolkit: Toolkit): string {
@@ -220,10 +385,16 @@ export class Client {
220
385
  /** Run one tool through beforeTool/afterTool hooks (mutate args, short-circuit, transform result). */
221
386
  private async runTool(toolkit: Toolkit, name: string, args: Record<string, unknown>, id: string | undefined, turn: number) {
222
387
  const h = this.opts.hooks
388
+ const source = toolkit.get(name)?.source ?? "custom"
389
+ const t0 = Date.now()
223
390
  let a = args
224
391
  if (h?.beforeTool) {
225
392
  const ov = await h.beforeTool({ name, args: a, id, turn })
226
- if (ov?.result) return { args: a, result: ov.result } // short-circuit (deny/cache/dry-run)
393
+ if (ov?.result) {
394
+ // short-circuit (deny/cache/dry-run) — still a tool call for metrics purposes
395
+ this.emit({ event: "tool", tool: name, source, isError: ov.result.isError, ms: Date.now() - t0 })
396
+ return { args: a, result: ov.result }
397
+ }
227
398
  if (ov?.args) a = ov.args
228
399
  }
229
400
  let result = await toolkit.execute(name, a)
@@ -231,13 +402,31 @@ export class Client {
231
402
  const ov = await h.afterTool({ name, args: a, result, id, turn })
232
403
  if (ov?.result) result = ov.result
233
404
  }
405
+ this.emit({ event: "tool", tool: name, source, isError: result.isError, ms: Date.now() - t0 })
234
406
  return { args: a, result }
235
407
  }
236
408
 
409
+ /** One non-streaming LLM call, with an `llm` metric event (ok/error + per-call tokens + ms). */
410
+ private async llmCallJson(url: string, init: RequestInit, signal: AbortSignal, style: ClientStyle): Promise<any> {
411
+ const t0 = Date.now()
412
+ try {
413
+ const res = await this.llmFetch(url, init, signal)
414
+ if (!res.ok) throw new Error(`LLM ${res.status}: ${await res.text()}`)
415
+ const data: any = await res.json()
416
+ const tok = perCall(data.usage, style)
417
+ this.emit({ event: "llm", model: this.opts.model, status: "ok", ms: Date.now() - t0, promptTokens: tok.prompt, completionTokens: tok.completion })
418
+ return data
419
+ } catch (e) {
420
+ this.emit({ event: "llm", model: this.opts.model, status: "error", ms: Date.now() - t0, promptTokens: 0, completionTokens: 0 })
421
+ throw e
422
+ }
423
+ }
424
+
237
425
  // ---- OpenAI-style: POST {baseUrl}/chat/completions ----
238
426
  private async runOpenAI(prompt: string, toolkit: Toolkit, external?: AbortSignal, history?: any[]): Promise<RunResult> {
239
427
  const key = resolveKey(this.opts)
240
428
  const signal = this.makeSignal(external)
429
+ const runStart = Date.now()
241
430
  let messages: any[] = history && history.length ? [...history] : []
242
431
  if (!messages.length) {
243
432
  const system = this.system(toolkit)
@@ -249,37 +438,40 @@ export class Client {
249
438
  const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
250
439
  let turns = 0
251
440
 
252
- for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
253
- turns++
254
- if (this.opts.hooks?.beforeLLM) {
255
- const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn })
256
- if (ov?.messages) messages = ov.messages
257
- if (ov?.tools) tools = ov.tools
441
+ try {
442
+ for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
443
+ turns++
444
+ if (this.opts.hooks?.beforeLLM) {
445
+ const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn })
446
+ if (ov?.messages) messages = ov.messages
447
+ if (ov?.tools) tools = ov.tools
448
+ }
449
+ const data: any = await this.llmCallJson(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
450
+ method: "POST",
451
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
452
+ body: JSON.stringify({ model: this.opts.model, messages, tools, tool_choice: "auto" }),
453
+ }, signal, "openai")
454
+ addUsage(usage, data.usage, "openai")
455
+ if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn })
456
+ const msg = data.choices[0].message
457
+ messages.push(msg)
458
+ const calls = msg.tool_calls ?? []
459
+ if (calls.length === 0) return this.endRun(runStart, msg.content ?? "", messages, toolCalls, turns, usage)
460
+ // execute all tool calls in this turn concurrently (true parallel tool calling)
461
+ const results = await Promise.all(
462
+ calls.map(async (call: any) => {
463
+ const { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn)
464
+ toolCalls.push({ name: call.function.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
465
+ return { role: "tool", tool_call_id: call.id, content: result.output }
466
+ }),
467
+ )
468
+ messages.push(...results)
258
469
  }
259
- const res = await this.llmFetch(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
260
- method: "POST",
261
- headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
262
- body: JSON.stringify({ model: this.opts.model, messages, tools, tool_choice: "auto" }),
263
- }, signal)
264
- if (!res.ok) throw new Error(`LLM ${res.status}: ${await res.text()}`)
265
- const data: any = await res.json()
266
- addUsage(usage, data.usage, "openai")
267
- if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn })
268
- const msg = data.choices[0].message
269
- messages.push(msg)
270
- const calls = msg.tool_calls ?? []
271
- if (calls.length === 0) return this.result(msg.content ?? "", messages, toolCalls, turns, usage)
272
- // execute all tool calls in this turn concurrently (true parallel tool calling)
273
- const results = await Promise.all(
274
- calls.map(async (call: any) => {
275
- const { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn)
276
- toolCalls.push({ name: call.function.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
277
- return { role: "tool", tool_call_id: call.id, content: result.output }
278
- }),
279
- )
280
- messages.push(...results)
470
+ return this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage)
471
+ } catch (e) {
472
+ this.emitRunError(runStart, toolCalls, turns, usage, e)
473
+ throw e
281
474
  }
282
- return this.result(lastText(messages), messages, toolCalls, turns, usage)
283
475
  }
284
476
 
285
477
  private result(text: string, messages: any[], toolCalls: ToolCallRecord[], turns: number, usage: Usage): RunResult {
@@ -293,184 +485,216 @@ export class Client {
293
485
  const base = this.opts.baseUrl.replace(/\/$/, "")
294
486
  const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`
295
487
  const system = this.system(toolkit)
488
+ const runStart = Date.now()
296
489
  let messages: any[] = history && history.length ? [...history, { role: "user", content: prompt }] : [{ role: "user", content: prompt }]
297
490
  let tools = toolkit.toAnthropic()
298
491
  const toolCalls: ToolCallRecord[] = []
299
492
  const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
300
493
  let turns = 0
301
494
 
302
- for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
303
- turns++
304
- if (this.opts.hooks?.beforeLLM) {
305
- const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn })
306
- if (ov?.messages) messages = ov.messages
307
- if (ov?.tools) tools = ov.tools
308
- }
309
- const res = await this.llmFetch(endpoint, {
310
- method: "POST",
311
- headers: {
312
- "x-api-key": key,
313
- "anthropic-version": "2023-06-01",
314
- "Content-Type": "application/json",
315
- ...this.opts.headers,
316
- },
317
- body: JSON.stringify({ model: this.opts.model, max_tokens: 4096, system, messages, tools }),
318
- }, signal)
319
- if (!res.ok) throw new Error(`LLM ${res.status}: ${await res.text()}`)
320
- const data: any = await res.json()
321
- addUsage(usage, data.usage, "anthropic")
322
- if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn })
323
- messages.push({ role: "assistant", content: data.content })
324
- const uses = (data.content ?? []).filter((b: any) => b.type === "tool_use")
325
- if (uses.length === 0) {
326
- const text = (data.content ?? []).filter((b: any) => b.type === "text").map((b: any) => b.text).join("")
327
- return this.result(text, messages, toolCalls, turns, usage)
495
+ try {
496
+ for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
497
+ turns++
498
+ if (this.opts.hooks?.beforeLLM) {
499
+ const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn })
500
+ if (ov?.messages) messages = ov.messages
501
+ if (ov?.tools) tools = ov.tools
502
+ }
503
+ const data: any = await this.llmCallJson(endpoint, {
504
+ method: "POST",
505
+ headers: {
506
+ "x-api-key": key,
507
+ "anthropic-version": "2023-06-01",
508
+ "Content-Type": "application/json",
509
+ ...this.opts.headers,
510
+ },
511
+ body: JSON.stringify({ model: this.opts.model, max_tokens: 4096, system, messages, tools }),
512
+ }, signal, "anthropic")
513
+ addUsage(usage, data.usage, "anthropic")
514
+ if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn })
515
+ messages.push({ role: "assistant", content: data.content })
516
+ const uses = (data.content ?? []).filter((b: any) => b.type === "tool_use")
517
+ if (uses.length === 0) {
518
+ const text = (data.content ?? []).filter((b: any) => b.type === "text").map((b: any) => b.text).join("")
519
+ return this.endRun(runStart, text, messages, toolCalls, turns, usage)
520
+ }
521
+ // execute all tool_use blocks in this turn concurrently (true parallel tool calling)
522
+ const results = await Promise.all(
523
+ uses.map(async (use: any) => {
524
+ const { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn)
525
+ toolCalls.push({ name: use.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
526
+ return { type: "tool_result", tool_use_id: use.id, content: result.output, is_error: result.isError }
527
+ }),
528
+ )
529
+ messages.push({ role: "user", content: results })
328
530
  }
329
- // execute all tool_use blocks in this turn concurrently (true parallel tool calling)
330
- const results = await Promise.all(
331
- uses.map(async (use: any) => {
332
- const { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn)
333
- toolCalls.push({ name: use.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
334
- return { type: "tool_result", tool_use_id: use.id, content: result.output, is_error: result.isError }
335
- }),
336
- )
337
- messages.push({ role: "user", content: results })
531
+ return this.endRun(runStart, "", messages, toolCalls, turns, usage)
532
+ } catch (e) {
533
+ this.emitRunError(runStart, toolCalls, turns, usage, e)
534
+ throw e
338
535
  }
339
- return this.result("", messages, toolCalls, turns, usage)
340
536
  }
341
537
 
342
538
  // ---- Streaming: OpenAI-style ----
343
- private async *streamOpenAI(prompt: string, toolkit: Toolkit, external?: AbortSignal): AsyncGenerator<StreamEvent, void, unknown> {
539
+ private async *streamOpenAI(prompt: string, toolkit: Toolkit, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
344
540
  const key = resolveKey(this.opts)
345
541
  const signal = this.makeSignal(external)
346
- let messages: any[] = []
347
- const system = this.system(toolkit)
348
- if (system) messages.push({ role: "system", content: system })
542
+ const runStart = Date.now()
543
+ let messages: any[] = history && history.length ? [...history] : []
544
+ if (!messages.length) {
545
+ const system = this.system(toolkit)
546
+ if (system) messages.push({ role: "system", content: system })
547
+ }
349
548
  messages.push({ role: "user", content: prompt })
350
549
  let tools = toolkit.toOpenAI()
351
550
  const toolCalls: ToolCallRecord[] = []
352
551
  const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
353
552
  let turns = 0
354
553
 
355
- for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
356
- turns++
357
- if (this.opts.hooks?.beforeLLM) {
358
- const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn })
359
- if (ov?.messages) messages = ov.messages
360
- if (ov?.tools) tools = ov.tools
361
- }
362
- const res = await this.llmFetch(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
363
- method: "POST",
364
- headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
365
- body: JSON.stringify({ model: this.opts.model, messages, tools, tool_choice: "auto", stream: true, stream_options: { include_usage: true } }),
366
- }, signal)
367
- if (!res.ok || !res.body) throw new Error(`LLM ${res.status}: ${await res.text()}`)
368
-
369
- let content = ""
370
- const acc = new Map<number, { id: string; name: string; args: string }>()
371
- for await (const line of sseLines(res.body)) {
372
- if (!line.startsWith("data:")) continue
373
- const payload = line.slice(5).trim()
374
- if (payload === "[DONE]") break
375
- const j = safeParse(payload)
376
- if (j?.usage) addUsage(usage, j.usage, "openai")
377
- const choice = j?.choices?.[0]
378
- const d = choice?.delta
379
- if (d?.content) { content += d.content; yield { type: "text", delta: d.content } }
380
- for (const tc of d?.tool_calls ?? []) {
381
- const slot = acc.get(tc.index) ?? { id: "", name: "", args: "" }
382
- if (tc.id) slot.id = tc.id
383
- if (tc.function?.name) slot.name += tc.function.name
384
- if (tc.function?.arguments) slot.args += tc.function.arguments
385
- acc.set(tc.index, slot)
554
+ try {
555
+ for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
556
+ turns++
557
+ if (this.opts.hooks?.beforeLLM) {
558
+ const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn })
559
+ if (ov?.messages) messages = ov.messages
560
+ if (ov?.tools) tools = ov.tools
561
+ }
562
+ const t0 = Date.now()
563
+ const beforeP = usage.promptTokens, beforeC = usage.completionTokens
564
+ let content = ""
565
+ const acc = new Map<number, { id: string; name: string; args: string }>()
566
+ try {
567
+ const res = await this.llmFetch(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
568
+ method: "POST",
569
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
570
+ body: JSON.stringify({ model: this.opts.model, messages, tools, tool_choice: "auto", stream: true, stream_options: { include_usage: true } }),
571
+ }, signal)
572
+ if (!res.ok || !res.body) throw new Error(`LLM ${res.status}: ${await res.text()}`)
573
+ for await (const line of sseLines(res.body)) {
574
+ if (!line.startsWith("data:")) continue
575
+ const payload = line.slice(5).trim()
576
+ if (payload === "[DONE]") break
577
+ const j = safeParse(payload)
578
+ if (j?.usage) addUsage(usage, j.usage, "openai")
579
+ const choice = j?.choices?.[0]
580
+ const d = choice?.delta
581
+ if (d?.content) { content += d.content; yield { type: "text", delta: d.content } }
582
+ for (const tc of d?.tool_calls ?? []) {
583
+ const slot = acc.get(tc.index) ?? { id: "", name: "", args: "" }
584
+ if (tc.id) slot.id = tc.id
585
+ if (tc.function?.name) slot.name += tc.function.name
586
+ if (tc.function?.arguments) slot.args += tc.function.arguments
587
+ acc.set(tc.index, slot)
588
+ }
589
+ }
590
+ } catch (e) {
591
+ this.emit({ event: "llm", model: this.opts.model, status: "error", ms: Date.now() - t0, promptTokens: 0, completionTokens: 0 })
592
+ throw e
593
+ }
594
+ this.emit({ event: "llm", model: this.opts.model, status: "ok", ms: Date.now() - t0, promptTokens: usage.promptTokens - beforeP, completionTokens: usage.completionTokens - beforeC })
595
+ if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: { streamed: true, usage }, model: this.opts.model, turn })
596
+
597
+ const calls = [...acc.values()]
598
+ if (calls.length === 0) {
599
+ messages.push({ role: "assistant", content })
600
+ yield { type: "usage", usage }
601
+ yield { type: "done", result: this.endRun(runStart, content, messages, toolCalls, turns, usage) }
602
+ return
603
+ }
604
+ messages.push({ role: "assistant", content: content || null, tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.args } })) })
605
+ for (const c of calls) yield { type: "tool_call", id: c.id, name: c.name, args: safeJson(c.args) }
606
+ const settled = await Promise.all(calls.map((c) => this.runTool(toolkit, c.name, safeJson(c.args), c.id, turn)))
607
+ for (let i = 0; i < calls.length; i++) {
608
+ const c = calls[i], { args, result } = settled[i]
609
+ toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
610
+ messages.push({ role: "tool", tool_call_id: c.id, content: result.output })
611
+ yield { type: "tool_result", id: c.id, name: c.name, output: result.output, isError: result.isError }
386
612
  }
387
613
  }
388
- if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: { streamed: true, usage }, model: this.opts.model, turn })
389
-
390
- const calls = [...acc.values()]
391
- if (calls.length === 0) {
392
- messages.push({ role: "assistant", content })
393
- yield { type: "usage", usage }
394
- yield { type: "done", result: this.result(content, messages, toolCalls, turns, usage) }
395
- return
396
- }
397
- messages.push({ role: "assistant", content: content || null, tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.args } })) })
398
- for (const c of calls) yield { type: "tool_call", id: c.id, name: c.name, args: safeJson(c.args) }
399
- const settled = await Promise.all(calls.map((c) => this.runTool(toolkit, c.name, safeJson(c.args), c.id, turn)))
400
- for (let i = 0; i < calls.length; i++) {
401
- const c = calls[i], { args, result } = settled[i]
402
- toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
403
- messages.push({ role: "tool", tool_call_id: c.id, content: result.output })
404
- yield { type: "tool_result", id: c.id, name: c.name, output: result.output, isError: result.isError }
405
- }
614
+ yield { type: "done", result: this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage) }
615
+ } catch (e) {
616
+ this.emitRunError(runStart, toolCalls, turns, usage, e)
617
+ throw e
406
618
  }
407
- yield { type: "done", result: this.result(lastText(messages), messages, toolCalls, turns, usage) }
408
619
  }
409
620
 
410
621
  // ---- Streaming: Anthropic-style ----
411
- private async *streamAnthropic(prompt: string, toolkit: Toolkit, external?: AbortSignal): AsyncGenerator<StreamEvent, void, unknown> {
622
+ private async *streamAnthropic(prompt: string, toolkit: Toolkit, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
412
623
  const key = resolveKey(this.opts)
413
624
  const signal = this.makeSignal(external)
625
+ const runStart = Date.now()
414
626
  const base = this.opts.baseUrl.replace(/\/$/, "")
415
627
  const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`
416
628
  const system = this.system(toolkit)
417
- let messages: any[] = [{ role: "user", content: prompt }]
629
+ let messages: any[] = history && history.length ? [...history, { role: "user", content: prompt }] : [{ role: "user", content: prompt }]
418
630
  let tools = toolkit.toAnthropic()
419
631
  const toolCalls: ToolCallRecord[] = []
420
632
  const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
421
633
  let turns = 0
422
634
 
423
- for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
424
- turns++
425
- if (this.opts.hooks?.beforeLLM) {
426
- const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn })
427
- if (ov?.messages) messages = ov.messages
428
- if (ov?.tools) tools = ov.tools
429
- }
430
- const res = await this.llmFetch(endpoint, {
431
- method: "POST",
432
- headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "Content-Type": "application/json", ...this.opts.headers },
433
- body: JSON.stringify({ model: this.opts.model, max_tokens: 4096, system, messages, tools, stream: true }),
434
- }, signal)
435
- if (!res.ok || !res.body) throw new Error(`LLM ${res.status}: ${await res.text()}`)
436
-
437
- const blocks = new Map<number, { type: string; text?: string; id?: string; name?: string; json?: string }>()
438
- let stopReason = ""
439
- for await (const line of sseLines(res.body)) {
440
- if (!line.startsWith("data:")) continue
441
- const j = safeParse(line.slice(5).trim())
442
- if (!j) continue
443
- if (j.type === "message_start") addUsage(usage, j.message?.usage, "anthropic")
444
- 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: "" })
445
- else if (j.type === "content_block_delta") {
446
- const b = blocks.get(j.index)!
447
- if (j.delta.type === "text_delta") { b.text += j.delta.text; yield { type: "text", delta: j.delta.text } }
448
- else if (j.delta.type === "input_json_delta") b.json += j.delta.partial_json
449
- } else if (j.type === "message_delta") { stopReason = j.delta?.stop_reason ?? stopReason; addUsage(usage, j.usage, "anthropic") }
450
- }
451
- if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: { streamed: true, usage }, model: this.opts.model, turn })
452
-
453
- 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 })
454
- messages.push({ role: "assistant", content })
455
- const uses = content.filter((b: any) => b.type === "tool_use")
456
- if (stopReason !== "tool_use" || uses.length === 0) {
457
- const text = content.filter((b: any) => b.type === "text").map((b: any) => b.text).join("")
458
- yield { type: "usage", usage }
459
- yield { type: "done", result: this.result(text, messages, toolCalls, turns, usage) }
460
- return
461
- }
462
- for (const u of uses as any[]) yield { type: "tool_call", id: u.id, name: u.name, args: u.input }
463
- const settled = await Promise.all((uses as any[]).map((u) => this.runTool(toolkit, u.name, u.input ?? {}, u.id, turn)))
464
- const results: any[] = []
465
- for (let i = 0; i < uses.length; i++) {
466
- const u = uses[i] as any, { args, result } = settled[i]
467
- toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
468
- results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError })
469
- yield { type: "tool_result", id: u.id, name: u.name, output: result.output, isError: result.isError }
635
+ try {
636
+ for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
637
+ turns++
638
+ if (this.opts.hooks?.beforeLLM) {
639
+ const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn })
640
+ if (ov?.messages) messages = ov.messages
641
+ if (ov?.tools) tools = ov.tools
642
+ }
643
+ const t0 = Date.now()
644
+ const beforeP = usage.promptTokens, beforeC = usage.completionTokens
645
+ const blocks = new Map<number, { type: string; text?: string; id?: string; name?: string; json?: string }>()
646
+ let stopReason = ""
647
+ try {
648
+ const res = await this.llmFetch(endpoint, {
649
+ method: "POST",
650
+ headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "Content-Type": "application/json", ...this.opts.headers },
651
+ body: JSON.stringify({ model: this.opts.model, max_tokens: 4096, system, messages, tools, stream: true }),
652
+ }, signal)
653
+ if (!res.ok || !res.body) throw new Error(`LLM ${res.status}: ${await res.text()}`)
654
+ for await (const line of sseLines(res.body)) {
655
+ if (!line.startsWith("data:")) continue
656
+ const j = safeParse(line.slice(5).trim())
657
+ if (!j) continue
658
+ if (j.type === "message_start") addUsage(usage, j.message?.usage, "anthropic")
659
+ 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: "" })
660
+ else if (j.type === "content_block_delta") {
661
+ const b = blocks.get(j.index)!
662
+ if (j.delta.type === "text_delta") { b.text += j.delta.text; yield { type: "text", delta: j.delta.text } }
663
+ else if (j.delta.type === "input_json_delta") b.json += j.delta.partial_json
664
+ } else if (j.type === "message_delta") { stopReason = j.delta?.stop_reason ?? stopReason; addUsage(usage, j.usage, "anthropic") }
665
+ }
666
+ } catch (e) {
667
+ this.emit({ event: "llm", model: this.opts.model, status: "error", ms: Date.now() - t0, promptTokens: 0, completionTokens: 0 })
668
+ throw e
669
+ }
670
+ this.emit({ event: "llm", model: this.opts.model, status: "ok", ms: Date.now() - t0, promptTokens: usage.promptTokens - beforeP, completionTokens: usage.completionTokens - beforeC })
671
+ if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: { streamed: true, usage }, model: this.opts.model, turn })
672
+
673
+ 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 })
674
+ messages.push({ role: "assistant", content })
675
+ const uses = content.filter((b: any) => b.type === "tool_use")
676
+ if (stopReason !== "tool_use" || uses.length === 0) {
677
+ const text = content.filter((b: any) => b.type === "text").map((b: any) => b.text).join("")
678
+ yield { type: "usage", usage }
679
+ yield { type: "done", result: this.endRun(runStart, text, messages, toolCalls, turns, usage) }
680
+ return
681
+ }
682
+ for (const u of uses as any[]) yield { type: "tool_call", id: u.id, name: u.name, args: u.input }
683
+ const settled = await Promise.all((uses as any[]).map((u) => this.runTool(toolkit, u.name, u.input ?? {}, u.id, turn)))
684
+ const results: any[] = []
685
+ for (let i = 0; i < uses.length; i++) {
686
+ const u = uses[i] as any, { args, result } = settled[i]
687
+ toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
688
+ results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError })
689
+ yield { type: "tool_result", id: u.id, name: u.name, output: result.output, isError: result.isError }
690
+ }
691
+ messages.push({ role: "user", content: results })
470
692
  }
471
- messages.push({ role: "user", content: results })
693
+ yield { type: "done", result: this.endRun(runStart, "", messages, toolCalls, turns, usage) }
694
+ } catch (e) {
695
+ this.emitRunError(runStart, toolCalls, turns, usage, e)
696
+ throw e
472
697
  }
473
- yield { type: "done", result: this.result("", messages, toolCalls, turns, usage) }
474
698
  }
475
699
  }
476
700