@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.
@@ -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
- 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 } : {}) }));
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
- if (!retry || attempt >= 3 || (error instanceof AgentError && error.status < 500))
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
- await pause(100 * 2 ** attempt);
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
- /** A request with a raw body or response (volume file contents). */
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
- const response = await this.fetcher(this.base + path, { method: init.method ?? "GET", body: init.body, headers: { Authorization: `Bearer ${token}`, ...init.headers }, redirect: "manual" });
62
- await rejectRedirect(response);
63
- if (!response.ok) {
64
- const value = await response.json().catch(() => ({}));
65
- throw new AgentError(value.error ?? `HTTP ${response.status}`, response.status);
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 session = await this.transport.json("/client-sessions", key, "POST", { tools: definitions(options.tools), ...(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() });
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, calls: {} };
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
- delivered = new Set();
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 || !journal.calls || typeof journal.calls !== "object")
420
+ if (journal.version !== 1 || !Number.isSafeInteger(journal.cursor) || journal.cursor < 0)
184
421
  throw new AgentError("Unsupported client journal");
185
- this.journal = structuredClone(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 concurrent tool completions cannot overwrite newer receipts.
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 id = Number(lines.find(line => line.startsWith("id:"))?.slice(3));
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 === "tool_call")
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
- dispatch(call) {
340
- if (this.closed || this.active.has(call.id) || this.delivered.has(call.id) || ["completed", "cancelled"].includes(call.state))
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
- const controller = new AbortController();
343
- // Defer execution until the active entry exists; replay can arrive immediately.
344
- const task = Promise.resolve().then(() => this.runTool(call, controller)).catch(error => this.report(error)).finally(() => this.active.delete(call.id));
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
- if (call.state === "uncertain")
355
- return; // Already settled as unknown; never execute again.
356
- let value;
357
- if (call.state === "started")
358
- value = { error: "The application lost this tool call's outcome; it may or may not have taken effect", uncertain: true };
359
- else {
360
- this.journal.calls[call.id] = { state: "started" };
361
- await this.save();
362
- try {
363
- // Claim is deliberately NOT retried. A lost acknowledgement is ambiguous.
364
- const claim = await this.http(`/calls/${call.id}/claim`, "POST", {}, false);
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
- prompt(text, options) { return this.request("prompt", { text, ...(options?.images ? { images: options.images } : {}) }, options); }
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.request("steer", { text }); }
464
- followUp(text) { return this.request("followUp", { text }); }
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 result = await this.request("configure", { ...options, ...(options.tools ? { tools: definitions(options.tools) } : {}) });
468
- if (options.tools) {
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, options.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 { controller } of this.active.values())
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));