@camelai/agent-runtime 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/dist/clients/node.d.ts +8 -0
- package/dist/clients/node.js +34 -1
- package/dist/clients/server.d.ts +77 -0
- package/dist/clients/server.js +171 -0
- package/dist/clients/testing.d.ts +59 -0
- package/dist/clients/testing.js +53 -0
- package/dist/clients/typescript.d.ts +363 -8
- package/dist/clients/typescript.js +289 -44
- package/dist/shared/client-protocol.d.ts +7 -1
- package/package.json +9 -1
|
@@ -2,29 +2,120 @@ import { Type } from "typebox";
|
|
|
2
2
|
import { Check } from "typebox/value";
|
|
3
3
|
import { FRAME_BYTES } from "../shared/client-protocol.js";
|
|
4
4
|
export { Type as schema };
|
|
5
|
+
/** A runtime identity from its claims (a verified token's payload, or an attached call's `_meta`). */
|
|
6
|
+
export function identityFromClaims(claims) {
|
|
7
|
+
const text = (value) => typeof value === "string" && value ? value : undefined;
|
|
8
|
+
const agent = text(claims.agent) ?? "";
|
|
9
|
+
const subject = text(claims.sub) ?? agent;
|
|
10
|
+
const actor = text(claims.act);
|
|
11
|
+
return {
|
|
12
|
+
user: actor ?? subject, subject, ...(actor ? { actor } : {}), tenant: text(claims.tenant) ?? "", agent,
|
|
13
|
+
...(text(claims.definition) ? { definition: claims.definition } : {}),
|
|
14
|
+
context: isRecord(claims.ctx) ? claims.ctx : {}, ...(isRecord(claims.origin) ? { origin: claims.origin } : {}),
|
|
15
|
+
...(isRecord(claims.approval) ? { approval: claims.approval } : {}),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** Thrown by a ToolContext's asks: the call answers MCP's `input_required`, and runs again once the user answers. */
|
|
19
|
+
export class InputRequired extends Error {
|
|
20
|
+
inputRequests;
|
|
21
|
+
/** The answers so far, which the runtime hands back on the next call (MCP's `requestState`). */
|
|
22
|
+
requestState;
|
|
23
|
+
constructor(inputRequests, requestState) {
|
|
24
|
+
super("Waiting for the user's input");
|
|
25
|
+
this.name = "InputRequired";
|
|
26
|
+
this.inputRequests = inputRequests;
|
|
27
|
+
if (requestState)
|
|
28
|
+
this.requestState = requestState;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
5
31
|
/** Infer callback arguments from the schema; no manually duplicated argument type. */
|
|
6
32
|
export function tool(definition) {
|
|
7
33
|
return { ...definition, input: definition.input };
|
|
8
34
|
}
|
|
9
35
|
const META = "agent-runtime/";
|
|
10
|
-
|
|
36
|
+
function isRecord(value) { return !!value && typeof value === "object" && !Array.isArray(value); }
|
|
37
|
+
/**
|
|
38
|
+
* A call's context from its params: ids, origin, and the identity the runtime sent in `_meta` (or
|
|
39
|
+
* `identity`, from a verified token); its asks answer from the retry's `inputResponses`, by position.
|
|
40
|
+
*/
|
|
41
|
+
export function toolContext(params, fallbackId, signal, identity) {
|
|
42
|
+
const meta = isRecord(params._meta) ? params._meta : {};
|
|
43
|
+
const sent = isRecord(meta[`${META}identity`]) ? identityFromClaims(meta[`${META}identity`]) : undefined;
|
|
44
|
+
const who = identity ?? sent;
|
|
45
|
+
const origin = isRecord(meta[`${META}origin`]) ? meta[`${META}origin`] : who?.origin;
|
|
46
|
+
// Each round answers only its own ask: earlier answers come back in the state this call handed out.
|
|
47
|
+
let earlier = {};
|
|
48
|
+
try {
|
|
49
|
+
earlier = typeof params.requestState === "string" ? JSON.parse(atob(params.requestState)) : {};
|
|
50
|
+
}
|
|
51
|
+
catch { /* not ours: start over */ }
|
|
52
|
+
const responses = { ...isRecord(earlier) ? earlier : {}, ...isRecord(params.inputResponses) ? params.inputResponses : {} };
|
|
53
|
+
let asked = 0;
|
|
54
|
+
const request = async (input) => {
|
|
55
|
+
const key = `input_${++asked}`;
|
|
56
|
+
if (isRecord(responses[key]))
|
|
57
|
+
return responses[key];
|
|
58
|
+
throw new InputRequired({ [key]: { method: "elicitation/create", params: input } }, Object.keys(responses).length ? btoa(JSON.stringify(responses)) : undefined);
|
|
59
|
+
};
|
|
60
|
+
return {
|
|
61
|
+
callId: typeof meta[`${META}callId`] === "string" ? meta[`${META}callId`] : fallbackId, signal,
|
|
62
|
+
...(typeof meta[`${META}toolCallId`] === "string" ? { toolCallId: meta[`${META}toolCallId`] } : {}),
|
|
63
|
+
...(origin ? { origin } : {}), ...(who ? { identity: who } : {}),
|
|
64
|
+
confirm: async (message) => (await request({ mode: "form", message, requestedSchema: { type: "object", properties: {} } })).action === "accept",
|
|
65
|
+
ask: async (message, schema) => { const answer = await request({ mode: "form", message, requestedSchema: schema }); return answer.action === "accept" ? answer.content : undefined; },
|
|
66
|
+
requireUrl: async (url, message) => (await request({ mode: "url", message, url, elicitationId: `${fallbackId}-${asked + 1}` })).action === "accept",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Answer one MCP JSON-RPC request as a tool server: initialize, ping, tools/list and tools/call.
|
|
71
|
+
* Both an attached server (answering over the agent's connection) and `serveTools` (over HTTP) use it.
|
|
72
|
+
*/
|
|
73
|
+
export async function answerMcp(message, server, context, info = { name: "agent-runtime-sdk", version: "1.0.0" }) {
|
|
74
|
+
const params = isRecord(message.params) ? message.params : {};
|
|
75
|
+
if (message.method === "initialize")
|
|
76
|
+
return { result: { protocolVersion: typeof params.protocolVersion === "string" ? params.protocolVersion : "2025-06-18", capabilities: { tools: {} }, serverInfo: info } };
|
|
77
|
+
if (message.method === "ping")
|
|
78
|
+
return { result: {} };
|
|
79
|
+
if (message.method === "tools/list")
|
|
80
|
+
return { result: { tools: await server.listTools() } };
|
|
81
|
+
if (message.method !== "tools/call")
|
|
82
|
+
return { error: { code: -32601, message: `Unknown method ${message.method}` } };
|
|
83
|
+
try {
|
|
84
|
+
const result = await server.callTool(String(params.name), isRecord(params.arguments) ? params.arguments : {}, context(params));
|
|
85
|
+
if (!isRecord(result) || (!Array.isArray(result.content) && result.resultType !== "input_required") || byteLength(JSON.stringify(result)) > 1024 * 1024)
|
|
86
|
+
throw new Error("The MCP server must answer with a bounded CallToolResult");
|
|
87
|
+
return { result };
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
return { error: { code: -32603, message: String(error).slice(0, 2048) } };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
11
93
|
/** `tool({...})` definitions as an attached MCP server: JSON results become a text block (and structured content for objects). */
|
|
12
94
|
export function toolServer(tools) {
|
|
13
95
|
return {
|
|
14
96
|
listTools: () => Object.entries(tools).map(([name, tool]) => ({
|
|
15
97
|
name, description: tool.description, inputSchema: tool.input,
|
|
16
|
-
...(tool.exposure || tool.executionMode
|
|
98
|
+
...(tool.exposure || tool.executionMode || tool.needsApproval ? { _meta: {
|
|
99
|
+
...(tool.exposure ? { [`${META}exposure`]: tool.exposure } : {}), ...(tool.executionMode ? { [`${META}executionMode`]: tool.executionMode } : {}),
|
|
100
|
+
...(tool.needsApproval ? { [`${META}needsApproval`]: true } : {}),
|
|
101
|
+
} } : {}),
|
|
17
102
|
})),
|
|
18
103
|
async callTool(name, args, context) {
|
|
19
104
|
const definition = tools[name];
|
|
20
105
|
if (!Object.hasOwn(tools, name) || !Check(definition.input, args))
|
|
21
106
|
throw new Error("Tool is missing or arguments failed validation");
|
|
22
107
|
context.signal.throwIfAborted();
|
|
108
|
+
// Not yet approved: the runtime asks the user, showing this call, and calls again once they approve.
|
|
109
|
+
const asks = typeof definition.needsApproval === "function" ? await definition.needsApproval(args, context) : definition.needsApproval;
|
|
110
|
+
if (asks && !context.identity?.approval)
|
|
111
|
+
return { resultType: "input_required", inputRequests: { approval: { method: `${META}approval` } } };
|
|
23
112
|
let result;
|
|
24
113
|
try {
|
|
25
114
|
result = await definition.execute(args, context);
|
|
26
115
|
}
|
|
27
116
|
catch (error) {
|
|
117
|
+
if (error instanceof InputRequired)
|
|
118
|
+
return { resultType: "input_required", inputRequests: error.inputRequests, ...(error.requestState ? { requestState: error.requestState } : {}) };
|
|
28
119
|
if (context.signal.aborted)
|
|
29
120
|
throw error;
|
|
30
121
|
return { content: [{ type: "text", text: String(error).slice(0, 2048) }], isError: true };
|
|
@@ -56,6 +147,8 @@ function retryAfter(response) {
|
|
|
56
147
|
const RATE_LIMIT_ATTEMPTS = 8;
|
|
57
148
|
const byteLength = (value) => new TextEncoder().encode(value).byteLength;
|
|
58
149
|
const pause = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
150
|
+
/** A download's content type, without parameters (text is always UTF-8). */
|
|
151
|
+
const contentTypeOf = (response) => (response.headers.get("content-type") ?? "application/octet-stream").split(";")[0].trim();
|
|
59
152
|
async function rejectRedirect(response) {
|
|
60
153
|
if (response.status >= 300 && response.status < 400) {
|
|
61
154
|
await response.body?.cancel();
|
|
@@ -102,15 +195,51 @@ class Transport {
|
|
|
102
195
|
}
|
|
103
196
|
}
|
|
104
197
|
}
|
|
105
|
-
/**
|
|
198
|
+
/**
|
|
199
|
+
* A request with a raw body or response (file contents). It fails once nothing arrives for 30 s
|
|
200
|
+
* (an upload has the runtime's 15 minutes to be sent), so a stalled transfer never hangs its caller.
|
|
201
|
+
*/
|
|
106
202
|
async raw(path, token, init = {}) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
203
|
+
// A 503 (the agent is moving, a node draining) was refused before anything happened; a read may be retried after anything.
|
|
204
|
+
for (let attempt = 0;; attempt++) {
|
|
205
|
+
try {
|
|
206
|
+
return await this.transfer(path, token, init);
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
const status = error instanceof AgentError ? error.status : 500;
|
|
210
|
+
if (attempt >= 3 || !(status === 503 || (status >= 500 && (init.method ?? "GET") === "GET")))
|
|
211
|
+
throw error;
|
|
212
|
+
await pause(error.retryAfterMs ?? 100 * 2 ** attempt);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
async transfer(path, token, init) {
|
|
217
|
+
const controller = new AbortController();
|
|
218
|
+
let timer;
|
|
219
|
+
const wait = (ms) => { clearTimeout(timer); timer = setTimeout(() => controller.abort(new AgentError("File transfer stalled")), ms); };
|
|
220
|
+
wait(init.body === undefined ? 30_000 : 15 * 60_000);
|
|
221
|
+
try {
|
|
222
|
+
const response = await this.fetcher(this.base + path, { method: init.method ?? "GET", body: init.body, headers: { Authorization: `Bearer ${token}`, ...init.headers }, redirect: "manual", signal: controller.signal });
|
|
223
|
+
await rejectRedirect(response);
|
|
224
|
+
if (!response.ok) {
|
|
225
|
+
const value = await response.json().catch(() => ({}));
|
|
226
|
+
throw Object.assign(new AgentError(value.error ?? `HTTP ${response.status}`, response.status), { retryAfterMs: retryAfter(response) });
|
|
227
|
+
}
|
|
228
|
+
if (!response.body) {
|
|
229
|
+
clearTimeout(timer);
|
|
230
|
+
return response;
|
|
231
|
+
}
|
|
232
|
+
wait(30_000);
|
|
233
|
+
const body = response.body.pipeThrough(new TransformStream({
|
|
234
|
+
transform(chunk, stream) { wait(30_000); stream.enqueue(chunk); },
|
|
235
|
+
flush() { clearTimeout(timer); },
|
|
236
|
+
}));
|
|
237
|
+
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
clearTimeout(timer);
|
|
241
|
+
throw error;
|
|
112
242
|
}
|
|
113
|
-
return response;
|
|
114
243
|
}
|
|
115
244
|
}
|
|
116
245
|
/** Trusted-backend SDK. Only createAgent needs the operator key. */
|
|
@@ -123,7 +252,7 @@ export class AgentRuntime {
|
|
|
123
252
|
if (!key)
|
|
124
253
|
throw new AgentError("Set apiKey to provision an agent");
|
|
125
254
|
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() });
|
|
255
|
+
const session = await this.transport.json("/client-sessions", key, "POST", { mcp: { tools: await server.listTools() }, ...(options.subject !== undefined ? { subject: options.subject } : {}), ...(options.context !== undefined ? { context: options.context } : {}), ...(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() });
|
|
127
256
|
return this.connectAgent(session, options);
|
|
128
257
|
}
|
|
129
258
|
async connectAgent(session, options) {
|
|
@@ -139,7 +268,7 @@ export class AgentRuntime {
|
|
|
139
268
|
}
|
|
140
269
|
operator() {
|
|
141
270
|
if (!this.options.apiKey)
|
|
142
|
-
throw new AgentError("Set apiKey to manage volumes and mounts");
|
|
271
|
+
throw new AgentError("Set apiKey to manage definitions, volumes and mounts");
|
|
143
272
|
return this.options.apiKey;
|
|
144
273
|
}
|
|
145
274
|
createVolume(options = {}) { return this.transport.json("/v1/volumes", this.operator(), "POST", options, false); }
|
|
@@ -150,9 +279,29 @@ export class AgentRuntime {
|
|
|
150
279
|
throw new AgentError("Invalid volume id");
|
|
151
280
|
return new VolumeHandle(this.transport, this.operator(), id);
|
|
152
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* Definitions: reusable agent configurations with their tool sources (MCP servers, OpenAPI
|
|
284
|
+
* specs, built-ins). Make agents from one with `createAgent({ definition: id })`.
|
|
285
|
+
*/
|
|
286
|
+
createDefinition(input) { return this.transport.json("/v1/definitions", this.operator(), "POST", input, false); }
|
|
287
|
+
/** Replace the fields given (null removes one); `apply: "all"` also reconfigures its live agents between their turns. */
|
|
288
|
+
updateDefinition(id, input) { return this.transport.json(`/v1/definitions/${encodeURIComponent(id)}`, this.operator(), "PATCH", input, false); }
|
|
289
|
+
definition(id) { return this.transport.json(`/v1/definitions/${encodeURIComponent(id)}`, this.operator()); }
|
|
290
|
+
definitions() { return this.transport.json("/v1/definitions", this.operator()); }
|
|
291
|
+
deleteDefinition(id) { return this.transport.json(`/v1/definitions/${encodeURIComponent(id)}`, this.operator(), "DELETE", undefined, false); }
|
|
153
292
|
mounts(agentId) { return this.transport.json(`/v1/agents/${encodeURIComponent(agentId)}/mounts`, this.operator()); }
|
|
154
293
|
/** Replace an agent's mounts; an idle agent restarts so its tools describe them. */
|
|
155
294
|
setMounts(agentId, mounts) { return this.transport.json(`/v1/agents/${encodeURIComponent(agentId)}/mounts`, this.operator(), "PUT", { mounts }, false); }
|
|
295
|
+
/**
|
|
296
|
+
* Every source of an agent's tools (its application, file tools, built-ins, MCP servers, OpenAPI
|
|
297
|
+
* specs) and what each offers the model. `schemas` includes input schemas; `refresh` lists MCP servers now.
|
|
298
|
+
*/
|
|
299
|
+
/** Inputs waiting on someone across all the tenant's agents (`pending` ones, say), newest first. */
|
|
300
|
+
inbox(state) { return this.transport.json(`/v1/inputs${state ? `?state=${state}` : ""}`, this.operator()); }
|
|
301
|
+
async toolSources(agentId, options = {}) {
|
|
302
|
+
const query = [options.schemas && "schemas=true", options.refresh && "refresh=true"].filter(Boolean).join("&");
|
|
303
|
+
return (await this.transport.json(`/v1/agents/${encodeURIComponent(agentId)}${query ? `?${query}` : ""}`, this.operator())).toolSources;
|
|
304
|
+
}
|
|
156
305
|
}
|
|
157
306
|
/** Files are versioned: pass `version` to write or remove only if nobody changed the file since (0: must not exist). */
|
|
158
307
|
export class VolumeHandle {
|
|
@@ -174,18 +323,21 @@ export class VolumeHandle {
|
|
|
174
323
|
const query = new URLSearchParams(Object.entries(options).filter(([, value]) => value !== undefined).map(([key, value]) => [key, String(value)]));
|
|
175
324
|
return this.transport.json(this.path(`/files${query.size ? `?${query}` : ""}`), this.token);
|
|
176
325
|
}
|
|
326
|
+
/** Without `contentType`, the runtime sniffs it from the file's first bytes and name. */
|
|
177
327
|
async write(path, data, options = {}) {
|
|
178
328
|
const body = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
|
179
|
-
const headers = { "Content-Type": "application/octet-stream", ...(options.version === 0 ? { "If-None-Match": "*" } : options.version !== undefined ? { "If-Match": `"${options.version}"` } : {}) };
|
|
329
|
+
const headers = { "Content-Type": options.contentType ?? "application/octet-stream", ...(options.version === 0 ? { "If-None-Match": "*" } : options.version !== undefined ? { "If-Match": `"${options.version}"` } : {}) };
|
|
180
330
|
return (await this.transport.raw(this.file(path), this.token, { method: "PUT", body, headers })).json();
|
|
181
331
|
}
|
|
182
332
|
/** A file's bytes, or `range` of them ([start, end) in bytes). */
|
|
183
333
|
async read(path, options = {}) {
|
|
184
334
|
const [start, end] = options.range ?? [];
|
|
185
335
|
const response = await this.transport.raw(this.file(path), this.token, start !== undefined ? { headers: { Range: `bytes=${start}-${end !== undefined ? end - 1 : ""}` } } : {});
|
|
186
|
-
return { data: new Uint8Array(await response.arrayBuffer()), version: Number(response.headers.get("etag")?.replaceAll('"', "")) };
|
|
336
|
+
return { data: new Uint8Array(await response.arrayBuffer()), version: Number(response.headers.get("etag")?.replaceAll('"', "")), contentType: contentTypeOf(response) };
|
|
187
337
|
}
|
|
188
338
|
async readText(path) { return new TextDecoder().decode((await this.read(path)).data); }
|
|
339
|
+
/** A signed URL to download (GET) or upload (PUT) one file without a token. */
|
|
340
|
+
link(path, options = {}) { return this.transport.json(this.path("/links"), this.token, "POST", { path, ...options }, false); }
|
|
189
341
|
async remove(path, options = {}) {
|
|
190
342
|
return (await this.transport.raw(this.file(path), this.token, { method: "DELETE", headers: options.version !== undefined ? { "If-Match": `"${options.version}"` } : {} })).json();
|
|
191
343
|
}
|
|
@@ -197,6 +349,33 @@ export function memoryJournalStore() {
|
|
|
197
349
|
async save(id, journal) { entries.set(id, structuredClone(journal)); },
|
|
198
350
|
};
|
|
199
351
|
}
|
|
352
|
+
const encodePath = (path) => path.split("/").filter(Boolean).map(encodeURIComponent).join("/");
|
|
353
|
+
/**
|
|
354
|
+
* The agent's files, at the paths it sees them (`/workspace/report.pdf`), with the agent's own
|
|
355
|
+
* token: what it wrote during a run (a run's outcome lists `files`), and links to hand them on.
|
|
356
|
+
*/
|
|
357
|
+
export class AgentFiles {
|
|
358
|
+
transport;
|
|
359
|
+
token;
|
|
360
|
+
base;
|
|
361
|
+
constructor(transport, token, base) { this.transport = transport; this.token = token; this.base = base; }
|
|
362
|
+
/** Files under `path` (default: the first mount), in path order, a page at a time. */
|
|
363
|
+
list(options = {}) {
|
|
364
|
+
const query = new URLSearchParams(Object.entries(options).filter(([, value]) => value !== undefined).map(([key, value]) => [key, String(value)]));
|
|
365
|
+
return this.transport.json(`${this.base}/files${query.size ? `?${query}` : ""}`, this.token);
|
|
366
|
+
}
|
|
367
|
+
async download(path) {
|
|
368
|
+
const response = await this.transport.raw(`${this.base}/files/${encodePath(path)}`, this.token);
|
|
369
|
+
return { data: new Uint8Array(await response.arrayBuffer()), contentType: contentTypeOf(response), version: Number(response.headers.get("etag")?.replaceAll('"', "")) };
|
|
370
|
+
}
|
|
371
|
+
/** Write a file into a writable mount; without `contentType` the runtime sniffs it. */
|
|
372
|
+
async upload(path, data, options = {}) {
|
|
373
|
+
const body = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
|
374
|
+
return (await this.transport.raw(`${this.base}/files/${encodePath(path)}`, this.token, { method: "PUT", body, headers: options.contentType ? { "Content-Type": options.contentType } : {} })).json();
|
|
375
|
+
}
|
|
376
|
+
/** A signed URL to download (GET) or upload (PUT) one file without a token, e.g. for a browser or another service. */
|
|
377
|
+
link(path, options = {}) { return this.transport.json(`${this.base}/links`, this.token, "POST", { path, ...options }, false); }
|
|
378
|
+
}
|
|
200
379
|
export class AgentClient {
|
|
201
380
|
session;
|
|
202
381
|
tools;
|
|
@@ -207,6 +386,10 @@ export class AgentClient {
|
|
|
207
386
|
loaded;
|
|
208
387
|
saving = Promise.resolve();
|
|
209
388
|
options;
|
|
389
|
+
openFile;
|
|
390
|
+
pollMs;
|
|
391
|
+
/** The agent's files: list, download, upload and link. */
|
|
392
|
+
files;
|
|
210
393
|
pending = new Map();
|
|
211
394
|
/** Tool calls running, by JSON-RPC id, so the runtime can cancel them. */
|
|
212
395
|
active = new Map();
|
|
@@ -226,6 +409,9 @@ export class AgentClient {
|
|
|
226
409
|
this.options = options;
|
|
227
410
|
this.transport = new Transport(runtime);
|
|
228
411
|
this.store = runtime.journalStore ?? memoryJournalStore();
|
|
412
|
+
this.openFile = runtime.openFile;
|
|
413
|
+
this.pollMs = runtime.pollMs ?? 30_000;
|
|
414
|
+
this.files = new AgentFiles(this.transport, this.session.token, this.path());
|
|
229
415
|
}
|
|
230
416
|
async load() {
|
|
231
417
|
const journal = await this.store.load(this.session.id);
|
|
@@ -370,8 +556,16 @@ export class AgentClient {
|
|
|
370
556
|
async receive(event) {
|
|
371
557
|
if (event.type === "response")
|
|
372
558
|
this.settle(event.id, event.outcome);
|
|
373
|
-
else if (event.type === "event")
|
|
559
|
+
else if (event.type === "event") {
|
|
374
560
|
await this.options.onEvent?.(event.event, event.requestId);
|
|
561
|
+
const onInput = this.options.onInput;
|
|
562
|
+
if (onInput && event.event?.type === "input_required")
|
|
563
|
+
void (async () => {
|
|
564
|
+
const answer = await onInput(event.event.input, event.requestId);
|
|
565
|
+
if (answer)
|
|
566
|
+
await this.answer(event.event.input.id, answer);
|
|
567
|
+
})().catch(error => this.report(error));
|
|
568
|
+
}
|
|
375
569
|
}
|
|
376
570
|
settle(id, value) {
|
|
377
571
|
const waiter = this.pending.get(id);
|
|
@@ -383,6 +577,20 @@ export class AgentClient {
|
|
|
383
577
|
else
|
|
384
578
|
waiter.resolve(value.result);
|
|
385
579
|
}
|
|
580
|
+
/**
|
|
581
|
+
* A request's result arrives as an event; a reconnect also settles from /state. As a last resort,
|
|
582
|
+
* ask for its status now and then, so an event lost on the way can never strand the caller.
|
|
583
|
+
*/
|
|
584
|
+
async outcome(id, result) {
|
|
585
|
+
const poll = setInterval(() => void this.requestStatus(id).then(record => { if (record.outcome)
|
|
586
|
+
this.settle(id, record.outcome); }, () => { }), this.pollMs);
|
|
587
|
+
try {
|
|
588
|
+
return await result;
|
|
589
|
+
}
|
|
590
|
+
finally {
|
|
591
|
+
clearInterval(poll);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
386
594
|
async sync() {
|
|
387
595
|
const state = await this.outcomes();
|
|
388
596
|
for (const request of state.requests)
|
|
@@ -404,31 +612,13 @@ export class AgentClient {
|
|
|
404
612
|
return;
|
|
405
613
|
}
|
|
406
614
|
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
615
|
const key = String(message.id);
|
|
419
|
-
|
|
420
|
-
|
|
616
|
+
const controller = new AbortController();
|
|
617
|
+
if (message.method === "tools/call")
|
|
618
|
+
this.active.set(key, controller);
|
|
421
619
|
try {
|
|
422
|
-
const
|
|
423
|
-
|
|
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) } });
|
|
620
|
+
const answer = await answerMcp(message, this.server, params => toolContext(params, key, controller.signal));
|
|
621
|
+
await this.transport.json(this.path("/mcp"), this.session.token, "POST", { jsonrpc: "2.0", id: message.id, ...answer }, true, { "X-Agent-Connection": connection ?? "" });
|
|
432
622
|
}
|
|
433
623
|
finally {
|
|
434
624
|
this.active.delete(key);
|
|
@@ -454,7 +644,7 @@ export class AgentClient {
|
|
|
454
644
|
const record = await this.http("/requests", "POST", { id, method, params });
|
|
455
645
|
if (record.outcome)
|
|
456
646
|
this.settle(id, record.outcome);
|
|
457
|
-
return await deferred.promise;
|
|
647
|
+
return await this.outcome(id, deferred.promise);
|
|
458
648
|
}
|
|
459
649
|
catch (error) {
|
|
460
650
|
if (error instanceof AgentError) {
|
|
@@ -484,18 +674,69 @@ export class AgentClient {
|
|
|
484
674
|
const record = await this.requestStatus(id);
|
|
485
675
|
if (record.outcome)
|
|
486
676
|
this.settle(id, record.outcome);
|
|
487
|
-
return await deferred.promise;
|
|
677
|
+
return await this.outcome(id, deferred.promise);
|
|
488
678
|
}
|
|
489
679
|
finally {
|
|
490
680
|
clearTimeout(timer);
|
|
491
681
|
this.pending.delete(id);
|
|
492
682
|
}
|
|
493
683
|
}
|
|
494
|
-
|
|
684
|
+
/**
|
|
685
|
+
* `from` says who sent the message: the model sees it in a block only the runtime can write, and
|
|
686
|
+
* `from.id` is the turn's actor. `actor` names someone else acting (`act` in identity tokens) without telling the model.
|
|
687
|
+
*/
|
|
688
|
+
prompt(text, options) {
|
|
689
|
+
return this.message("prompt", text, options, { ...(options?.actor ? { actor: options.actor } : {}) });
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Send a message with its files: each is uploaded to the agent's workspace under the request's
|
|
693
|
+
* id first, then attached by path. `images` (base64 blocks) are sent inline and saved as files.
|
|
694
|
+
*/
|
|
695
|
+
async message(method, text, options, extra = {}) {
|
|
696
|
+
const id = options?.idempotencyKey ?? globalThis.crypto.randomUUID();
|
|
697
|
+
const files = options?.files?.length ? await this.attach(id, options.files) : undefined;
|
|
698
|
+
return this.request(method, { text, ...(files ? { files } : {}), ...(options?.images ? { images: options.images } : {}), ...extra, ...(options?.from ? { from: options.from } : {}) }, { ...options, idempotencyKey: id });
|
|
699
|
+
}
|
|
700
|
+
async attach(requestId, files) {
|
|
701
|
+
const names = new Set();
|
|
702
|
+
const attached = [];
|
|
703
|
+
for (const [index, file] of files.entries()) {
|
|
704
|
+
if (isRecord(file) && "path" in file && typeof file.path === "string" && !("data" in file)) {
|
|
705
|
+
attached.push({ path: file.path });
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
let data, name, contentType;
|
|
709
|
+
if (typeof file === "string") {
|
|
710
|
+
if (!this.openFile)
|
|
711
|
+
throw new AgentError("Attaching a local path needs the Node entry (@camelai/agent-runtime/node); pass bytes or a Blob instead");
|
|
712
|
+
data = await this.openFile(file);
|
|
713
|
+
name = file.split(/[\\/]/).pop();
|
|
714
|
+
}
|
|
715
|
+
else if (file instanceof Uint8Array || file instanceof Blob) {
|
|
716
|
+
data = file;
|
|
717
|
+
name = file.name;
|
|
718
|
+
contentType = file instanceof Blob && file.type ? file.type : undefined;
|
|
719
|
+
}
|
|
720
|
+
else {
|
|
721
|
+
const entry = file;
|
|
722
|
+
({ data, name } = entry);
|
|
723
|
+
contentType = entry.contentType ?? (data instanceof Blob && data.type ? data.type : undefined);
|
|
724
|
+
}
|
|
725
|
+
// Each file in a request needs its own name: they share uploads/<request>/.
|
|
726
|
+
const base = name || `attachment-${index + 1}`;
|
|
727
|
+
let unique = base;
|
|
728
|
+
for (let n = 2; names.has(unique); n++)
|
|
729
|
+
unique = base.replace(/(\.[^.]*)?$/, extension => `-${n}${extension}`);
|
|
730
|
+
names.add(unique);
|
|
731
|
+
const response = await this.transport.raw(this.path(`/uploads/${encodeURIComponent(requestId)}/${encodeURIComponent(unique)}`), this.session.token, { method: "PUT", body: data, headers: contentType ? { "Content-Type": contentType } : {} });
|
|
732
|
+
attached.push({ path: (await response.json()).path });
|
|
733
|
+
}
|
|
734
|
+
return attached;
|
|
735
|
+
}
|
|
495
736
|
history() { return this.http("/history"); }
|
|
496
|
-
continue(options) { return this.request("continue", {}, options); }
|
|
497
|
-
steer(text) { return this.
|
|
498
|
-
followUp(text) { return this.
|
|
737
|
+
continue(options) { return this.request("continue", options?.actor ? { actor: options.actor } : {}, options); }
|
|
738
|
+
steer(text, options) { return this.message("steer", text, options); }
|
|
739
|
+
followUp(text, options) { return this.message("followUp", text, options); }
|
|
499
740
|
/** Change the prompt, thinking level, tools, or model ("provider/model-id") between runs. */
|
|
500
741
|
async configure(options) {
|
|
501
742
|
const { tools, mcp, ...rest } = options;
|
|
@@ -511,7 +752,7 @@ export class AgentClient {
|
|
|
511
752
|
return result;
|
|
512
753
|
}
|
|
513
754
|
execute(code, options) {
|
|
514
|
-
return this.request("execute", { code, ...(options?.executionTimeoutMs ? { timeoutMs: options.executionTimeoutMs } : {}) }, options);
|
|
755
|
+
return this.request("execute", { code, ...(options?.executionTimeoutMs ? { timeoutMs: options.executionTimeoutMs } : {}), ...(options?.actor ? { actor: options.actor } : {}) }, options);
|
|
515
756
|
}
|
|
516
757
|
/**
|
|
517
758
|
* Wake this agent later: with `text` it gets a prompt, with `code` it runs sandboxed
|
|
@@ -525,6 +766,10 @@ export class AgentClient {
|
|
|
525
766
|
status() { return this.request("status"); }
|
|
526
767
|
abort() { return this.request("abort"); }
|
|
527
768
|
requestStatus(id) { return this.http(`/requests/${encodeURIComponent(id)}`); }
|
|
769
|
+
/** Answer an input the agent waits on. `request` is the run resuming its turn, once its last input is answered. */
|
|
770
|
+
answer(inputId, answer) { return this.http(`/inputs/${encodeURIComponent(inputId)}`, "POST", answer); }
|
|
771
|
+
/** The agent's inputs, newest first: `pending` ones, say. */
|
|
772
|
+
inputs(state) { return this.http(`/inputs${state ? `?state=${state}` : ""}`); }
|
|
528
773
|
outcomes() { return this.http("/state"); }
|
|
529
774
|
async close() {
|
|
530
775
|
this.closed = true;
|
|
@@ -15,7 +15,7 @@ export interface SessionCredentials {
|
|
|
15
15
|
token: string;
|
|
16
16
|
expiresAt: number | null;
|
|
17
17
|
}
|
|
18
|
-
export type RequestMethod = "prompt" | "execute" | "status" | "abort" | "history" | "continue" | "steer" | "followUp" | "configure";
|
|
18
|
+
export type RequestMethod = "prompt" | "execute" | "status" | "abort" | "history" | "continue" | "steer" | "followUp" | "configure" | "resume";
|
|
19
19
|
export type RequestRecord = {
|
|
20
20
|
id: string;
|
|
21
21
|
startedAt?: number;
|
|
@@ -33,6 +33,10 @@ export type RequestRecord = {
|
|
|
33
33
|
params?: unknown;
|
|
34
34
|
/** Times a new owner resumed this run's turn after the node running it was lost. */
|
|
35
35
|
resumes?: number;
|
|
36
|
+
/** Who the application said is acting in this run: passed to its tool calls (`act` in identity tokens). */
|
|
37
|
+
actor?: string;
|
|
38
|
+
/** A `resume` run's suspension: the run whose turn waited on human input, which this one continues. */
|
|
39
|
+
suspension?: string;
|
|
36
40
|
};
|
|
37
41
|
/** 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. */
|
|
38
42
|
export type ClientEvent = {
|
|
@@ -59,4 +63,6 @@ export interface ToolDefinition {
|
|
|
59
63
|
resultFormat?: "json" | "content";
|
|
60
64
|
exposure?: "direct" | "codemode" | "both";
|
|
61
65
|
executionMode?: "sequential" | "parallel";
|
|
66
|
+
/** The user approves each call before it runs (a source's approval policy, or the tool's own needsApproval); such a tool is declared directly. */
|
|
67
|
+
needsApproval?: boolean;
|
|
62
68
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camelai/agent-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|
|
@@ -28,6 +28,14 @@
|
|
|
28
28
|
"./mcp": {
|
|
29
29
|
"types": "./dist/clients/mcp.d.ts",
|
|
30
30
|
"default": "./dist/clients/mcp.js"
|
|
31
|
+
},
|
|
32
|
+
"./server": {
|
|
33
|
+
"types": "./dist/clients/server.d.ts",
|
|
34
|
+
"default": "./dist/clients/server.js"
|
|
35
|
+
},
|
|
36
|
+
"./testing": {
|
|
37
|
+
"types": "./dist/clients/testing.d.ts",
|
|
38
|
+
"default": "./dist/clients/testing.js"
|
|
31
39
|
}
|
|
32
40
|
},
|
|
33
41
|
"files": [
|