agents
Version:
A home for your AI agents
483 lines (482 loc) • 17.3 kB
JavaScript
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