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/README.md CHANGED
@@ -105,6 +105,64 @@ automatically (`.messages`, `.reset()`).
105
105
  The same `store` powers inbound A2A: `toolkit.serve` keys each peer's turns by the A2A
106
106
  `contextId` through `client.ask`, so a remote agent's conversation is remembered too (see below).
107
107
 
108
+ **Streaming with memory.** The `id` also works while streaming. Pass `on_text` to `ask` to stream
109
+ text deltas as they arrive — `ask` still returns the final `RunResult` — or iterate `stream()`
110
+ directly. With an `id`, the thread is loaded before the stream and saved on the `done` event.
111
+
112
+ ```ts
113
+ // block-style: stream deltas to stdout, still get the RunResult back — remembered under `id`
114
+ const res = await agent.ask("Draft a reply.", {
115
+ toolkit: tk,
116
+ id: "user-42",
117
+ on_text: (delta) => process.stdout.write(delta),
118
+ })
119
+
120
+ // iterator: consume text + tool events; `id` makes it stateful (load before, save on `done`)
121
+ for await (const ev of agent.stream("And summarise it.", { toolkit: tk, id: "user-42" })) {
122
+ if (ev.type === "text") process.stdout.write(ev.delta)
123
+ else if (ev.type === "done") console.log("\n", ev.result.usage)
124
+ }
125
+ ```
126
+
127
+ ## Observability / metrics
128
+
129
+ Zero-dependency, two outputs from one internal instrumentation — both opt-in, no cost when unused.
130
+
131
+ **`onMetric` — a semantic event feed.** Pass it to `createClient` and it receives a readable
132
+ `MetricEvent` at each significant point: `{ event: "llm", model, status, ms, promptTokens,
133
+ completionTokens }` per model call, `{ event: "tool", tool, source, isError, ms }` per tool call,
134
+ and a terminal `{ event: "run", model, turns, toolCalls, totalTokens, ms, error? }` per `run`/`ask`.
135
+ Forward it anywhere (statsd, logs, OpenTelemetry) — the library holds no opinion.
136
+
137
+ ```ts
138
+ const agent = createClient({
139
+ baseUrl, style: "openai", model,
140
+ onMetric: (ev) => console.log("[metric]", ev.event, JSON.stringify(ev)),
141
+ })
142
+ ```
143
+
144
+ **`client.metrics()` — built-in Prometheus text.** The same events feed a tiny in-memory registry
145
+ that renders the Prometheus text exposition format (no third-party dep). Mount it at `GET /metrics`:
146
+
147
+ ```ts
148
+ import { createServer } from "node:http"
149
+
150
+ createServer((req, res) => {
151
+ if (req.url === "/metrics") {
152
+ res.setHeader("Content-Type", "text/plain; version=0.0.4")
153
+ res.end(agent.metrics())
154
+ } else {
155
+ res.statusCode = 404
156
+ res.end()
157
+ }
158
+ }).listen(9090)
159
+ ```
160
+
161
+ Series: `toolnexus_llm_requests_total{model,status}`, `toolnexus_llm_tokens_total{type}`,
162
+ `toolnexus_tool_calls_total{tool,source,is_error}`, `toolnexus_run_errors_total{model}`, plus the
163
+ `toolnexus_llm_request_duration_seconds` and `toolnexus_tool_duration_seconds` histograms. The
164
+ rendered text is byte-identical across all five ports; OTLP push is a planned future companion.
165
+
108
166
  ## Add your own tools
109
167
 
110
168
  ```ts
@@ -210,6 +268,29 @@ Task persistence is a pluggable `TaskStore` — in-memory default, `"file:<dir>"
210
268
  it **remembers**: a message's A2A `contextId` keys the conversation through `client.ask`, so a
211
269
  peer's turns in the same context carry history via the client's `ConversationStore`.
212
270
 
271
+ ## Serve as an MCP server (be a gateway)
272
+
273
+ The inbound mirror of A2A: expose your **whole toolkit as an MCP server** so any MCP client — an IDE,
274
+ another agent, a remote host — can call its tools. Point toolnexus at N MCP servers + skills + your
275
+ own functions, then re-expose the union as **one** MCP server. Unlike A2A (which advertises skills and
276
+ runs the client loop), the MCP client *is* the LLM host, so each `tools/call` dispatches straight to
277
+ `Tool.execute` — no `client`, no tasks, no store.
278
+
279
+ ```ts
280
+ // streamable-HTTP — an embeddable MCP server mounted at POST /mcp, beside any A2A routes:
281
+ const srv = await tk.serve("127.0.0.1:0", {
282
+ mcp: { name: "my-gateway" /*, tools: ["echo"] // subset; omit ⇒ every toolkit tool */ },
283
+ onCall: (ev) => console.error(ev.name, ev.ms, ev.isError),
284
+ })
285
+ console.log(srv.url + "/mcp") // connect any MCP client here
286
+ await srv.stop()
287
+ ```
288
+
289
+ `tools/list` advertises every toolkit tool (name **verbatim**, `inputSchema` = the tool's parameters);
290
+ `mcp.tools` narrows the surface. The `mcp` profile can also live in the config file as a top-level
291
+ **`mcpServer`** block (singular — distinct from the client-side `mcpServers`). (Transport is
292
+ streamable-HTTP; a stdio transport for local clients like Claude Desktop is a planned follow-up.)
293
+
213
294
  ## Bring your own loop
214
295
 
215
296
  Don't want the host loop? Use the schema adapters and execute calls yourself:
@@ -238,11 +319,12 @@ a sixth.
238
319
  | Member | Description |
239
320
  |--------|-------------|
240
321
  | `createToolkit(opts)` | async factory → `Toolkit` (`mcpConfig`, `skillsDir`, `builtins`, `agents`, `extraTools`) |
241
- | `createClient(opts)` | the unified host loop (`baseUrl`, `style`, `model`, `store?`, `hooks?`, `maxTurns?`, `retries?`, `timeoutMs?`) |
322
+ | `createClient(opts)` | the unified host loop (`baseUrl`, `style`, `model`, `store?`, `hooks?`, `maxTurns?`, `retries?`, `timeoutMs?`, `onMetric?`) |
242
323
  | `client.run(prompt, { toolkit, signal?, history? })` | stateless run → `RunResult` |
243
- | `client.ask(prompt, { toolkit, id?, signal? })` | stateful with `id` (loads → runs → saves via `store`); one-shot without `id` |
324
+ | `client.ask(prompt, { toolkit, id?, on_text?, signal? })` | stateful with `id` (loads → runs → saves via `store`); one-shot without `id`; `on_text` streams text deltas |
244
325
  | `client.conversation({ toolkit })` | in-process multi-turn object — `.send(prompt)`, `.messages`, `.reset()` |
245
- | `client.stream(prompt, { toolkit })` | async-iterate `StreamEvent`s (text deltas, tool calls/results, usage, done) |
326
+ | `client.stream(prompt, { toolkit, id? })` | async-iterate `StreamEvent`s (text deltas, tool calls/results, usage, done); `id` ⇒ stateful |
327
+ | `client.metrics()` | Prometheus text exposition of cumulative metrics — mount at `GET /metrics` |
246
328
  | `ConversationStore` | `get(id)` / `save(id, messages)` — implement for file/db; default `InMemoryConversationStore` |
247
329
  | `tk.tools()` / `tk.get(name)` | the uniform tools |
248
330
  | `tk.register(...tools)` | add native/http/custom tools at runtime |
@@ -250,7 +332,7 @@ a sixth.
250
332
  | `tk.skillsPrompt()` | system-prompt skill catalog |
251
333
  | `tk.mcpStatus()` | per-server connection status |
252
334
  | `tk.toOpenAI()` / `toAnthropic()` / `toGemini()` | provider tool schemas |
253
- | `tk.serve(addr, { client, a2a?, onTask? })` | serve the toolkit as an A2A agent → `ServeHandle` |
335
+ | `tk.serve(addr, { client?, a2a?, onTask?, mcp?, onCall? })` | serve the toolkit over HTTP — A2A agent and/or streamable-HTTP MCP server (`/mcp`) → `ServeHandle` |
254
336
  | `tk.addAgent(cardUrl \| Agent)` | fetch a remote agent's card and register its skills as tools |
255
337
  | `tk.close()` | disconnect MCP servers |
256
338
  | `defineTool` / `httpTool` / `agent` | build native / HTTP / A2A tools |
package/dist/client.d.ts CHANGED
@@ -24,7 +24,37 @@ export interface ClientOptions {
24
24
  /** Conversation provider for `ask(prompt, { id })`. Default: in-memory (process lifetime).
25
25
  * Supply a file/db store to persist conversations across processes. */
26
26
  store?: ConversationStore;
27
+ /** Observability sink — receives semantic metric events ({event:"llm"|"tool"|"run"…}) as the
28
+ * loop runs (§8 Observability). Forward to statsd/logs/OTel/anything. No cost when unset. */
29
+ onMetric?: (ev: MetricEvent) => void;
27
30
  }
31
+ /**
32
+ * Semantic observability events (§8). NOT counter/histogram primitives — readable records the
33
+ * host can forward anywhere. The same events also feed the built-in Prometheus registry
34
+ * (`client.metrics()`).
35
+ */
36
+ export type MetricEvent = {
37
+ event: "llm";
38
+ model: string;
39
+ status: "ok" | "error";
40
+ ms: number;
41
+ promptTokens: number;
42
+ completionTokens: number;
43
+ } | {
44
+ event: "tool";
45
+ tool: string;
46
+ source: string;
47
+ isError: boolean;
48
+ ms: number;
49
+ } | {
50
+ event: "run";
51
+ model: string;
52
+ turns: number;
53
+ toolCalls: number;
54
+ totalTokens: number;
55
+ ms: number;
56
+ error?: string;
57
+ };
28
58
  /**
29
59
  * Where `ask()` conversations are remembered — two methods. Ship the in-memory
30
60
  * default; implement this for a file/db/redis provider to persist across processes.
@@ -145,7 +175,17 @@ export declare class Client {
145
175
  private readonly opts;
146
176
  /** Conversation provider for `ask()` — from opts.store, else in-memory. */
147
177
  private readonly store;
178
+ /** Cumulative Prometheus registry fed by the same events `onMetric` sees. */
179
+ private readonly registry;
148
180
  constructor(opts: ClientOptions);
181
+ /** Prometheus text exposition of cumulative metrics (§8). Always valid, empty-but-valid before activity. */
182
+ metrics(): string;
183
+ /** Feed a semantic metric event to the built-in registry and the optional onMetric sink. */
184
+ private emit;
185
+ /** Emit the terminal `run` metric event and build the RunResult. */
186
+ private endRun;
187
+ /** Emit a `run` error metric event (once, on a thrown run). */
188
+ private emitRunError;
149
189
  run(prompt: string, ctx: {
150
190
  toolkit: Toolkit;
151
191
  signal?: AbortSignal;
@@ -160,6 +200,7 @@ export declare class Client {
160
200
  ask(prompt: string, ctx: {
161
201
  toolkit: Toolkit;
162
202
  id?: string;
203
+ on_text?: (delta: string) => void;
163
204
  signal?: AbortSignal;
164
205
  }): Promise<RunResult>;
165
206
  /** A stateful multi-turn conversation that retains history across sends. */
@@ -167,9 +208,14 @@ export declare class Client {
167
208
  toolkit: Toolkit;
168
209
  signal?: AbortSignal;
169
210
  }): Conversation;
170
- /** Streaming variant: async-iterate live events (text deltas, tool calls/results, usage, done). */
211
+ /**
212
+ * Streaming variant: async-iterate live events (text deltas, tool calls/results, usage, done).
213
+ * With an `id`, it is stateful (like `ask`): the thread's transcript is loaded as history before
214
+ * streaming, and saved back to the ConversationStore on the terminal `done` event. No `id` ⇒ stateless.
215
+ */
171
216
  stream(prompt: string, ctx: {
172
217
  toolkit: Toolkit;
218
+ id?: string;
173
219
  signal?: AbortSignal;
174
220
  }): AsyncGenerator<StreamEvent, void, unknown>;
175
221
  private system;
@@ -179,6 +225,8 @@ export declare class Client {
179
225
  private llmFetch;
180
226
  /** Run one tool through beforeTool/afterTool hooks (mutate args, short-circuit, transform result). */
181
227
  private runTool;
228
+ /** One non-streaming LLM call, with an `llm` metric event (ok/error + per-call tokens + ms). */
229
+ private llmCallJson;
182
230
  private runOpenAI;
183
231
  private result;
184
232
  private runAnthropic;