agents
Version:
A home for your AI agents
244 lines (243 loc) • 12.3 kB
JavaScript
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