@camelai/agent-runtime 0.3.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 +39 -2
- package/dist/clients/mcp.d.ts +13 -0
- package/dist/clients/mcp.js +38 -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 +411 -14
- package/dist/clients/typescript.js +383 -100
- package/dist/shared/client-protocol.d.ts +13 -24
- package/package.json +22 -2
|
@@ -2,18 +2,153 @@ 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
|
}
|
|
35
|
+
const META = "agent-runtime/";
|
|
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
|
+
}
|
|
93
|
+
/** `tool({...})` definitions as an attached MCP server: JSON results become a text block (and structured content for objects). */
|
|
94
|
+
export function toolServer(tools) {
|
|
95
|
+
return {
|
|
96
|
+
listTools: () => Object.entries(tools).map(([name, tool]) => ({
|
|
97
|
+
name, description: tool.description, inputSchema: tool.input,
|
|
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
|
+
} } : {}),
|
|
102
|
+
})),
|
|
103
|
+
async callTool(name, args, context) {
|
|
104
|
+
const definition = tools[name];
|
|
105
|
+
if (!Object.hasOwn(tools, name) || !Check(definition.input, args))
|
|
106
|
+
throw new Error("Tool is missing or arguments failed validation");
|
|
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` } } };
|
|
112
|
+
let result;
|
|
113
|
+
try {
|
|
114
|
+
result = await definition.execute(args, context);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (error instanceof InputRequired)
|
|
118
|
+
return { resultType: "input_required", inputRequests: error.inputRequests, ...(error.requestState ? { requestState: error.requestState } : {}) };
|
|
119
|
+
if (context.signal.aborted)
|
|
120
|
+
throw error;
|
|
121
|
+
return { content: [{ type: "text", text: String(error).slice(0, 2048) }], isError: true };
|
|
122
|
+
}
|
|
123
|
+
if (result === undefined || byteLength(JSON.stringify(result)) > 1024 * 1024)
|
|
124
|
+
throw new Error("Tool must return a bounded JSON value");
|
|
125
|
+
if (definition.resultFormat === "content")
|
|
126
|
+
return result;
|
|
127
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], ...(isRecord(result) ? { structuredContent: result } : {}) };
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
9
131
|
export class AgentError extends Error {
|
|
10
132
|
status;
|
|
11
133
|
requestId;
|
|
134
|
+
/** Milliseconds the runtime asked to wait before retrying (its Retry-After), for 429 and 503. */
|
|
135
|
+
retryAfterMs;
|
|
12
136
|
constructor(message, status = 0, requestId) { super(message); this.name = "AgentError"; this.status = status; this.requestId = requestId; }
|
|
13
137
|
}
|
|
138
|
+
/** Retry-After as milliseconds (seconds or an HTTP date), capped so a bad value cannot stall a caller. */
|
|
139
|
+
function retryAfter(response) {
|
|
140
|
+
const value = response.headers.get("retry-after");
|
|
141
|
+
if (value === null)
|
|
142
|
+
return undefined;
|
|
143
|
+
const ms = /^\d+$/.test(value.trim()) ? Number(value) * 1000 : Date.parse(value) - Date.now();
|
|
144
|
+
return Number.isFinite(ms) ? Math.min(Math.max(0, ms), 60_000) : undefined;
|
|
145
|
+
}
|
|
146
|
+
/** A 429 (quota, or an agent's queue is full) was refused before anything happened, so any request may be retried after it. */
|
|
147
|
+
const RATE_LIMIT_ATTEMPTS = 8;
|
|
14
148
|
const byteLength = (value) => new TextEncoder().encode(value).byteLength;
|
|
15
149
|
const pause = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
16
|
-
|
|
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();
|
|
17
152
|
async function rejectRedirect(response) {
|
|
18
153
|
if (response.status >= 300 && response.status < 400) {
|
|
19
154
|
await response.body?.cancel();
|
|
@@ -44,27 +179,67 @@ class Transport {
|
|
|
44
179
|
redirect: "manual", signal: AbortSignal.timeout(10_000),
|
|
45
180
|
});
|
|
46
181
|
await rejectRedirect(response);
|
|
47
|
-
const value = await response.json();
|
|
182
|
+
const value = await (response.ok ? response.json() : response.json().catch(() => ({})));
|
|
48
183
|
if (!response.ok)
|
|
49
|
-
throw new AgentError(value.error ?? `HTTP ${response.status}`, response.status);
|
|
184
|
+
throw Object.assign(new AgentError(value.error ?? `HTTP ${response.status}`, response.status), { retryAfterMs: retryAfter(response) });
|
|
50
185
|
return value;
|
|
51
186
|
}
|
|
52
187
|
catch (error) {
|
|
53
|
-
|
|
188
|
+
const limited = error instanceof AgentError && error.status === 429;
|
|
189
|
+
if (limited ? attempt >= RATE_LIMIT_ATTEMPTS - 1 : !retry || attempt >= 3 || (error instanceof AgentError && error.status < 500))
|
|
54
190
|
throw error;
|
|
55
|
-
|
|
191
|
+
// Honour the runtime's Retry-After, with jitter so refused callers do not return together; else back off exponentially.
|
|
192
|
+
const backoff = Math.min(10_000, (limited ? 500 : 100) * 2 ** attempt);
|
|
193
|
+
const hinted = error instanceof AgentError ? error.retryAfterMs : undefined;
|
|
194
|
+
await pause(hinted !== undefined ? hinted + Math.random() * Math.min(1000, backoff) : backoff);
|
|
56
195
|
}
|
|
57
196
|
}
|
|
58
197
|
}
|
|
59
|
-
/**
|
|
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
|
+
*/
|
|
60
202
|
async raw(path, token, init = {}) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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;
|
|
66
242
|
}
|
|
67
|
-
return response;
|
|
68
243
|
}
|
|
69
244
|
}
|
|
70
245
|
/** Trusted-backend SDK. Only createAgent needs the operator key. */
|
|
@@ -76,7 +251,8 @@ export class AgentRuntime {
|
|
|
76
251
|
const key = this.options.apiKey;
|
|
77
252
|
if (!key)
|
|
78
253
|
throw new AgentError("Set apiKey to provision an agent");
|
|
79
|
-
const
|
|
254
|
+
const server = options.mcp ?? toolServer(options.tools ?? {});
|
|
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() });
|
|
80
256
|
return this.connectAgent(session, options);
|
|
81
257
|
}
|
|
82
258
|
async connectAgent(session, options) {
|
|
@@ -92,7 +268,7 @@ export class AgentRuntime {
|
|
|
92
268
|
}
|
|
93
269
|
operator() {
|
|
94
270
|
if (!this.options.apiKey)
|
|
95
|
-
throw new AgentError("Set apiKey to manage volumes and mounts");
|
|
271
|
+
throw new AgentError("Set apiKey to manage definitions, volumes and mounts");
|
|
96
272
|
return this.options.apiKey;
|
|
97
273
|
}
|
|
98
274
|
createVolume(options = {}) { return this.transport.json("/v1/volumes", this.operator(), "POST", options, false); }
|
|
@@ -103,9 +279,29 @@ export class AgentRuntime {
|
|
|
103
279
|
throw new AgentError("Invalid volume id");
|
|
104
280
|
return new VolumeHandle(this.transport, this.operator(), id);
|
|
105
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); }
|
|
106
292
|
mounts(agentId) { return this.transport.json(`/v1/agents/${encodeURIComponent(agentId)}/mounts`, this.operator()); }
|
|
107
293
|
/** Replace an agent's mounts; an idle agent restarts so its tools describe them. */
|
|
108
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
|
+
}
|
|
109
305
|
}
|
|
110
306
|
/** Files are versioned: pass `version` to write or remove only if nobody changed the file since (0: must not exist). */
|
|
111
307
|
export class VolumeHandle {
|
|
@@ -127,18 +323,21 @@ export class VolumeHandle {
|
|
|
127
323
|
const query = new URLSearchParams(Object.entries(options).filter(([, value]) => value !== undefined).map(([key, value]) => [key, String(value)]));
|
|
128
324
|
return this.transport.json(this.path(`/files${query.size ? `?${query}` : ""}`), this.token);
|
|
129
325
|
}
|
|
326
|
+
/** Without `contentType`, the runtime sniffs it from the file's first bytes and name. */
|
|
130
327
|
async write(path, data, options = {}) {
|
|
131
328
|
const body = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
|
132
|
-
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}"` } : {}) };
|
|
133
330
|
return (await this.transport.raw(this.file(path), this.token, { method: "PUT", body, headers })).json();
|
|
134
331
|
}
|
|
135
332
|
/** A file's bytes, or `range` of them ([start, end) in bytes). */
|
|
136
333
|
async read(path, options = {}) {
|
|
137
334
|
const [start, end] = options.range ?? [];
|
|
138
335
|
const response = await this.transport.raw(this.file(path), this.token, start !== undefined ? { headers: { Range: `bytes=${start}-${end !== undefined ? end - 1 : ""}` } } : {});
|
|
139
|
-
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) };
|
|
140
337
|
}
|
|
141
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); }
|
|
142
341
|
async remove(path, options = {}) {
|
|
143
342
|
return (await this.transport.raw(this.file(path), this.token, { method: "DELETE", headers: options.version !== undefined ? { "If-Match": `"${options.version}"` } : {} })).json();
|
|
144
343
|
}
|
|
@@ -150,18 +349,52 @@ export function memoryJournalStore() {
|
|
|
150
349
|
async save(id, journal) { entries.set(id, structuredClone(journal)); },
|
|
151
350
|
};
|
|
152
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
|
+
}
|
|
153
379
|
export class AgentClient {
|
|
154
380
|
session;
|
|
155
381
|
tools;
|
|
382
|
+
server;
|
|
156
383
|
transport;
|
|
157
384
|
store;
|
|
158
|
-
journal = { version: 1, cursor: 0
|
|
385
|
+
journal = { version: 1, cursor: 0 };
|
|
159
386
|
loaded;
|
|
160
387
|
saving = Promise.resolve();
|
|
161
388
|
options;
|
|
389
|
+
openFile;
|
|
390
|
+
pollMs;
|
|
391
|
+
/** The agent's files: list, download, upload and link. */
|
|
392
|
+
files;
|
|
162
393
|
pending = new Map();
|
|
394
|
+
/** Tool calls running, by JSON-RPC id, so the runtime can cancel them. */
|
|
163
395
|
active = new Map();
|
|
164
|
-
|
|
396
|
+
/** The event stream's connection, named in the MCP messages this client sends back. */
|
|
397
|
+
connection;
|
|
165
398
|
stream;
|
|
166
399
|
loop;
|
|
167
400
|
closed = false;
|
|
@@ -172,21 +405,25 @@ export class AgentClient {
|
|
|
172
405
|
throw new AgentError("Invalid session id");
|
|
173
406
|
this.session = { id: session.id, token: session.token, expiresAt: session.expiresAt };
|
|
174
407
|
this.tools = { ...options.tools };
|
|
408
|
+
this.server = options.mcp ?? toolServer(this.tools);
|
|
175
409
|
this.options = options;
|
|
176
410
|
this.transport = new Transport(runtime);
|
|
177
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());
|
|
178
415
|
}
|
|
179
416
|
async load() {
|
|
180
417
|
const journal = await this.store.load(this.session.id);
|
|
181
418
|
if (!journal)
|
|
182
419
|
return;
|
|
183
|
-
if (journal.version !== 1 || !Number.isSafeInteger(journal.cursor) || journal.cursor < 0
|
|
420
|
+
if (journal.version !== 1 || !Number.isSafeInteger(journal.cursor) || journal.cursor < 0)
|
|
184
421
|
throw new AgentError("Unsupported client journal");
|
|
185
|
-
this.journal =
|
|
422
|
+
this.journal = { version: 1, cursor: journal.cursor };
|
|
186
423
|
}
|
|
187
424
|
save() {
|
|
188
425
|
const snapshot = structuredClone(this.journal);
|
|
189
|
-
// Serialize commits so
|
|
426
|
+
// Serialize commits so an older cursor never overwrites a newer one.
|
|
190
427
|
this.saving = this.saving.then(() => this.store.save(this.session.id, snapshot));
|
|
191
428
|
return this.saving;
|
|
192
429
|
}
|
|
@@ -251,13 +488,22 @@ export class AgentClient {
|
|
|
251
488
|
if (!data)
|
|
252
489
|
continue;
|
|
253
490
|
if (lines.includes("event: ready")) {
|
|
491
|
+
this.connection = JSON.parse(data).connection;
|
|
254
492
|
await this.sync();
|
|
255
493
|
backoff = 250;
|
|
256
494
|
this.ready.resolve();
|
|
257
495
|
this.options.onConnection?.(true);
|
|
258
496
|
continue;
|
|
259
497
|
}
|
|
260
|
-
const
|
|
498
|
+
const idLine = lines.find(line => line.startsWith("id:"));
|
|
499
|
+
// The runtime's MCP messages are live only: no id, never replayed, no cursor.
|
|
500
|
+
if (!idLine) {
|
|
501
|
+
const event = JSON.parse(data);
|
|
502
|
+
if (event.type === "mcp")
|
|
503
|
+
void this.mcp(event.message).catch(error => this.report(error));
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
const id = Number(idLine.slice(3));
|
|
261
507
|
if (!Number.isSafeInteger(id) || id <= 0)
|
|
262
508
|
throw new AgentError("Invalid SSE cursor");
|
|
263
509
|
if (id <= this.journal.cursor)
|
|
@@ -308,14 +554,18 @@ export class AgentClient {
|
|
|
308
554
|
return this.transport.json(this.path('/metadata'), this.session.token, 'POST', metadata);
|
|
309
555
|
}
|
|
310
556
|
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")
|
|
557
|
+
if (event.type === "response")
|
|
316
558
|
this.settle(event.id, event.outcome);
|
|
317
|
-
else if (event.type === "event")
|
|
559
|
+
else if (event.type === "event") {
|
|
318
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
|
+
}
|
|
319
569
|
}
|
|
320
570
|
settle(id, value) {
|
|
321
571
|
const waiter = this.pending.get(id);
|
|
@@ -327,78 +577,52 @@ export class AgentClient {
|
|
|
327
577
|
else
|
|
328
578
|
waiter.resolve(value.result);
|
|
329
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
|
+
}
|
|
330
594
|
async sync() {
|
|
331
595
|
const state = await this.outcomes();
|
|
332
596
|
for (const request of state.requests)
|
|
333
597
|
if (request.outcome)
|
|
334
598
|
this.settle(request.id, request.outcome);
|
|
335
|
-
for (const call of state.calls)
|
|
336
|
-
this.dispatch(call);
|
|
337
599
|
return state;
|
|
338
600
|
}
|
|
339
|
-
|
|
340
|
-
|
|
601
|
+
/**
|
|
602
|
+
* Answer the runtime's JSON-RPC messages as the agent's attached MCP server: initialize,
|
|
603
|
+
* ping, tools/list and tools/call, and cancellation. The runtime runs a call once; a call
|
|
604
|
+
* whose answer is lost with the connection ends for the agent as "outcome unknown".
|
|
605
|
+
*/
|
|
606
|
+
async mcp(message) {
|
|
607
|
+
if (typeof message.method !== "string")
|
|
341
608
|
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);
|
|
609
|
+
if (message.id === undefined) {
|
|
610
|
+
if (message.method === "notifications/cancelled")
|
|
611
|
+
this.active.get(String(message.params?.requestId))?.abort();
|
|
352
612
|
return;
|
|
353
613
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
if (
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
this.
|
|
361
|
-
await this.
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
if (!claim.execute) {
|
|
366
|
-
if (claim.call.state !== "started")
|
|
367
|
-
return;
|
|
368
|
-
value = { error: "Tool execution was already claimed; outcome unknown", uncertain: true };
|
|
369
|
-
}
|
|
370
|
-
else {
|
|
371
|
-
const timer = setTimeout(() => controller.abort(), Math.max(1, call.deadline - Date.now()));
|
|
372
|
-
// A callback that ignores cancellation must not keep a closed client's process alive until the deadline.
|
|
373
|
-
timer.unref?.();
|
|
374
|
-
try {
|
|
375
|
-
const definition = this.tools[call.name];
|
|
376
|
-
if (!Object.hasOwn(this.tools, call.name) || !Check(definition.input, call.args))
|
|
377
|
-
throw new Error("Tool is missing or arguments failed validation");
|
|
378
|
-
controller.signal.throwIfAborted();
|
|
379
|
-
const result = await definition.execute(call.args, { callId: call.id, toolCallId: call.toolCallId, signal: controller.signal, ...(call.origin ? { origin: call.origin } : {}) });
|
|
380
|
-
if (result === undefined || byteLength(JSON.stringify(result)) > 1024 * 1024)
|
|
381
|
-
throw new Error("Tool must return a bounded JSON value");
|
|
382
|
-
value = { result };
|
|
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
|
-
}
|
|
614
|
+
const connection = this.connection;
|
|
615
|
+
const key = String(message.id);
|
|
616
|
+
const controller = new AbortController();
|
|
617
|
+
if (message.method === "tools/call")
|
|
618
|
+
this.active.set(key, controller);
|
|
619
|
+
try {
|
|
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 ?? "" });
|
|
622
|
+
}
|
|
623
|
+
finally {
|
|
624
|
+
this.active.delete(key);
|
|
395
625
|
}
|
|
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
626
|
}
|
|
403
627
|
async request(method, params = {}, options = {}) {
|
|
404
628
|
if (this.closed || this.fatal)
|
|
@@ -420,7 +644,7 @@ export class AgentClient {
|
|
|
420
644
|
const record = await this.http("/requests", "POST", { id, method, params });
|
|
421
645
|
if (record.outcome)
|
|
422
646
|
this.settle(id, record.outcome);
|
|
423
|
-
return await deferred.promise;
|
|
647
|
+
return await this.outcome(id, deferred.promise);
|
|
424
648
|
}
|
|
425
649
|
catch (error) {
|
|
426
650
|
if (error instanceof AgentError) {
|
|
@@ -450,30 +674,85 @@ export class AgentClient {
|
|
|
450
674
|
const record = await this.requestStatus(id);
|
|
451
675
|
if (record.outcome)
|
|
452
676
|
this.settle(id, record.outcome);
|
|
453
|
-
return await deferred.promise;
|
|
677
|
+
return await this.outcome(id, deferred.promise);
|
|
454
678
|
}
|
|
455
679
|
finally {
|
|
456
680
|
clearTimeout(timer);
|
|
457
681
|
this.pending.delete(id);
|
|
458
682
|
}
|
|
459
683
|
}
|
|
460
|
-
|
|
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
|
+
}
|
|
461
736
|
history() { return this.http("/history"); }
|
|
462
|
-
continue(options) { return this.request("continue", {}, options); }
|
|
463
|
-
steer(text) { return this.
|
|
464
|
-
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); }
|
|
465
740
|
/** Change the prompt, thinking level, tools, or model ("provider/model-id") between runs. */
|
|
466
741
|
async configure(options) {
|
|
467
|
-
const
|
|
468
|
-
|
|
742
|
+
const { tools, mcp, ...rest } = options;
|
|
743
|
+
const server = mcp ?? (tools ? toolServer(tools) : undefined);
|
|
744
|
+
const result = await this.request("configure", { ...rest, ...(server ? { mcp: { tools: await server.listTools() } } : {}) });
|
|
745
|
+
if (tools) {
|
|
469
746
|
for (const key of Object.keys(this.tools))
|
|
470
747
|
delete this.tools[key];
|
|
471
|
-
Object.assign(this.tools,
|
|
748
|
+
Object.assign(this.tools, tools);
|
|
472
749
|
}
|
|
750
|
+
if (server)
|
|
751
|
+
this.server = mcp ?? toolServer(this.tools);
|
|
473
752
|
return result;
|
|
474
753
|
}
|
|
475
754
|
execute(code, options) {
|
|
476
|
-
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);
|
|
477
756
|
}
|
|
478
757
|
/**
|
|
479
758
|
* Wake this agent later: with `text` it gets a prompt, with `code` it runs sandboxed
|
|
@@ -487,11 +766,15 @@ export class AgentClient {
|
|
|
487
766
|
status() { return this.request("status"); }
|
|
488
767
|
abort() { return this.request("abort"); }
|
|
489
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}` : ""}`); }
|
|
490
773
|
outcomes() { return this.http("/state"); }
|
|
491
774
|
async close() {
|
|
492
775
|
this.closed = true;
|
|
493
776
|
this.stream?.abort();
|
|
494
|
-
for (const
|
|
777
|
+
for (const controller of this.active.values())
|
|
495
778
|
controller.abort();
|
|
496
779
|
for (const [id, waiter] of this.pending)
|
|
497
780
|
waiter.reject(new AgentError("Client closed; request may still be running", 0, id));
|