UNPKG

agents

Version:

A home for your AI agents

483 lines (482 loc) 17.3 kB
import "./types.js"; import { camelCaseToKebabCase } from "./utils.js"; import { AgentConnectionError, createStubProxy, isTerminalCloseEvent } from "./client.js"; import { n as applyAgentToolEvent, r as createAgentToolEventState } from "./agent-tools-y7zLfw4Q.js"; import { usePartySocket } from "partysocket/react"; import { use, useCallback, useEffect, useMemo, useRef, useState } from "react"; //#region src/react.tsx const queryCache = /* @__PURE__ */ new Map(); function createCacheKey(agentNamespace, name, subChainOrDeps, deps) { if (deps === void 0) return JSON.stringify([ agentNamespace, name || "default", ...subChainOrDeps ]); const subChain = subChainOrDeps; if (subChain.length === 0) return JSON.stringify([ agentNamespace, name || "default", ...deps ]); return JSON.stringify([ agentNamespace, name || "default", subChain.map((s) => [s.agent, s.name]), ...deps ]); } /** Build a URL path tail `/sub/{agent-kebab}/{name}/...` from a sub chain. */ function buildSubPath(subChain, extraPath) { if (subChain.length === 0) return extraPath ?? ""; const combined = subChain.flatMap((step) => [ "sub", camelCaseToKebabCase(step.agent), encodeURIComponent(step.name) ]).join("/"); if (extraPath) return `${combined}/${extraPath.startsWith("/") ? extraPath.slice(1) : extraPath}`; return combined; } function getCacheEntry(key) { const entry = queryCache.get(key); if (!entry) return void 0; if (Date.now() >= entry.expiresAt) { queryCache.delete(key); return; } return entry; } function setCacheEntry(key, promise, cacheTtl) { const entry = { promise, expiresAt: Date.now() + cacheTtl }; queryCache.set(key, entry); return entry; } function deleteCacheEntry(key) { queryCache.delete(key); } const _testUtils = { queryCache, setCacheEntry, getCacheEntry, deleteCacheEntry, clearCache: () => queryCache.clear(), createStubProxy, createCacheKey }; function useAgent(options) { const agentNamespace = camelCaseToKebabCase(options.agent); const { query, queryDeps, cacheTtl, sub: subOption, path: userPath, defaultCallTimeout, onConnectionError, shouldReconnectOnClose, ...restOptions } = options; const subChain = useMemo(() => (subOption ?? []).map((s) => ({ agent: s.agent, name: s.name })), [JSON.stringify(subOption ?? [])]); const leafAgent = subChain.length > 0 ? subChain[subChain.length - 1].agent : options.agent; const leafName = subChain.length > 0 ? subChain[subChain.length - 1].name : options.name || "default"; const fullPath = useMemo(() => [{ agent: options.agent, name: options.name || "default" }, ...subChain], [ options.agent, options.name, subChain ]); const pendingCallsRef = useRef(/* @__PURE__ */ new Map()); const socketRef = useRef(null); const defaultCallTimeoutRef = useRef(defaultCallTimeout ?? 3e4); defaultCallTimeoutRef.current = defaultCallTimeout ?? 3e4; /** Reject (and remove) every pending call transmitted on `socket`. */ const rejectCallsSentOn = (socket, reason) => { const error = new Error(reason); for (const [id, pending] of pendingCallsRef.current) if (pending.sentOn === socket) { if (pending.timeoutId) clearTimeout(pending.timeoutId); pendingCallsRef.current.delete(id); pending.reject(error); pending.stream?.onError?.(reason); } }; /** Transmit queued (never-sent) calls if the live socket is open. */ const flushQueuedCalls = () => { const socket = socketRef.current; if (!socket || socket.readyState !== socket.OPEN) return; for (const pending of pendingCallsRef.current.values()) if (pending.sentOn === null) { socket.send(pending.request); pending.sentOn = socket; } }; /** Reject (and remove) every still-queued (never transmitted) call. */ const rejectQueuedCalls = (reason) => { const error = new Error(reason); for (const [id, pending] of pendingCallsRef.current) if (pending.sentOn === null) { if (pending.timeoutId) clearTimeout(pending.timeoutId); pendingCallsRef.current.delete(id); pending.reject(error); pending.stream?.onError?.(reason); } }; const cacheKey = useMemo(() => createCacheKey(agentNamespace, options.name, subChain, queryDeps || []), [ agentNamespace, options.name, subChain, queryDeps ]); const cacheKeyRef = useRef(cacheKey); cacheKeyRef.current = cacheKey; const ttl = cacheTtl ?? 300 * 1e3; const [cacheInvalidatedAt, setCacheInvalidatedAt] = useState(0); const isAsyncQuery = query && typeof query === "function"; const [awaitingQueryRefresh, setAwaitingQueryRefresh] = useState(false); const queryPromise = useMemo(() => { if (!query || typeof query !== "function") return null; const cached = getCacheEntry(cacheKey); if (cached) return cached.promise; const promise = query().catch((error) => { console.error(`[useAgent] Query failed for agent "${options.agent}":`, error); deleteCacheEntry(cacheKey); throw error; }); setCacheEntry(cacheKey, promise, ttl); return promise; }, [ cacheKey, query, options.agent, ttl, cacheInvalidatedAt ]); useEffect(() => { if (!queryPromise || ttl <= 0) return; const entry = getCacheEntry(cacheKey); if (!entry) return; const timeUntilExpiry = entry.expiresAt - Date.now(); const timer = setTimeout(() => { deleteCacheEntry(cacheKey); setCacheInvalidatedAt(Date.now()); }, Math.max(0, timeUntilExpiry)); return () => clearTimeout(timer); }, [ cacheKey, queryPromise, ttl ]); let resolvedQuery; if (query) if (typeof query === "function") { const queryResult = use(queryPromise); if (queryResult) { for (const [key, value] of Object.entries(queryResult)) if (value !== null && value !== void 0 && typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") console.warn(`[useAgent] Query parameter "${key}" is an object and will be converted to "[object Object]". Query parameters should be string, number, boolean, or null.`); resolvedQuery = queryResult; } } else resolvedQuery = query; useEffect(() => { if (awaitingQueryRefresh && resolvedQuery !== void 0) setAwaitingQueryRefresh(false); }, [awaitingQueryRefresh, resolvedQuery]); const [agentState, setAgentState] = useState(void 0); const [connectionError, setConnectionError] = useState(null); const connectionErrorRef = useRef(null); const connectionErrorAddressKeyRef = useRef(null); const shouldReconnectOnCloseRef = useRef(shouldReconnectOnClose); shouldReconnectOnCloseRef.current = shouldReconnectOnClose; const classifyReconnect = useCallback((event) => (shouldReconnectOnCloseRef.current?.(event) ?? true) && !isTerminalCloseEvent(event), []); const [identity, setIdentity] = useState({ name: leafName, agent: camelCaseToKebabCase(leafAgent), identified: false }); const previousIdentityRef = useRef({ name: null, agent: null }); const readyRef = useRef(void 0); const resetReady = () => { let resolve; readyRef.current = { promise: new Promise((r) => { resolve = r; }), resolve }; }; if (!readyRef.current) resetReady(); const mutableAgentRef = useRef(null); const combinedPath = useMemo(() => buildSubPath(subChain, userPath), [subChain, userPath]); const socketOptions = options.basePath ? { basePath: options.basePath, path: combinedPath || void 0, query: resolvedQuery, ...restOptions, shouldReconnectOnClose: classifyReconnect } : { party: agentNamespace, prefix: "agents", room: options.name || "default", path: combinedPath || void 0, query: resolvedQuery, ...restOptions, shouldReconnectOnClose: classifyReconnect }; const socketEnabled = !awaitingQueryRefresh && (restOptions.enabled ?? true); const addressKey = JSON.stringify([ options.host ?? null, options.basePath ?? null, agentNamespace, options.name || "default", combinedPath || null ]); const visibleConnectionError = connectionErrorAddressKeyRef.current === addressKey ? connectionError : null; connectionErrorRef.current = visibleConnectionError; const agent = usePartySocket({ ...socketOptions, enabled: socketEnabled, onOpen: (event) => { connectionErrorAddressKeyRef.current = null; setConnectionError(null); flushQueuedCalls(); options.onOpen?.(event); }, onMessage: (message) => { if (typeof message.data === "string") { let parsedMessage; try { parsedMessage = JSON.parse(message.data); } catch (_error) { return options.onMessage?.(message); } if (parsedMessage.type === "cf_agent_identity") { const oldName = previousIdentityRef.current.name; const oldAgent = previousIdentityRef.current.agent; const newName = parsedMessage.name; const newAgent = parsedMessage.agent; const currentAgent = mutableAgentRef.current; if (currentAgent) { currentAgent.name = newName; currentAgent.agent = newAgent; currentAgent.identified = true; } setIdentity({ name: newName, agent: newAgent, identified: true }); readyRef.current?.resolve(); if (oldName !== null && oldAgent !== null && (oldName !== newName || oldAgent !== newAgent)) if (options.onIdentityChange) 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.`); } previousIdentityRef.current = { name: newName, agent: newAgent }; options.onIdentity?.(newName, newAgent); return; } if (parsedMessage.type === "cf_agent_state") { setAgentState(parsedMessage.state); options.onStateUpdate?.(parsedMessage.state, "server"); return; } if (parsedMessage.type === "cf_agent_state_error") { options.onStateUpdateError?.(parsedMessage.error); return; } if (parsedMessage.type === "cf_agent_mcp_servers") { options.onMcpUpdate?.(parsedMessage.mcp); return; } if (parsedMessage.type === "rpc") { const response = parsedMessage; const pending = pendingCallsRef.current.get(response.id); if (!pending) { console.warn(`[useAgent] 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) { if (pending.timeoutId) clearTimeout(pending.timeoutId); pending.reject(new Error(response.error)); pendingCallsRef.current.delete(response.id); pending.stream?.onError?.(response.error); return; } if ("done" in response) if (response.done) { if (pending.timeoutId) clearTimeout(pending.timeoutId); pending.resolve(response.result); pendingCallsRef.current.delete(response.id); pending.stream?.onDone?.(response.result); } else pending.stream?.onChunk?.(response.result); else { if (pending.timeoutId) clearTimeout(pending.timeoutId); pending.resolve(response.result); pendingCallsRef.current.delete(response.id); } return; } } options.onMessage?.(message); }, onClose: (event) => { const closedSocket = event.target ?? socketRef.current; const isCurrentSocket = closedSocket === socketRef.current; const terminalClose = isTerminalCloseEvent(event); if (closedSocket) { rejectCallsSentOn(closedSocket, "Connection closed"); if (isCurrentSocket && !closedSocket.shouldReconnect) rejectQueuedCalls("Connection closed"); } if (isCurrentSocket) { resetReady(); if (mutableAgentRef.current) mutableAgentRef.current.identified = false; setIdentity((prev) => ({ ...prev, identified: false })); if (closedSocket?.shouldReconnect) { if (isAsyncQuery) setAwaitingQueryRefresh(true); deleteCacheEntry(cacheKeyRef.current); setCacheInvalidatedAt(Date.now()); } if (!closedSocket?.shouldReconnect && terminalClose) { const error = new AgentConnectionError(event); connectionErrorAddressKeyRef.current = addressKey; setConnectionError(error); onConnectionError?.(error); } } options.onClose?.(event); } }); socketRef.current = agent; const prevSocketRef = useRef(null); const prevAddressKeyRef = useRef(addressKey); useEffect(() => { const prev = prevSocketRef.current; prevSocketRef.current = agent; const prevAddress = prevAddressKeyRef.current; prevAddressKeyRef.current = addressKey; if (prevAddress !== addressKey) { connectionErrorAddressKeyRef.current = null; setConnectionError(null); rejectQueuedCalls("Call discarded: the agent address changed before the request could be sent"); } if (prev && prev !== agent) { rejectCallsSentOn(prev, "Connection closed"); resetReady(); if (mutableAgentRef.current) mutableAgentRef.current.identified = false; setIdentity((current) => current.identified ? { ...current, identified: false } : current); } }, [agent, addressKey]); const call = useCallback((method, args = [], options) => { return new Promise((resolve, reject) => { const socket = socketRef.current; if (socket && connectionErrorRef.current && socket.readyState === socket.CLOSED) { reject(/* @__PURE__ */ new Error("Connection closed")); return; } 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 : defaultCallTimeoutRef.current; if (effectiveTimeout) timeoutId = setTimeout(() => { const pending = pendingCallsRef.current.get(id); pendingCallsRef.current.delete(id); const errorMessage = `RPC call to ${method} timed out after ${effectiveTimeout}ms`; pending?.stream?.onError?.(errorMessage); reject(new Error(errorMessage)); }, effectiveTimeout); const request = JSON.stringify({ args, id, method, type: "rpc" }); pendingCallsRef.current.set(id, { reject, resolve, stream: streamOptions, timeoutId, request, sentOn: null }); if (socket && socket.readyState === socket.OPEN) { socket.send(request); const pending = pendingCallsRef.current.get(id); if (pending) pending.sentOn = socket; } }); }, []); agent.setState = (newState) => { (socketRef.current ?? agent).send(JSON.stringify({ state: newState, type: "cf_agent_state" })); setAgentState(newState); options.onStateUpdate?.(newState, "client"); }; agent.call = call; agent.agent = identity.agent; agent.name = identity.name; agent.path = fullPath; agent.identified = identity.identified; agent.ready = readyRef.current.promise; agent.state = agentState; agent.connectionError = visibleConnectionError; mutableAgentRef.current = agent; agent.stub = useMemo(() => createStubProxy(call), [call]); agent.getHttpUrl = () => { return (agent._url || agent._pkurl || "").replace("ws://", "http://").replace("wss://", "https://"); }; if (identity.agent !== identity.agent.toLowerCase()) console.warn("Agent name: " + identity.agent + " should probably be in lowercase. Received: " + identity.agent); return agent; } function agentToolDedupeKey(message) { return [ message.parentToolCallId ?? "", message.event.runId, String(message.sequence) ].join("\0"); } function useAgentToolEvents(options) { const { agent } = options; const [state, setState] = useState(() => createAgentToolEventState()); const seenRef = useRef(/* @__PURE__ */ new Set()); useEffect(() => { const onMessage = (event) => { if (typeof event.data !== "string") return; let message; try { message = JSON.parse(event.data); } catch { return; } if (message.type !== "agent-tool-event") return; const key = agentToolDedupeKey(message); if (seenRef.current.has(key)) return; seenRef.current.add(key); setState((prev) => applyAgentToolEvent(prev, message)); }; agent.addEventListener("message", onMessage); return () => agent.removeEventListener("message", onMessage); }, [agent]); const resetLocalState = useCallback(() => { seenRef.current.clear(); setState(createAgentToolEventState()); }, []); const getRunsForToolCall = useCallback((toolCallId) => state.runsByToolCallId[toolCallId] ?? [], [state.runsByToolCallId]); return { ...state, getRunsForToolCall, resetLocalState }; } //#endregion export { _testUtils, useAgent, useAgentToolEvents }; //# sourceMappingURL=react.js.map