toolnexus 0.2.1 → 0.3.1

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
@@ -7,9 +7,9 @@
7
7
  and you have a working agent — MCP servers, agent skills, your own functions, and HTTP endpoints
8
8
  unified as one tool set, driving any LLM.
9
9
 
10
- > **Right-sized.** Not a framework (no builders, advisors, runnables, config), not a toy that
11
- > falls over the moment you need streaming or a retry. Everything a real agent needs — the loop,
12
- > hooks, streaming, retries, memory — and nothing it doesn't.
10
+ > **Right-sized.** Not a framework (no builders, advisors, runnables, config graphs), not a toy
11
+ > that falls over the moment you need streaming or a retry. Everything a real agent needs — the
12
+ > loop, hooks, streaming, retries, memory — and nothing it doesn't.
13
13
 
14
14
  The JS/TypeScript port of [toolnexus](https://github.com/muthuishere/toolnexus) — the same
15
15
  library, byte-identical, also in **Python, Go, Java, and C#**. Built on
@@ -21,30 +21,89 @@ library, byte-identical, also in **Python, Go, Java, and C#**. Built on
21
21
  npm install toolnexus
22
22
  ```
23
23
 
24
- ## An agent in 3 steps
24
+ ## Quick start
25
+
26
+ Built-in tools are on by default, so an empty toolkit can already act:
25
27
 
26
28
  ```ts
27
29
  import { createToolkit, createClient } from "toolnexus"
28
30
 
29
- // 1. tools from an mcp.json + a skills/ folder
30
- const tk = await createToolkit({ mcpConfig: "./mcp.json", skillsDir: "./skills" })
31
-
32
- // 2. point at any OpenAI- or Anthropic-style endpoint
31
+ const tk = await createToolkit() // 10 built-in tools, on by default
33
32
  const agent = createClient({
34
- baseUrl: "https://openrouter.ai/api/v1",
35
- style: "openai", // or "anthropic"
33
+ baseUrl: "https://openrouter.ai/api/v1", // any OpenAI- or Anthropic-style endpoint
34
+ style: "openai", // or "anthropic"
36
35
  model: "openai/gpt-4o-mini",
37
36
  })
38
37
 
39
- // 3. run — skills are injected into the system prompt, tools are called for you, looped to an answer
40
- const { text } = await agent.run("Refund order 1234 for the customer.", { toolkit: tk })
38
+ const { text } = await agent.run("What files are in this folder?", { toolkit: tk })
41
39
  console.log(text)
42
- await tk.close()
43
40
  ```
44
41
 
45
42
  `createClient` reads the key from `OPENROUTER_API_KEY` / `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`
46
- by default. The loop runs call → execute tools → feed back → repeat (up to `maxTurns`, default 10),
47
- with hooks, streaming (`agent.stream(...)`), retries/backoff, and conversation memory available.
43
+ (or pass `apiKey`). The loop runs call → execute tools → feed back → repeat (up to `maxTurns`,
44
+ default 10), with hooks, streaming (`agent.stream(...)`), retries/backoff, timeout, and
45
+ conversation memory available.
46
+
47
+ ## With MCP + skills
48
+
49
+ Point the toolkit at an `mcp.json` and a `skills/` folder — every MCP server tool and every
50
+ `SKILL.md` becomes part of the same tool set. Skills are injected into the system prompt; a
51
+ single `skill` tool loads each on demand (progressive disclosure).
52
+
53
+ ```ts
54
+ const tk = await createToolkit({
55
+ mcpConfig: "./mcp.json", // path or a config object; local stdio + remote streamable-HTTP
56
+ skillsDir: "./skills", // a folder of <name>/SKILL.md (string or string[])
57
+ })
58
+
59
+ const { text } = await agent.run("Refund order 1234 for the customer.", { toolkit: tk })
60
+ console.log(text)
61
+ await tk.close() // disconnect MCP servers
62
+ ```
63
+
64
+ ## Conversations / memory
65
+
66
+ `run()` is stateless — each call starts fresh. To keep a thread's history, use `ask()` with an
67
+ `id`, or a `conversation()` object.
68
+
69
+ **`ask(prompt, { toolkit, id? })`** — with an `id`, the client's `ConversationStore` remembers
70
+ the thread: it *loads* that id's transcript, *runs*, *saves* the updated transcript, and returns
71
+ the answer. The next `ask` with the same `id` continues it. Without an `id` it's a stateless
72
+ one-shot (identical to `run`).
73
+
74
+ ```ts
75
+ const agent = createClient({
76
+ baseUrl: "https://openrouter.ai/api/v1",
77
+ style: "openai",
78
+ model: "openai/gpt-4o-mini",
79
+ // store defaults to InMemoryConversationStore (process lifetime).
80
+ })
81
+
82
+ await agent.ask("My name is Muthu.", { toolkit: tk, id: "user-42" })
83
+ const { text } = await agent.ask("What's my name?", { toolkit: tk, id: "user-42" })
84
+ console.log(text) // → "Your name is Muthu."
85
+ ```
86
+
87
+ **Persist across processes.** A `ConversationStore` is two methods — implement it for a file, DB,
88
+ or Redis and pass it as `store`:
89
+
90
+ ```ts
91
+ import type { ConversationStore } from "toolnexus"
92
+
93
+ interface ConversationStore {
94
+ get(id: string): Promise<any[] | undefined> // stored transcript for id, or undefined
95
+ save(id: string, messages: any[]): Promise<void>
96
+ }
97
+
98
+ const agent = createClient({ baseUrl, style: "openai", model, store: myFileStore })
99
+ ```
100
+
101
+ The default is `InMemoryConversationStore` (also exported). For a quick in-process thread without
102
+ ids, `agent.conversation({ toolkit })` returns an object whose `.send(prompt)` retains history
103
+ automatically (`.messages`, `.reset()`).
104
+
105
+ The same `store` powers inbound A2A: `toolkit.serve` keys each peer's turns by the A2A
106
+ `contextId` through `client.ask`, so a remote agent's conversation is remembered too (see below).
48
107
 
49
108
  ## Add your own tools
50
109
 
@@ -52,64 +111,53 @@ with hooks, streaming (`agent.stream(...)`), retries/backoff, and conversation m
52
111
  import { defineTool, httpTool } from "toolnexus"
53
112
 
54
113
  tk.register(
55
- // a plain function → a tool
114
+ // a plain function → a tool (run returns a string or a full ToolResult)
56
115
  defineTool({
57
- name: "add", description: "Add two numbers",
58
- inputSchema: { type: "object", properties: { a: { type: "number" }, b: { type: "number" } }, required: ["a", "b"] },
59
- run: ({ a, b }) => `${(a as number) + (b as number)}`,
116
+ name: "add",
117
+ description: "Add two numbers",
118
+ inputSchema: {
119
+ type: "object",
120
+ properties: { a: { type: "number" }, b: { type: "number" } },
121
+ required: ["a", "b"],
122
+ },
123
+ run: (args) => `${(args.a as number) + (args.b as number)}`,
60
124
  }),
61
125
  // a REST endpoint → a tool
62
126
  httpTool({
63
- name: "create_ticket", description: "Create a ticket", method: "POST",
127
+ name: "create_ticket",
128
+ description: "Create a ticket",
129
+ method: "POST",
64
130
  url: "https://api.example.com/tickets",
65
131
  headers: { Authorization: "Bearer ${API_TOKEN}" }, // ${ENV} expands from process.env, never logged
66
- inputSchema: { type: "object", properties: { title: { type: "string" } }, required: ["title"] },
132
+ inputSchema: {
133
+ type: "object",
134
+ properties: { title: { type: "string" } },
135
+ required: ["title"],
136
+ },
67
137
  }),
68
138
  )
69
139
  ```
70
140
 
71
- URL `{placeholders}` are filled from args; `query` args go to the querystring; the rest become
72
- the JSON body. Non-2xx → `{ isError: true, output: "HTTP <status>: <body>" }`.
73
-
74
- ## Bring your own loop
75
-
76
- Don't want the host loop? Use the schema adapters and execute calls yourself:
77
-
78
- ```ts
79
- const tools = tk.toOpenAI() // or tk.toAnthropic() / tk.toGemini()
80
- const system = tk.skillsPrompt() // skills catalog for your system prompt (opens with a preamble telling the model to use the skill tool)
81
- // when the model returns a tool call { name, arguments }:
82
- const res = await tk.execute(name, args) // -> { output, isError, metadata }
83
- ```
84
-
85
- ## The four sources
86
-
87
- | Source | How |
88
- |--------|-----|
89
- | **MCP servers** | an `mcp.json` (`mcpServers`/`servers`/`mcp`); local stdio + remote streamable-HTTP, `headers` for auth |
90
- | **Agent skills** | a folder of `<name>/SKILL.md`; a `skill` tool loads each on demand + a system-prompt catalog |
91
- | **Native tools** | `defineTool({...})` — a function becomes a tool |
92
- | **HTTP / REST** | `httpTool({...})` — an endpoint becomes a tool, `${ENV}` headers |
93
-
94
- All four appear as one uniform `Tool` in `tk.tools()`. Each is à la carte — use just `loadMcp(config)`
95
- or `loadSkills(dir)` if that's all you need.
141
+ For `httpTool`: URL `{placeholders}` are filled from args; `query` arg names go to the
142
+ querystring (all args on GET); the rest become the JSON body (`body: "json" | "form" | "raw"`).
143
+ Non-2xx → `{ isError: true, output: "HTTP <status>: <body>" }`.
96
144
 
97
145
  ## Built-in tools
98
146
 
99
147
  A fifth source ships **10 built-in tools** — `bash`, `read`, `write`, `edit`, `grep`, `glob`,
100
- `webfetch`, `question`, `apply_patch`, `todowrite` (names + input schemas match
101
- opencode) — so an agent can act with zero wiring. They appear in the tool schema
148
+ `webfetch`, `question`, `apply_patch`, `todowrite` (names + input schemas match opencode) — so an
149
+ agent can act with zero wiring. They appear in the tool schema
102
150
  (`toOpenAI()`/`toAnthropic()`/`toGemini()`), like MCP tools — not the system prompt.
103
151
 
104
152
  **On by default.** One global toggle turns the whole source off, or a per-tool `tools` map
105
153
  disables individual builtins on the all-on baseline:
106
154
 
107
155
  ```ts
108
- const tk = await createToolkit({ mcpConfig: "./mcp.json", builtins: false })
156
+ const tk = await createToolkit({ builtins: false })
109
157
  // also accepts { disabled: true } or { enabled: false }
110
158
 
111
159
  // per-tool: drop bash, keep the other nine (unknown names ignored; whole-source-off still wins)
112
- const tk2 = await createToolkit({ mcpConfig: "./mcp.json", builtins: { tools: { bash: false } } })
160
+ const tk2 = await createToolkit({ builtins: { tools: { bash: false } } })
113
161
  ```
114
162
 
115
163
  `bash`/`write`/`edit`/`apply_patch` run commands and mutate the filesystem — the toggle is the
@@ -136,7 +184,7 @@ await tk.addAgent("https://writer.example.com/.well-known/agent-card.json")
136
184
  ```
137
185
 
138
186
  `agent({ card, headers?, timeout?, pollEvery? })` — `headers` support `${ENV}` expansion (never
139
- logged); `timeout` defaults to 300000ms, `pollEvery` to 1000ms. A config file can also carry an
187
+ logged); `timeout` defaults to 300000ms, `pollEvery` to 1000ms. A config object can also carry an
140
188
  `agents` block (mirrors `mcpServers`). A failing agent is isolated — logged, contributes no tools,
141
189
  never fatal.
142
190
 
@@ -144,12 +192,8 @@ never fatal.
144
192
  built from your **SKILL.md skills** (never raw tools):
145
193
 
146
194
  ```ts
147
- import { createClient } from "toolnexus"
148
-
149
- const agent = createClient({ baseUrl: "https://openrouter.ai/api/v1", style: "openai", model: "openai/gpt-4o-mini" })
150
-
151
195
  const handle = await tk.serve("127.0.0.1:0", {
152
- client: agent,
196
+ client: agent, // a createClient(...) instance fulfils each task
153
197
  a2a: {
154
198
  name: "research-agent",
155
199
  description: "Answers research questions.",
@@ -161,26 +205,61 @@ console.log(handle.url) // GET /.well-known/agent-card.json ; POST / (SendMess
161
205
  await handle.stop()
162
206
  ```
163
207
 
164
- `serve(addr, { client, a2a?, onTask? })` fulfils each inbound `SendMessage` task via `client.run`.
165
- Task persistence is a pluggable `TaskStore` — in-memory default, `"file:<dir>"`, or your own.
208
+ `serve(addr, { client, a2a?, onTask? })` fulfils each inbound `SendMessage` task via the client.
209
+ Task persistence is a pluggable `TaskStore` — in-memory default, `"file:<dir>"`, or your own. And
210
+ it **remembers**: a message's A2A `contextId` keys the conversation through `client.ask`, so a
211
+ peer's turns in the same context carry history via the client's `ConversationStore`.
212
+
213
+ ## Bring your own loop
214
+
215
+ Don't want the host loop? Use the schema adapters and execute calls yourself:
216
+
217
+ ```ts
218
+ const tools = tk.toOpenAI() // or tk.toAnthropic() / tk.toGemini()
219
+ const system = tk.skillsPrompt() // skills catalog for your system prompt
220
+ // when the model returns a tool call { name, arguments }:
221
+ const res = await tk.execute(name, args) // → { output, isError, metadata }
222
+ ```
223
+
224
+ ## The four sources
225
+
226
+ | Source | How |
227
+ |--------|-----|
228
+ | **MCP servers** | an `mcp.json` (`mcpServers`/`servers`/`mcp`); local stdio + remote streamable-HTTP, `headers` for auth |
229
+ | **Agent skills** | a folder of `<name>/SKILL.md`; a `skill` tool loads each on demand + a system-prompt catalog |
230
+ | **Native tools** | `defineTool({...})` — a function becomes a tool |
231
+ | **HTTP / REST** | `httpTool({...})` — an endpoint becomes a tool, `${ENV}` headers |
232
+
233
+ All appear as one uniform `Tool` in `tk.tools()`. Built-ins are a fifth source; remote A2A agents
234
+ a sixth.
166
235
 
167
236
  ## API
168
237
 
169
238
  | Member | Description |
170
239
  |--------|-------------|
171
- | `createToolkit(opts)` | async factory → `Toolkit` |
172
- | `createClient(opts)` | the unified host loop (`run` / `stream`) |
240
+ | `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?`) |
242
+ | `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` |
244
+ | `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) |
246
+ | `ConversationStore` | `get(id)` / `save(id, messages)` — implement for file/db; default `InMemoryConversationStore` |
173
247
  | `tk.tools()` / `tk.get(name)` | the uniform tools |
248
+ | `tk.register(...tools)` | add native/http/custom tools at runtime |
174
249
  | `tk.execute(name, args, ctx?)` | run a tool → `ToolResult` |
175
250
  | `tk.skillsPrompt()` | system-prompt skill catalog |
176
251
  | `tk.mcpStatus()` | per-server connection status |
177
252
  | `tk.toOpenAI()` / `toAnthropic()` / `toGemini()` | provider tool schemas |
178
- | `tk.register(...tools)` | add native/http/custom tools |
253
+ | `tk.serve(addr, { client, a2a?, onTask? })` | serve the toolkit as an A2A agent → `ServeHandle` |
254
+ | `tk.addAgent(cardUrl \| Agent)` | fetch a remote agent's card and register its skills as tools |
179
255
  | `tk.close()` | disconnect MCP servers |
256
+ | `defineTool` / `httpTool` / `agent` | build native / HTTP / A2A tools |
257
+ | `InMemoryConversationStore` · `InMemoryTaskStore` / `FileTaskStore` | default stores (memory / A2A tasks) |
180
258
 
181
259
  ## More
182
260
 
183
- Full docs, the other four language ports, the shared behavior spec, and runnable examples:
184
- **https://github.com/muthuishere/toolnexus**
261
+ Full docs, the other four language ports, and the shared cross-language behavior contract
262
+ ([`SPEC.md`](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md)) — plus runnable
263
+ `examples/`: **https://github.com/muthuishere/toolnexus**
185
264
 
186
265
  MIT licensed.
package/dist/a2a.d.ts CHANGED
@@ -22,6 +22,8 @@ export interface A2ATaskStatus {
22
22
  }
23
23
  export interface A2ATask {
24
24
  id: string;
25
+ /** Groups a peer's turns into one conversation (A2A contextId). */
26
+ contextId?: string;
25
27
  status: A2ATaskStatus;
26
28
  artifacts?: A2AArtifact[];
27
29
  history?: A2AMessage[];
package/dist/client.d.ts CHANGED
@@ -21,6 +21,25 @@ export interface ClientOptions {
21
21
  retryBaseMs?: number;
22
22
  /** Whole-run deadline in ms; aborts the run (and its in-flight request) when exceeded. */
23
23
  timeoutMs?: number;
24
+ /** Conversation provider for `ask(prompt, { id })`. Default: in-memory (process lifetime).
25
+ * Supply a file/db store to persist conversations across processes. */
26
+ store?: ConversationStore;
27
+ }
28
+ /**
29
+ * Where `ask()` conversations are remembered — two methods. Ship the in-memory
30
+ * default; implement this for a file/db/redis provider to persist across processes.
31
+ */
32
+ export interface ConversationStore {
33
+ /** Return the stored transcript for `id`, or undefined if none. */
34
+ get(id: string): Promise<any[] | undefined>;
35
+ /** Persist the (updated) transcript for `id`. */
36
+ save(id: string, messages: any[]): Promise<void>;
37
+ }
38
+ /** Default conversation provider — keeps transcripts in memory for the client's lifetime. */
39
+ export declare class InMemoryConversationStore implements ConversationStore {
40
+ private readonly map;
41
+ get(id: string): Promise<any[] | undefined>;
42
+ save(id: string, messages: any[]): Promise<void>;
24
43
  }
25
44
  /**
26
45
  * Lifecycle hooks around the agent loop. Each may observe, and (where noted)
@@ -124,12 +143,25 @@ export type StreamEvent = {
124
143
  };
125
144
  export declare class Client {
126
145
  private readonly opts;
146
+ /** Conversation provider for `ask()` — from opts.store, else in-memory. */
147
+ private readonly store;
127
148
  constructor(opts: ClientOptions);
128
149
  run(prompt: string, ctx: {
129
150
  toolkit: Toolkit;
130
151
  signal?: AbortSignal;
131
152
  history?: any[];
132
153
  }): Promise<RunResult>;
154
+ /**
155
+ * Stateful ask. With an `id`, the client's `store` remembers the conversation:
156
+ * it loads that id's transcript, runs, saves the updated transcript, and returns
157
+ * the answer — so the next `ask` with the same `id` continues it. Without an
158
+ * `id` it is a stateless one-shot (identical to `run`).
159
+ */
160
+ ask(prompt: string, ctx: {
161
+ toolkit: Toolkit;
162
+ id?: string;
163
+ signal?: AbortSignal;
164
+ }): Promise<RunResult>;
133
165
  /** A stateful multi-turn conversation that retains history across sends. */
134
166
  conversation(ctx: {
135
167
  toolkit: Toolkit;
package/dist/client.js CHANGED
@@ -1,3 +1,14 @@
1
+ /** Default conversation provider — keeps transcripts in memory for the client's lifetime. */
2
+ export class InMemoryConversationStore {
3
+ map = new Map();
4
+ async get(id) {
5
+ const m = this.map.get(id);
6
+ return m ? [...m] : undefined;
7
+ }
8
+ async save(id, messages) {
9
+ this.map.set(id, [...messages]);
10
+ }
11
+ }
1
12
  const RETRYABLE = new Set([429, 500, 502, 503, 504]);
2
13
  function delay(ms, signal) {
3
14
  return new Promise((resolve, reject) => {
@@ -33,14 +44,31 @@ function resolveKey(opts) {
33
44
  }
34
45
  export class Client {
35
46
  opts;
47
+ /** Conversation provider for `ask()` — from opts.store, else in-memory. */
48
+ store;
36
49
  constructor(opts) {
37
50
  this.opts = opts;
51
+ this.store = opts.store ?? new InMemoryConversationStore();
38
52
  }
39
53
  async run(prompt, ctx) {
40
54
  return this.opts.style === "anthropic"
41
55
  ? this.runAnthropic(prompt, ctx.toolkit, ctx.signal, ctx.history)
42
56
  : this.runOpenAI(prompt, ctx.toolkit, ctx.signal, ctx.history);
43
57
  }
58
+ /**
59
+ * Stateful ask. With an `id`, the client's `store` remembers the conversation:
60
+ * it loads that id's transcript, runs, saves the updated transcript, and returns
61
+ * the answer — so the next `ask` with the same `id` continues it. Without an
62
+ * `id` it is a stateless one-shot (identical to `run`).
63
+ */
64
+ async ask(prompt, ctx) {
65
+ if (!ctx.id)
66
+ return this.run(prompt, { toolkit: ctx.toolkit, signal: ctx.signal });
67
+ const history = (await this.store.get(ctx.id)) ?? [];
68
+ const result = await this.run(prompt, { toolkit: ctx.toolkit, signal: ctx.signal, history });
69
+ await this.store.save(ctx.id, result.messages);
70
+ return result;
71
+ }
44
72
  /** A stateful multi-turn conversation that retains history across sends. */
45
73
  conversation(ctx) {
46
74
  return new Conversation(this, ctx.toolkit, ctx.signal);
package/dist/serve.d.ts CHANGED
@@ -67,8 +67,9 @@ export interface StartServerOptions {
67
67
  a2a?: A2AConfig;
68
68
  /** The toolkit's skills, for the card. */
69
69
  skills: SkillInfo[];
70
- /** Runs one served Task through the client loop. */
71
- runTask: (text: string) => Promise<RunResult>;
70
+ /** Runs one served Task through the client loop. `contextId` (from the A2A
71
+ * message) keys the conversation so served turns are remembered via the store. */
72
+ runTask: (text: string, contextId?: string) => Promise<RunResult>;
72
73
  onTask?: OnTask;
73
74
  }
74
75
  /**
package/dist/serve.js CHANGED
@@ -149,11 +149,12 @@ export async function startA2AServer(opts) {
149
149
  }
150
150
  if (rpc?.method === "SendMessage") {
151
151
  const text = messageText(rpc.params);
152
+ const contextId = typeof rpc.params?.message?.contextId === "string" ? rpc.params.message.contextId : undefined;
152
153
  const id = randomUUID();
153
- const submitted = { id, status: { state: "submitted" } };
154
+ const submitted = { id, contextId, status: { state: "submitted" } };
154
155
  // Persist submitted, kick fulfilment async, return the id immediately.
155
156
  store.save(submitted).then(() => {
156
- void fulfil(id, text, store, runTask, onTask);
157
+ void fulfil(id, text, store, runTask, onTask, contextId);
157
158
  rpcOk(rpc.id, submitted);
158
159
  });
159
160
  return;
@@ -189,13 +190,15 @@ export async function startA2AServer(opts) {
189
190
  * Background fulfilment: submitted → working → (completed | failed). Never
190
191
  * throws — a fulfilment error becomes a `failed` Task, keeping the server alive.
191
192
  */
192
- async function fulfil(id, text, store, runTask, onTask) {
193
+ async function fulfil(id, text, store, runTask, onTask, contextId) {
193
194
  let task;
194
195
  let result;
195
196
  let state;
196
197
  try {
197
198
  await store.save({ id, status: { state: "working" } });
198
- result = await runTask(text);
199
+ // contextId groups a peer's turns into one conversation — thread it so the
200
+ // client's ConversationStore remembers across tasks in the same context.
201
+ result = await runTask(text, contextId);
199
202
  const artifact = {
200
203
  artifactId: randomUUID(),
201
204
  parts: [{ kind: "text", text: result.text }],
package/dist/toolkit.d.ts CHANGED
@@ -37,7 +37,9 @@ export declare class Toolkit {
37
37
  * Serve this toolkit as an agent over HTTP. When the `a2a` profile is present
38
38
  * (inline, or a top-level `a2a` config block the toolkit was built from), it
39
39
  * mounts the A2A Agent Card (`/.well-known/agent-card.json`, built from skills)
40
- * and a JSON-RPC endpoint (`SendMessage` + `GetTask`) fulfilled by `client.run`.
40
+ * and a JSON-RPC endpoint (`SendMessage` + `GetTask`) fulfilled by the client.
41
+ * A message's A2A `contextId` keys the conversation via `client.ask`, so a
42
+ * peer's turns are remembered through the client's ConversationStore.
41
43
  * When `a2a` is absent, no A2A routes are mounted. Returns a stoppable handle.
42
44
  */
43
45
  serve(addr: string, opts: {
package/dist/toolkit.js CHANGED
@@ -92,7 +92,9 @@ export class Toolkit {
92
92
  * Serve this toolkit as an agent over HTTP. When the `a2a` profile is present
93
93
  * (inline, or a top-level `a2a` config block the toolkit was built from), it
94
94
  * mounts the A2A Agent Card (`/.well-known/agent-card.json`, built from skills)
95
- * and a JSON-RPC endpoint (`SendMessage` + `GetTask`) fulfilled by `client.run`.
95
+ * and a JSON-RPC endpoint (`SendMessage` + `GetTask`) fulfilled by the client.
96
+ * A message's A2A `contextId` keys the conversation via `client.ask`, so a
97
+ * peer's turns are remembered through the client's ConversationStore.
96
98
  * When `a2a` is absent, no A2A routes are mounted. Returns a stoppable handle.
97
99
  */
98
100
  serve(addr, opts) {
@@ -102,7 +104,9 @@ export class Toolkit {
102
104
  addr,
103
105
  a2a,
104
106
  skills,
105
- runTask: (text) => opts.client.run(text, { toolkit: this }),
107
+ runTask: (text, contextId) => contextId
108
+ ? opts.client.ask(text, { toolkit: this, id: contextId })
109
+ : opts.client.run(text, { toolkit: this }),
106
110
  onTask: opts.onTask,
107
111
  });
108
112
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolnexus",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Dynamic MCP servers + agent skills as uniform tools for any LLM (opencode-style).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/a2a.ts CHANGED
@@ -54,6 +54,8 @@ export interface A2ATaskStatus {
54
54
 
55
55
  export interface A2ATask {
56
56
  id: string
57
+ /** Groups a peer's turns into one conversation (A2A contextId). */
58
+ contextId?: string
57
59
  status: A2ATaskStatus
58
60
  artifacts?: A2AArtifact[]
59
61
  history?: A2AMessage[]
package/src/client.ts CHANGED
@@ -23,6 +23,32 @@ export interface ClientOptions {
23
23
  retryBaseMs?: number
24
24
  /** Whole-run deadline in ms; aborts the run (and its in-flight request) when exceeded. */
25
25
  timeoutMs?: number
26
+ /** Conversation provider for `ask(prompt, { id })`. Default: in-memory (process lifetime).
27
+ * Supply a file/db store to persist conversations across processes. */
28
+ store?: ConversationStore
29
+ }
30
+
31
+ /**
32
+ * Where `ask()` conversations are remembered — two methods. Ship the in-memory
33
+ * default; implement this for a file/db/redis provider to persist across processes.
34
+ */
35
+ export interface ConversationStore {
36
+ /** Return the stored transcript for `id`, or undefined if none. */
37
+ get(id: string): Promise<any[] | undefined>
38
+ /** Persist the (updated) transcript for `id`. */
39
+ save(id: string, messages: any[]): Promise<void>
40
+ }
41
+
42
+ /** Default conversation provider — keeps transcripts in memory for the client's lifetime. */
43
+ export class InMemoryConversationStore implements ConversationStore {
44
+ private readonly map = new Map<string, any[]>()
45
+ async get(id: string): Promise<any[] | undefined> {
46
+ const m = this.map.get(id)
47
+ return m ? [...m] : undefined
48
+ }
49
+ async save(id: string, messages: any[]): Promise<void> {
50
+ this.map.set(id, [...messages])
51
+ }
26
52
  }
27
53
 
28
54
  const RETRYABLE = new Set([429, 500, 502, 503, 504])
@@ -114,7 +140,11 @@ function resolveKey(opts: ClientOptions): string {
114
140
  }
115
141
 
116
142
  export class Client {
117
- constructor(private readonly opts: ClientOptions) {}
143
+ /** Conversation provider for `ask()` — from opts.store, else in-memory. */
144
+ private readonly store: ConversationStore
145
+ constructor(private readonly opts: ClientOptions) {
146
+ this.store = opts.store ?? new InMemoryConversationStore()
147
+ }
118
148
 
119
149
  async run(prompt: string, ctx: { toolkit: Toolkit; signal?: AbortSignal; history?: any[] }): Promise<RunResult> {
120
150
  return this.opts.style === "anthropic"
@@ -122,6 +152,20 @@ export class Client {
122
152
  : this.runOpenAI(prompt, ctx.toolkit, ctx.signal, ctx.history)
123
153
  }
124
154
 
155
+ /**
156
+ * Stateful ask. With an `id`, the client's `store` remembers the conversation:
157
+ * it loads that id's transcript, runs, saves the updated transcript, and returns
158
+ * the answer — so the next `ask` with the same `id` continues it. Without an
159
+ * `id` it is a stateless one-shot (identical to `run`).
160
+ */
161
+ async ask(prompt: string, ctx: { toolkit: Toolkit; id?: string; signal?: AbortSignal }): Promise<RunResult> {
162
+ if (!ctx.id) return this.run(prompt, { toolkit: ctx.toolkit, signal: ctx.signal })
163
+ const history = (await this.store.get(ctx.id)) ?? []
164
+ const result = await this.run(prompt, { toolkit: ctx.toolkit, signal: ctx.signal, history })
165
+ await this.store.save(ctx.id, result.messages)
166
+ return result
167
+ }
168
+
125
169
  /** A stateful multi-turn conversation that retains history across sends. */
126
170
  conversation(ctx: { toolkit: Toolkit; signal?: AbortSignal }): Conversation {
127
171
  return new Conversation(this, ctx.toolkit, ctx.signal)
package/src/serve.ts CHANGED
@@ -158,8 +158,9 @@ export interface StartServerOptions {
158
158
  a2a?: A2AConfig
159
159
  /** The toolkit's skills, for the card. */
160
160
  skills: SkillInfo[]
161
- /** Runs one served Task through the client loop. */
162
- runTask: (text: string) => Promise<RunResult>
161
+ /** Runs one served Task through the client loop. `contextId` (from the A2A
162
+ * message) keys the conversation so served turns are remembered via the store. */
163
+ runTask: (text: string, contextId?: string) => Promise<RunResult>
163
164
  onTask?: OnTask
164
165
  }
165
166
 
@@ -222,11 +223,12 @@ export async function startA2AServer(opts: StartServerOptions): Promise<ServeHan
222
223
  }
223
224
  if (rpc?.method === "SendMessage") {
224
225
  const text = messageText(rpc.params)
226
+ const contextId = typeof rpc.params?.message?.contextId === "string" ? rpc.params.message.contextId : undefined
225
227
  const id = randomUUID()
226
- const submitted: A2ATask = { id, status: { state: "submitted" } }
228
+ const submitted: A2ATask = { id, contextId, status: { state: "submitted" } }
227
229
  // Persist submitted, kick fulfilment async, return the id immediately.
228
230
  store.save(submitted).then(() => {
229
- void fulfil(id, text, store, runTask, onTask)
231
+ void fulfil(id, text, store, runTask, onTask, contextId)
230
232
  rpcOk(rpc.id, submitted)
231
233
  })
232
234
  return
@@ -269,15 +271,18 @@ async function fulfil(
269
271
  id: string,
270
272
  text: string,
271
273
  store: TaskStore,
272
- runTask: (text: string) => Promise<RunResult>,
274
+ runTask: (text: string, contextId?: string) => Promise<RunResult>,
273
275
  onTask?: OnTask,
276
+ contextId?: string,
274
277
  ): Promise<void> {
275
278
  let task: A2ATask
276
279
  let result: RunResult | undefined
277
280
  let state: A2ATaskState
278
281
  try {
279
282
  await store.save({ id, status: { state: "working" } })
280
- result = await runTask(text)
283
+ // contextId groups a peer's turns into one conversation — thread it so the
284
+ // client's ConversationStore remembers across tasks in the same context.
285
+ result = await runTask(text, contextId)
281
286
  const artifact: A2AArtifact = {
282
287
  artifactId: randomUUID(),
283
288
  parts: [{ kind: "text", text: result.text }],
package/src/toolkit.ts CHANGED
@@ -124,7 +124,9 @@ export class Toolkit {
124
124
  * Serve this toolkit as an agent over HTTP. When the `a2a` profile is present
125
125
  * (inline, or a top-level `a2a` config block the toolkit was built from), it
126
126
  * mounts the A2A Agent Card (`/.well-known/agent-card.json`, built from skills)
127
- * and a JSON-RPC endpoint (`SendMessage` + `GetTask`) fulfilled by `client.run`.
127
+ * and a JSON-RPC endpoint (`SendMessage` + `GetTask`) fulfilled by the client.
128
+ * A message's A2A `contextId` keys the conversation via `client.ask`, so a
129
+ * peer's turns are remembered through the client's ConversationStore.
128
130
  * When `a2a` is absent, no A2A routes are mounted. Returns a stoppable handle.
129
131
  */
130
132
  serve(addr: string, opts: { client: Client; a2a?: A2AConfig; onTask?: OnTask }): Promise<ServeHandle> {
@@ -134,7 +136,10 @@ export class Toolkit {
134
136
  addr,
135
137
  a2a,
136
138
  skills,
137
- runTask: (text) => opts.client.run(text, { toolkit: this }),
139
+ runTask: (text, contextId) =>
140
+ contextId
141
+ ? opts.client.ask(text, { toolkit: this, id: contextId })
142
+ : opts.client.run(text, { toolkit: this }),
138
143
  onTask: opts.onTask,
139
144
  })
140
145
  }