agents
Version:
A home for your AI agents
267 lines (266 loc) • 12.4 kB
JavaScript
import { camelCaseToKebabCase, isInternalJsStubProp } from "./utils.js";
//#region src/sub-routing.ts
/**
* Sub-agent routing primitives — external addressability for facets.
*
* The public surface:
* - `routeSubAgentRequest(req, parent, options?)` — the sub-agent
* analog of `routeAgentRequest`. Use in custom fetch handlers.
* - `getSubAgentByName(parent, Cls, name)` — the sub-agent analog
* of `getAgentByName`. Returns a typed RPC stub that proxies
* method calls through the parent. No `.fetch()` support —
* external HTTP/WS routing goes through `routeSubAgentRequest`.
*
* Internal:
* - `parseSubAgentPath(url)` — URL → `{ childClass, childName, remainingPath }`.
* - `forwardToFacet(req, parent, match)` — resolves `ctx.facets.get(...)`
* on the parent and returns `facetStub.fetch(rewrittenReq)`.
*
* @experimental The API surface may change before stabilizing.
*/
/**
* URL segment marking a parent↔child boundary.
*
* Exposed as a constant so callers can build URLs symbolically, but
* not configurable — the routing layer matches on the literal `sub`
* token everywhere (parent fetch, client, helpers).
*/
const SUB_PREFIX = "sub";
function validateLeafPath(leafPath) {
const normalized = leafPath.startsWith("/") ? leafPath : `/${leafPath}`;
const parsed = new URL(normalized, "https://agents.invalid");
if (normalized.includes("//") || normalized.length > 1 && normalized.endsWith("/") || parsed.pathname !== normalized || parsed.search || parsed.hash) throw new Error(`Cannot build an Agent path for leaf path ${JSON.stringify(leafPath)} because it is not a stable pathname.`);
return normalized;
}
function validateRoutingPrefix(prefix) {
const prefixParts = prefix.split("/");
const rawPath = `/${prefix}/leaf`;
const parsed = new URL(rawPath, "https://agents.invalid");
if (prefixParts.some((part) => !part || part === "." || part === ".." || part === "sub") || parsed.pathname !== rawPath || parsed.search || parsed.hash) throw new Error(`Cannot build an Agent path for routing prefix ${JSON.stringify(prefix)} because it is not externally routable.`);
return prefix;
}
function encodeAgentClassName(className) {
const segment = camelCaseToKebabCase(className);
if (segment === "sub") throw new Error(`Cannot build an Agent path for Agent class name ${JSON.stringify(className)} because ${JSON.stringify("sub")} is reserved.`);
const rawPath = `/root/${segment}/leaf`;
const parsed = new URL(rawPath, "https://agents.invalid");
const parts = parsed.pathname.split("/");
if (!segment || parsed.pathname !== rawPath || parsed.search || parsed.hash || parts.length !== 4 || parts[2] !== segment) throw new Error(`Cannot build an Agent path for Agent class name ${JSON.stringify(className)} because it is not externally routable.`);
return segment;
}
function encodeChildAgentName(name) {
if (!name || name === "." || name === ".." || name.includes("\0")) throw new Error(`Cannot build an Agent path for child Agent name ${JSON.stringify(name)} because it is not externally routable.`);
try {
return encodeURIComponent(name);
} catch {
throw new Error(`Cannot build an Agent path for child Agent name ${JSON.stringify(name)} because it is not valid Unicode.`);
}
}
function validateRootAgentName(name) {
if (name === "sub") throw new Error(`Cannot build an Agent path for root Agent name ${JSON.stringify(name)} because ${JSON.stringify("sub")} is reserved.`);
const rawPath = `/root/${name}/leaf`;
const parsed = new URL(rawPath, "https://agents.invalid");
const parts = parsed.pathname.split("/");
if (!name || parsed.pathname !== rawPath || parsed.search || parsed.hash || parts.length !== 4 || parts[2] !== name) throw new Error(`Cannot build an Agent path for root Agent name ${JSON.stringify(name)} because it is not externally routable.`);
return name;
}
function serializeSubAgentPath(path, leafPath, validate) {
if (path.length === 0) return leafPath ?? "";
const subPath = path.flatMap((child) => [
"sub",
validate ? encodeAgentClassName(child.className) : camelCaseToKebabCase(child.className),
validate ? encodeChildAgentName(child.name) : encodeURIComponent(child.name)
]).join("/");
if (!leafPath) return subPath;
return `${subPath}${leafPath.startsWith("/") ? leafPath : `/${leafPath}`}`;
}
/** @internal Build the strictly validated path tail for sub-agent routing. */
function buildSubAgentPath(path, leafPath) {
return serializeSubAgentPath(path, leafPath, true);
}
/** @internal Preserve React's tolerant path composition for disabled placeholders. */
function buildSubAgentPathUnchecked(path, leafPath) {
return serializeSubAgentPath(path, leafPath, false);
}
/**
* Build the canonical pathname for a root Agent or nested sub-agent.
*
* The address is root-first and accepts `Agent#selfPath`. Pass
* `rootBinding` when the root Durable Object binding and class names differ.
*/
function buildAgentPath(path, options = {}) {
const [root, ...children] = path;
if (!root) throw new Error("Agent path must contain at least one step.");
const rootPath = [
validateRoutingPrefix(options.prefix ?? "agents"),
encodeAgentClassName(options.rootBinding ?? root.className),
validateRootAgentName(root.name)
].join("/");
const leafPath = options.leafPath ? validateLeafPath(options.leafPath) : void 0;
if (children.length === 0) return `/${rootPath}${leafPath ?? ""}`;
return `/${rootPath}/${buildSubAgentPath(children, leafPath)}`;
}
/** Build an absolute URL for a root Agent or nested sub-agent. */
function buildAgentUrl(origin, path, options = {}) {
let base;
try {
base = new URL(origin.toString());
} catch {
throw new Error(`Invalid Agent URL origin ${JSON.stringify(origin)}.`);
}
if (![
"http:",
"https:",
"ws:",
"wss:"
].includes(base.protocol) || base.username || base.password || base.pathname !== "/" || base.search || base.hash) throw new Error(`Invalid Agent URL origin ${JSON.stringify(origin.toString())}. Pass an HTTP(S) or WS(S) origin without credentials, a pathname, query, or fragment.`);
return new URL(buildAgentPath(path, options), base);
}
/**
* Parse a URL and extract the first `/sub/{class}/{name}` segment,
* if any. Recursive nesting is handled naturally: callers parse one
* level at a time; the child then parses its own URL (which still
* contains any deeper `/sub/...` markers).
*
* Names are URL-decoded. Classes are kebab-to-CamelCase converted
* via a best-effort match against a provided lookup — pass
* `ctx.exports` keys to get exact CamelCase; pass `undefined` for
* a tolerant conversion without validation.
*
* Returns `null` when the URL doesn't contain the marker at a
* recognized position, or when the marker has no following
* class+name pair.
*/
function parseSubAgentPath(url, options) {
const parts = new URL(url).pathname.split("/").filter(Boolean);
for (let i = 0; i < parts.length; i++) {
if (parts[i] !== "sub") continue;
if (i + 2 >= parts.length) continue;
const classSegment = parts[i + 1];
const nameSegment = parts[i + 2];
const childClass = resolveClassName(classSegment, options?.knownClasses);
if (!childClass) continue;
let childName;
try {
childName = decodeURIComponent(nameSegment);
} catch {
continue;
}
const remainingParts = parts.slice(i + 3);
const remainingPath = remainingParts.length > 0 ? "/" + remainingParts.join("/") : "/";
return {
childClass,
childName,
remainingPath
};
}
return null;
}
/**
* Best-effort kebab-to-CamelCase match. If `knownClasses` is
* provided, returns the matching CamelCase entry (or null if no
* match). If not, performs a naive kebab→CamelCase conversion.
*/
function resolveClassName(segment, knownClasses) {
if (knownClasses) return knownClasses.find((name) => camelCaseToKebabCase(name) === segment) ?? null;
return segment.split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
}
/**
* Route a request into a sub-agent via its parent DO.
*
* Use this in a custom fetch handler when your URL shape doesn't
* match the `/agents/{class}/{name}` default — you identify and
* fetch the parent yourself, then let this helper parse the
* `/sub/{child}/...` tail and forward it.
*
* Runs `onBeforeSubAgent` on the parent DO (authorization / request
* mutation / short-circuit response).
*
* For the default `/agents/...` URL shape, use `routeAgentRequest`
* instead — it handles the parent lookup and this dispatch in one
* call.
*
* @example
* ```ts
* export default {
* async fetch(req, env) {
* const { parentName, rest } = myCustomParse(req.url);
* const parent = await getAgentByName(env.Inbox, parentName);
* return routeSubAgentRequest(req, parent, { fromPath: rest });
* }
* };
* ```
*
* @experimental The API surface may change before stabilizing.
*/
async function routeSubAgentRequest(req, parent, options) {
if (!parseSubAgentPath(options?.fromPath ? `http://placeholder${options.fromPath.startsWith("/") ? "" : "/"}${options.fromPath}` : req.url)) return new Response("Sub-agent path not found in request URL", { status: 400 });
const forwardUrl = options?.fromPath !== void 0 ? rewritePathname(req.url, options.fromPath) : req.url;
const forwardInit = {
method: req.method,
headers: new Headers(req.headers)
};
if (req.body && req.method !== "GET" && req.method !== "HEAD") forwardInit.body = req.body;
const forwardReq = new Request(forwardUrl, forwardInit);
return parent.fetch(forwardReq);
}
/**
* Replace a URL's pathname (and optionally its search) while
* preserving every other component. Matches how `_cf_forwardToFacet`
* forwards requests — pathname is the only thing that changes by
* default; if the replacement path carries its own query string,
* that wins.
*/
function rewritePathname(url, fromPath) {
const normalized = fromPath.startsWith("/") ? fromPath : `/${fromPath}`;
const queryIdx = normalized.indexOf("?");
const pathOnly = queryIdx >= 0 ? normalized.slice(0, queryIdx) : normalized;
const querySuffix = queryIdx >= 0 ? normalized.slice(queryIdx) : "";
const rewritten = new URL(url);
rewritten.pathname = pathOnly;
if (querySuffix) rewritten.search = querySuffix;
return rewritten.toString();
}
/**
* Get a typed RPC stub for a sub-agent from outside the parent DO.
*
* The returned stub proxies method calls through the parent via a
* stateless per-call bridge (caller → parent → facet), so each
* method invocation costs one extra RPC hop. Works across parent
* hibernation — no cached references to go stale.
*
* Limitations:
* - RPC methods only. `.fetch()` is not supported (will throw).
* Use `routeSubAgentRequest` for external HTTP/WS.
* - Arguments and return values must be structured-cloneable,
* same as any DO RPC call.
* - Does not run `onBeforeSubAgent` on the parent — analogous to
* `getAgentByName` not running `onBeforeConnect`. The caller is
* assumed to have performed whatever access checks are needed.
*
* @example
* ```ts
* const inbox = await getAgentByName(env.MyInbox, userId);
* const chat = await getSubAgentByName(inbox, MyChat, chatId);
* await chat.addMessage({ role: "user", content: "hi" });
* ```
*
* @experimental The API surface may change before stabilizing.
*/
async function getSubAgentByName(parent, cls, name) {
if (name.includes("\0")) throw new Error(`Sub-agent name contains null character (\\0), which is reserved.`);
const bridge = parent;
const className = cls?.name;
if (!className) throw new Error(`getSubAgentByName: could not determine class name from ${cls}. Ensure you are passing the class constructor (e.g. getSubAgentByName(parent, MyChat, name)), not a string or undefined.`);
return new Proxy({}, { get(_target, prop) {
if (isInternalJsStubProp(prop)) return void 0;
if (typeof prop !== "string") return void 0;
if (prop === "fetch") return () => {
throw new Error("getSubAgentByName returns an RPC-only stub — .fetch() is not supported. Use routeSubAgentRequest() or the /agents/{parent}/{name}/sub/{child}/{name} URL for external HTTP/WS routing.");
};
return async (...args) => bridge._cf_invokeSubAgent(className, name, prop, args);
} });
}
//#endregion
export { SUB_PREFIX, buildAgentPath, buildAgentUrl, buildSubAgentPath, buildSubAgentPathUnchecked, getSubAgentByName, parseSubAgentPath, routeSubAgentRequest };
//# sourceMappingURL=sub-routing.js.map