UNPKG

agents

Version:

A home for your AI agents

244 lines (243 loc) 12.3 kB
import { getAgentByName, routeAgentRequest } from "../agent-routing.js"; import { i as _classPrivateFieldInitSpec, n as _classPrivateFieldSet2, r as _assertClassBrand, t as _classPrivateFieldGet2 } from "../classPrivateFieldGet2-DZBYAB34.js"; import { t as LifecycleCapability } from "../capability-B4WbF81e.js"; import { t as _classPrivateMethodInitSpec } from "../classPrivateMethodInitSpec-qMjJ6sHQ.js"; //#region src/routing/routed-agents.ts /** * Catalog of the entries an owning Durable Object has created, keyed by * route so several namespaces can share one owner. `WITHOUT ROWID` keeps * an insert at one billed row; `agent_name` is a random UUID, so it needs * no unique index of its own. */ const TABLE = "cf_agents_routed_agents"; /** * Derives the next per-route sequence number from a `MAX(seq)` read * instead of a maintained counter row. Breaks ties between equal * `Date.now()` values deterministically by write order, which a random * entry `id` cannot: DO SQLite millisecond timestamps collide easily * under rapid same-route writes. * * This scans every row for the route on each create()/setMetadata() — * intentionally, not a missed index. DO SQLite bills roughly 1000 * writes for the cost of 1000 reads, so a maintained counter row (an * extra write on every call) only wins at deep four-figure entries per * route; a `(route, seq)` index would cost an extra write on every call * too, since `seq` changes on every write it would index. `RoutedAgents` * targets one owner's own catalog (chats, documents, sessions) — for a * route expected to hold thousands of entries, benchmark before relying * on this ordering; it is not built for that scale. */ const NEXT_SEQ = `(SELECT COALESCE(MAX(seq), 0) + 1 FROM ${TABLE} WHERE route = ?)`; function encodeMetadata(value) { const encoded = JSON.stringify(value ?? null); if (encoded === void 0) throw new TypeError("RoutedAgents metadata must be JSON-serializable"); return encoded; } var _namespace = /* @__PURE__ */ new WeakMap(); var _route = /* @__PURE__ */ new WeakMap(); var _RoutedAgents_brand = /* @__PURE__ */ new WeakSet(); /** * A durable, routed collection of independent top-level Agents. * * Install this on the owning Durable Object, typically a per-user hub. It * maps public entry IDs to opaque physical Agent names, handles catalog * CRUD without waking any target, and forwards matching HTTP requests and * WebSocket upgrades to the selected Agent. After an upgrade the target * owns the socket, so ordinary frames never wake the owner. The target * Agent needs no matching capability. Destroying the owner condemns every * remaining entry with a few retries so targets don't casually outlive * their catalog — this is best-effort, not a durability guarantee; see * {@link RoutedAgents.dispose}. * * Pick a `route` that cannot appear as a literal path segment elsewhere * under the owner (its own name, another route, or a path the owner's own * `onRequest` handles) — forwarding matches every occurrence of the route * segment in the path, so a coincidental match with no active entry * behind it is answered `404` instead of reaching the owner. * * A forwarded suffix is not searched for a `/sub/{class}/{name}` dynamic * agents marker: `Agent.fetch()` resolves that marker against the OWNER's * exported classes before this capability's `onRequest` ever runs, so a * matching marker is served as a facet of the owner, not forwarded to the * target. Address a target's own dynamic agents through a direct * connection to that target, not through the owner's route. * * @experimental The API surface may change before stabilizing. */ var RoutedAgents = class extends LifecycleCapability { /** * @param options - Target binding and the route segment this capability * claims. Install with `this.lifecycle.use()` before startup. */ constructor(options) { const route = options.route.replace(/^\/+|\/+$/g, ""); if (!/^[A-Za-z0-9_-]+$/.test(route)) throw new Error("RoutedAgents route must be one non-empty URL-safe path segment"); super(`routed-agents:${route}`); _classPrivateMethodInitSpec(this, _RoutedAgents_brand); _classPrivateFieldInitSpec(this, _namespace, void 0); _classPrivateFieldInitSpec(this, _route, void 0); _classPrivateFieldSet2(_namespace, this, options.namespace); _classPrivateFieldSet2(_route, this, route); } /** Create an entry without waking the target Agent. */ async create(options) { await this.lifecycle.ready(); const id = crypto.randomUUID(); const encoded = encodeMetadata(options?.metadata ?? null); const now = Date.now(); _assertClassBrand(_RoutedAgents_brand, this, _sql).call(this, `INSERT INTO ${TABLE} (route, id, agent_name, status, metadata, created_at, updated_at, seq) VALUES (?, ?, ?, 'active', ?, ?, ?, ${NEXT_SEQ})`, _classPrivateFieldGet2(_route, this), id, crypto.randomUUID(), encoded, now, now, _classPrivateFieldGet2(_route, this)); return { id, metadata: JSON.parse(encoded), createdAt: now, updatedAt: now }; } /** Resolve an active entry to an initialized, typed Agent stub. */ async get(id) { await this.lifecycle.ready(); const agentName = _assertClassBrand(_RoutedAgents_brand, this, _agentName).call(this, id, "active"); return agentName ? _assertClassBrand(_RoutedAgents_brand, this, _stub).call(this, agentName) : null; } /** * List active entries, most recently updated first. Entries whose * `updatedAt` ties are ordered by actual write order, not by the * random entry `id`. */ async list() { await this.lifecycle.ready(); return _assertClassBrand(_RoutedAgents_brand, this, _sql).call(this, `SELECT id, metadata, created_at AS createdAt, updated_at AS updatedAt FROM ${TABLE} WHERE route = ? AND status = 'active' ORDER BY updated_at DESC, seq DESC, id ASC`, _classPrivateFieldGet2(_route, this)).map((row) => ({ ...row, metadata: JSON.parse(row.metadata) })); } /** Replace an active entry's metadata. Returns false for unknown IDs. */ async setMetadata(id, metadata) { await this.lifecycle.ready(); return _assertClassBrand(_RoutedAgents_brand, this, _sql).call(this, `UPDATE ${TABLE} SET metadata = ?, updated_at = ?, seq = ${NEXT_SEQ} WHERE route = ? AND id = ? AND status = 'active' RETURNING id`, encodeMetadata(metadata), Date.now(), _classPrivateFieldGet2(_route, this), _classPrivateFieldGet2(_route, this), id).length > 0; } /** * Make an entry unreachable, condemn its Agent, then remove the row. * Returns false for unknown IDs. * * The target is condemned through Agent's deferred teardown, which * durably marks it and returns without aborting the isolate; its storage * is wiped on its own next wake, moments later, and the marker survives * interruption. A failed RPC leaves a hidden `deleting` row so a * repeated call retries. */ async delete(id) { await this.lifecycle.ready(); const agentName = _assertClassBrand(_RoutedAgents_brand, this, _agentName).call(this, id); if (!agentName) return false; _assertClassBrand(_RoutedAgents_brand, this, _sql).call(this, `UPDATE ${TABLE} SET status = 'deleting', updated_at = ? WHERE route = ? AND id = ?`, Date.now(), _classPrivateFieldGet2(_route, this), id); await _classPrivateFieldGet2(_namespace, this).get(_classPrivateFieldGet2(_namespace, this).idFromName(agentName))._cf_scheduleDestroy(); _assertClassBrand(_RoutedAgents_brand, this, _sql).call(this, `DELETE FROM ${TABLE} WHERE route = ? AND id = ?`, _classPrivateFieldGet2(_route, this), id); return true; } onStart() { _assertClassBrand(_RoutedAgents_brand, this, _sql).call(this, `CREATE TABLE IF NOT EXISTS ${TABLE} ( route TEXT NOT NULL, id TEXT NOT NULL, agent_name TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('active', 'deleting')), metadata TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, seq INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (route, id) ) WITHOUT ROWID`); } /** * Condemn every remaining entry (including one already `deleting`, in * case its own condemnation RPC never landed) when the owner itself is * destroyed. * * `Agent.destroy()` disposes capabilities before it wipes its own * storage, so the catalog is still readable here — without this, the * catalog would vanish with the owner while every target it named kept * running and billing storage, unreachable forever. * * This is best-effort, not a durability guarantee: `Agent.destroy()` * wipes the owner's storage immediately after disposal regardless of * whether any capability's `dispose()` reports failure, so a target * that is still unreachable after retries here is orphaned for good — * there is no later "repeated call retries" for a catalog row that no * longer exists. Retrying briefly here converts the common transient * failure into a condemned target instead of an orphan; it cannot * convert a target that is durably unreachable. */ async dispose() { const entries = _assertClassBrand(_RoutedAgents_brand, this, _sql).call(this, `SELECT agent_name AS agentName FROM ${TABLE} WHERE route = ? AND status IN ('active', 'deleting')`, _classPrivateFieldGet2(_route, this)); await Promise.all(entries.map(({ agentName }) => _assertClassBrand(_RoutedAgents_brand, this, _condemnWithRetry).call(this, agentName))); } /** Forward a matching HTTP request to the selected Agent. */ onRequest({ request }) { return _assertClassBrand(_RoutedAgents_brand, this, _forward).call(this, request); } /** Forward a matching upgrade so the selected Agent owns the WebSocket. */ onWebSocketUpgrade({ request }) { return _assertClassBrand(_RoutedAgents_brand, this, _forward).call(this, request); } }; async function _condemnWithRetry(agentName, attempts = 3) { for (let attempt = 1; attempt <= attempts; attempt++) try { await _classPrivateFieldGet2(_namespace, this).get(_classPrivateFieldGet2(_namespace, this).idFromName(agentName))._cf_scheduleDestroy(); return; } catch (error) { if (attempt === attempts) { console.error(`RoutedAgents "${_classPrivateFieldGet2(_route, this)}" could not condemn ${agentName} on owner disposal after ${attempts} attempts; its storage will leak, since the owner's catalog — the only record of it — is wiped immediately after disposal`, error); return; } await new Promise((resolve) => setTimeout(resolve, attempt * 50)); } } /** * The route segment may also appear as the owner's own name or inside * the forwarded suffix, so every `/{route}/{id}` occurrence is tried * against the catalog and the first active entry wins. A route match * with no active entry is a 404; no match at all lets the request * continue to the owner's other capabilities. */ async function _forward(request) { const url = new URL(request.url); const segments = url.pathname.split("/"); let matched = false; for (let i = 1; i < segments.length - 1; i++) { if (segments[i] !== _classPrivateFieldGet2(_route, this) || segments[i + 1] === "") continue; matched = true; const agentName = _assertClassBrand(_RoutedAgents_brand, this, _agentName).call(this, decode(segments[i + 1]), "active"); if (!agentName) continue; url.pathname = `/${segments.slice(i + 2).join("/")}`; return _classPrivateFieldGet2(_namespace, this).get(_classPrivateFieldGet2(_namespace, this).idFromName(agentName)).fetch(new Request(url, request)); } return matched ? new Response("Agent not found", { status: 404 }) : void 0; } /** Initialized stub; the explicit generics keep inference shallow. */ function _stub(agentName) { return getAgentByName(_classPrivateFieldGet2(_namespace, this), agentName); } function _agentName(id, status) { const [row] = _assertClassBrand(_RoutedAgents_brand, this, _sql).call(this, `SELECT agent_name AS agentName FROM ${TABLE} WHERE route = ? AND id = ? AND status = COALESCE(?, status)`, _classPrivateFieldGet2(_route, this), id, status ?? null); return row?.agentName; } function _sql(query, ...values) { return this.lifecycle.storage.sql.exec(query, ...values).toArray(); } function decode(segment) { try { return decodeURIComponent(segment); } catch { return segment; } } //#endregion export { RoutedAgents, getAgentByName, routeAgentRequest }; //# sourceMappingURL=index.js.map