@camelai/run 0.0.0 → 0.11.1

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.
@@ -0,0 +1,1037 @@
1
+ import { Type } from "typebox";
2
+ import { Check } from "typebox/value";
3
+ import { FRAME_BYTES } from "../shared/client-protocol.js";
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
+ }
31
+ /** Infer callback arguments from the schema; no manually duplicated argument type. */
32
+ export function tool(definition) {
33
+ return { ...definition, input: definition.input };
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, notify) {
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
+ const callId = typeof meta[`${META}callId`] === "string" ? meta[`${META}callId`] : fallbackId;
61
+ const toolCallId = typeof meta[`${META}toolCallId`] === "string" ? meta[`${META}toolCallId`] : undefined;
62
+ const innerCallId = typeof meta[`${META}innerCallId`] === "string" ? meta[`${META}innerCallId`] : undefined;
63
+ // The runtime's stable key; from a runtime that sends none, the model's call (and the code's call within it).
64
+ const idempotencyKey = typeof meta[`${META}idempotencyKey`] === "string" ? meta[`${META}idempotencyKey`]
65
+ : toolCallId ? [who?.agent ?? "", toolCallId, innerCallId ?? ""].join(":") : callId;
66
+ const progressToken = meta.progressToken;
67
+ let reported = 0;
68
+ return {
69
+ callId, idempotencyKey, signal,
70
+ ...(toolCallId ? { toolCallId } : {}),
71
+ progress(update) {
72
+ if (progressToken === undefined || !notify || signal.aborted)
73
+ return;
74
+ const value = typeof update === "string" ? { progress: ++reported, message: update } : { ...update, progress: Math.max(update.progress, reported) };
75
+ reported = value.progress;
76
+ notify({ jsonrpc: "2.0", method: "notifications/progress", params: { progressToken, ...value } });
77
+ },
78
+ ...(origin ? { origin } : {}), ...(who ? { identity: who } : {}),
79
+ confirm: async (message) => (await request({ mode: "form", message, requestedSchema: { type: "object", properties: {} } })).action === "accept",
80
+ ask: async (message, schema) => { const answer = await request({ mode: "form", message, requestedSchema: schema }); return answer.action === "accept" ? answer.content : undefined; },
81
+ requireUrl: async (url, message) => (await request({ mode: "url", message, url, elicitationId: `${fallbackId}-${asked + 1}` })).action === "accept",
82
+ };
83
+ }
84
+ /**
85
+ * Answer one MCP JSON-RPC request as a tool server: initialize, ping, tools/list and tools/call.
86
+ * Both an attached server (answering over the agent's connection) and `serveTools` (over HTTP) use it.
87
+ */
88
+ export async function answerMcp(message, server, context, info = { name: "agent-runtime-sdk", version: "1.0.0" }) {
89
+ const params = isRecord(message.params) ? message.params : {};
90
+ if (message.method === "initialize")
91
+ return { result: { protocolVersion: typeof params.protocolVersion === "string" ? params.protocolVersion : "2025-06-18", capabilities: { tools: {} }, serverInfo: info } };
92
+ if (message.method === "ping")
93
+ return { result: {} };
94
+ if (message.method === "tools/list")
95
+ return { result: { tools: await server.listTools() } };
96
+ if (message.method !== "tools/call")
97
+ return { error: { code: -32601, message: `Unknown method ${message.method}` } };
98
+ try {
99
+ const result = await server.callTool(String(params.name), isRecord(params.arguments) ? params.arguments : {}, context(params));
100
+ if (!isRecord(result) || (!Array.isArray(result.content) && result.resultType !== "input_required") || byteLength(JSON.stringify(result)) > 1024 * 1024)
101
+ throw new Error("The MCP server must answer with a bounded CallToolResult");
102
+ return { result };
103
+ }
104
+ catch (error) {
105
+ return { error: { code: -32603, message: String(error).slice(0, 2048) } };
106
+ }
107
+ }
108
+ /** `tool({...})` definitions as an attached MCP server: JSON results become a text block (and structured content for objects). */
109
+ export function toolServer(tools) {
110
+ return {
111
+ listTools: () => Object.entries(tools).map(([name, tool]) => ({
112
+ name, description: tool.description, inputSchema: tool.input,
113
+ ...(tool.exposure || tool.executionMode || tool.needsApproval || tool.timeoutMs ? { _meta: {
114
+ ...(tool.exposure ? { [`${META}exposure`]: tool.exposure } : {}), ...(tool.executionMode ? { [`${META}executionMode`]: tool.executionMode } : {}),
115
+ ...(tool.needsApproval ? { [`${META}needsApproval`]: true } : {}), ...(tool.timeoutMs ? { [`${META}timeoutMs`]: tool.timeoutMs } : {}),
116
+ } } : {}),
117
+ })),
118
+ async callTool(name, args, context) {
119
+ const definition = tools[name];
120
+ if (!Object.hasOwn(tools, name) || !Check(definition.input, args))
121
+ throw new Error("Tool is missing or arguments failed validation");
122
+ context.signal.throwIfAborted();
123
+ // Not yet approved: the runtime asks the user, showing this call, and calls again once they approve.
124
+ const asks = typeof definition.needsApproval === "function" ? await definition.needsApproval(args, context) : definition.needsApproval;
125
+ if (asks && !context.identity?.approval)
126
+ return { resultType: "input_required", inputRequests: { approval: { method: `${META}approval` } } };
127
+ let result;
128
+ try {
129
+ result = await definition.execute(args, context);
130
+ }
131
+ catch (error) {
132
+ if (error instanceof InputRequired)
133
+ return { resultType: "input_required", inputRequests: error.inputRequests, ...(error.requestState ? { requestState: error.requestState } : {}) };
134
+ if (context.signal.aborted)
135
+ throw error;
136
+ return { content: [{ type: "text", text: String(error).slice(0, 2048) }], isError: true };
137
+ }
138
+ // A tool that returns nothing did its work: the model hears null, not a failure it would retry.
139
+ if (result === undefined)
140
+ result = null;
141
+ if (byteLength(JSON.stringify(result)) > 1024 * 1024)
142
+ throw new Error("Tool must return a bounded JSON value");
143
+ if (definition.resultFormat === "content")
144
+ return result;
145
+ return { content: [{ type: "text", text: JSON.stringify(result) }], ...(isRecord(result) ? { structuredContent: result } : {}) };
146
+ },
147
+ };
148
+ }
149
+ /** The hosted runtime; `url` points elsewhere (a self-hosted runtime, or http://127.0.0.1:8790 in development). */
150
+ export const DEFAULT_URL = "https://run.camelai.com";
151
+ export class AgentError extends Error {
152
+ /** The HTTP status, or 0 for a failure that is not an HTTP response's (a run's, the connection's). */
153
+ status;
154
+ requestId;
155
+ /** A stable name for the failure where the runtime gives one, e.g. APPLICATION_NOT_CONNECTED. */
156
+ code;
157
+ /** The runtime could not tell whether the work took effect (a restart cut it short). */
158
+ uncertain;
159
+ /** Milliseconds the runtime asked to wait before retrying (its Retry-After), for 429 and 503. */
160
+ retryAfterMs;
161
+ constructor(message, status = 0, requestId) { super(message); this.name = "AgentError"; this.status = status; this.requestId = requestId; }
162
+ }
163
+ /** A run that failed (`agent.run` throws it unless `throwOnError: false`): `run` is how it ended, `code` why. */
164
+ export class RunError extends AgentError {
165
+ run;
166
+ constructor(run) {
167
+ super(run.error?.message ?? "The run failed", 0, run.id);
168
+ this.name = "RunError";
169
+ this.run = run;
170
+ this.code = run.error?.code;
171
+ if (run.error?.uncertain)
172
+ this.uncertain = true;
173
+ }
174
+ }
175
+ /** An error body's stable name: its `code`, or the prefix of its message (`APPLICATION_CONNECTED: …`). */
176
+ function codeOf(value) {
177
+ if (typeof value.code === "string")
178
+ return { code: value.code };
179
+ const prefix = typeof value.error === "string" ? /^([A-Z][A-Z0-9_]+):/.exec(value.error)?.[1] : undefined;
180
+ return prefix ? { code: prefix } : {};
181
+ }
182
+ /** Retry-After as milliseconds (seconds or an HTTP date), capped so a bad value cannot stall a caller. */
183
+ function retryAfter(response) {
184
+ const value = response.headers.get("retry-after");
185
+ if (value === null)
186
+ return undefined;
187
+ const ms = /^\d+$/.test(value.trim()) ? Number(value) * 1000 : Date.parse(value) - Date.now();
188
+ return Number.isFinite(ms) ? Math.min(Math.max(0, ms), 60_000) : undefined;
189
+ }
190
+ /** A 429 (quota, or an agent's queue is full) was refused before anything happened, so any request may be retried after it. */
191
+ const RATE_LIMIT_ATTEMPTS = 8;
192
+ const byteLength = (value) => new TextEncoder().encode(value).byteLength;
193
+ const pause = (ms) => new Promise(resolve => setTimeout(resolve, ms));
194
+ /** A download's content type, without parameters (text is always UTF-8). */
195
+ const contentTypeOf = (response) => (response.headers.get("content-type") ?? "application/octet-stream").split(";")[0].trim();
196
+ /** A downloaded file's version, from X-File-Version: proxies may rewrite the ETag (W/"n" once gzipped). */
197
+ const fileVersion = (response) => Number(response.headers.get("x-file-version"));
198
+ async function rejectRedirect(response) {
199
+ if (response.status >= 300 && response.status < 400) {
200
+ await response.body?.cancel();
201
+ throw new AgentError("Runtime redirects are not allowed", response.status);
202
+ }
203
+ }
204
+ /**
205
+ * Hosts plain http:// may reach: loopback, and names and addresses only a private network resolves
206
+ * (a Docker Compose service, `*.internal`, `*.local`, 10/8, 172.16/12, 192.168/16), as for a runtime kept private.
207
+ */
208
+ export function privateHost(hostname) {
209
+ const host = hostname.toLowerCase().replace(/\.$/, "");
210
+ if (["localhost", "[::1]"].includes(host) || /\.(?:localhost|internal|local)$/.test(host))
211
+ return true;
212
+ const ip = /^(\d{1,3})\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/.exec(host);
213
+ if (ip) {
214
+ const [a, b] = [Number(ip[1]), Number(ip[2])];
215
+ return a === 127 || a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
216
+ }
217
+ return !host.includes(".") && !host.startsWith("[");
218
+ }
219
+ class Transport {
220
+ base;
221
+ fetcher;
222
+ constructor(options) {
223
+ const url = new URL(options.url ?? DEFAULT_URL);
224
+ if (url.username || url.password || url.search || url.hash || url.pathname !== "/")
225
+ throw new Error("Use a runtime origin without credentials, path, or query");
226
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && privateHost(url.hostname)))
227
+ throw new Error("Remote runtimes require https://; http:// only on a private network (localhost, a single-label or .internal name, a private IP)");
228
+ this.base = url.origin;
229
+ const fetcher = options.fetch;
230
+ this.fetcher = fetcher ? (input, init) => fetcher(input, init) : globalThis.fetch.bind(globalThis);
231
+ }
232
+ async json(path, token, method = "GET", body, retry = true, headers = {}) {
233
+ const data = body === undefined ? undefined : JSON.stringify(body);
234
+ if (data && byteLength(data) > FRAME_BYTES)
235
+ throw new AgentError("Request exceeds transport limit");
236
+ for (let attempt = 0;; attempt++) {
237
+ try {
238
+ const response = await this.fetcher(this.base + path, {
239
+ method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...headers }, body: data,
240
+ redirect: "manual", signal: AbortSignal.timeout(10_000),
241
+ });
242
+ await rejectRedirect(response);
243
+ const value = await (response.ok ? response.json() : response.json().catch(() => ({})));
244
+ if (!response.ok)
245
+ throw Object.assign(new AgentError(value.error ?? `HTTP ${response.status}`, response.status), { retryAfterMs: retryAfter(response), ...codeOf(value) });
246
+ return value;
247
+ }
248
+ catch (error) {
249
+ const limited = error instanceof AgentError && error.status === 429;
250
+ if (limited ? attempt >= RATE_LIMIT_ATTEMPTS - 1 : !retry || attempt >= 3 || (error instanceof AgentError && error.status < 500))
251
+ throw error;
252
+ // Honour the runtime's Retry-After, with jitter so refused callers do not return together; else back off exponentially.
253
+ const backoff = Math.min(10_000, (limited ? 500 : 100) * 2 ** attempt);
254
+ const hinted = error instanceof AgentError ? error.retryAfterMs : undefined;
255
+ await pause(hinted !== undefined ? hinted + Math.random() * Math.min(1000, backoff) : backoff);
256
+ }
257
+ }
258
+ }
259
+ /**
260
+ * A request with a raw body or response (file contents). It fails once nothing arrives for 30 s
261
+ * (an upload has the runtime's 15 minutes to be sent), so a stalled transfer never hangs its caller.
262
+ */
263
+ async raw(path, token, init = {}) {
264
+ // A 503 (the agent is moving, a node draining) was refused before anything happened; a read may be retried after anything.
265
+ for (let attempt = 0;; attempt++) {
266
+ try {
267
+ return await this.transfer(path, token, init);
268
+ }
269
+ catch (error) {
270
+ const status = error instanceof AgentError ? error.status : 500;
271
+ if (attempt >= 3 || !(status === 503 || (status >= 500 && (init.method ?? "GET") === "GET")))
272
+ throw error;
273
+ await pause(error.retryAfterMs ?? 100 * 2 ** attempt);
274
+ }
275
+ }
276
+ }
277
+ async transfer(path, token, init) {
278
+ const controller = new AbortController();
279
+ let timer;
280
+ const wait = (ms) => { clearTimeout(timer); timer = setTimeout(() => controller.abort(new AgentError("File transfer stalled")), ms); };
281
+ wait(init.body === undefined ? 30_000 : 15 * 60_000);
282
+ try {
283
+ 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 });
284
+ await rejectRedirect(response);
285
+ if (!response.ok) {
286
+ const value = await response.json().catch(() => ({}));
287
+ throw Object.assign(new AgentError(value.error ?? `HTTP ${response.status}`, response.status), { retryAfterMs: retryAfter(response) });
288
+ }
289
+ if (!response.body) {
290
+ clearTimeout(timer);
291
+ return response;
292
+ }
293
+ wait(30_000);
294
+ const body = response.body.pipeThrough(new TransformStream({
295
+ transform(chunk, stream) { wait(30_000); stream.enqueue(chunk); },
296
+ flush() { clearTimeout(timer); },
297
+ }));
298
+ return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
299
+ }
300
+ catch (error) {
301
+ clearTimeout(timer);
302
+ throw error;
303
+ }
304
+ }
305
+ }
306
+ /** What an agent's key, and a request's id (its idempotency key), may be. */
307
+ const AGENT_KEY = /^[A-Za-z0-9_-]{1,80}$/;
308
+ const REQUEST_ID = AGENT_KEY;
309
+ /** A create request's fields, from the options given. */
310
+ function provisioning(options) {
311
+ const fields = ["subject", "context", "keyScope", "spendLimit", "modelHeaders", "definition", "mounts", "model", "thinkingLevel", "initialMessages", "name", "type", "systemPrompt", "systemPromptAppend", "fileTools", "builtins", "prompt"];
312
+ return Object.fromEntries(fields.filter(field => options[field] !== undefined).map(field => [field, options[field]]));
313
+ }
314
+ /** Trusted-backend SDK. Only createAgent needs the operator key. */
315
+ export class AgentRuntime {
316
+ options;
317
+ transport;
318
+ constructor(options = {}) { this.options = options; this.transport = new Transport(options); }
319
+ /**
320
+ * The agent for `key`: made if there is none, set to `options` if it differs. Returns its credentials;
321
+ * connect with `connectAgent`. Keyed agents live until they are deleted.
322
+ */
323
+ async upsertAgent(key, options) {
324
+ const apiKey = this.options.apiKey;
325
+ if (!apiKey)
326
+ throw new AgentError("Set apiKey to provision an agent");
327
+ if (!AGENT_KEY.test(key))
328
+ throw new AgentError(`An agent's key is 1 to 80 letters, digits, _ and -: ${JSON.stringify(key.slice(0, 100))} is not`);
329
+ const server = options.mcp ?? toolServer(options.tools ?? {});
330
+ // The key is the agent's idempotency key: the same key is the same agent, reconfigured when its configuration differs.
331
+ const answer = await this.transport.json("/v1/agents", apiKey, "POST", { mcp: { tools: await server.listTools() }, ...provisioning(options) }, true, { "Idempotency-Key": key });
332
+ return { session: { id: answer.id, token: answer.token, expiresAt: answer.expiresAt ?? null }, ...(answer.reconfigured ? { reconfigured: answer.reconfigured } : {}), ...(answer.prompt ? { prompt: answer.prompt } : {}) };
333
+ }
334
+ async createAgent(options) {
335
+ const key = this.options.apiKey;
336
+ if (!key)
337
+ throw new AgentError("Set apiKey to provision an agent");
338
+ const server = options.mcp ?? toolServer(options.tools ?? {});
339
+ // A key of the caller's makes the agent durable (it lives until deleted); one the SDK makes up, only so a retried
340
+ // create finds the same agent, keeps a scratch agent's day, said explicitly since any key would make it durable.
341
+ const ttlSeconds = options.ttlSeconds !== undefined ? options.ttlSeconds : options.idempotencyKey === undefined ? 86_400 : undefined;
342
+ const session = await this.transport.json("/v1/agents", key, "POST", { mcp: { tools: await server.listTools() }, ...provisioning(options), ...(ttlSeconds !== undefined ? { ttlSeconds } : {}) }, true, { "Idempotency-Key": options.idempotencyKey ?? globalThis.crypto.randomUUID() });
343
+ return this.connectAgent(session, options);
344
+ }
345
+ async connectAgent(session, options) {
346
+ const client = new AgentClient(this.options, session, options);
347
+ try {
348
+ await client.connect();
349
+ return client;
350
+ }
351
+ catch (error) {
352
+ await client.close();
353
+ throw error;
354
+ }
355
+ }
356
+ operator() {
357
+ if (!this.options.apiKey)
358
+ throw new AgentError("Set apiKey to manage definitions, volumes and mounts");
359
+ return this.options.apiKey;
360
+ }
361
+ /**
362
+ * Add or replace a provider of your own: a server that speaks OpenAI Chat Completions, with the models it has. Agents
363
+ * name them `<name>/<model id>`. `apiKey` and `headers` left out keep what is stored; null removes them.
364
+ */
365
+ setProvider(name, config) { return this.transport.json(`/v1/providers/${encodeURIComponent(name)}`, this.operator(), "PUT", config); }
366
+ deleteProvider(name) { return this.transport.json(`/v1/providers/${encodeURIComponent(name)}`, this.operator(), "DELETE", undefined, false); }
367
+ /** Every provider: the built-in ones with your keys' status, and your own (`custom`). */
368
+ providers() { return this.transport.json("/v1/providers", this.operator()); }
369
+ /** The tenant's agents, each with the key it was made with (null for one made without) and its name. */
370
+ listAgents() { return this.transport.json("/v1/agents", this.operator()); }
371
+ createVolume(options = {}) { return this.transport.json("/v1/volumes", this.operator(), "POST", options, false); }
372
+ listVolumes() { return this.transport.json("/v1/volumes", this.operator()); }
373
+ /** A handle on one volume's files, snapshots and forks. */
374
+ volume(id) {
375
+ if (!/^vol_[a-f0-9]{24}$/.test(id))
376
+ throw new AgentError("Invalid volume id");
377
+ return new VolumeHandle(this.transport, this.operator(), id);
378
+ }
379
+ /**
380
+ * Definitions: reusable agent configurations with their tool sources (MCP servers, OpenAPI
381
+ * specs, built-ins). Make agents from one with `createAgent({ definition: id })`.
382
+ */
383
+ createDefinition(input) { return this.transport.json("/v1/definitions", this.operator(), "POST", input, false); }
384
+ /** The definition for `key`, set to `input` whole: made if there is none, else a new revision if `input` changes it. The same key is the same definition. */
385
+ upsertDefinition(key, input) {
386
+ if (!AGENT_KEY.test(key))
387
+ throw new AgentError(`A definition's key is 1 to 80 letters, digits, _ and -: ${JSON.stringify(key.slice(0, 100))} is not`);
388
+ return this.transport.json("/v1/definitions", this.operator(), "POST", input, true, { "Idempotency-Key": key });
389
+ }
390
+ /** Replace the fields given (null removes one); `apply: "all"` also reconfigures its live agents between their turns. */
391
+ updateDefinition(id, input) { return this.transport.json(`/v1/definitions/${encodeURIComponent(id)}`, this.operator(), "PATCH", input, false); }
392
+ definition(id) { return this.transport.json(`/v1/definitions/${encodeURIComponent(id)}`, this.operator()); }
393
+ definitions() { return this.transport.json("/v1/definitions", this.operator()); }
394
+ deleteDefinition(id) { return this.transport.json(`/v1/definitions/${encodeURIComponent(id)}`, this.operator(), "DELETE", undefined, false); }
395
+ mounts(agentId) { return this.transport.json(`/v1/agents/${encodeURIComponent(agentId)}/mounts`, this.operator()); }
396
+ /** Replace an agent's mounts; an idle agent restarts so its tools describe them. */
397
+ setMounts(agentId, mounts) { return this.transport.json(`/v1/agents/${encodeURIComponent(agentId)}/mounts`, this.operator(), "PUT", { mounts }, false); }
398
+ /**
399
+ * Every source of an agent's tools (its application, file tools, built-ins, MCP servers, OpenAPI
400
+ * specs) and what each offers the model. `schemas` includes input schemas; `refresh` lists MCP servers now.
401
+ */
402
+ /**
403
+ * A token a browser reads one agent with (`@camelai/run/watch`): mint one per user, after your
404
+ * own access checks. It reads only that agent's events, state, history and inputs (or `scopes`), for
405
+ * `ttlSeconds` (default 900, 5 to 3600).
406
+ */
407
+ browserToken(agentId, options = {}) {
408
+ return this.transport.json(`/v1/agents/${encodeURIComponent(agentId)}/browser-tokens`, this.operator(), "POST", options, false);
409
+ }
410
+ /** Who the API key is: `tenant` is your tenant's id, which serveTools and verifyRuntimeToken take. */
411
+ /** Who the API key is (`tenant`), and `defaultModel`, the model an agent gets when it names none. */
412
+ me() { return this.transport.json("/v1/me", this.operator()); }
413
+ /** Inputs waiting on someone across all the tenant's agents (`pending` ones, say), newest first. */
414
+ inbox(state) { return this.transport.json(`/v1/inputs${state ? `?state=${state}` : ""}`, this.operator()); }
415
+ async toolSources(agentId, options = {}) {
416
+ const query = [options.schemas && "schemas=true", options.refresh && "refresh=true"].filter(Boolean).join("&");
417
+ return (await this.transport.json(`/v1/agents/${encodeURIComponent(agentId)}${query ? `?${query}` : ""}`, this.operator())).toolSources;
418
+ }
419
+ }
420
+ /** Files are versioned: pass `version` to write or remove only if nobody changed the file since (0: must not exist). */
421
+ export class VolumeHandle {
422
+ id;
423
+ transport;
424
+ token;
425
+ constructor(transport, token, id) { this.transport = transport; this.token = token; this.id = id; }
426
+ path(suffix = "") { return `/v1/volumes/${this.id}${suffix}`; }
427
+ file(path) { return this.path(`/files/${path.split("/").filter(Boolean).map(encodeURIComponent).join("/")}`); }
428
+ info() { return this.transport.json(this.path(), this.token); }
429
+ delete() { return this.transport.json(this.path(), this.token, "DELETE", undefined, false); }
430
+ snapshot(options = {}) { return this.transport.json(this.path("/snapshots"), this.token, "POST", options, false); }
431
+ snapshots() { return this.transport.json(this.path("/snapshots"), this.token); }
432
+ deleteSnapshot(id) { return this.transport.json(this.path(`/snapshots/${encodeURIComponent(id)}`), this.token, "DELETE", undefined, false); }
433
+ /** A new volume with this one's files (or a snapshot's); only metadata is copied. */
434
+ fork(options = {}) { return this.transport.json(this.path("/fork"), this.token, "POST", options, false); }
435
+ changes(since = 0) { return this.transport.json(this.path(`/changes?since=${since}`), this.token); }
436
+ list(options = {}) {
437
+ const query = new URLSearchParams(Object.entries(options).filter(([, value]) => value !== undefined).map(([key, value]) => [key, String(value)]));
438
+ return this.transport.json(this.path(`/files${query.size ? `?${query}` : ""}`), this.token);
439
+ }
440
+ /** Without `contentType`, the runtime sniffs it from the file's first bytes and name. */
441
+ async write(path, data, options = {}) {
442
+ const body = typeof data === "string" ? new TextEncoder().encode(data) : data;
443
+ const headers = { "Content-Type": options.contentType ?? "application/octet-stream", ...(options.version === 0 ? { "If-None-Match": "*" } : options.version !== undefined ? { "If-Match": `"${options.version}"` } : {}) };
444
+ return (await this.transport.raw(this.file(path), this.token, { method: "PUT", body, headers })).json();
445
+ }
446
+ /** A file's bytes, or `range` of them ([start, end) in bytes). */
447
+ async read(path, options = {}) {
448
+ const [start, end] = options.range ?? [];
449
+ const response = await this.transport.raw(this.file(path), this.token, start !== undefined ? { headers: { Range: `bytes=${start}-${end !== undefined ? end - 1 : ""}` } } : {});
450
+ return { data: new Uint8Array(await response.arrayBuffer()), version: fileVersion(response), contentType: contentTypeOf(response) };
451
+ }
452
+ async readText(path) { return new TextDecoder().decode((await this.read(path)).data); }
453
+ /** A signed URL to download (GET) or upload (PUT) one file without a token. */
454
+ link(path, options = {}) { return this.transport.json(this.path("/links"), this.token, "POST", { path, ...options }, false); }
455
+ async remove(path, options = {}) {
456
+ return (await this.transport.raw(this.file(path), this.token, { method: "DELETE", headers: options.version !== undefined ? { "If-Match": `"${options.version}"` } : {} })).json();
457
+ }
458
+ }
459
+ /** Requests one client may wait on at once. */
460
+ const MAX_PENDING = 1000;
461
+ /** Events waiting for a slow onEvent; past this, streamed deltas are dropped (the messages they build still arrive). */
462
+ const MAX_QUEUED_EVENTS = 10_000;
463
+ const INSPECT = Symbol.for("nodejs.util.inspect.custom");
464
+ /** Credentials whose token stays out of logs: not enumerable, so JSON.stringify and console.log leave it out. */
465
+ function redacted(session) {
466
+ const value = { id: session.id, expiresAt: session.expiresAt };
467
+ Object.defineProperty(value, "token", { value: session.token, enumerable: false });
468
+ return value;
469
+ }
470
+ const encodePath = (path) => path.split("/").filter(Boolean).map(encodeURIComponent).join("/");
471
+ /**
472
+ * The agent's files, at the paths it sees them (`/workspace/report.pdf`), with the agent's own
473
+ * token: what it wrote during a run (a run's outcome lists `files`), and links to hand them on.
474
+ */
475
+ export class AgentFiles {
476
+ transport;
477
+ token;
478
+ base;
479
+ constructor(transport, token, base) { this.transport = transport; this.token = token; this.base = base; }
480
+ /** Files under `path` (default: the first mount), in path order, a page at a time. */
481
+ list(options = {}) {
482
+ const query = new URLSearchParams(Object.entries(options).filter(([, value]) => value !== undefined).map(([key, value]) => [key, String(value)]));
483
+ return this.transport.json(`${this.base}/files${query.size ? `?${query}` : ""}`, this.token);
484
+ }
485
+ async download(path) {
486
+ const response = await this.transport.raw(`${this.base}/files/${encodePath(path)}`, this.token);
487
+ return { data: new Uint8Array(await response.arrayBuffer()), contentType: contentTypeOf(response), version: fileVersion(response) };
488
+ }
489
+ /** Write a file into a writable mount; without `contentType` the runtime sniffs it. */
490
+ async upload(path, data, options = {}) {
491
+ const body = typeof data === "string" ? new TextEncoder().encode(data) : data;
492
+ return (await this.transport.raw(`${this.base}/files/${encodePath(path)}`, this.token, { method: "PUT", body, headers: options.contentType ? { "Content-Type": options.contentType } : {} })).json();
493
+ }
494
+ /** A signed URL to download (GET) or upload (PUT) one file without a token, e.g. for a browser or another service. */
495
+ link(path, options = {}) { return this.transport.json(`${this.base}/links`, this.token, "POST", { path, ...options }, false); }
496
+ }
497
+ export class AgentClient {
498
+ /** The agent's id (client_…): safe to log and to store. */
499
+ id;
500
+ /** The agent's id and its token: keep the token secret (it is left out of logs and JSON). */
501
+ session;
502
+ tools;
503
+ server;
504
+ transport;
505
+ /** The last event taken from the stream: a reconnect resumes after it (a new client starts from a snapshot). */
506
+ cursor = 0;
507
+ options;
508
+ openFile;
509
+ pollMs;
510
+ /** The agent's files: list, download, upload and link. */
511
+ files;
512
+ pending = new Map();
513
+ /** Tool calls running, by JSON-RPC id, so the runtime can cancel them. */
514
+ active = new Map();
515
+ /** The event stream's connection, named in the MCP messages this client sends back. */
516
+ connection;
517
+ stream;
518
+ loop;
519
+ closed = false;
520
+ fatal;
521
+ ready = Promise.withResolvers();
522
+ /** onEvent's queue: events wait here, in order, so the stream never waits on the application. */
523
+ dispatching = Promise.resolve();
524
+ queued = 0;
525
+ dropped = 0;
526
+ listeners = new Set();
527
+ attaching;
528
+ /** The stream was cut on purpose, to reconnect in another mode: not an error to report. */
529
+ switching = false;
530
+ constructor(runtime, session, options) {
531
+ if (!/^client_[a-f0-9]{40}$/.test(session.id))
532
+ throw new AgentError("Invalid session id");
533
+ this.id = session.id;
534
+ this.session = redacted(session);
535
+ this.attaching = options.attach !== false;
536
+ this.tools = { ...options.tools };
537
+ this.server = options.mcp ?? toolServer(this.tools);
538
+ this.options = options;
539
+ this.transport = new Transport(runtime);
540
+ this.openFile = runtime.openFile;
541
+ this.pollMs = runtime.pollMs ?? 30_000;
542
+ this.files = new AgentFiles(this.transport, this.session.token, this.path());
543
+ }
544
+ path(suffix = "") { return `/clients/${this.session.id}${suffix}`; }
545
+ http(suffix, method = "GET", body, retry = true) { return this.transport.json(this.path(suffix), this.session.token, method, body, retry); }
546
+ report(error) { this.options.onError?.(error instanceof Error ? error : new Error(String(error))); }
547
+ async connect() {
548
+ if (this.closed)
549
+ throw new AgentError("Client closed");
550
+ this.loop ??= this.events();
551
+ await Promise.race([this.ready.promise, new Promise((_, reject) => {
552
+ const timer = setTimeout(() => reject(new AgentError("Timed out connecting to agent")), 10_000);
553
+ this.ready.promise.finally(() => clearTimeout(timer)).catch(() => { });
554
+ })]);
555
+ }
556
+ async events() {
557
+ let backoff = 250;
558
+ while (!this.closed) {
559
+ this.stream = new AbortController();
560
+ let watchdog;
561
+ const touch = () => { clearTimeout(watchdog); watchdog = setTimeout(() => this.stream?.abort(), 20_000); };
562
+ touch();
563
+ try {
564
+ // One application serves an agent's tools at a time: a reconnect names the connection it held; `takeover` replaces another's, once.
565
+ const mode = !this.attaching ? "&watch=1" : this.options.takeover && !this.connection ? "&takeover=true" : "";
566
+ const response = await this.transport.fetcher(this.transport.base + this.path(`/events?snapshot=1${mode}`), {
567
+ headers: {
568
+ Authorization: `Bearer ${this.session.token}`, Accept: "text/event-stream", "Last-Event-ID": String(this.cursor),
569
+ ...(this.attaching && this.connection ? { "X-Agent-Connection": this.connection } : {}),
570
+ },
571
+ signal: this.stream.signal, redirect: "manual",
572
+ });
573
+ await rejectRedirect(response);
574
+ if (response.status === 409) {
575
+ const refusal = await response.json().catch(() => ({}));
576
+ if (refusal.code === "APPLICATION_CONNECTED" || refusal.error?.startsWith("APPLICATION_CONNECTED")) {
577
+ throw Object.assign(new AgentError("Another process serves this agent's tools. One process at a time answers an agent's tool calls: close that one, pass takeover: true to replace it, or connect with attach: false to run the agent without serving its tools", 409), { code: "APPLICATION_CONNECTED" });
578
+ }
579
+ const snapshot = await this.sync();
580
+ this.cursor = snapshot.cursor;
581
+ this.emit({ type: "replay_gap", cursor: snapshot.cursor });
582
+ continue;
583
+ }
584
+ if (!response.ok) {
585
+ await response.body?.cancel();
586
+ throw new AgentError(`Event stream HTTP ${response.status}`, response.status);
587
+ }
588
+ if (!response.headers.get("content-type")?.startsWith("text/event-stream") || !response.body)
589
+ throw new AgentError("Expected an SSE response");
590
+ const reader = response.body.getReader();
591
+ const decoder = new TextDecoder();
592
+ let buffer = "";
593
+ try {
594
+ while (!this.closed) {
595
+ const { value, done } = await reader.read();
596
+ if (done)
597
+ break;
598
+ touch();
599
+ buffer += decoder.decode(value, { stream: true });
600
+ let end;
601
+ while ((end = buffer.indexOf("\n\n")) !== -1) {
602
+ const frame = buffer.slice(0, end);
603
+ buffer = buffer.slice(end + 2);
604
+ if (byteLength(frame) > FRAME_BYTES)
605
+ throw new AgentError("SSE frame too large");
606
+ const lines = frame.split("\n");
607
+ const data = lines.filter(line => line.startsWith("data:")).map(line => line.slice(5).trimStart()).join("\n");
608
+ if (!data)
609
+ continue;
610
+ // Another process took over this agent's tools: this one stops, rather than take them back.
611
+ if (lines.includes("event: closed")) {
612
+ throw Object.assign(new AgentError("Another process took over this agent's tools (takeover): this client no longer serves them, and follows the agent without them", 409), { code: "APPLICATION_REPLACED" });
613
+ }
614
+ if (lines.includes("event: ready")) {
615
+ const ready = JSON.parse(data);
616
+ this.connection = ready.connection;
617
+ // Tools that differ from those the agent was last given: declare these, between its turns.
618
+ if (this.attaching && ready.toolsHash && this.options.syncTools !== false)
619
+ void this.syncTools(ready.toolsHash).catch(error => this.report(error));
620
+ await this.sync();
621
+ backoff = 250;
622
+ this.ready.resolve();
623
+ this.options.onConnection?.(true);
624
+ continue;
625
+ }
626
+ const idLine = lines.find(line => line.startsWith("id:"));
627
+ // The runtime's MCP messages are live only: no id, never replayed, no cursor.
628
+ if (!idLine) {
629
+ const event = JSON.parse(data);
630
+ if (event.type === "mcp")
631
+ void this.mcp(event.message).catch(error => this.report(error));
632
+ continue;
633
+ }
634
+ const id = Number(idLine.slice(3));
635
+ if (!Number.isSafeInteger(id) || id <= 0)
636
+ throw new AgentError("Invalid SSE cursor");
637
+ const event = JSON.parse(data);
638
+ // A snapshot restarts the stream at its cursor, even one below the last (a restarted host).
639
+ if (event.type === "snapshot") {
640
+ this.cursor = id;
641
+ this.emit(event);
642
+ continue;
643
+ }
644
+ if (id <= this.cursor)
645
+ continue;
646
+ await this.receive(event);
647
+ this.cursor = id;
648
+ }
649
+ if (byteLength(buffer) > FRAME_BYTES)
650
+ throw new AgentError("SSE frame too large");
651
+ }
652
+ }
653
+ finally {
654
+ await reader.cancel().catch(() => { });
655
+ reader.releaseLock();
656
+ }
657
+ }
658
+ catch (error) {
659
+ if (this.closed)
660
+ break;
661
+ // Another process serves the tools now (it took over, or took them while this one was away): this one
662
+ // goes on following the agent, and running it, without serving them, so its requests still settle.
663
+ if (error instanceof AgentError && (error.code === "APPLICATION_REPLACED" || (error.code === "APPLICATION_CONNECTED" && this.connection))) {
664
+ this.attaching = false;
665
+ this.report(error);
666
+ }
667
+ else if (error instanceof AgentError && ([401, 403, 410].includes(error.status) || (error.status >= 300 && error.status < 400) || error.code === "APPLICATION_CONNECTED")) {
668
+ this.fatal = error;
669
+ this.ready.reject(error);
670
+ for (const waiter of this.pending.values())
671
+ waiter.reject(error);
672
+ this.pending.clear();
673
+ this.report(error);
674
+ break;
675
+ }
676
+ else if (this.switching)
677
+ this.switching = false;
678
+ else
679
+ this.report(error);
680
+ }
681
+ finally {
682
+ clearTimeout(watchdog);
683
+ this.options.onConnection?.(false);
684
+ }
685
+ if (!this.closed) {
686
+ await pause(backoff);
687
+ backoff = Math.min(5000, backoff * 2);
688
+ }
689
+ }
690
+ }
691
+ /** Rename/regroup this agent without changing its conversation or tools. */
692
+ async setMetadata(metadata) {
693
+ return this.transport.json(this.path('/metadata'), this.session.token, 'POST', metadata);
694
+ }
695
+ /** Hand an event to the listeners, and queue it for onEvent: the stream goes on without waiting for either. */
696
+ emit(event, requestId) {
697
+ for (const listener of this.listeners) {
698
+ try {
699
+ listener(event, requestId);
700
+ }
701
+ catch (error) {
702
+ this.report(error);
703
+ }
704
+ }
705
+ const onEvent = this.options.onEvent;
706
+ if (!onEvent || this.closed)
707
+ return;
708
+ if (this.queued >= MAX_QUEUED_EVENTS && event.type === "message_update") {
709
+ if (this.dropped++ === 0)
710
+ this.report(new AgentError(`onEvent is falling behind: over ${MAX_QUEUED_EVENTS} events wait, so streamed deltas are dropped until it catches up`));
711
+ return;
712
+ }
713
+ this.queued++;
714
+ this.dispatching = this.dispatching.then(async () => {
715
+ // A closed client calls onEvent no more: events still queued are dropped.
716
+ try {
717
+ if (!this.closed)
718
+ await onEvent(event, requestId);
719
+ }
720
+ catch (error) {
721
+ this.report(error);
722
+ }
723
+ finally {
724
+ if (--this.queued === 0)
725
+ this.dropped = 0;
726
+ }
727
+ });
728
+ }
729
+ /** @internal Hear every event as it arrives (synchronously, before onEvent); returns the unsubscribe. */
730
+ listen(listener) {
731
+ this.listeners.add(listener);
732
+ return () => { this.listeners.delete(listener); };
733
+ }
734
+ /** Resolves once onEvent has handled every event received so far. */
735
+ drained() { return this.dispatching; }
736
+ async receive(event) {
737
+ if (event.type === "response")
738
+ this.settle(event.id, event.outcome);
739
+ else if (event.type === "event") {
740
+ this.emit(event.event, event.requestId);
741
+ const onInput = this.options.onInput;
742
+ if (onInput && event.event?.type === "input_required")
743
+ void (async () => {
744
+ const answer = await onInput(event.event.input, event.requestId);
745
+ if (answer)
746
+ await this.answer(event.event.input.id, answer);
747
+ })().catch(error => this.report(error));
748
+ }
749
+ }
750
+ settle(id, value) {
751
+ const waiter = this.pending.get(id);
752
+ if (!waiter)
753
+ return;
754
+ this.pending.delete(id);
755
+ if ("error" in value) {
756
+ const outcome = value;
757
+ waiter.reject(Object.assign(new AgentError(outcome.error ?? "Unknown failure", 0, id), {
758
+ ...(typeof outcome.code === "string" ? { code: outcome.code } : {}), ...(outcome.uncertain ? { uncertain: true } : {}),
759
+ }));
760
+ }
761
+ else
762
+ waiter.resolve(value.result);
763
+ }
764
+ /**
765
+ * A request's result arrives as an event; a reconnect also settles from /state. As a last resort,
766
+ * ask for its status now and then, so an event lost on the way can never strand the caller.
767
+ */
768
+ async outcome(id, result) {
769
+ const poll = setInterval(() => void this.requestStatus(id).then(record => { if (record.outcome)
770
+ this.settle(id, record.outcome); }, () => { }), this.pollMs);
771
+ try {
772
+ return await result;
773
+ }
774
+ finally {
775
+ clearInterval(poll);
776
+ }
777
+ }
778
+ async sync() {
779
+ const state = await this.outcomes();
780
+ for (const request of state.requests)
781
+ if (request.outcome)
782
+ this.settle(request.id, request.outcome);
783
+ return state;
784
+ }
785
+ /**
786
+ * Answer the runtime's JSON-RPC messages as the agent's attached MCP server: initialize,
787
+ * ping, tools/list and tools/call, and cancellation. The runtime runs a call once; a call
788
+ * whose answer is lost with the connection ends for the agent as "outcome unknown".
789
+ */
790
+ async mcp(message) {
791
+ if (typeof message.method !== "string")
792
+ return;
793
+ if (message.id === undefined) {
794
+ if (message.method === "notifications/cancelled")
795
+ this.active.get(String(message.params?.requestId))?.abort();
796
+ return;
797
+ }
798
+ const connection = this.connection;
799
+ const key = String(message.id);
800
+ const controller = new AbortController();
801
+ if (message.method === "tools/call")
802
+ this.active.set(key, controller);
803
+ const notify = (notification) => void this.transport.json(this.path("/mcp"), this.session.token, "POST", notification, false, { "X-Agent-Connection": connection ?? "" }).catch(() => { });
804
+ try {
805
+ const answer = await answerMcp(message, this.server, params => toolContext(params, key, controller.signal, undefined, notify));
806
+ await this.transport.json(this.path("/mcp"), this.session.token, "POST", { jsonrpc: "2.0", id: message.id, ...answer }, true, { "X-Agent-Connection": connection ?? "" });
807
+ }
808
+ finally {
809
+ this.active.delete(key);
810
+ }
811
+ }
812
+ /**
813
+ * Send a request and wait for its outcome, however long the run takes: there is no timeout unless
814
+ * `timeoutMs` or `signal` says so, and either only stops the wait (the request goes on).
815
+ */
816
+ async request(method, params = {}, options = {}) {
817
+ if (this.closed || this.fatal)
818
+ throw this.fatal ?? new AgentError("Client closed");
819
+ const id = options.idempotencyKey ?? globalThis.crypto.randomUUID();
820
+ if (!REQUEST_ID.test(id))
821
+ throw new AgentError(`An idempotency key is 1 to 80 letters, digits, _ and -: ${JSON.stringify(id.slice(0, 100))} is not`, 400);
822
+ options.signal?.throwIfAborted();
823
+ const deferred = this.waiter(id, options, "Request timed out; it may still be running: requestStatus() or waitForRequest() observe it");
824
+ try {
825
+ const record = await this.http("/requests", "POST", { id, method, params });
826
+ if (record.outcome)
827
+ this.settle(id, record.outcome);
828
+ return await this.outcome(id, deferred.promise);
829
+ }
830
+ catch (error) {
831
+ if (error instanceof AgentError) {
832
+ error.requestId ??= id;
833
+ throw error;
834
+ }
835
+ if (options.signal?.aborted && error === options.signal.reason)
836
+ throw error;
837
+ throw new AgentError(String(error), 0, id);
838
+ }
839
+ finally {
840
+ deferred.done();
841
+ }
842
+ }
843
+ /**
844
+ * Wait for request `id` to settle, until `timeoutMs` or `signal` says to stop waiting. Calls waiting on the same
845
+ * request share its settlement (the same key sent again joins the run), and each stops waiting on its own.
846
+ */
847
+ waiter(id, options, timedOut) {
848
+ let shared = this.pending.get(id);
849
+ if (!shared) {
850
+ if (this.pending.size >= MAX_PENDING)
851
+ throw new AgentError("Too many outstanding requests");
852
+ shared = { ...Promise.withResolvers(), waiters: 0 };
853
+ // Attach immediately, even while the POST is pending, to avoid unhandled errors.
854
+ shared.promise.catch(() => { });
855
+ this.pending.set(id, shared);
856
+ }
857
+ shared.waiters++;
858
+ const own = Promise.withResolvers();
859
+ own.promise.catch(() => { });
860
+ shared.promise.then(own.resolve, own.reject);
861
+ const timer = options.timeoutMs !== undefined ? setTimeout(() => own.reject(new AgentError(timedOut, 0, id)), options.timeoutMs) : undefined;
862
+ const aborted = () => own.reject(options.signal.reason);
863
+ options.signal?.addEventListener("abort", aborted, { once: true });
864
+ const settled = shared;
865
+ return {
866
+ promise: own.promise,
867
+ done: () => {
868
+ clearTimeout(timer);
869
+ options.signal?.removeEventListener("abort", aborted);
870
+ if (--settled.waiters === 0 && this.pending.get(id) === settled)
871
+ this.pending.delete(id);
872
+ },
873
+ };
874
+ }
875
+ /** Wait for a request already sent (by this process or another) to settle. This never submits or re-executes work. */
876
+ async waitForRequest(id, options = {}) {
877
+ if (this.closed || this.fatal)
878
+ throw this.fatal ?? new AgentError("Client closed");
879
+ options.signal?.throwIfAborted();
880
+ const deferred = this.waiter(id, options, "Stopped waiting; the request may still be running");
881
+ try {
882
+ const record = await this.requestStatus(id);
883
+ if (record.outcome)
884
+ this.settle(id, record.outcome);
885
+ return await this.outcome(id, deferred.promise);
886
+ }
887
+ finally {
888
+ deferred.done();
889
+ }
890
+ }
891
+ /**
892
+ * `from` says who sent the message: the model sees it in a block only the runtime can write, and
893
+ * `from.id` is the turn's actor. `actor` names someone else acting (`act` in identity tokens) without telling the model.
894
+ * `metadata` is the application's own key-value data about the message (at most 16 string values): the
895
+ * stored message and its request carry it, with the request's id, in history, events and webhooks; the model never sees it.
896
+ * `whileRunning: "steer"` hands the message to a running turn, and resolves with that turn's outcome. `spendLimit` is this run's own
897
+ * budget: it ends before its next model request once it has spent that; the agent's spendLimit is unchanged.
898
+ */
899
+ prompt(text, options) {
900
+ return this.message("prompt", text, options, { ...(options?.actor ? { actor: options.actor } : {}), ...(options?.whileRunning === "steer" ? { whileRunning: "steer" } : {}), ...(options?.allowDisconnected ? { allowDisconnected: true } : {}), ...(options?.spendLimit ? { spendLimit: options.spendLimit } : {}) });
901
+ }
902
+ /**
903
+ * Send a message with its files: each is uploaded to the agent's workspace under the request's
904
+ * id first, then attached by path.
905
+ */
906
+ async message(method, text, options, extra = {}) {
907
+ const id = options?.idempotencyKey ?? globalThis.crypto.randomUUID();
908
+ const files = options?.files?.length ? await this.attach(id, options.files) : undefined;
909
+ return this.request(method, { text, ...(files ? { files } : {}), ...extra, ...(options?.from ? { from: options.from } : {}), ...(options?.metadata ? { metadata: options.metadata } : {}) }, { ...options, idempotencyKey: id });
910
+ }
911
+ async attach(requestId, files) {
912
+ const names = new Set();
913
+ const attached = [];
914
+ for (const [index, file] of files.entries()) {
915
+ if (isRecord(file) && "path" in file && typeof file.path === "string" && !("data" in file)) {
916
+ attached.push({ path: file.path });
917
+ continue;
918
+ }
919
+ let data, name, contentType;
920
+ if (typeof file === "string") {
921
+ if (!this.openFile)
922
+ throw new AgentError("Attaching a local path needs the Node entry (@camelai/run/node); pass bytes or a Blob instead");
923
+ data = await this.openFile(file);
924
+ name = file.split(/[\\/]/).pop();
925
+ }
926
+ else if (file instanceof Uint8Array || file instanceof Blob) {
927
+ data = file;
928
+ name = file.name;
929
+ contentType = file instanceof Blob && file.type ? file.type : undefined;
930
+ }
931
+ else {
932
+ const entry = file;
933
+ ({ data, name } = entry);
934
+ contentType = entry.contentType ?? (data instanceof Blob && data.type ? data.type : undefined);
935
+ }
936
+ // Each file in a request needs its own name: they share uploads/<request>/.
937
+ const base = name || `attachment-${index + 1}`;
938
+ let unique = base;
939
+ for (let n = 2; names.has(unique); n++)
940
+ unique = base.replace(/(\.[^.]*)?$/, extension => `-${n}${extension}`);
941
+ names.add(unique);
942
+ 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 } : {} });
943
+ attached.push({ path: (await response.json()).path });
944
+ }
945
+ return attached;
946
+ }
947
+ history() { return this.http("/history"); }
948
+ /**
949
+ * The page of whole turns ending before `before` (default: the newest message, the running turn's
950
+ * included), with at least `limit` messages (default 50) where there are that many. It reads only
951
+ * that page, however long the history.
952
+ */
953
+ historyPage(options = {}) {
954
+ const query = new URLSearchParams({ limit: String(options.limit ?? 50), ...(options.before !== undefined ? { before: String(options.before) } : {}) });
955
+ return this.http(`/history?${query}`);
956
+ }
957
+ continue(options) { return this.request("continue", { ...(options?.actor ? { actor: options.actor } : {}), ...(options?.allowDisconnected ? { allowDisconnected: true } : {}) }, options); }
958
+ /** The legacy steer request: a message held for the running turn. New code: `prompt(text, { whileRunning: "steer" })`. */
959
+ steer(text, options) { return this.message("steer", text, options); }
960
+ /** Change the prompt, thinking level, tools, or model ("provider/model-id") between runs. */
961
+ async configure(options) {
962
+ const { tools, mcp, ...rest } = options;
963
+ const server = mcp ?? (tools ? toolServer(tools) : undefined);
964
+ const result = await this.request("configure", { ...rest, ...(server ? { mcp: { tools: await server.listTools() } } : {}) });
965
+ if (tools) {
966
+ for (const key of Object.keys(this.tools))
967
+ delete this.tools[key];
968
+ Object.assign(this.tools, tools);
969
+ }
970
+ if (server)
971
+ this.server = mcp ?? toolServer(this.tools);
972
+ // Tools that run here now: answer the agent's calls, as its application.
973
+ if (server && !this.attaching && (mcp || Object.keys(tools ?? {}).length))
974
+ await this.reconnect(true);
975
+ return result;
976
+ }
977
+ /** Declare this client's tools when they differ from what the agent has (its `toolsHash`). */
978
+ async syncTools(declared) {
979
+ const tools = await this.server.listTools();
980
+ const digest = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(tools))));
981
+ if ([...digest].map(byte => byte.toString(16).padStart(2, "0")).join("") === declared)
982
+ return;
983
+ await this.request("configure", { mcp: { tools } });
984
+ }
985
+ /** Connect again, attached (answering tool calls) or not. */
986
+ async reconnect(attach) {
987
+ this.attaching = attach;
988
+ this.ready = Promise.withResolvers();
989
+ this.switching = true;
990
+ this.stream?.abort();
991
+ await this.connect();
992
+ }
993
+ execute(code, options) {
994
+ return this.request("execute", { code, ...(options?.executionTimeoutMs ? { timeoutMs: options.executionTimeoutMs } : {}), ...(options?.actor ? { actor: options.actor } : {}), ...(options?.allowDisconnected ? { allowDisconnected: true } : {}) }, options);
995
+ }
996
+ /**
997
+ * Wake this agent later: with `text` it gets a prompt, with `code` it runs sandboxed
998
+ * code against your tools. `everySeconds` (at least 60) repeats it.
999
+ */
1000
+ schedule(input) {
1001
+ return this.http("/schedules", "POST", { ...input, ...(input.at instanceof Date ? { at: input.at.toISOString() } : {}) }, false);
1002
+ }
1003
+ schedules() { return this.http("/schedules"); }
1004
+ unschedule(id) { return this.http(`/schedules/${encodeURIComponent(id)}`, "DELETE", undefined, false); }
1005
+ status() { return this.request("status"); }
1006
+ abort() { return this.request("abort"); }
1007
+ requestStatus(id) { return this.http(`/requests/${encodeURIComponent(id)}`); }
1008
+ /** Answer an input the agent waits on. `request` is the run resuming its turn, once its last input is answered. */
1009
+ answer(inputId, answer) { return this.http(`/inputs/${encodeURIComponent(inputId)}`, "POST", answer); }
1010
+ /** The agent's inputs, newest first: `pending` ones, say. */
1011
+ inputs(state) { return this.http(`/inputs${state ? `?state=${state}` : ""}`); }
1012
+ outcomes() { return this.http("/state"); }
1013
+ toJSON() { return { id: this.id }; }
1014
+ [INSPECT]() { return `AgentClient { id: '${this.id}' }`; }
1015
+ async close() {
1016
+ this.closed = true;
1017
+ this.stream?.abort();
1018
+ for (const controller of this.active.values())
1019
+ controller.abort();
1020
+ for (const [id, waiter] of this.pending)
1021
+ waiter.reject(new AgentError("Client closed; request may still be running", 0, id));
1022
+ this.pending.clear();
1023
+ await this.loop;
1024
+ // onEvent is called no more; the call in progress may finish, but one that never returns cannot hang shutdown.
1025
+ let timer;
1026
+ await Promise.race([this.dispatching, new Promise(resolve => { timer = setTimeout(resolve, 2000); })]);
1027
+ clearTimeout(timer);
1028
+ }
1029
+ async destroy() { try {
1030
+ await this.http("", "DELETE");
1031
+ }
1032
+ finally {
1033
+ await this.close();
1034
+ } }
1035
+ async [Symbol.asyncDispose]() { await this.close(); }
1036
+ }
1037
+ export { Agents, Agent } from "./agents.js";