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 +142 -63
- package/dist/a2a.d.ts +2 -0
- package/dist/client.d.ts +32 -0
- package/dist/client.js +28 -0
- package/dist/serve.d.ts +3 -2
- package/dist/serve.js +7 -4
- package/dist/toolkit.d.ts +3 -1
- package/dist/toolkit.js +6 -2
- package/package.json +1 -1
- package/src/a2a.ts +2 -0
- package/src/client.ts +45 -1
- package/src/serve.ts +11 -6
- package/src/toolkit.ts +7 -2
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
|
|
11
|
-
> falls over the moment you need streaming or a retry. Everything a real agent needs — the
|
|
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
|
-
##
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
with hooks, streaming (`agent.stream(...)`), retries/backoff, and
|
|
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",
|
|
58
|
-
|
|
59
|
-
|
|
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",
|
|
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: {
|
|
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`
|
|
72
|
-
the JSON 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
|
-
|
|
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({
|
|
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({
|
|
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
|
|
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
|
|
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 (`
|
|
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.
|
|
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
|
|
184
|
-
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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) =>
|
|
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
package/src/a2a.ts
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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
|
}
|