@camelai/agent-runtime 0.3.0 → 0.4.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 +12 -2
- package/dist/clients/mcp.d.ts +13 -0
- package/dist/clients/mcp.js +38 -0
- package/dist/clients/typescript.d.ts +51 -9
- package/dist/clients/typescript.js +120 -82
- package/dist/shared/client-protocol.d.ts +6 -23
- package/package.json +14 -2
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ Upgrade with `npm update @camelai/agent-runtime`.
|
|
|
18
18
|
|
|
19
19
|
The SDK has one runtime dependency (`typebox`). Its message and model types come
|
|
20
20
|
from Pi; for full typing of history and events, also install
|
|
21
|
-
`@earendil-works/pi-agent-core@0.
|
|
21
|
+
`@earendil-works/pi-agent-core@0.87.1` and `@earendil-works/pi-ai@0.87.1` as dev
|
|
22
22
|
dependencies. Without them those types resolve to `any` (with `skipLibCheck`).
|
|
23
23
|
|
|
24
24
|
## Use
|
|
@@ -59,6 +59,16 @@ const agent = await runtime.createAgent({
|
|
|
59
59
|
await agent.prompt("Plan restocks for anything below target.");
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
+
Already have an MCP server? Attach it instead of (or alongside) `tools`: the SDK
|
|
63
|
+
talks to it in memory, and the same server can later run remotely as a definition's
|
|
64
|
+
`mcpServers` entry without changing its tools. Install `@modelcontextprotocol/sdk` too.
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { fromMcpServer } from "@camelai/agent-runtime/mcp";
|
|
68
|
+
|
|
69
|
+
const agent = await runtime.createAgent({ name: "Inventory planner", mcp: await fromMcpServer(server) });
|
|
70
|
+
```
|
|
71
|
+
|
|
62
72
|
Switch models between turns with `await agent.configure({ model: "openai/gpt-5.2" })`;
|
|
63
73
|
the history carries over. Your tenant needs a key for that provider.
|
|
64
74
|
|
|
@@ -80,7 +90,7 @@ The routes are `/v1/me`, `/v1/providers` (+ `/:provider/key`), `/v1/models`,
|
|
|
80
90
|
`/v1/tokens` and `/v1/usage`; see `src/api.ts`.
|
|
81
91
|
|
|
82
92
|
Save `agent.session` (it contains a scoped credential) to reconnect later with
|
|
83
|
-
`runtime.connectAgent(session, { tools })
|
|
93
|
+
`runtime.connectAgent(session, { tools })` (or `{ mcp }`). Pass the same `idempotencyKey` to
|
|
84
94
|
`createAgent` to get the same agent back instead of a new one.
|
|
85
95
|
|
|
86
96
|
`@camelai/agent-runtime` (without `/node`) is the portable build for Workers and
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import type { ToolServer } from "./typescript.ts";
|
|
4
|
+
/**
|
|
5
|
+
* Attach an MCP SDK server (`McpServer` or low-level `Server`) to an agent: pass the result
|
|
6
|
+
* as `mcp` to `createAgent`, `connectAgent` or `configure`. The SDK talks to the server in
|
|
7
|
+
* memory, so the same server can later run remotely, as a definition's `mcpServers` entry,
|
|
8
|
+
* without changing its tools. `callId`, `toolCallId` and `origin` reach its handlers as
|
|
9
|
+
* `_meta["agent-runtime/…"]`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function fromMcpServer(server: McpServer | Server): Promise<ToolServer & {
|
|
12
|
+
close(): Promise<void>;
|
|
13
|
+
}>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
3
|
+
/** Tool names the runtime accepts; others are renamed, and calls map back. */
|
|
4
|
+
const safeName = (name) => (/^[A-Za-z]/.test(name) ? name : `t_${name}`).replace(/[^A-Za-z0-9_]/g, "_").slice(0, 80);
|
|
5
|
+
/**
|
|
6
|
+
* Attach an MCP SDK server (`McpServer` or low-level `Server`) to an agent: pass the result
|
|
7
|
+
* as `mcp` to `createAgent`, `connectAgent` or `configure`. The SDK talks to the server in
|
|
8
|
+
* memory, so the same server can later run remotely, as a definition's `mcpServers` entry,
|
|
9
|
+
* without changing its tools. `callId`, `toolCallId` and `origin` reach its handlers as
|
|
10
|
+
* `_meta["agent-runtime/…"]`.
|
|
11
|
+
*/
|
|
12
|
+
export async function fromMcpServer(server) {
|
|
13
|
+
const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
|
|
14
|
+
await server.connect(serverSide);
|
|
15
|
+
const client = new Client({ name: "agent-runtime-sdk", version: "1.0.0" });
|
|
16
|
+
await client.connect(clientSide);
|
|
17
|
+
const names = new Map();
|
|
18
|
+
return {
|
|
19
|
+
async listTools() {
|
|
20
|
+
const tools = [];
|
|
21
|
+
let cursor;
|
|
22
|
+
do {
|
|
23
|
+
const page = await client.listTools(cursor ? { cursor } : {});
|
|
24
|
+
for (const tool of page.tools) {
|
|
25
|
+
names.set(safeName(tool.name), tool.name);
|
|
26
|
+
tools.push({ ...tool, name: safeName(tool.name) });
|
|
27
|
+
}
|
|
28
|
+
cursor = page.nextCursor;
|
|
29
|
+
} while (cursor);
|
|
30
|
+
return tools;
|
|
31
|
+
},
|
|
32
|
+
async callTool(name, args, context) {
|
|
33
|
+
const _meta = { "agent-runtime/callId": context.callId, ...(context.toolCallId ? { "agent-runtime/toolCallId": context.toolCallId } : {}), ...(context.origin ? { "agent-runtime/origin": context.origin } : {}) };
|
|
34
|
+
return await client.callTool({ name: names.get(name) ?? name, arguments: args, _meta }, undefined, { signal: context.signal });
|
|
35
|
+
},
|
|
36
|
+
async close() { await client.close(); },
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
2
2
|
import type { Api, ImageContent, Model } from "@earendil-works/pi-ai";
|
|
3
3
|
import { Type, type TSchema, type Static } from "typebox";
|
|
4
|
-
import { type
|
|
4
|
+
import { type RequestMethod, type SessionCredentials, type SessionState } from "../shared/client-protocol.ts";
|
|
5
5
|
export { Type as schema };
|
|
6
6
|
export type { SessionCredentials, SessionState };
|
|
7
7
|
export interface ToolContext {
|
|
@@ -24,6 +24,32 @@ export declare function tool<S extends TSchema>(definition: Omit<Tool<Static<S>>
|
|
|
24
24
|
input: S;
|
|
25
25
|
}): Tool<Static<S>>;
|
|
26
26
|
export type Tools = Record<string, Tool>;
|
|
27
|
+
/** A tool as an MCP server lists it (`tools/list`). Runtime options ride in `_meta` under "agent-runtime/". */
|
|
28
|
+
export interface McpTool {
|
|
29
|
+
name: string;
|
|
30
|
+
title?: string;
|
|
31
|
+
description?: string;
|
|
32
|
+
inputSchema: Record<string, unknown>;
|
|
33
|
+
annotations?: Record<string, unknown>;
|
|
34
|
+
_meta?: Record<string, unknown>;
|
|
35
|
+
}
|
|
36
|
+
/** An MCP `tools/call` result. */
|
|
37
|
+
export interface CallToolResult {
|
|
38
|
+
content: Array<Record<string, unknown>>;
|
|
39
|
+
structuredContent?: Record<string, unknown>;
|
|
40
|
+
isError?: boolean;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The MCP server an application attaches to its agent: the SDK relays the runtime's
|
|
44
|
+
* `tools/list` and `tools/call` to it over the agent's connection. Throw from `callTool`
|
|
45
|
+
* only when the call could not be answered; a tool's own failure is an `isError` result.
|
|
46
|
+
*/
|
|
47
|
+
export interface ToolServer {
|
|
48
|
+
listTools(): McpTool[] | Promise<McpTool[]>;
|
|
49
|
+
callTool(name: string, args: Record<string, unknown>, context: ToolContext): Promise<CallToolResult>;
|
|
50
|
+
}
|
|
51
|
+
/** `tool({...})` definitions as an attached MCP server: JSON results become a text block (and structured content for objects). */
|
|
52
|
+
export declare function toolServer(tools: Tools): ToolServer;
|
|
27
53
|
export interface RuntimeOptions {
|
|
28
54
|
url?: string;
|
|
29
55
|
apiKey?: string;
|
|
@@ -33,7 +59,10 @@ export interface RuntimeOptions {
|
|
|
33
59
|
fetch?: typeof globalThis.fetch;
|
|
34
60
|
}
|
|
35
61
|
export interface AgentOptions {
|
|
36
|
-
tools
|
|
62
|
+
/** The application's tools, served to the agent as an attached MCP server. */
|
|
63
|
+
tools?: Tools;
|
|
64
|
+
/** Or an MCP server of the application's own (see `clients/mcp.ts` for MCP SDK servers). */
|
|
65
|
+
mcp?: ToolServer;
|
|
37
66
|
onEvent?: (event: any, requestId?: string) => unknown | Promise<unknown>;
|
|
38
67
|
onConnection?: (connected: boolean) => void;
|
|
39
68
|
onError?: (error: Error) => void;
|
|
@@ -41,6 +70,12 @@ export interface AgentOptions {
|
|
|
41
70
|
export type { ThinkingLevel };
|
|
42
71
|
export interface CreateAgentOptions extends AgentOptions {
|
|
43
72
|
idempotencyKey?: string;
|
|
73
|
+
/**
|
|
74
|
+
* Make the agent from a definition (GET /v1/definitions): it supplies the model, system
|
|
75
|
+
* prompt, thinking level and tool sources, so leave those out. `tools` (or `mcp`) are added
|
|
76
|
+
* as the agent's attached server.
|
|
77
|
+
*/
|
|
78
|
+
definition?: string;
|
|
44
79
|
/** Agent lifetime in seconds (60 to 366 days), or null to keep the agent until it is deleted. Default one day. */
|
|
45
80
|
ttlSeconds?: number | null;
|
|
46
81
|
systemPrompt?: string;
|
|
@@ -126,6 +161,8 @@ export interface RequestOptions {
|
|
|
126
161
|
export declare class AgentError extends Error {
|
|
127
162
|
status: number;
|
|
128
163
|
requestId?: string;
|
|
164
|
+
/** Milliseconds the runtime asked to wait before retrying (its Retry-After), for 429 and 503. */
|
|
165
|
+
retryAfterMs?: number;
|
|
129
166
|
constructor(message: string, status?: number, requestId?: string);
|
|
130
167
|
}
|
|
131
168
|
declare class Transport {
|
|
@@ -203,13 +240,10 @@ export declare class VolumeHandle {
|
|
|
203
240
|
version?: number;
|
|
204
241
|
}): Promise<any>;
|
|
205
242
|
}
|
|
243
|
+
/** The client's event cursor, saved so a restarted client resumes where it was. */
|
|
206
244
|
export type Journal = {
|
|
207
245
|
version: 1;
|
|
208
246
|
cursor: number;
|
|
209
|
-
calls: Record<string, {
|
|
210
|
-
state: "started" | "done";
|
|
211
|
-
outcome?: Outcome;
|
|
212
|
-
}>;
|
|
213
247
|
};
|
|
214
248
|
/** Stores must resolve only once the complete snapshot is committed. Use one active client per agent. */
|
|
215
249
|
export interface JournalStore {
|
|
@@ -220,6 +254,7 @@ export declare function memoryJournalStore(): JournalStore;
|
|
|
220
254
|
export declare class AgentClient {
|
|
221
255
|
readonly session: SessionCredentials;
|
|
222
256
|
readonly tools: Tools;
|
|
257
|
+
private server;
|
|
223
258
|
private readonly transport;
|
|
224
259
|
private readonly store;
|
|
225
260
|
private journal;
|
|
@@ -227,8 +262,10 @@ export declare class AgentClient {
|
|
|
227
262
|
private saving;
|
|
228
263
|
private readonly options;
|
|
229
264
|
private readonly pending;
|
|
265
|
+
/** Tool calls running, by JSON-RPC id, so the runtime can cancel them. */
|
|
230
266
|
private readonly active;
|
|
231
|
-
|
|
267
|
+
/** The event stream's connection, named in the MCP messages this client sends back. */
|
|
268
|
+
private connection?;
|
|
232
269
|
private stream?;
|
|
233
270
|
private loop?;
|
|
234
271
|
private closed;
|
|
@@ -250,8 +287,12 @@ export declare class AgentClient {
|
|
|
250
287
|
private receive;
|
|
251
288
|
private settle;
|
|
252
289
|
private sync;
|
|
253
|
-
|
|
254
|
-
|
|
290
|
+
/**
|
|
291
|
+
* Answer the runtime's JSON-RPC messages as the agent's attached MCP server: initialize,
|
|
292
|
+
* ping, tools/list and tools/call, and cancellation. The runtime runs a call once; a call
|
|
293
|
+
* whose answer is lost with the connection ends for the agent as "outcome unknown".
|
|
294
|
+
*/
|
|
295
|
+
private mcp;
|
|
255
296
|
request(method: RequestMethod, params?: Record<string, unknown>, options?: RequestOptions): Promise<any>;
|
|
256
297
|
/** Observe an already accepted request. This never submits or re-executes work. */
|
|
257
298
|
waitForRequest(id: string, options?: {
|
|
@@ -269,6 +310,7 @@ export declare class AgentClient {
|
|
|
269
310
|
systemPrompt?: string;
|
|
270
311
|
thinkingLevel?: ThinkingLevel;
|
|
271
312
|
tools?: Tools;
|
|
313
|
+
mcp?: ToolServer;
|
|
272
314
|
model?: string;
|
|
273
315
|
}): Promise<any>;
|
|
274
316
|
execute(code: string, options?: RequestOptions & {
|
|
@@ -6,14 +6,56 @@ export { Type as schema };
|
|
|
6
6
|
export function tool(definition) {
|
|
7
7
|
return { ...definition, input: definition.input };
|
|
8
8
|
}
|
|
9
|
+
const META = "agent-runtime/";
|
|
10
|
+
const isRecord = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
11
|
+
/** `tool({...})` definitions as an attached MCP server: JSON results become a text block (and structured content for objects). */
|
|
12
|
+
export function toolServer(tools) {
|
|
13
|
+
return {
|
|
14
|
+
listTools: () => Object.entries(tools).map(([name, tool]) => ({
|
|
15
|
+
name, description: tool.description, inputSchema: tool.input,
|
|
16
|
+
...(tool.exposure || tool.executionMode ? { _meta: { ...(tool.exposure ? { [`${META}exposure`]: tool.exposure } : {}), ...(tool.executionMode ? { [`${META}executionMode`]: tool.executionMode } : {}) } } : {}),
|
|
17
|
+
})),
|
|
18
|
+
async callTool(name, args, context) {
|
|
19
|
+
const definition = tools[name];
|
|
20
|
+
if (!Object.hasOwn(tools, name) || !Check(definition.input, args))
|
|
21
|
+
throw new Error("Tool is missing or arguments failed validation");
|
|
22
|
+
context.signal.throwIfAborted();
|
|
23
|
+
let result;
|
|
24
|
+
try {
|
|
25
|
+
result = await definition.execute(args, context);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (context.signal.aborted)
|
|
29
|
+
throw error;
|
|
30
|
+
return { content: [{ type: "text", text: String(error).slice(0, 2048) }], isError: true };
|
|
31
|
+
}
|
|
32
|
+
if (result === undefined || byteLength(JSON.stringify(result)) > 1024 * 1024)
|
|
33
|
+
throw new Error("Tool must return a bounded JSON value");
|
|
34
|
+
if (definition.resultFormat === "content")
|
|
35
|
+
return result;
|
|
36
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], ...(isRecord(result) ? { structuredContent: result } : {}) };
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
9
40
|
export class AgentError extends Error {
|
|
10
41
|
status;
|
|
11
42
|
requestId;
|
|
43
|
+
/** Milliseconds the runtime asked to wait before retrying (its Retry-After), for 429 and 503. */
|
|
44
|
+
retryAfterMs;
|
|
12
45
|
constructor(message, status = 0, requestId) { super(message); this.name = "AgentError"; this.status = status; this.requestId = requestId; }
|
|
13
46
|
}
|
|
47
|
+
/** Retry-After as milliseconds (seconds or an HTTP date), capped so a bad value cannot stall a caller. */
|
|
48
|
+
function retryAfter(response) {
|
|
49
|
+
const value = response.headers.get("retry-after");
|
|
50
|
+
if (value === null)
|
|
51
|
+
return undefined;
|
|
52
|
+
const ms = /^\d+$/.test(value.trim()) ? Number(value) * 1000 : Date.parse(value) - Date.now();
|
|
53
|
+
return Number.isFinite(ms) ? Math.min(Math.max(0, ms), 60_000) : undefined;
|
|
54
|
+
}
|
|
55
|
+
/** A 429 (quota, or an agent's queue is full) was refused before anything happened, so any request may be retried after it. */
|
|
56
|
+
const RATE_LIMIT_ATTEMPTS = 8;
|
|
14
57
|
const byteLength = (value) => new TextEncoder().encode(value).byteLength;
|
|
15
58
|
const pause = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
16
|
-
const definitions = (tools) => Object.entries(tools).map(([name, tool]) => ({ name, description: tool.description, parameters: tool.input, ...(tool.resultFormat ? { resultFormat: tool.resultFormat } : {}), ...(tool.exposure ? { exposure: tool.exposure } : {}), ...(tool.executionMode ? { executionMode: tool.executionMode } : {}) }));
|
|
17
59
|
async function rejectRedirect(response) {
|
|
18
60
|
if (response.status >= 300 && response.status < 400) {
|
|
19
61
|
await response.body?.cancel();
|
|
@@ -44,15 +86,19 @@ class Transport {
|
|
|
44
86
|
redirect: "manual", signal: AbortSignal.timeout(10_000),
|
|
45
87
|
});
|
|
46
88
|
await rejectRedirect(response);
|
|
47
|
-
const value = await response.json();
|
|
89
|
+
const value = await (response.ok ? response.json() : response.json().catch(() => ({})));
|
|
48
90
|
if (!response.ok)
|
|
49
|
-
throw new AgentError(value.error ?? `HTTP ${response.status}`, response.status);
|
|
91
|
+
throw Object.assign(new AgentError(value.error ?? `HTTP ${response.status}`, response.status), { retryAfterMs: retryAfter(response) });
|
|
50
92
|
return value;
|
|
51
93
|
}
|
|
52
94
|
catch (error) {
|
|
53
|
-
|
|
95
|
+
const limited = error instanceof AgentError && error.status === 429;
|
|
96
|
+
if (limited ? attempt >= RATE_LIMIT_ATTEMPTS - 1 : !retry || attempt >= 3 || (error instanceof AgentError && error.status < 500))
|
|
54
97
|
throw error;
|
|
55
|
-
|
|
98
|
+
// Honour the runtime's Retry-After, with jitter so refused callers do not return together; else back off exponentially.
|
|
99
|
+
const backoff = Math.min(10_000, (limited ? 500 : 100) * 2 ** attempt);
|
|
100
|
+
const hinted = error instanceof AgentError ? error.retryAfterMs : undefined;
|
|
101
|
+
await pause(hinted !== undefined ? hinted + Math.random() * Math.min(1000, backoff) : backoff);
|
|
56
102
|
}
|
|
57
103
|
}
|
|
58
104
|
}
|
|
@@ -76,7 +122,8 @@ export class AgentRuntime {
|
|
|
76
122
|
const key = this.options.apiKey;
|
|
77
123
|
if (!key)
|
|
78
124
|
throw new AgentError("Set apiKey to provision an agent");
|
|
79
|
-
const
|
|
125
|
+
const server = options.mcp ?? toolServer(options.tools ?? {});
|
|
126
|
+
const session = await this.transport.json("/client-sessions", key, "POST", { mcp: { tools: await server.listTools() }, ...(options.definition !== undefined ? { definition: options.definition } : {}), ...(options.mounts !== undefined ? { mounts: options.mounts } : {}), ...(options.model !== undefined ? { model: options.model } : {}), ...(options.thinkingLevel !== undefined ? { thinkingLevel: options.thinkingLevel } : {}), ...(options.initialMessages !== undefined ? { initialMessages: options.initialMessages } : {}), ...(options.name !== undefined ? { name: options.name } : {}), ...(options.type !== undefined ? { type: options.type } : {}), ...(options.systemPrompt !== undefined ? { systemPrompt: options.systemPrompt } : {}), ...(options.ttlSeconds !== undefined ? { ttlSeconds: options.ttlSeconds } : {}) }, true, { "Idempotency-Key": options.idempotencyKey ?? globalThis.crypto.randomUUID() });
|
|
80
127
|
return this.connectAgent(session, options);
|
|
81
128
|
}
|
|
82
129
|
async connectAgent(session, options) {
|
|
@@ -153,15 +200,18 @@ export function memoryJournalStore() {
|
|
|
153
200
|
export class AgentClient {
|
|
154
201
|
session;
|
|
155
202
|
tools;
|
|
203
|
+
server;
|
|
156
204
|
transport;
|
|
157
205
|
store;
|
|
158
|
-
journal = { version: 1, cursor: 0
|
|
206
|
+
journal = { version: 1, cursor: 0 };
|
|
159
207
|
loaded;
|
|
160
208
|
saving = Promise.resolve();
|
|
161
209
|
options;
|
|
162
210
|
pending = new Map();
|
|
211
|
+
/** Tool calls running, by JSON-RPC id, so the runtime can cancel them. */
|
|
163
212
|
active = new Map();
|
|
164
|
-
|
|
213
|
+
/** The event stream's connection, named in the MCP messages this client sends back. */
|
|
214
|
+
connection;
|
|
165
215
|
stream;
|
|
166
216
|
loop;
|
|
167
217
|
closed = false;
|
|
@@ -172,6 +222,7 @@ export class AgentClient {
|
|
|
172
222
|
throw new AgentError("Invalid session id");
|
|
173
223
|
this.session = { id: session.id, token: session.token, expiresAt: session.expiresAt };
|
|
174
224
|
this.tools = { ...options.tools };
|
|
225
|
+
this.server = options.mcp ?? toolServer(this.tools);
|
|
175
226
|
this.options = options;
|
|
176
227
|
this.transport = new Transport(runtime);
|
|
177
228
|
this.store = runtime.journalStore ?? memoryJournalStore();
|
|
@@ -180,13 +231,13 @@ export class AgentClient {
|
|
|
180
231
|
const journal = await this.store.load(this.session.id);
|
|
181
232
|
if (!journal)
|
|
182
233
|
return;
|
|
183
|
-
if (journal.version !== 1 || !Number.isSafeInteger(journal.cursor) || journal.cursor < 0
|
|
234
|
+
if (journal.version !== 1 || !Number.isSafeInteger(journal.cursor) || journal.cursor < 0)
|
|
184
235
|
throw new AgentError("Unsupported client journal");
|
|
185
|
-
this.journal =
|
|
236
|
+
this.journal = { version: 1, cursor: journal.cursor };
|
|
186
237
|
}
|
|
187
238
|
save() {
|
|
188
239
|
const snapshot = structuredClone(this.journal);
|
|
189
|
-
// Serialize commits so
|
|
240
|
+
// Serialize commits so an older cursor never overwrites a newer one.
|
|
190
241
|
this.saving = this.saving.then(() => this.store.save(this.session.id, snapshot));
|
|
191
242
|
return this.saving;
|
|
192
243
|
}
|
|
@@ -251,13 +302,22 @@ export class AgentClient {
|
|
|
251
302
|
if (!data)
|
|
252
303
|
continue;
|
|
253
304
|
if (lines.includes("event: ready")) {
|
|
305
|
+
this.connection = JSON.parse(data).connection;
|
|
254
306
|
await this.sync();
|
|
255
307
|
backoff = 250;
|
|
256
308
|
this.ready.resolve();
|
|
257
309
|
this.options.onConnection?.(true);
|
|
258
310
|
continue;
|
|
259
311
|
}
|
|
260
|
-
const
|
|
312
|
+
const idLine = lines.find(line => line.startsWith("id:"));
|
|
313
|
+
// The runtime's MCP messages are live only: no id, never replayed, no cursor.
|
|
314
|
+
if (!idLine) {
|
|
315
|
+
const event = JSON.parse(data);
|
|
316
|
+
if (event.type === "mcp")
|
|
317
|
+
void this.mcp(event.message).catch(error => this.report(error));
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const id = Number(idLine.slice(3));
|
|
261
321
|
if (!Number.isSafeInteger(id) || id <= 0)
|
|
262
322
|
throw new AgentError("Invalid SSE cursor");
|
|
263
323
|
if (id <= this.journal.cursor)
|
|
@@ -308,11 +368,7 @@ export class AgentClient {
|
|
|
308
368
|
return this.transport.json(this.path('/metadata'), this.session.token, 'POST', metadata);
|
|
309
369
|
}
|
|
310
370
|
async receive(event) {
|
|
311
|
-
if (event.type === "
|
|
312
|
-
this.dispatch(event.call);
|
|
313
|
-
else if (event.type === "tool_cancel")
|
|
314
|
-
this.active.get(event.id)?.controller.abort();
|
|
315
|
-
else if (event.type === "response")
|
|
371
|
+
if (event.type === "response")
|
|
316
372
|
this.settle(event.id, event.outcome);
|
|
317
373
|
else if (event.type === "event")
|
|
318
374
|
await this.options.onEvent?.(event.event, event.requestId);
|
|
@@ -332,73 +388,51 @@ export class AgentClient {
|
|
|
332
388
|
for (const request of state.requests)
|
|
333
389
|
if (request.outcome)
|
|
334
390
|
this.settle(request.id, request.outcome);
|
|
335
|
-
for (const call of state.calls)
|
|
336
|
-
this.dispatch(call);
|
|
337
391
|
return state;
|
|
338
392
|
}
|
|
339
|
-
|
|
340
|
-
|
|
393
|
+
/**
|
|
394
|
+
* Answer the runtime's JSON-RPC messages as the agent's attached MCP server: initialize,
|
|
395
|
+
* ping, tools/list and tools/call, and cancellation. The runtime runs a call once; a call
|
|
396
|
+
* whose answer is lost with the connection ends for the agent as "outcome unknown".
|
|
397
|
+
*/
|
|
398
|
+
async mcp(message) {
|
|
399
|
+
if (typeof message.method !== "string")
|
|
341
400
|
return;
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
this.active.set(call.id, { controller, task });
|
|
346
|
-
}
|
|
347
|
-
async runTool(call, controller) {
|
|
348
|
-
let receipt = this.journal.calls[call.id];
|
|
349
|
-
if (receipt?.state === "done") {
|
|
350
|
-
await this.http(`/calls/${call.id}/outcome`, "POST", receipt.outcome);
|
|
351
|
-
this.delivered.add(call.id);
|
|
401
|
+
if (message.id === undefined) {
|
|
402
|
+
if (message.method === "notifications/cancelled")
|
|
403
|
+
this.active.get(String(message.params?.requestId))?.abort();
|
|
352
404
|
return;
|
|
353
405
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
if (
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
}
|
|
384
|
-
catch (error) {
|
|
385
|
-
value = { error: String(error).slice(0, 2048), ...(controller.signal.aborted ? { uncertain: true } : {}) };
|
|
386
|
-
}
|
|
387
|
-
finally {
|
|
388
|
-
clearTimeout(timer);
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
catch (error) {
|
|
393
|
-
value = { error: `Execution claim failed: ${String(error).slice(0, 1800)}`, uncertain: true };
|
|
394
|
-
}
|
|
406
|
+
const connection = this.connection;
|
|
407
|
+
const reply = (answer) => this.transport.json(this.path("/mcp"), this.session.token, "POST", { jsonrpc: "2.0", id: message.id, ...answer }, true, { "X-Agent-Connection": connection ?? "" });
|
|
408
|
+
const params = message.params ?? {};
|
|
409
|
+
if (message.method === "initialize")
|
|
410
|
+
return reply({ result: { protocolVersion: params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "agent-runtime-sdk", version: "1.0.0" } } });
|
|
411
|
+
if (message.method === "ping")
|
|
412
|
+
return reply({ result: {} });
|
|
413
|
+
if (message.method === "tools/list")
|
|
414
|
+
return reply({ result: { tools: await this.server.listTools() } });
|
|
415
|
+
if (message.method !== "tools/call")
|
|
416
|
+
return reply({ error: { code: -32601, message: `Unknown method ${message.method}` } });
|
|
417
|
+
const controller = new AbortController();
|
|
418
|
+
const key = String(message.id);
|
|
419
|
+
this.active.set(key, controller);
|
|
420
|
+
const meta = params._meta ?? {};
|
|
421
|
+
try {
|
|
422
|
+
const result = await this.server.callTool(params.name, params.arguments ?? {}, {
|
|
423
|
+
callId: meta["agent-runtime/callId"] ?? key, signal: controller.signal,
|
|
424
|
+
...(meta["agent-runtime/toolCallId"] ? { toolCallId: meta["agent-runtime/toolCallId"] } : {}), ...(meta["agent-runtime/origin"] ? { origin: meta["agent-runtime/origin"] } : {}),
|
|
425
|
+
});
|
|
426
|
+
if (!isRecord(result) || !Array.isArray(result.content) || byteLength(JSON.stringify(result)) > 1024 * 1024)
|
|
427
|
+
throw new Error("The MCP server must answer with a bounded CallToolResult");
|
|
428
|
+
await reply({ result });
|
|
429
|
+
}
|
|
430
|
+
catch (error) {
|
|
431
|
+
await reply({ error: { code: -32603, message: String(error).slice(0, 2048) } });
|
|
432
|
+
}
|
|
433
|
+
finally {
|
|
434
|
+
this.active.delete(key);
|
|
395
435
|
}
|
|
396
|
-
// Persist before POST; reconnect resends this receipt, never the side effect.
|
|
397
|
-
receipt = { state: "done", outcome: value };
|
|
398
|
-
this.journal.calls[call.id] = receipt;
|
|
399
|
-
await this.save();
|
|
400
|
-
await this.http(`/calls/${call.id}/outcome`, "POST", value);
|
|
401
|
-
this.delivered.add(call.id);
|
|
402
436
|
}
|
|
403
437
|
async request(method, params = {}, options = {}) {
|
|
404
438
|
if (this.closed || this.fatal)
|
|
@@ -464,12 +498,16 @@ export class AgentClient {
|
|
|
464
498
|
followUp(text) { return this.request("followUp", { text }); }
|
|
465
499
|
/** Change the prompt, thinking level, tools, or model ("provider/model-id") between runs. */
|
|
466
500
|
async configure(options) {
|
|
467
|
-
const
|
|
468
|
-
|
|
501
|
+
const { tools, mcp, ...rest } = options;
|
|
502
|
+
const server = mcp ?? (tools ? toolServer(tools) : undefined);
|
|
503
|
+
const result = await this.request("configure", { ...rest, ...(server ? { mcp: { tools: await server.listTools() } } : {}) });
|
|
504
|
+
if (tools) {
|
|
469
505
|
for (const key of Object.keys(this.tools))
|
|
470
506
|
delete this.tools[key];
|
|
471
|
-
Object.assign(this.tools,
|
|
507
|
+
Object.assign(this.tools, tools);
|
|
472
508
|
}
|
|
509
|
+
if (server)
|
|
510
|
+
this.server = mcp ?? toolServer(this.tools);
|
|
473
511
|
return result;
|
|
474
512
|
}
|
|
475
513
|
execute(code, options) {
|
|
@@ -491,7 +529,7 @@ export class AgentClient {
|
|
|
491
529
|
async close() {
|
|
492
530
|
this.closed = true;
|
|
493
531
|
this.stream?.abort();
|
|
494
|
-
for (const
|
|
532
|
+
for (const controller of this.active.values())
|
|
495
533
|
controller.abort();
|
|
496
534
|
for (const [id, waiter] of this.pending)
|
|
497
535
|
waiter.reject(new AgentError("Client closed; request may still be running", 0, id));
|
|
@@ -16,22 +16,6 @@ export interface SessionCredentials {
|
|
|
16
16
|
expiresAt: number | null;
|
|
17
17
|
}
|
|
18
18
|
export type RequestMethod = "prompt" | "execute" | "status" | "abort" | "history" | "continue" | "steer" | "followUp" | "configure";
|
|
19
|
-
export type CallRecord = {
|
|
20
|
-
id: string;
|
|
21
|
-
toolCallId?: string;
|
|
22
|
-
requestId?: string;
|
|
23
|
-
createdAt?: number;
|
|
24
|
-
name: string;
|
|
25
|
-
args: Record<string, unknown>;
|
|
26
|
-
deadline: number;
|
|
27
|
-
/** `uncertain`: claimed, but the outcome was lost; the model was told it is unknown. */
|
|
28
|
-
state: "offered" | "started" | "completed" | "cancelled" | "uncertain";
|
|
29
|
-
outcome?: Outcome;
|
|
30
|
-
/** A result that arrived after the call was already settled as uncertain. */
|
|
31
|
-
lateOutcome?: Outcome;
|
|
32
|
-
/** Where the turn came from, set by the runtime (e.g. a channel and its sender), so tools can authorize. */
|
|
33
|
-
origin?: Record<string, unknown>;
|
|
34
|
-
};
|
|
35
19
|
export type RequestRecord = {
|
|
36
20
|
id: string;
|
|
37
21
|
startedAt?: number;
|
|
@@ -47,14 +31,11 @@ export type RequestRecord = {
|
|
|
47
31
|
began?: number;
|
|
48
32
|
/** Kept until the run begins, so a queued run survives a restart and runs exactly once. */
|
|
49
33
|
params?: unknown;
|
|
34
|
+
/** Times a new owner resumed this run's turn after the node running it was lost. */
|
|
35
|
+
resumes?: number;
|
|
50
36
|
};
|
|
37
|
+
/** Events on an agent's stream. `mcp` carries the runtime's JSON-RPC messages to the application's attached MCP server: live only, with no id, never replayed. */
|
|
51
38
|
export type ClientEvent = {
|
|
52
|
-
type: "tool_call";
|
|
53
|
-
call: CallRecord;
|
|
54
|
-
} | {
|
|
55
|
-
type: "tool_cancel";
|
|
56
|
-
id: string;
|
|
57
|
-
} | {
|
|
58
39
|
type: "event";
|
|
59
40
|
requestId: string;
|
|
60
41
|
event: any;
|
|
@@ -62,10 +43,12 @@ export type ClientEvent = {
|
|
|
62
43
|
type: "response";
|
|
63
44
|
id: string;
|
|
64
45
|
outcome: Outcome;
|
|
46
|
+
} | {
|
|
47
|
+
type: "mcp";
|
|
48
|
+
message: Record<string, unknown>;
|
|
65
49
|
};
|
|
66
50
|
export type SessionState = {
|
|
67
51
|
cursor: number;
|
|
68
|
-
calls: CallRecord[];
|
|
69
52
|
requests: RequestRecord[];
|
|
70
53
|
};
|
|
71
54
|
/** A tool an application offers its agent; shared by the SDKs and the runtime. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camelai/agent-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "SDK for the camelAI hosted agent runtime: define tools in your app, and the runtime runs the model loop, history and sandbox.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
"./node": {
|
|
25
25
|
"types": "./dist/clients/node.d.ts",
|
|
26
26
|
"default": "./dist/clients/node.js"
|
|
27
|
+
},
|
|
28
|
+
"./mcp": {
|
|
29
|
+
"types": "./dist/clients/mcp.d.ts",
|
|
30
|
+
"default": "./dist/clients/mcp.js"
|
|
27
31
|
}
|
|
28
32
|
},
|
|
29
33
|
"files": [
|
|
@@ -38,5 +42,13 @@
|
|
|
38
42
|
"dependencies": {
|
|
39
43
|
"typebox": "1.1.38"
|
|
40
44
|
},
|
|
41
|
-
"homepage": "https://agents.camelai.dev"
|
|
45
|
+
"homepage": "https://agents.camelai.dev",
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@modelcontextprotocol/sdk": "^1.30.1"
|
|
48
|
+
},
|
|
49
|
+
"peerDependenciesMeta": {
|
|
50
|
+
"@modelcontextprotocol/sdk": {
|
|
51
|
+
"optional": true
|
|
52
|
+
}
|
|
53
|
+
}
|
|
42
54
|
}
|