@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.
@@ -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
- const isRecord = (value) => !!value && typeof value === "object" && !Array.isArray(value);
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 ? { _meta: { ...(tool.exposure ? { [`${META}exposure`]: tool.exposure } : {}), ...(tool.executionMode ? { [`${META}executionMode`]: 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
- /** 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
+ */
106
202
  async raw(path, token, init = {}) {
107
- const response = await this.fetcher(this.base + path, { method: init.method ?? "GET", body: init.body, headers: { Authorization: `Bearer ${token}`, ...init.headers }, redirect: "manual" });
108
- await rejectRedirect(response);
109
- if (!response.ok) {
110
- const value = await response.json().catch(() => ({}));
111
- 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;
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
- this.active.set(key, controller);
420
- const meta = params._meta ?? {};
616
+ const controller = new AbortController();
617
+ if (message.method === "tools/call")
618
+ this.active.set(key, controller);
421
619
  try {
422
- const result = await this.server.callTool(params.name, params.arguments ?? {}, {
423
- callId: meta["agent-runtime/callId"] ?? key, signal: controller.signal,
424
- ...(meta["agent-runtime/toolCallId"] ? { toolCallId: meta["agent-runtime/toolCallId"] } : {}), ...(meta["agent-runtime/origin"] ? { origin: meta["agent-runtime/origin"] } : {}),
425
- });
426
- if (!isRecord(result) || !Array.isArray(result.content) || byteLength(JSON.stringify(result)) > 1024 * 1024)
427
- throw new Error("The MCP server must answer with a bounded CallToolResult");
428
- await reply({ result });
429
- }
430
- catch (error) {
431
- await reply({ error: { code: -32603, message: String(error).slice(0, 2048) } });
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
- 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
+ }
495
736
  history() { return this.http("/history"); }
496
- continue(options) { return this.request("continue", {}, options); }
497
- steer(text) { return this.request("steer", { text }); }
498
- 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); }
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.4.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": [