agents
Version:
A home for your AI agents
227 lines (226 loc) • 8.76 kB
JavaScript
//#region src/harness/container/protocol.ts
/**
* The wire between `ContainerHarness` (in a Durable Object) and the harness
* daemon (in the container). Isomorphic and dependency-free: the daemon
* imports it in Node, the harness in Workers.
*
* One WebSocket per session, dialled by the object through
* `ctx.container.getTcpPort(port)` at `/sessions/<id>`. The daemon speaks
* first with `hello`; the object answers with `open` (when the daemon does
* not hold the session yet) and `replay`, then delivers `prompt`s.
*
* Everything the daemon produces for a session is a numbered `frame`. The
* numbers start at 1 for each session in each container, so a frame is
* addressed by `(runtimeId, seq)`. The daemon keeps frames until they are
* acknowledged, so an object that lost its socket (evicted, redeployed)
* replays what it missed from its cursor. A different `runtimeId` in
* `hello` means a different container: its numbering starts again, and
* whatever the old container was doing is gone.
*
* @experimental The wire may change before it stabilizes. The object and the image are
* a matched pair; a version mismatch is reported in `hello`.
*/
/** Bumped on any incompatible change. */
const CONTAINER_PROTOCOL_VERSION = 1;
/** The daemon answers `200` here once it accepts connections. */
const CONTAINER_HEALTH_PATH = "/healthz";
/** The session socket: `/sessions/<encoded session id>`. */
const CONTAINER_SESSION_PATH = "/sessions/";
/** The shared secret the object passes on every request. */
const CONTAINER_TOKEN_HEADER = "x-cf-harness-token";
/** The environment variables the object starts the container with. */
const CONTAINER_ENV = {
/** Shared secret for `CONTAINER_TOKEN_HEADER`. */
token: "CF_HARNESS_TOKEN",
/** This container's id. Echoed in `hello`. */
runtimeId: "CF_HARNESS_RUNTIME_ID",
/** The port the daemon listens on. */
port: "CF_HARNESS_PORT"
};
/** WebSocket close code: a newer connection for the session replaced this one. */
const CLOSE_REPLACED = 4001;
/** WebSocket close code: the request was not authorized. */
const CLOSE_UNAUTHORIZED = 4003;
/**
* Most JSON characters the object puts in one `restore` message, and the
* daemon in one `persist` frame (a single larger entry travels alone).
* Keeps every message well inside WebSocket message limits.
*/
const MAX_CHUNK_CHARS = 256 * 1024;
function isFields(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isString(value) {
return typeof value === "string";
}
function isCount(value) {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
}
function isJson(value) {
if (value === null) return true;
switch (typeof value) {
case "string":
case "boolean": return true;
case "number": return Number.isFinite(value);
case "object":
if (Array.isArray(value)) return value.every(isJson);
return Object.values(value).every(isJson);
default: return false;
}
}
function isOutcome(value) {
if (!isFields(value)) return false;
if (value.status === "done") return isString(value.text);
return value.status === "unanswered" && isString(value.reason);
}
function isEvent(value) {
if (!isFields(value) || !isString(value.type) || !isJson(value)) return false;
switch (value.type) {
case "text-delta":
case "reasoning-delta": return isString(value.messageId) && isString(value.delta);
case "message": return isFields(value.message) && isString(value.message.id) && (value.message.role === "user" || value.message.role === "assistant") && Array.isArray(value.message.parts);
case "usage": return true;
case "log": return isString(value.message);
case "raw": return "value" in value;
default: return false;
}
}
function isFrame(value) {
if (!isFields(value)) return false;
switch (value.kind) {
case "start": return isString(value.operationId);
case "event": return (value.operationId === null || isString(value.operationId)) && isEvent(value.event);
case "persist": return Array.isArray(value.entries) && value.entries.every(isJson);
case "settle": return isString(value.operationId) && isOutcome(value.outcome);
default: return false;
}
}
function isDaemonOperation(value) {
if (!isFields(value) || !isString(value.operationId)) return false;
if (value.status === "queued" || value.status === "running") return true;
return value.status === "settled" && isOutcome(value.outcome);
}
function isInput(value) {
if (isString(value)) return true;
return Array.isArray(value) && value.every((part) => isFields(part) && (part.type === "text" && isString(part.text) || part.type === "image" && isString(part.mediaType) && isString(part.data)));
}
function isSettings(value) {
return isFields(value) && (value.model === void 0 || isString(value.model)) && (value.options === void 0 || isJson(value.options));
}
function decode(text) {
try {
return JSON.parse(text);
} catch {
return;
}
}
/**
* Parse one message from the daemon.
*
* @param text - The raw WebSocket message.
* @returns The message, or `undefined` when it is not one.
*/
function parseDaemonMessage(text) {
const value = decode(text);
if (!isFields(value)) return void 0;
switch (value.type) {
case "hello":
if (isCount(value.protocol) && isString(value.runtimeId) && isString(value.session) && isFields(value.adapter) && isString(value.adapter.id) && isString(value.adapter.version) && Array.isArray(value.adapter.capabilities) && value.adapter.capabilities.every(isString) && (value.open === null || isCount(value.open)) && isCount(value.lastSeq) && Array.isArray(value.operations) && value.operations.every(isDaemonOperation)) return value;
return;
case "frame": return isCount(value.seq) && isFrame(value.frame) ? value : void 0;
case "caught-up": return isCount(value.lastSeq) ? {
type: "caught-up",
lastSeq: value.lastSeq
} : void 0;
case "error": return isString(value.message) ? {
type: "error",
message: value.message
} : void 0;
default: return;
}
}
/**
* Parse one message from the object.
*
* @param text - The raw WebSocket message.
* @returns The message, or `undefined` when it is not one.
*/
function parseHostMessage(text) {
const value = decode(text);
if (!isFields(value)) return void 0;
switch (value.type) {
case "restore": return isCount(value.generation) && Array.isArray(value.entries) && value.entries.every(isJson) ? {
type: "restore",
generation: value.generation,
entries: value.entries
} : void 0;
case "open": return isCount(value.generation) && isSettings(value.settings) && Array.isArray(value.restore) && value.restore.every(isJson) ? {
type: "open",
generation: value.generation,
settings: value.settings,
restore: value.restore
} : void 0;
case "replay": return isCount(value.after) ? {
type: "replay",
after: value.after
} : void 0;
case "prompt": return isString(value.operationId) && isInput(value.input) && (value.whenBusy === "followUp" || value.whenBusy === "steer") ? {
type: "prompt",
operationId: value.operationId,
input: value.input,
whenBusy: value.whenBusy
} : void 0;
case "abort":
if (value.operationId === void 0) return { type: "abort" };
return isString(value.operationId) ? {
type: "abort",
operationId: value.operationId
} : void 0;
case "configure": return isSettings(value.settings) ? {
type: "configure",
settings: value.settings
} : void 0;
case "ack": return isCount(value.seq) ? {
type: "ack",
seq: value.seq
} : void 0;
default: return;
}
}
/**
* The text of an input, with image parts left out.
*
* @param input - A prompt's input.
* @returns Its text.
*/
function inputText(input) {
if (typeof input === "string") return input;
return input.map((part) => part.type === "text" ? part.text : "").join("\n");
}
/**
* Split entries into groups whose JSON stays under `MAX_CHUNK_CHARS`. An
* entry larger than that is a group of its own.
*
* @param entries - The entries, in order.
* @returns The groups, in order. Empty for no entries.
*/
function chunkEntries(entries) {
const groups = [];
let group = [];
let size = 0;
for (const entry of entries) {
const length = JSON.stringify(entry).length;
if (group.length > 0 && size + length > 262144) {
groups.push(group);
group = [];
size = 0;
}
group.push(entry);
size += length;
}
if (group.length > 0) groups.push(group);
return groups;
}
//#endregion
export { CONTAINER_PROTOCOL_VERSION as a, MAX_CHUNK_CHARS as c, parseDaemonMessage as d, parseHostMessage as f, CONTAINER_HEALTH_PATH as i, chunkEntries as l, CLOSE_UNAUTHORIZED as n, CONTAINER_SESSION_PATH as o, CONTAINER_ENV as r, CONTAINER_TOKEN_HEADER as s, CLOSE_REPLACED as t, inputText as u };
//# sourceMappingURL=protocol-cOklVBBD.js.map