agents
Version:
A home for your AI agents
274 lines (273 loc) • 9.92 kB
JavaScript
import "./types.js";
import { camelCaseToKebabCase, isInternalJsStubProp } from "./utils.js";
import { PartySocket } from "partysocket";
//#region src/client.ts
var AgentConnectionError = class extends Error {
constructor(event) {
const reason = event.reason || `WebSocket closed with code ${event.code}`;
super(`Agent connection closed: ${reason}`);
this.name = "AgentConnectionError";
this.code = event.code;
this.reason = event.reason;
this.wasClean = event.wasClean;
}
};
function isTerminalCloseEvent(event) {
return event.code === 1008 || event.code >= 4e3 && event.code <= 4999;
}
/**
* Default timeout (in milliseconds) applied to non-streaming RPC calls
* that don't pass an explicit `timeout`. Acts as a backstop so calls
* whose response is lost (e.g. the connection drops mid-flight) reject
* instead of hanging forever. Override per client via
* `defaultCallTimeout`, or per call via `timeout` (0 disables).
*/
const DEFAULT_CALL_TIMEOUT_MS = 3e4;
/**
* Creates a proxy that wraps RPC method calls.
* Internal JS methods (toJSON, then, etc.) return undefined to avoid
* triggering RPC calls during serialization (e.g., console.log)
*/
function createStubProxy(call) {
return new Proxy({}, { get: (_target, method) => {
if (isInternalJsStubProp(method)) return;
return (...args) => call(method, args);
} });
}
/**
* WebSocket client for connecting to an Agent
*/
var AgentClient = class extends PartySocket {
/**
* @deprecated Use agentFetch instead
*/
static fetch(_opts) {
throw new Error("AgentClient.fetch is not implemented, use agentFetch instead");
}
/**
* Promise that resolves when identity has been received from the server.
* Useful for waiting before making calls that depend on knowing the instance.
* Resets on connection close so it can be awaited again after reconnect.
*/
get ready() {
return this._readyPromise;
}
_resetReady() {
this._readyPromise = new Promise((resolve) => {
this._resolveReady = resolve;
});
}
constructor(options) {
const agentNamespace = camelCaseToKebabCase(options.agent);
const shouldReconnectOnClose = options.shouldReconnectOnClose;
const classifyReconnect = (event) => (shouldReconnectOnClose?.(event) ?? true) && !isTerminalCloseEvent(event);
const socketOptions = options.basePath ? {
basePath: options.basePath,
path: options.path,
...options,
shouldReconnectOnClose: classifyReconnect
} : {
party: agentNamespace,
prefix: "agents",
room: options.name || "default",
path: options.path,
...options,
shouldReconnectOnClose: classifyReconnect
};
super(socketOptions);
this.state = void 0;
this.identified = false;
this.connectionError = null;
this._pendingCalls = /* @__PURE__ */ new Map();
this._previousName = null;
this._previousAgent = null;
this.agent = agentNamespace;
this.name = options.name || "default";
this.options = options;
this._resetReady();
this.addEventListener("message", (event) => {
if (typeof event.data === "string") {
let parsedMessage;
try {
parsedMessage = JSON.parse(event.data);
} catch (_error) {
return;
}
if (parsedMessage.type === "cf_agent_identity") {
const oldName = this._previousName;
const oldAgent = this._previousAgent;
const newName = parsedMessage.name;
const newAgent = parsedMessage.agent;
this.identified = true;
this._resolveReady();
if (oldName !== null && oldAgent !== null && (oldName !== newName || oldAgent !== newAgent)) if (this.options.onIdentityChange) this.options.onIdentityChange(oldName, newName, oldAgent, newAgent);
else {
const agentChanged = oldAgent !== newAgent;
const nameChanged = oldName !== newName;
let changeDescription = "";
if (agentChanged && nameChanged) changeDescription = `agent "${oldAgent}" → "${newAgent}", instance "${oldName}" → "${newName}"`;
else if (agentChanged) changeDescription = `agent "${oldAgent}" → "${newAgent}"`;
else changeDescription = `instance "${oldName}" → "${newName}"`;
console.warn(`[agents] Identity changed on reconnect: ${changeDescription}. This can happen with server-side routing (e.g., basePath with getAgentByName) where the instance is determined by auth/session. Provide onIdentityChange callback to handle this explicitly, or ignore if this is expected for your routing pattern.`);
}
this._previousName = newName;
this._previousAgent = newAgent;
this.name = newName;
this.agent = newAgent;
this.options.onIdentity?.(newName, newAgent);
return;
}
if (parsedMessage.type === "cf_agent_state") {
this.state = parsedMessage.state;
this.options.onStateUpdate?.(parsedMessage.state, "server");
return;
}
if (parsedMessage.type === "cf_agent_state_error") {
this.options.onStateUpdateError?.(parsedMessage.error);
return;
}
if (parsedMessage.type === "rpc") {
const response = parsedMessage;
const pending = this._pendingCalls.get(response.id);
if (!pending) {
console.warn(`[AgentClient] Discarded an RPC response with no matching pending call (id "${response.id}"). The call likely timed out or was rejected when its connection closed before the response arrived.`);
return;
}
if (!response.success) {
pending.reject(new Error(response.error));
this._pendingCalls.delete(response.id);
pending.stream?.onError?.(response.error);
return;
}
if ("done" in response) if (response.done) {
pending.resolve(response.result);
this._pendingCalls.delete(response.id);
pending.stream?.onDone?.(response.result);
} else pending.stream?.onChunk?.(response.result);
else {
pending.resolve(response.result);
this._pendingCalls.delete(response.id);
}
}
}
});
this.addEventListener("open", () => {
this.connectionError = null;
for (const pending of this._pendingCalls.values()) pending.transmitted = true;
});
this.addEventListener("close", (event) => {
const terminalClose = isTerminalCloseEvent(event);
this.identified = false;
this._resetReady();
if (this.shouldReconnect) this._rejectPendingCalls("Connection closed", { onlyTransmitted: true });
else {
this._rejectPendingCalls("Connection closed");
if (terminalClose) {
const error = new AgentConnectionError(event);
this.connectionError = error;
this.options.onConnectionError?.(error);
}
}
});
this.call = this._callImpl.bind(this);
this.stub = createStubProxy((method, args) => this._callImpl(method, args));
}
/**
* Reject pending RPC calls with the given reason.
* With `onlyTransmitted`, calls still sitting in the send buffer are
* kept pending (they'll be flushed on reconnect).
*/
_rejectPendingCalls(reason, { onlyTransmitted = false } = {}) {
const error = new Error(reason);
for (const [id, pending] of this._pendingCalls) {
if (onlyTransmitted && !pending.transmitted) continue;
this._pendingCalls.delete(id);
pending.reject(error);
pending.stream?.onError?.(reason);
}
}
setState(state) {
this.send(JSON.stringify({
state,
type: "cf_agent_state"
}));
this.state = state;
this.options.onStateUpdate?.(state, "client");
}
/**
* Close the connection and immediately reject all pending RPC calls.
* This provides immediate feedback on intentional close rather than
* waiting for the WebSocket close handshake to complete.
*
* Note: Any calls made after `close()` will be rejected when the
* underlying WebSocket close event fires.
*/
close(code, reason) {
this._rejectPendingCalls("Connection closed");
super.close(code, reason);
}
/**
* Call a method on the Agent.
* When AgentT is provided, method names are inferred from the agent's methods.
* Falls back to untyped string-based calls when AgentT is not provided.
*/
async _callImpl(method, args = [], options) {
if (this.connectionError && this.readyState === this.CLOSED) throw new Error("Connection closed");
return new Promise((resolve, reject) => {
const id = crypto.randomUUID();
let timeoutId;
const isLegacyFormat = options && ("onChunk" in options || "onDone" in options || "onError" in options);
const streamOptions = isLegacyFormat ? options : options?.stream;
const timeout = isLegacyFormat ? void 0 : options?.timeout;
const effectiveTimeout = timeout !== void 0 ? timeout : streamOptions ? void 0 : this.options.defaultCallTimeout ?? 3e4;
if (effectiveTimeout) timeoutId = setTimeout(() => {
const pending = this._pendingCalls.get(id);
this._pendingCalls.delete(id);
const errorMessage = `RPC call to ${method} timed out after ${effectiveTimeout}ms`;
pending?.stream?.onError?.(errorMessage);
reject(new Error(errorMessage));
}, effectiveTimeout);
this._pendingCalls.set(id, {
reject: (e) => {
if (timeoutId) clearTimeout(timeoutId);
reject(e);
},
resolve: (value) => {
if (timeoutId) clearTimeout(timeoutId);
resolve(value);
},
stream: streamOptions,
transmitted: this.readyState === this.OPEN
});
const request = {
args,
id,
method,
type: "rpc"
};
this.send(JSON.stringify(request));
});
}
};
/**
* Make an HTTP request to an Agent
* @param opts Connection options
* @param init Request initialization options
* @returns Promise resolving to a Response
*/
function agentFetch(opts, init) {
const agentNamespace = camelCaseToKebabCase(opts.agent);
if (opts.basePath) return PartySocket.fetch({
basePath: opts.basePath,
...opts
}, init);
return PartySocket.fetch({
party: agentNamespace,
prefix: "agents",
room: opts.name || "default",
...opts
}, init);
}
//#endregion
export { AgentClient, AgentConnectionError, DEFAULT_CALL_TIMEOUT_MS, agentFetch, createStubProxy, isTerminalCloseEvent };
//# sourceMappingURL=client.js.map