@camelai/run 0.0.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +62 -1
- package/dist/clients/agents.d.ts +265 -0
- package/dist/clients/agents.js +283 -0
- package/dist/clients/ai-sdk.d.ts +137 -0
- package/dist/clients/ai-sdk.js +461 -0
- package/dist/clients/chat.d.ts +237 -0
- package/dist/clients/chat.js +633 -0
- package/dist/clients/handler.d.ts +116 -0
- package/dist/clients/handler.js +513 -0
- package/dist/clients/markdown.d.ts +72 -0
- package/dist/clients/markdown.js +385 -0
- package/dist/clients/mcp.d.ts +13 -0
- package/dist/clients/mcp.js +38 -0
- package/dist/clients/node.d.ts +21 -0
- package/dist/clients/node.js +76 -0
- package/dist/clients/server.d.ts +83 -0
- package/dist/clients/server.js +187 -0
- package/dist/clients/testing.d.ts +60 -0
- package/dist/clients/testing.js +53 -0
- package/dist/clients/types.d.ts +264 -0
- package/dist/clients/types.js +6 -0
- package/dist/clients/typescript.d.ts +1161 -0
- package/dist/clients/typescript.js +1035 -0
- package/dist/clients/watch.d.ts +90 -0
- package/dist/clients/watch.js +482 -0
- package/dist/shared/client-protocol.d.ts +106 -0
- package/dist/shared/client-protocol.js +9 -0
- package/package.json +78 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The route in your app that lets its users talk to their agents from a browser. Your API key stays
|
|
3
|
+
* on your server; the browser gets short-lived read-only tokens for its user's own agent, and every
|
|
4
|
+
* write (a message, an answer, a stop) goes through here, as the user your `authorize` says it is.
|
|
5
|
+
*
|
|
6
|
+
* // app/api/agent/route.ts
|
|
7
|
+
* export const POST = createAgentHandler({
|
|
8
|
+
* authorize: async request => { const user = await auth(request); return user ? { userId: user.id, name: user.name } : null; },
|
|
9
|
+
* agent: { instructions: "You help with orders." },
|
|
10
|
+
* });
|
|
11
|
+
*
|
|
12
|
+
* Fetch-standard: Next.js route handlers, Hono (`c => handler(c.req.raw)`), Workers, Bun and Deno take it
|
|
13
|
+
* as is; Express and node:http with `nodeListener(handler)` from "@camelai/run/node".
|
|
14
|
+
*
|
|
15
|
+
* With `proxy: true`, browsers read their agent through this route too (GET <route>/v1/agents/:id/…),
|
|
16
|
+
* so they only ever talk to your origin: mount it on the route and everything under it.
|
|
17
|
+
*/
|
|
18
|
+
import { type AgentConfig } from "./agents.ts";
|
|
19
|
+
/** Who is asking, as your `authorize` found: the user, and optionally which of their agents. */
|
|
20
|
+
export interface AgentAuth {
|
|
21
|
+
/** Your user's id: `from.id` on everything this user sends or answers, and the subject of their own agent. */
|
|
22
|
+
userId: string;
|
|
23
|
+
/** Their display name, shown to the model and in the transcript as the sender's. */
|
|
24
|
+
name?: string;
|
|
25
|
+
/**
|
|
26
|
+
* The agent to use (1 to 80 letters, digits, `_` and `-`). Default: `agentKeyFor(userId, thread)`, one
|
|
27
|
+
* agent per user and thread. Give your own to share an agent between users (a team's agent, say).
|
|
28
|
+
*/
|
|
29
|
+
agentKey?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Who the agent acts for (its tools' `identity.subject`), set when it is made and never changed. Default:
|
|
32
|
+
* the user, for their own agent; the `agentKey`, for an agent you name (one several people share).
|
|
33
|
+
*/
|
|
34
|
+
subject?: string;
|
|
35
|
+
}
|
|
36
|
+
/** What an agent is made with: the parts of `AgentConfig` a handler sets. Its `subject` is always the user. */
|
|
37
|
+
export type AgentSetup = Pick<AgentConfig, "model" | "instructions" | "tools" | "mcp" | "definition" | "builtins" | "thinkingLevel" | "context" | "keyScope" | "spendLimit" | "modelHeaders" | "mounts" | "name">;
|
|
38
|
+
export interface SendEvent<A extends AgentAuth = AgentAuth> {
|
|
39
|
+
auth: A;
|
|
40
|
+
thread: string | null;
|
|
41
|
+
text: string;
|
|
42
|
+
/** Whatever JSON the browser sent with the message (`send(text, { data })`); it reaches the agent only through you. */
|
|
43
|
+
data: unknown;
|
|
44
|
+
request: Request;
|
|
45
|
+
}
|
|
46
|
+
export interface AgentHandlerOptions<A extends AgentAuth = AgentAuth> {
|
|
47
|
+
/** Your API key. Default: the CAMELAI_API_KEY environment variable. */
|
|
48
|
+
apiKey?: string;
|
|
49
|
+
/** The runtime's origin. Default: CAMELAI_BASE_URL, else https://agents.camelai.dev. */
|
|
50
|
+
url?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Your own session check, on every request. Return the user (and optionally which agent), or null to
|
|
53
|
+
* refuse it (401). `thread` is the browser's name for a conversation: untrusted, and never an agent id.
|
|
54
|
+
*/
|
|
55
|
+
authorize(request: Request, context: {
|
|
56
|
+
thread: string | null;
|
|
57
|
+
action: HandlerAction;
|
|
58
|
+
}): A | null | Promise<A | null>;
|
|
59
|
+
/**
|
|
60
|
+
* How the user's agent is made (on first use), and set again when this changes. A function gets the
|
|
61
|
+
* user; keep what it returns stable (build tools once, not per call): a setup that differs from the
|
|
62
|
+
* last reconfigures the agent, and one with tools reattaches them.
|
|
63
|
+
*/
|
|
64
|
+
agent?: AgentSetup | ((auth: A, context: {
|
|
65
|
+
thread: string | null;
|
|
66
|
+
}) => AgentSetup | Promise<AgentSetup>);
|
|
67
|
+
/**
|
|
68
|
+
* Before each message is sent: return `{ text?, metadata? }` to change it or label it (metadata is yours,
|
|
69
|
+
* never shown to the model), or throw a Response to refuse it (a quota, moderation).
|
|
70
|
+
*/
|
|
71
|
+
onSend?(event: SendEvent<A>): void | {
|
|
72
|
+
text?: string;
|
|
73
|
+
metadata?: Record<string, string>;
|
|
74
|
+
} | Promise<void | {
|
|
75
|
+
text?: string;
|
|
76
|
+
metadata?: Record<string, string>;
|
|
77
|
+
}>;
|
|
78
|
+
/** The browser tokens it mints. Default: 15 minutes, every event, no provider cost (`redact: ["usage.cost"]`). */
|
|
79
|
+
browserToken?: {
|
|
80
|
+
ttlSeconds?: number;
|
|
81
|
+
events?: string[];
|
|
82
|
+
redact?: "usage.cost"[];
|
|
83
|
+
/** Where browsers reach the runtime, when not where this server does (a token's `url` by default). */
|
|
84
|
+
url?: string;
|
|
85
|
+
};
|
|
86
|
+
/** Other origins whose pages may call this route (with CORS); by default only this site's. */
|
|
87
|
+
allowedOrigins?: string[];
|
|
88
|
+
/**
|
|
89
|
+
* Pass the browser's reads (the event stream, its long-poll fallback, history, state and inputs) through
|
|
90
|
+
* this route, so the browser talks only to your origin (default false: it reads the runtime directly,
|
|
91
|
+
* with a browser token). The route must then also take GET requests under its path. Each read is
|
|
92
|
+
* checked with `authorize` like everything else, and streams as it arrives. Serverless functions
|
|
93
|
+
* end a stream at their time limit; the chat reconnects, and falls back to long polls.
|
|
94
|
+
*/
|
|
95
|
+
proxy?: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* `link` signs downloads of the files an agent presented (present_file), and of files in its own
|
|
98
|
+
* workspace volume. true: of any path in its mounts; the boundary is then what the agent can read,
|
|
99
|
+
* including volumes the app mounted that its users may not all be meant to read (default false).
|
|
100
|
+
*/
|
|
101
|
+
linkAnyMountedPath?: boolean;
|
|
102
|
+
/** How long an agent with tools served from this process stays attached after its last use. Default 15 minutes. */
|
|
103
|
+
idleMs?: number;
|
|
104
|
+
fetch?: typeof globalThis.fetch;
|
|
105
|
+
}
|
|
106
|
+
export type HandlerAction = "token" | "send" | "answer" | "stop" | "link" | "read";
|
|
107
|
+
/** The route: a fetch handler. `close()` detaches agents whose tools this process serves. */
|
|
108
|
+
export type AgentHandler = ((request: Request) => Promise<Response>) & {
|
|
109
|
+
close(): Promise<void>;
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* The agent key for a user's thread: a hash of both, so it is a valid key whatever they are, and no
|
|
113
|
+
* user's thread can name another user's agent.
|
|
114
|
+
*/
|
|
115
|
+
export declare function agentKeyFor(userId: string, thread?: string | null): Promise<string>;
|
|
116
|
+
export declare function createAgentHandler<A extends AgentAuth = AgentAuth>(options: AgentHandlerOptions<A>): AgentHandler;
|
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The route in your app that lets its users talk to their agents from a browser. Your API key stays
|
|
3
|
+
* on your server; the browser gets short-lived read-only tokens for its user's own agent, and every
|
|
4
|
+
* write (a message, an answer, a stop) goes through here, as the user your `authorize` says it is.
|
|
5
|
+
*
|
|
6
|
+
* // app/api/agent/route.ts
|
|
7
|
+
* export const POST = createAgentHandler({
|
|
8
|
+
* authorize: async request => { const user = await auth(request); return user ? { userId: user.id, name: user.name } : null; },
|
|
9
|
+
* agent: { instructions: "You help with orders." },
|
|
10
|
+
* });
|
|
11
|
+
*
|
|
12
|
+
* Fetch-standard: Next.js route handlers, Hono (`c => handler(c.req.raw)`), Workers, Bun and Deno take it
|
|
13
|
+
* as is; Express and node:http with `nodeListener(handler)` from "@camelai/run/node".
|
|
14
|
+
*
|
|
15
|
+
* With `proxy: true`, browsers read their agent through this route too (GET <route>/v1/agents/:id/…),
|
|
16
|
+
* so they only ever talk to your origin: mount it on the route and everything under it.
|
|
17
|
+
*/
|
|
18
|
+
import { Agents } from "./agents.js";
|
|
19
|
+
import { AgentError, DEFAULT_URL } from "./typescript.js";
|
|
20
|
+
const env = (name) => globalThis.process?.env?.[name] || undefined;
|
|
21
|
+
const ACTIONS = new Set(["token", "send", "answer", "stop", "link"]);
|
|
22
|
+
/** A read the proxy passes through: [thread segment, agent id, route]. */
|
|
23
|
+
const READ = /(?:\/threads\/([^/]+))?\/v1\/agents\/([^/]+)\/(events|history|state|inputs)$/;
|
|
24
|
+
/** A file link the proxy passes through: its runtime path, whose signed token is its only credential. */
|
|
25
|
+
const LINK = /\/v1\/links\/[^/]+\/[^/]+$/;
|
|
26
|
+
/** What a file download answers with, passed on from the runtime. */
|
|
27
|
+
const LINK_HEADERS = ["content-type", "content-length", "content-disposition", "content-range", "accept-ranges", "etag", "last-modified", "cache-control", "x-content-type-options", "content-security-policy"];
|
|
28
|
+
const KEY = /^[A-Za-z0-9_-]{1,80}$/;
|
|
29
|
+
const CLIENT_ID = /^[A-Za-z0-9_-]{8,80}$/;
|
|
30
|
+
const INPUT_ID = /^[A-Za-z0-9_-]{1,100}$/;
|
|
31
|
+
const MAX_BODY = 1024 * 1024;
|
|
32
|
+
const MAX_CACHED = 5000;
|
|
33
|
+
/**
|
|
34
|
+
* The agent key for a user's thread: a hash of both, so it is a valid key whatever they are, and no
|
|
35
|
+
* user's thread can name another user's agent.
|
|
36
|
+
*/
|
|
37
|
+
export async function agentKeyFor(userId, thread) {
|
|
38
|
+
const data = new TextEncoder().encode(JSON.stringify([userId, thread ?? null]));
|
|
39
|
+
const digest = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", data));
|
|
40
|
+
let binary = "";
|
|
41
|
+
for (const byte of digest)
|
|
42
|
+
binary += String.fromCharCode(byte);
|
|
43
|
+
return `u_${btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")}`;
|
|
44
|
+
}
|
|
45
|
+
/** A path segment, decoded; a malformed one is the request's fault. */
|
|
46
|
+
function decodeSegment(value) {
|
|
47
|
+
try {
|
|
48
|
+
return decodeURIComponent(value);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return fail(400, "invalid_request", "The path is not valid");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** An error the handler answers with: `{ error: { code, message } }`. */
|
|
55
|
+
class HandlerError extends Error {
|
|
56
|
+
status;
|
|
57
|
+
code;
|
|
58
|
+
constructor(status, code, message) { super(message); this.status = status; this.code = code; }
|
|
59
|
+
}
|
|
60
|
+
const fail = (status, code, message) => { throw new HandlerError(status, code, message); };
|
|
61
|
+
/** The fields of a setup that decide the agent's configuration, as a string (tools by name and description). */
|
|
62
|
+
function fingerprintOf(setup) {
|
|
63
|
+
const tools = Object.entries(setup.tools ?? {}).map(([name, tool]) => [name, tool.description ?? ""]).sort();
|
|
64
|
+
const { tools: _tools, mcp, ...rest } = setup;
|
|
65
|
+
return JSON.stringify([rest, tools, mcp ? "mcp" : null]);
|
|
66
|
+
}
|
|
67
|
+
export function createAgentHandler(options) {
|
|
68
|
+
const apiKey = options.apiKey ?? env("CAMELAI_API_KEY") ?? env("AGENT_RUNTIME_TOKEN");
|
|
69
|
+
const url = (options.url ?? env("CAMELAI_BASE_URL") ?? env("AGENT_URL") ?? DEFAULT_URL).replace(/\/+$/, "");
|
|
70
|
+
const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
71
|
+
let agents;
|
|
72
|
+
const runtime = () => {
|
|
73
|
+
if (!apiKey)
|
|
74
|
+
fail(500, "missing_api_key", "Set apiKey (or the CAMELAI_API_KEY environment variable) on the agent handler");
|
|
75
|
+
agents ??= new Agents({ apiKey, url, ...(options.fetch ? { fetch: options.fetch } : {}) });
|
|
76
|
+
return agents;
|
|
77
|
+
};
|
|
78
|
+
const cache = new Map();
|
|
79
|
+
const idleMs = options.idleMs ?? 15 * 60_000;
|
|
80
|
+
let sweeper;
|
|
81
|
+
/** A call to the runtime with the API key (or an agent's token): its JSON, or a HandlerError with its status and code. */
|
|
82
|
+
async function call(path, body, token = apiKey, method = "POST") {
|
|
83
|
+
let response;
|
|
84
|
+
try {
|
|
85
|
+
response = await doFetch(url + path, { method, headers: { Authorization: `Bearer ${token}`, ...(body !== undefined ? { "Content-Type": "application/json" } : {}) }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "manual" });
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
return fail(502, "runtime_unreachable", `Could not reach the agent runtime: ${error.message}`);
|
|
89
|
+
}
|
|
90
|
+
const value = await response.json().catch(() => ({}));
|
|
91
|
+
if (response.ok)
|
|
92
|
+
return value;
|
|
93
|
+
const message = typeof value.error === "string" ? value.error : `HTTP ${response.status}`;
|
|
94
|
+
const code = typeof value.code === "string" ? value.code : /^([A-Z][A-Z0-9_]+):/.exec(message)?.[1] ?? codeFor(response.status);
|
|
95
|
+
return fail(response.status >= 500 ? 502 : response.status, code, message);
|
|
96
|
+
}
|
|
97
|
+
const get = (path) => call(path, undefined, apiKey, "GET");
|
|
98
|
+
async function upsert(key, setup, auth) {
|
|
99
|
+
// A shared agent's subject cannot be whoever opened it first: it is set once, and never changes.
|
|
100
|
+
const config = { ...setup, subject: auth.subject ?? (auth.agentKey ? key : auth.userId) };
|
|
101
|
+
const serves = !!setup.mcp || Object.keys(setup.tools ?? {}).length > 0;
|
|
102
|
+
try {
|
|
103
|
+
if (serves) {
|
|
104
|
+
// Tools served from this process: the agent stays attached while it is used, and a while after.
|
|
105
|
+
const agent = await runtime().upsert(key, { ...config, takeover: true, onEvent: () => { touch(key); } });
|
|
106
|
+
startSweeper();
|
|
107
|
+
return { id: agent.id, token: agent.session.token, fingerprint: fingerprintOf(setup), attached: agent, used: Date.now(), presented: new Set(), scanned: -1 };
|
|
108
|
+
}
|
|
109
|
+
const { instructions, ...rest } = config;
|
|
110
|
+
const created = { ...rest, ...(instructions !== undefined ? { systemPrompt: instructions } : {}) };
|
|
111
|
+
const { session } = await runtime().runtime.upsertAgent(key, created);
|
|
112
|
+
return { id: session.id, token: session.token, fingerprint: fingerprintOf(setup), used: Date.now(), presented: new Set(), scanned: -1 };
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (error instanceof HandlerError)
|
|
116
|
+
throw error;
|
|
117
|
+
if (error instanceof AgentError)
|
|
118
|
+
return fail(error.status >= 400 && error.status < 500 ? error.status : 502, error.code ?? codeFor(error.status), error.message);
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function touch(key) {
|
|
123
|
+
void cache.get(key)?.then(entry => { entry.used = Date.now(); }, () => { });
|
|
124
|
+
}
|
|
125
|
+
function startSweeper() {
|
|
126
|
+
if (sweeper)
|
|
127
|
+
return;
|
|
128
|
+
sweeper = setInterval(() => {
|
|
129
|
+
for (const [key, pending] of cache)
|
|
130
|
+
void pending.then(entry => {
|
|
131
|
+
if (entry.attached && Date.now() - entry.used > idleMs) {
|
|
132
|
+
cache.delete(key);
|
|
133
|
+
void entry.attached.close().catch(() => { });
|
|
134
|
+
}
|
|
135
|
+
}, () => { });
|
|
136
|
+
}, Math.min(60_000, idleMs));
|
|
137
|
+
sweeper.unref?.();
|
|
138
|
+
}
|
|
139
|
+
/** The user's agent: upserted once per process (and again when its setup changes). */
|
|
140
|
+
async function agentFor(auth, thread, refresh = false) {
|
|
141
|
+
const key = auth.agentKey ?? await agentKeyFor(auth.userId, thread);
|
|
142
|
+
if (!KEY.test(key))
|
|
143
|
+
fail(500, "invalid_agent_key", "authorize returned an agentKey that is not 1 to 80 letters, digits, _ and -");
|
|
144
|
+
const setup = typeof options.agent === "function" ? await options.agent(auth, { thread }) : options.agent ?? {};
|
|
145
|
+
const fingerprint = fingerprintOf(setup);
|
|
146
|
+
const cached = cache.get(key);
|
|
147
|
+
if (cached && !refresh) {
|
|
148
|
+
const entry = await cached.catch(() => undefined);
|
|
149
|
+
if (entry && entry.fingerprint === fingerprint) {
|
|
150
|
+
entry.used = Date.now();
|
|
151
|
+
// Most recently used last, so the oldest go first when the cache is full.
|
|
152
|
+
cache.delete(key);
|
|
153
|
+
cache.set(key, cached);
|
|
154
|
+
return entry;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const pending = upsert(key, setup, auth);
|
|
158
|
+
// The entry it replaces (a changed setup, or a renewal) no longer serves the agent's tools here.
|
|
159
|
+
if (cached)
|
|
160
|
+
void Promise.all([cached, pending]).then(([previous, next]) => { if (previous.attached && previous.attached !== next.attached)
|
|
161
|
+
return previous.attached.close(); }).catch(() => { });
|
|
162
|
+
cache.delete(key);
|
|
163
|
+
cache.set(key, pending);
|
|
164
|
+
pending.catch(() => { if (cache.get(key) === pending)
|
|
165
|
+
cache.delete(key); });
|
|
166
|
+
if (cache.size > MAX_CACHED) {
|
|
167
|
+
const [oldest, entry] = cache.entries().next().value;
|
|
168
|
+
cache.delete(oldest);
|
|
169
|
+
void entry.then(value => value.attached?.close(), () => { }).catch(() => { });
|
|
170
|
+
}
|
|
171
|
+
return pending;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* A download link for a file in the agent's mounts. For now the agent's own token signs it (so only its
|
|
175
|
+
* mounts are reachable), renewed once if it expired; once the runtime has POST /v1/agents/:id/links,
|
|
176
|
+
* the tenant key signs it here instead and nothing else changes.
|
|
177
|
+
*/
|
|
178
|
+
async function signLink(agent, path, renew) {
|
|
179
|
+
const sign = (entry) => call(`/clients/${encodeURIComponent(entry.id)}/links`, { path, method: "GET" }, entry.token);
|
|
180
|
+
return sign(agent).catch(async (error) => {
|
|
181
|
+
if (!(error instanceof HandlerError) || error.status !== 401)
|
|
182
|
+
throw error;
|
|
183
|
+
return sign(await renew());
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Whether `path` is in the agent's own workspace volume (the one the runtime made for it, not one the
|
|
188
|
+
* app mounted, which other agents may share), by its actual mounts.
|
|
189
|
+
*/
|
|
190
|
+
async function ownWorkspace(agent, path) {
|
|
191
|
+
if (agent.workspace === undefined) {
|
|
192
|
+
const mounts = await get(`/v1/agents/${encodeURIComponent(agent.id)}/mounts`);
|
|
193
|
+
// The runtime names an agent's own workspace volume after the agent (VolumeService.workspaceOf).
|
|
194
|
+
const digest = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(`workspace:${agent.id}`)));
|
|
195
|
+
const own = `vol_${[...digest].map(byte => byte.toString(16).padStart(2, "0")).join("").slice(0, 24)}`;
|
|
196
|
+
agent.workspace = mounts.find(mount => mount.volumeId === own)?.path.replace(/\/+$/, "") ?? null;
|
|
197
|
+
}
|
|
198
|
+
return agent.workspace !== null && path.startsWith(`${agent.workspace}/`);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Whether the agent presented `path` (a present_file call that succeeded), from its history, newest first.
|
|
202
|
+
* Only history newer than the last look is read again, so a path it never presented costs one page.
|
|
203
|
+
*/
|
|
204
|
+
async function presented(agent, path) {
|
|
205
|
+
if (agent.presented.has(path))
|
|
206
|
+
return true;
|
|
207
|
+
const calls = new Set();
|
|
208
|
+
const through = agent.scanned;
|
|
209
|
+
let before, newest = through;
|
|
210
|
+
// Read to the end (or to what was read before): then everything up to the newest message has been seen.
|
|
211
|
+
const exhausted = () => { agent.scanned = Math.max(agent.scanned, newest); return false; };
|
|
212
|
+
for (let page = 0; page < 20 && before !== null; page++) {
|
|
213
|
+
const value = await get(`/v1/agents/${encodeURIComponent(agent.id)}/history?limit=200${before !== undefined ? `&before=${before}` : ""}`);
|
|
214
|
+
if (page === 0)
|
|
215
|
+
newest = value.entries.at(-1)?.index ?? through;
|
|
216
|
+
// A page holds whole turns, oldest first: a call's result follows it, so read each page backwards.
|
|
217
|
+
for (const { index, message } of [...value.entries].reverse()) {
|
|
218
|
+
if (index <= through)
|
|
219
|
+
return exhausted();
|
|
220
|
+
if (message.role === "toolResult" && !message.isError && /(^|__)present_file$/.test(String(message.toolName ?? "")) && !message.details?.inputRequired) {
|
|
221
|
+
calls.add(message.toolCallId);
|
|
222
|
+
// Its result names the file as the agent's mounts show it, which is what a chat asks to link.
|
|
223
|
+
const shown = (() => { try {
|
|
224
|
+
return JSON.parse(message.content?.find((part) => part?.type === "text")?.text ?? "").path;
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return undefined;
|
|
228
|
+
} })();
|
|
229
|
+
if (typeof shown === "string") {
|
|
230
|
+
agent.presented.add(shown);
|
|
231
|
+
if (shown === path)
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (message.role === "assistant")
|
|
236
|
+
for (const block of message.content ?? []) {
|
|
237
|
+
if (block?.type === "toolCall" && calls.has(block.id) && typeof block.arguments?.path === "string") {
|
|
238
|
+
agent.presented.add(block.arguments.path);
|
|
239
|
+
if (block.arguments.path === path)
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
before = value.next;
|
|
245
|
+
}
|
|
246
|
+
return before === null ? exhausted() : false;
|
|
247
|
+
}
|
|
248
|
+
/** Browser tokens the proxy reads with, by agent: minted on this server, never sent to the browser. */
|
|
249
|
+
const readTokens = new Map();
|
|
250
|
+
async function readToken(agent, auth, fresh = false) {
|
|
251
|
+
const known = readTokens.get(agent.id);
|
|
252
|
+
if (known && !fresh && known.expiresAt - Date.now() > 120_000)
|
|
253
|
+
return known.token;
|
|
254
|
+
const token = options.browserToken ?? {};
|
|
255
|
+
const minted = await call(`/v1/agents/${encodeURIComponent(agent.id)}/browser-tokens`, {
|
|
256
|
+
subject: auth.userId.slice(0, 200), redact: token.redact ?? ["usage.cost"], ttlSeconds: token.ttlSeconds ?? 3600, ...(token.events ? { events: token.events } : {}),
|
|
257
|
+
});
|
|
258
|
+
if (readTokens.size >= MAX_CACHED)
|
|
259
|
+
readTokens.delete(readTokens.keys().next().value);
|
|
260
|
+
readTokens.set(agent.id, { token: minted.token, expiresAt: minted.expiresAt });
|
|
261
|
+
return minted.token;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* A read passed through to the runtime, streamed as it arrives: the user's own agent only (as
|
|
265
|
+
* `authorize` says), with a browser token added here, and cancelled when the browser goes away.
|
|
266
|
+
*/
|
|
267
|
+
async function read(request, match) {
|
|
268
|
+
const thread = match[1] === undefined ? null : decodeSegment(match[1]);
|
|
269
|
+
if (thread !== null && (!thread || thread.length > 200))
|
|
270
|
+
fail(400, "invalid_request", "thread is a string of 1 to 200 characters");
|
|
271
|
+
const requested = decodeSegment(match[2]);
|
|
272
|
+
const auth = checked(await options.authorize(request, { thread, action: "read" }));
|
|
273
|
+
const agent = await agentFor(auth, thread);
|
|
274
|
+
// The path names an agent; only the user's own is readable (another is not found, as on the runtime).
|
|
275
|
+
if (requested !== agent.id)
|
|
276
|
+
fail(404, "not_found", "No such agent");
|
|
277
|
+
const target = `${url}/v1/agents/${encodeURIComponent(agent.id)}/${match[3]}${new URL(request.url).search}`;
|
|
278
|
+
const forward = async (fresh) => {
|
|
279
|
+
const headers = { Authorization: `Bearer ${await readToken(agent, auth, fresh)}` };
|
|
280
|
+
for (const name of ["accept", "last-event-id"]) {
|
|
281
|
+
const value = request.headers.get(name);
|
|
282
|
+
if (value !== null)
|
|
283
|
+
headers[name] = value;
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
return await doFetch(target, { headers, signal: request.signal, redirect: "manual" });
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
if (request.signal.aborted)
|
|
290
|
+
throw error;
|
|
291
|
+
return fail(502, "runtime_unreachable", `Could not reach the agent runtime: ${error.message}`);
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
let upstream = await forward(false);
|
|
295
|
+
if (upstream.status === 401) {
|
|
296
|
+
await upstream.body?.cancel();
|
|
297
|
+
upstream = await forward(true);
|
|
298
|
+
}
|
|
299
|
+
const headers = new Headers({ "Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no" });
|
|
300
|
+
const type = upstream.headers.get("content-type");
|
|
301
|
+
if (type)
|
|
302
|
+
headers.set("Content-Type", type);
|
|
303
|
+
// The body as it comes: each chunk is passed on as it arrives, nothing is buffered or rewritten.
|
|
304
|
+
return new Response(upstream.body, { status: upstream.status, headers });
|
|
305
|
+
}
|
|
306
|
+
/** A signed file link passed through to the runtime (ranges included), with nothing added: its token is the credential. */
|
|
307
|
+
async function download(request, path) {
|
|
308
|
+
const headers = {};
|
|
309
|
+
for (const name of ["range", "if-range", "if-none-match", "if-modified-since"]) {
|
|
310
|
+
const value = request.headers.get(name);
|
|
311
|
+
if (value !== null)
|
|
312
|
+
headers[name] = value;
|
|
313
|
+
}
|
|
314
|
+
let upstream;
|
|
315
|
+
try {
|
|
316
|
+
upstream = await doFetch(`${url}${path}`, { headers, signal: request.signal, redirect: "manual" });
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
if (request.signal.aborted)
|
|
320
|
+
throw error;
|
|
321
|
+
return fail(502, "runtime_unreachable", `Could not reach the agent runtime: ${error.message}`);
|
|
322
|
+
}
|
|
323
|
+
const passed = new Headers();
|
|
324
|
+
for (const name of LINK_HEADERS) {
|
|
325
|
+
const value = upstream.headers.get(name);
|
|
326
|
+
if (value !== null)
|
|
327
|
+
passed.set(name, value);
|
|
328
|
+
}
|
|
329
|
+
return new Response(upstream.body, { status: upstream.status, headers: passed });
|
|
330
|
+
}
|
|
331
|
+
async function handle(request, body, auth, action, thread) {
|
|
332
|
+
const agent = await agentFor(auth, thread);
|
|
333
|
+
const path = `/v1/agents/${encodeURIComponent(agent.id)}`;
|
|
334
|
+
const from = { id: auth.userId, ...(auth.name ? { name: auth.name } : {}) };
|
|
335
|
+
switch (action) {
|
|
336
|
+
case "token": {
|
|
337
|
+
// Proxied, the browser reads through this route (its own endpoint), and needs no token of its own.
|
|
338
|
+
if (options.proxy)
|
|
339
|
+
return { proxy: true, agentId: agent.id, token: "", expiresAt: Date.now() + 3_600_000 };
|
|
340
|
+
const token = options.browserToken ?? {};
|
|
341
|
+
const minted = await call(`${path}/browser-tokens`, {
|
|
342
|
+
subject: auth.userId.slice(0, 200), redact: token.redact ?? ["usage.cost"],
|
|
343
|
+
...(token.ttlSeconds !== undefined ? { ttlSeconds: token.ttlSeconds } : {}), ...(token.events ? { events: token.events } : {}),
|
|
344
|
+
});
|
|
345
|
+
return { token: minted.token, expiresAt: minted.expiresAt, agentId: minted.agentId, url: token.url ?? minted.url ?? url };
|
|
346
|
+
}
|
|
347
|
+
case "send": {
|
|
348
|
+
let text = body.text;
|
|
349
|
+
const clientId = body.clientId;
|
|
350
|
+
if (typeof text !== "string" || !text.trim())
|
|
351
|
+
fail(400, "invalid_request", "text must be a nonblank string");
|
|
352
|
+
if (typeof clientId !== "string" || !CLIENT_ID.test(clientId))
|
|
353
|
+
fail(400, "invalid_request", "clientId must be 8 to 80 letters, digits, _ and -");
|
|
354
|
+
const whileRunning = body.whileRunning;
|
|
355
|
+
if (whileRunning !== undefined && whileRunning !== "queue" && whileRunning !== "steer")
|
|
356
|
+
fail(400, "invalid_request", "whileRunning is queue or steer");
|
|
357
|
+
let metadata;
|
|
358
|
+
const changed = await options.onSend?.({ auth, thread, text: text, data: body.data, request });
|
|
359
|
+
if (changed?.text !== undefined)
|
|
360
|
+
text = changed.text;
|
|
361
|
+
if (changed?.metadata)
|
|
362
|
+
metadata = changed.metadata;
|
|
363
|
+
const accepted = await call(`${path}/prompt`, {
|
|
364
|
+
text, requestId: clientId, from, ...(metadata ? { metadata } : {}), ...(whileRunning ? { whileRunning } : {}),
|
|
365
|
+
});
|
|
366
|
+
return { requestId: accepted.id, state: accepted.state, ...(accepted.steeredInto ? { steeredInto: accepted.steeredInto } : {}) };
|
|
367
|
+
}
|
|
368
|
+
case "answer": {
|
|
369
|
+
const inputId = body.inputId;
|
|
370
|
+
const answer = body.answer;
|
|
371
|
+
if (typeof inputId !== "string" || !INPUT_ID.test(inputId))
|
|
372
|
+
fail(400, "invalid_request", "inputId must be an input's id");
|
|
373
|
+
if (!answer || typeof answer !== "object" || !["accept", "decline", "cancel"].includes(answer.action))
|
|
374
|
+
fail(400, "invalid_request", "answer.action is accept, decline or cancel");
|
|
375
|
+
return call(`${path}/inputs/${encodeURIComponent(inputId)}`, { action: answer.action, ...(answer.content !== undefined ? { content: answer.content } : {}), from });
|
|
376
|
+
}
|
|
377
|
+
case "stop":
|
|
378
|
+
return call(`${path}/abort`, {});
|
|
379
|
+
case "link": {
|
|
380
|
+
const file = body.path;
|
|
381
|
+
if (typeof file !== "string" || !file.startsWith("/") || file.length > 1024 || file.split("/").some(segment => segment === ".." || segment === "."))
|
|
382
|
+
fail(400, "invalid_request", "path must be a file's absolute path");
|
|
383
|
+
if (!options.linkAnyMountedPath && !await ownWorkspace(agent, file) && !await presented(agent, file)) {
|
|
384
|
+
fail(403, "forbidden", "Only files the agent presented, or in its own workspace, can be linked (see linkAnyMountedPath)");
|
|
385
|
+
}
|
|
386
|
+
const link = await signLink(agent, file, () => agentFor(auth, thread, true));
|
|
387
|
+
// Proxied, the browser downloads through this route too, on the page's own origin.
|
|
388
|
+
if (options.proxy)
|
|
389
|
+
return { url: `${new URL(request.url).pathname.replace(/\/+$/, "")}${link.urlPath ?? new URL(link.url).pathname}`, expiresAt: link.expiresAt };
|
|
390
|
+
return { url: link.url, expiresAt: link.expiresAt };
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/** What authorize returned, if it is a user: null is a 401, and a malformed one the app's own bug. */
|
|
395
|
+
function checked(auth) {
|
|
396
|
+
if (!auth)
|
|
397
|
+
return fail(401, "unauthorized", "Sign in first");
|
|
398
|
+
if (typeof auth.userId !== "string" || !auth.userId || auth.userId.length > 200)
|
|
399
|
+
fail(500, "invalid_auth", "authorize returned no userId, or one longer than 200 characters");
|
|
400
|
+
if (auth.name !== undefined && (typeof auth.name !== "string" || auth.name.length > 200))
|
|
401
|
+
fail(500, "invalid_auth", "authorize returned a name longer than 200 characters");
|
|
402
|
+
return auth;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Requests only from this site (or an allowed origin): by Sec-Fetch-Site where the browser sends it,
|
|
406
|
+
* else by Origin against Host (hosts only: a proxy that ends TLS changes the scheme, not the host).
|
|
407
|
+
*/
|
|
408
|
+
function sameSite(request, origin) {
|
|
409
|
+
if (origin && allowed.has(origin))
|
|
410
|
+
return;
|
|
411
|
+
const site = request.headers.get("sec-fetch-site");
|
|
412
|
+
if (site === "cross-site" || site === "same-site")
|
|
413
|
+
fail(403, "forbidden_origin", "This route takes requests from its own site; list other origins in allowedOrigins");
|
|
414
|
+
if (site === null && origin && origin !== "null") {
|
|
415
|
+
let from;
|
|
416
|
+
try {
|
|
417
|
+
from = new URL(origin).host;
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
return fail(403, "forbidden_origin", "The request's Origin is not valid");
|
|
421
|
+
}
|
|
422
|
+
const host = request.headers.get("host") ?? new URL(request.url).host;
|
|
423
|
+
if (from !== host)
|
|
424
|
+
fail(403, "forbidden_origin", "This route takes requests from its own site; list other origins in allowedOrigins");
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
const allowed = new Set(options.allowedOrigins ?? []);
|
|
428
|
+
const cors = (origin) => origin && allowed.has(origin)
|
|
429
|
+
? { "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Credentials": "true", Vary: "Origin" } : {};
|
|
430
|
+
const respond = (status, value, origin) => new Response(JSON.stringify(value), { status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store", ...cors(origin) } });
|
|
431
|
+
const handler = (async (request) => {
|
|
432
|
+
const origin = request.headers.get("origin");
|
|
433
|
+
if (request.method === "OPTIONS" && origin && allowed.has(origin)) {
|
|
434
|
+
return new Response(null, { status: 204, headers: { ...cors(origin), "Access-Control-Allow-Methods": options.proxy ? "GET, POST" : "POST", "Access-Control-Allow-Headers": "Content-Type, Authorization, Last-Event-ID", "Access-Control-Max-Age": "86400" } });
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
if (request.method === "GET" && options.proxy) {
|
|
438
|
+
const pathname = new URL(request.url).pathname;
|
|
439
|
+
const link = LINK.exec(pathname);
|
|
440
|
+
if (link)
|
|
441
|
+
return await download(request, link[0]);
|
|
442
|
+
const match = READ.exec(pathname);
|
|
443
|
+
if (!match)
|
|
444
|
+
fail(404, "not_found", "No such route");
|
|
445
|
+
sameSite(request, origin);
|
|
446
|
+
const response = await read(request, match);
|
|
447
|
+
for (const [name, value] of Object.entries(cors(origin)))
|
|
448
|
+
response.headers.set(name, value);
|
|
449
|
+
return response;
|
|
450
|
+
}
|
|
451
|
+
if (request.method !== "POST")
|
|
452
|
+
fail(405, "method_not_allowed", "POST a JSON body");
|
|
453
|
+
// Only JSON (a form on another site cannot post it), and only from this site unless allowed.
|
|
454
|
+
if (!/^application\/json\s*(;|$)/i.test(request.headers.get("content-type") ?? ""))
|
|
455
|
+
fail(415, "unsupported_media_type", "Send Content-Type: application/json");
|
|
456
|
+
sameSite(request, origin);
|
|
457
|
+
const text = await request.text();
|
|
458
|
+
if (text.length > MAX_BODY)
|
|
459
|
+
fail(413, "too_large", "The request body is too large");
|
|
460
|
+
let body;
|
|
461
|
+
try {
|
|
462
|
+
body = JSON.parse(text);
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
return fail(400, "invalid_json", "The body is not JSON");
|
|
466
|
+
}
|
|
467
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
468
|
+
fail(400, "invalid_request", "The body is a JSON object");
|
|
469
|
+
const action = body.action;
|
|
470
|
+
if (!ACTIONS.has(action))
|
|
471
|
+
fail(400, "invalid_request", `action is one of ${[...ACTIONS].join(", ")}`);
|
|
472
|
+
const thread = body.thread ?? null;
|
|
473
|
+
if (thread !== null && (typeof thread !== "string" || !thread || thread.length > 200))
|
|
474
|
+
fail(400, "invalid_request", "thread is a string of 1 to 200 characters");
|
|
475
|
+
const auth = checked(await options.authorize(request, { thread: thread, action }));
|
|
476
|
+
return respond(200, await handle(request, body, auth, action, thread), origin);
|
|
477
|
+
}
|
|
478
|
+
catch (error) {
|
|
479
|
+
if (error instanceof Response) {
|
|
480
|
+
const headers = new Headers(error.headers);
|
|
481
|
+
for (const [name, value] of Object.entries(cors(origin)))
|
|
482
|
+
headers.set(name, value);
|
|
483
|
+
return new Response(error.body, { status: error.status, statusText: error.statusText, headers });
|
|
484
|
+
}
|
|
485
|
+
if (error instanceof HandlerError)
|
|
486
|
+
return respond(error.status, { error: { code: error.code, message: error.message } }, origin);
|
|
487
|
+
// The browser went away mid-read: nobody to answer.
|
|
488
|
+
if (request.signal?.aborted)
|
|
489
|
+
return new Response(null, { status: 499 });
|
|
490
|
+
console.error("[agent handler]", error);
|
|
491
|
+
return respond(500, { error: { code: "internal_error", message: "The agent handler failed" } }, origin);
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
handler.close = async () => {
|
|
495
|
+
clearInterval(sweeper);
|
|
496
|
+
sweeper = undefined;
|
|
497
|
+
const entries = [...cache.values()];
|
|
498
|
+
cache.clear();
|
|
499
|
+
await Promise.all(entries.map(pending => pending.then(entry => entry.attached?.close(), () => { }).catch(() => { })));
|
|
500
|
+
};
|
|
501
|
+
return handler;
|
|
502
|
+
}
|
|
503
|
+
function codeFor(status) {
|
|
504
|
+
switch (status) {
|
|
505
|
+
case 400: return "invalid_request";
|
|
506
|
+
case 401: return "unauthorized";
|
|
507
|
+
case 403: return "forbidden";
|
|
508
|
+
case 404: return "not_found";
|
|
509
|
+
case 409: return "conflict";
|
|
510
|
+
case 429: return "rate_limited";
|
|
511
|
+
default: return status >= 500 ? "runtime_error" : "request_failed";
|
|
512
|
+
}
|
|
513
|
+
}
|