UNPKG

agents

Version:

A home for your AI agents

274 lines (273 loc) 9.92 kB
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