UNPKG

agents

Version:

A home for your AI agents

1,166 lines (1,165 loc) 49.6 kB
import { n as STREAM_RESUME_NONE_REASONS, r as transition } from "../protocol-B0nh6KNf.js"; import "../wire-types-CnMt6_HR.js"; import { t as WebSocketChatTransport } from "../ws-chat-transport-rWwta645.js"; import { getToolName, isToolUIPart } from "ai"; import { nanoid } from "nanoid"; import { use, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useChat } from "@ai-sdk/react"; /** * Picks the throttle from an explicit caller value, the deprecated alias, or * the default — in that order. `false` turns throttling off. */ function resolveChatThrottleMs(options) { if (options.throttle === false) return void 0; return options.throttle ?? options.experimental_throttle ?? 50; } /** * The throttle spelled under both option names, or neither name when it is off. * * The two names are not interchangeable across the peer range. `@ai-sdk/react` * v3 only reads `experimental_throttle`; v4 renamed it to `throttle` and reads * `throttle ?? experimental_throttle`. Our peer range allows both majors, so * sending one name would silently do nothing on the other. Unknown option keys * are ignored by both, so sending both names is safe. * * Turning throttling off omits both names rather than inventing a numeric * value. Both majors also treat an explicit `0` as unthrottled, so callers that * pass `0` keep that value under both spellings. */ function chatThrottleOptions(options) { const ms = resolveChatThrottleMs(options); if (ms === void 0) return {}; return { experimental_throttle: ms, throttle: ms }; } //#endregion //#region src/chat/react.tsx /** * One-shot deprecation warnings (warns once per key per session). */ const _deprecationWarnings = /* @__PURE__ */ new Set(); function warnDeprecated(id, message) { if (!_deprecationWarnings.has(id)) { _deprecationWarnings.add(id); console.warn(`[agents/chat] Deprecated: ${message}`); } } /** * Extracts tool schemas from tools that have client-side execute functions. * These schemas are automatically sent to the server with each request. * * Called internally by `useAgentChat` when `tools` are provided. * Most apps do not need to call this directly. * * @param tools - Record of tool name to tool definition * @returns Array of tool schemas to send to server, or undefined if none */ function extractClientToolSchemas(tools) { if (!tools) return void 0; const schemas = Object.entries(tools).filter(([_, tool]) => tool.execute).map(([name, tool]) => { if (tool.inputSchema && !tool.parameters) console.warn(`[useAgentChat] Tool "${name}" uses deprecated 'inputSchema'. Please migrate to 'parameters'.`); return { name, description: tool.description, parameters: tool.parameters ?? tool.inputSchema }; }); return schemas.length > 0 ? schemas : void 0; } /** * Map internal tool part states to simplified UI-relevant states. * * @example * ```tsx * import { isToolUIPart } from "ai"; * import { getToolPartState } from "@cloudflare/ai-chat/react"; * * if (isToolUIPart(part)) { * const state = getToolPartState(part); * if (state === "complete") { ... } * if (state === "waiting-approval") { ... } * } * ``` */ function getToolPartState(part) { switch (part.state) { case "input-streaming": return "streaming"; case "approval-requested": return "waiting-approval"; case "approval-responded": return "approved"; case "output-available": return "complete"; case "output-error": return "error"; case "output-denied": return "denied"; default: return "loading"; } } /** Get the tool call ID from a tool UI part. */ function getToolCallId(part) { return part.toolCallId; } /** Get the tool input from a tool UI part (if available). */ function getToolInput(part) { return part.input; } /** Get the tool output from a tool UI part (if available). */ function getToolOutput(part) { return part.output; } /** Get the approval info from a tool UI part (if in approval state). */ function getToolApproval(part) { return part.approval; } function agentNameToKebab(name) { if (name === name.toUpperCase() && name !== name.toLowerCase()) return name.toLowerCase().replace(/_/g, "-"); let result = name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); result = result.startsWith("-") ? result.slice(1) : result; return result.replace(/_/g, "-").replace(/-$/, ""); } /** * Fetch messages from an agent's `/get-messages` HTTP endpoint. * * Use in framework route loaders to prefetch messages before the component * tree mounts, or anywhere you need messages outside a React hook. * * @example Standard routing * ```typescript * import { getAgentMessages } from "@cloudflare/ai-chat/react"; * * const messages = await getAgentMessages({ * host: "https://my-app.workers.dev", * agent: "ChatAgent", * name: "session-123" * }); * ``` * * @example With basePath (custom URL) * ```typescript * const messages = await getAgentMessages({ * url: "https://my-app.workers.dev/custom/path/get-messages" * }); * ``` */ async function getAgentMessages(options) { let messagesUrl; if ("url" in options) messagesUrl = options.url; else { const agentSlug = agentNameToKebab(options.agent); messagesUrl = `${options.host.endsWith("/") ? options.host.slice(0, -1) : options.host}/agents/${agentSlug}/${options.name}/get-messages`; } try { const response = await fetch(messagesUrl, { credentials: options.credentials, headers: options.headers }); if (!response.ok) { console.warn(`[getAgentMessages] Failed to fetch: ${response.status} ${response.statusText}`); return []; } const text = await response.text(); if (!text.trim()) return []; return JSON.parse(text); } catch (error) { console.warn("[getAgentMessages] Fetch error:", error); return []; } } /** * Module-level cache for initial message fetches. Intentionally shared across * all useAgentChat instances to deduplicate requests during React Strict Mode * double-renders and re-renders. Cache keys include the agent URL, agent type, * and thread name to prevent cross-agent collisions. */ const requestCache = /* @__PURE__ */ new Map(); function findLastAssistantMessage(messages) { for (let index = messages.length - 1; index >= 0; index--) { const message = messages[index]; if (message.role === "assistant") return { index, message }; } return null; } function moveMessageToEnd(messages, messageId) { const idx = messages.findIndex((m) => m.id === messageId); if (idx < 0 || idx === messages.length - 1) return messages; const result = [...messages]; const [msg] = result.splice(idx, 1); if (!msg) return messages; result.push(msg); return result; } function prependMissingHydratedMessages(hydratedMessages, currentMessages) { if (currentMessages.length === 0) return hydratedMessages; const currentMessageIds = new Set(currentMessages.map((message) => message.id)); const missingHydratedMessages = hydratedMessages.filter((message) => !currentMessageIds.has(message.id)); if (missingHydratedMessages.length === 0) return currentMessages; return [...missingHydratedMessages, ...currentMessages]; } /** * React hook for building AI chat interfaces using an Agent * @param options Chat options including the agent connection * @returns Chat interface controls and state with added clearHistory method */ /** * Automatically detects which tools require confirmation based on their configuration. * Tools require confirmation if they have no execute function AND are not server-executed. * @param tools - Record of tool name to tool definition * @returns Array of tool names that require confirmation * * @deprecated Use `needsApproval` on server-side tools instead. */ function detectToolsRequiringConfirmation(tools) { warnDeprecated("detectToolsRequiringConfirmation", "detectToolsRequiringConfirmation() is deprecated. Use needsApproval on server-side tools instead. Will be removed in the next major version."); if (!tools) return []; return Object.entries(tools).filter(([_name, tool]) => !tool.execute).map(([name]) => name); } function useAgentChat(options) { const { agent, getInitialMessages, messages: optionsInitialMessages, onToolCall, onData, experimental_automaticToolResolution, tools, toolsRequiringConfirmation: manualToolsRequiringConfirmation, autoContinueAfterToolResult = true, autoSendAfterAllConfirmationsResolved = true, resume = true, cancelOnClientAbort = false, syncMessagesToServer = true, body: bodyOption, prepareSendMessagesRequest, throttle, experimental_throttle, ...rest } = options; if (manualToolsRequiringConfirmation) warnDeprecated("useAgentChat.toolsRequiringConfirmation", "The 'toolsRequiringConfirmation' option is deprecated. Use needsApproval on server-side tools instead. Will be removed in the next major version."); if (experimental_automaticToolResolution) warnDeprecated("useAgentChat.experimental_automaticToolResolution", "The 'experimental_automaticToolResolution' option is deprecated. Use the onToolCall callback instead. Will be removed in the next major version."); if (options.autoSendAfterAllConfirmationsResolved !== void 0) warnDeprecated("useAgentChat.autoSendAfterAllConfirmationsResolved", "The 'autoSendAfterAllConfirmationsResolved' option is deprecated. Use sendAutomaticallyWhen from AI SDK instead. Will be removed in the next major version."); const toolsRequiringConfirmation = useMemo(() => { if (manualToolsRequiringConfirmation) return manualToolsRequiringConfirmation; if (!tools) return []; return Object.entries(tools).filter(([_name, tool]) => !tool.execute).map(([name]) => name); }, [manualToolsRequiringConfirmation, tools]); const onToolCallRef = useRef(onToolCall); onToolCallRef.current = onToolCall; const onDataRef = useRef(onData); onDataRef.current = onData; const rawHttpUrl = agent.getHttpUrl(); const agentUrl = rawHttpUrl ? new URL(rawHttpUrl) : null; if (agentUrl) agentUrl.searchParams.delete("_pk"); const agentUrlString = agentUrl?.toString() ?? null; const agentAddressKey = Array.isArray(agent.path) ? JSON.stringify(agent.path.map((step) => [step.agent, step.name])) : JSON.stringify([[agent.agent ?? "", agent.name ?? ""]]); const resolvedInitialMessagesCacheKey = agentUrl ? `${agentUrl.origin}${agentUrl.pathname}|${agentAddressKey}` : null; const initialMessagesCacheKey = agentAddressKey; const stableChatIdRef = useRef(null); const previousAgentRef = useRef(null); const previousAgentAddressKeyRef = useRef(null); const fallbackChatId = agentAddressKey; const agentPathChanged = Array.isArray(agent.path) && previousAgentAddressKeyRef.current !== null && previousAgentAddressKeyRef.current !== agentAddressKey; if (stableChatIdRef.current === null) stableChatIdRef.current = resolvedInitialMessagesCacheKey ?? fallbackChatId; else if (previousAgentRef.current !== agent || agentPathChanged) stableChatIdRef.current = resolvedInitialMessagesCacheKey ?? fallbackChatId; previousAgentRef.current = agent; previousAgentAddressKeyRef.current = agentAddressKey; const agentRef = useRef(agent); agentRef.current = agent; async function defaultGetInitialMessagesFetch({ url }) { if (!url) return []; const getMessagesUrl = new URL(url); getMessagesUrl.pathname += "/get-messages"; const response = await fetch(getMessagesUrl.toString(), { credentials: options.credentials, headers: options.headers }); if (!response.ok) { console.warn(`Failed to fetch initial messages: ${response.status} ${response.statusText}`); return []; } const text = await response.text(); if (!text.trim()) return []; try { return JSON.parse(text); } catch (error) { console.warn("Failed to parse initial messages JSON:", error); return []; } } const getInitialMessagesFetch = getInitialMessages || defaultGetInitialMessagesFetch; function doGetInitialMessages(getInitialMessagesOptions, cacheKey) { if (requestCache.has(cacheKey)) return requestCache.get(cacheKey); const promise = getInitialMessagesFetch(getInitialMessagesOptions); requestCache.set(cacheKey, promise); return promise; } const initialMessagesPromise = !(getInitialMessages === null ? false : getInitialMessages ? true : !!agentUrlString) ? null : doGetInitialMessages({ agent: agent.agent, name: agent.name, url: agentUrlString ?? void 0 }, initialMessagesCacheKey); const initialMessages = initialMessagesPromise ? use(initialMessagesPromise) : optionsInitialMessages ?? []; useEffect(() => { if (!initialMessagesPromise) return; requestCache.set(initialMessagesCacheKey, initialMessagesPromise); return () => { if (requestCache.get(initialMessagesCacheKey) === initialMessagesPromise) requestCache.delete(initialMessagesCacheKey); }; }, [initialMessagesCacheKey, initialMessagesPromise]); const toolsRef = useRef(tools); toolsRef.current = tools; const prepareSendMessagesRequestRef = useRef(prepareSendMessagesRequest); prepareSendMessagesRequestRef.current = prepareSendMessagesRequest; const bodyOptionRef = useRef(bodyOption); bodyOptionRef.current = bodyOption; /** * Tracks request IDs initiated by this tab via the transport. * Used by onAgentMessage to skip messages already handled by the transport. */ const localRequestIdsRef = useRef(/* @__PURE__ */ new Set()); const pendingReplayResumeRequestIdsRef = useRef(/* @__PURE__ */ new Set()); const replayHydratedAssistantMessageIdsRef = useRef(/* @__PURE__ */ new Set()); /** * Request ids this socket already ACKed via the fallback resume path. * The server sends CF_AGENT_STREAM_RESUMING for the same request from * both onConnect and its CF_AGENT_STREAM_RESUME_REQUEST handler (#1733). * The transport-handled path dedupes the second notify via * localRequestIdsRef, but the fallback path used to ACK both — triggering * a second full-buffer replay that duplicated streamed parts. Entries are * dropped when the turn completes; the whole set resets when the socket * closes, since a new connection legitimately needs a fresh ACK+replay. */ const fallbackAckedResumeRequestIdsRef = useRef(/* @__PURE__ */ new Set()); const customTransportRef = useRef(null); if (customTransportRef.current === null) customTransportRef.current = new WebSocketChatTransport({ agent: agentRef.current, activeRequestIds: localRequestIdsRef.current, cancelOnClientAbort, prepareBody: async ({ messages: msgs, trigger, messageId }) => { let extraBody = {}; const currentBody = bodyOptionRef.current; if (currentBody) extraBody = { ...typeof currentBody === "function" ? await currentBody() : currentBody }; if (toolsRef.current) { const clientToolSchemas = extractClientToolSchemas(toolsRef.current); if (clientToolSchemas) extraBody.clientTools = clientToolSchemas; } if (prepareSendMessagesRequestRef.current) { const userResult = await prepareSendMessagesRequestRef.current({ id: agentRef.current._pk, messages: msgs, trigger, messageId }); if (userResult.body) Object.assign(extraBody, userResult.body); } return extraBody; } }); customTransportRef.current.setAgent(agentRef.current); customTransportRef.current.setCancelOnClientAbort(cancelOnClientAbort); const customTransport = customTransportRef.current; const useChatHelpers = useChat({ ...rest, ...chatThrottleOptions({ experimental_throttle, throttle }), onData, messages: initialMessages, transport: customTransport, id: stableChatIdRef.current, resume: false }); const { messages: chatMessages, setMessages, addToolResult, addToolApprovalResponse, sendMessage, resumeStream: rawResumeStream, status, stop } = useChatHelpers; const statusRef = useRef(status); statusRef.current = status; const resumeGenerationRef = useRef(0); const resumeOperationRef = useRef(null); const reconnectProbePendingRef = useRef(false); const reconnectProbeRunnerRef = useRef(null); const invalidateResumeGeneration = useCallback(() => { resumeGenerationRef.current++; resumeOperationRef.current = null; }, []); const resumeStream = useCallback((...args) => { const active = resumeOperationRef.current; if (active) return active.promise; const operation = { generation: resumeGenerationRef.current, promise: Promise.resolve() }; resumeOperationRef.current = operation; operation.promise = rawResumeStream(...args).finally(() => { if (resumeOperationRef.current !== operation || resumeGenerationRef.current !== operation.generation) return; resumeOperationRef.current = null; reconnectProbeRunnerRef.current?.(); }); return operation.promise; }, [rawResumeStream]); const resumeStreamRef = useRef(resumeStream); resumeStreamRef.current = resumeStream; const resumingToolContinuationRef = useRef(false); const pendingToolContinuationRef = useRef(false); const observedToolContinuationRequestIdRef = useRef(null); const continuationLaunchTimerRef = useRef(null); const continuationGenerationRef = useRef(0); const [isToolContinuation, setIsToolContinuation] = useState(false); const resetToolContinuation = useCallback(() => { continuationGenerationRef.current++; pendingToolContinuationRef.current = false; resumingToolContinuationRef.current = false; observedToolContinuationRequestIdRef.current = null; if (continuationLaunchTimerRef.current) { clearTimeout(continuationLaunchTimerRef.current); continuationLaunchTimerRef.current = null; } setIsToolContinuation(false); }, []); const scheduleToolContinuationLaunch = useCallback(() => { if (!pendingToolContinuationRef.current || statusRef.current !== "ready" || continuationLaunchTimerRef.current) return; const timer = setTimeout(() => { (async () => { while (resumeOperationRef.current) await resumeOperationRef.current.promise.catch(() => {}); if (continuationLaunchTimerRef.current === timer) continuationLaunchTimerRef.current = null; if (!pendingToolContinuationRef.current || statusRef.current !== "ready") return; pendingToolContinuationRef.current = false; const myGeneration = continuationGenerationRef.current; customTransport.expectToolContinuation(); await resumeStream().catch((error) => { console.error("[useAgentChat] Tool continuation resume failed:", error); }).finally(() => { if (continuationGenerationRef.current !== myGeneration) return; resumingToolContinuationRef.current = false; setIsToolContinuation(false); }); })(); }, 0); continuationLaunchTimerRef.current = timer; }, [customTransport, resumeStream]); const startToolContinuation = useCallback(() => { if (!autoContinueAfterToolResult || resumingToolContinuationRef.current) return; ++continuationGenerationRef.current; resumingToolContinuationRef.current = true; pendingToolContinuationRef.current = true; setIsToolContinuation(true); scheduleToolContinuationLaunch(); }, [autoContinueAfterToolResult, scheduleToolContinuationLaunch]); useEffect(() => { if (status === "error" && pendingToolContinuationRef.current) { resetToolContinuation(); return; } scheduleToolContinuationLaunch(); }, [ resetToolContinuation, scheduleToolContinuationLaunch, status ]); const stopWithToolContinuationAbort = useCallback(async () => { try { customTransport.cancelActiveServerTurn(); await stop(); } finally { customTransport.abortActiveToolContinuation(); } }, [stop, customTransport]); const processedToolCalls = useRef(/* @__PURE__ */ new Set()); const isResolvingToolsRef = useRef(false); const [toolResolutionTrigger, setToolResolutionTrigger] = useState(0); const [clientToolResults, setClientToolResults] = useState(/* @__PURE__ */ new Map()); const initialMessagesRef = useRef(initialMessages); initialMessagesRef.current = initialMessages; const seededInitialMessagesKeyRef = useRef(null); const markInitialMessagesSeeded = useCallback(() => { seededInitialMessagesKeyRef.current = initialMessagesCacheKey; }, [initialMessagesCacheKey]); useEffect(() => { if (!initialMessagesPromise) return; if (seededInitialMessagesKeyRef.current === initialMessagesCacheKey) return; markInitialMessagesSeeded(); setMessages((prevMessages) => prependMissingHydratedMessages(initialMessagesRef.current, prevMessages)); }, [ initialMessagesCacheKey, initialMessagesPromise, markInitialMessagesSeeded, setMessages ]); const localResponseMessageIdsRef = useRef(/* @__PURE__ */ new Map()); const protectedStreamingAssistantRef = useRef(null); const preserveProtectedStreamingAssistant = useCallback((messages, currentMessages) => { const protection = protectedStreamingAssistantRef.current; if (!protection) return [...messages]; const protectedIndex = messages.findIndex((message) => message.id === protection.assistantId); if (protectedIndex >= 0 && messages.slice(protectedIndex + 1).some((message) => message.role === "assistant")) { protectedStreamingAssistantRef.current = null; return [...messages]; } const protectedAssistant = currentMessages.find((message) => message.id === protection.assistantId) ?? messages.find((message) => message.id === protection.assistantId); if (!protectedAssistant) return [...messages]; return [...messages.filter((message) => message.id !== protection.assistantId), protectedAssistant]; }, []); const protectStreamingAssistantTail = useCallback(() => { if (statusRef.current !== "streaming") return; setMessages((currentMessages) => { const assistantInfo = findLastAssistantMessage(currentMessages); if (!assistantInfo) return currentMessages; if (protectedStreamingAssistantRef.current?.assistantId !== assistantInfo.message.id) protectedStreamingAssistantRef.current = { assistantId: assistantInfo.message.id, anchorMessageId: currentMessages[assistantInfo.index - 1]?.id ?? null }; return moveMessageToEnd(currentMessages, assistantInfo.message.id); }); }, [setMessages]); const restoreProtectedStreamingAssistant = useCallback((assistantId) => { const protection = protectedStreamingAssistantRef.current; if (!protection || assistantId !== void 0 && protection.assistantId !== assistantId) return; protectedStreamingAssistantRef.current = null; setMessages((prevMessages) => { const sourceIdx = prevMessages.findIndex((m) => m.id === protection.assistantId); if (sourceIdx < 0) return prevMessages; const result = [...prevMessages]; const [msg] = result.splice(sourceIdx, 1); if (!msg) return prevMessages; if (protection.anchorMessageId === null) result.unshift(msg); else { const anchorIdx = result.findIndex((m) => m.id === protection.anchorMessageId); result.splice(anchorIdx >= 0 ? anchorIdx + 1 : sourceIdx, 0, msg); } return result; }); }, [setMessages]); const resetMatchingHydratedAssistantForReplay = useCallback((messageId) => { setMessages((prevMessages) => { const lastMessage = prevMessages[prevMessages.length - 1]; if (!lastMessage || lastMessage.role !== "assistant" || lastMessage.id !== messageId) return prevMessages; replayHydratedAssistantMessageIdsRef.current.add(messageId); const next = [...prevMessages]; next[next.length - 1] = { ...lastMessage, parts: [] }; return next; }); }, [setMessages]); const collapseHydratedReplayTextParts = useCallback((message) => { const parts = message.parts; const nextParts = parts.filter((part, index) => { if (part.type !== "text" || !("text" in part) || !part.text) return true; return !parts.some((candidate, candidateIndex) => { if (candidateIndex <= index) return false; if (candidate.type !== "text" || !("text" in candidate) || !candidate.text) return false; return candidate.text.startsWith(part.text); }); }); return nextParts.length === parts.length ? message : { ...message, parts: nextParts }; }, []); useEffect(() => { if (replayHydratedAssistantMessageIdsRef.current.size === 0) return; const idsToCollapse = new Set(chatMessages.filter((message) => replayHydratedAssistantMessageIdsRef.current.has(message.id) && message.role === "assistant" && collapseHydratedReplayTextParts(message) !== message).map((message) => message.id)); if (idsToCollapse.size === 0) return; setMessages((prevMessages) => { let changed = false; const nextMessages = prevMessages.map((message) => { if (!idsToCollapse.has(message.id)) return message; const nextMessage = collapseHydratedReplayTextParts(message); if (nextMessage !== message) changed = true; return nextMessage; }); return changed ? nextMessages : prevMessages; }); }, [ chatMessages, collapseHydratedReplayTextParts, setMessages ]); const resetLocalChatState = useCallback(() => { markInitialMessagesSeeded(); setMessages([]); setClientToolResults(/* @__PURE__ */ new Map()); setPendingOnToolCallIds(/* @__PURE__ */ new Set()); resetToolContinuation(); processedToolCalls.current.clear(); localResponseMessageIdsRef.current.clear(); pendingReplayResumeRequestIdsRef.current.clear(); fallbackAckedResumeRequestIdsRef.current.clear(); replayHydratedAssistantMessageIdsRef.current.clear(); protectedStreamingAssistantRef.current = null; }, [ markInitialMessagesSeeded, setMessages, resetToolContinuation ]); const sendMessageWithStreamingProtection = useCallback(async (message, options) => { const request = sendMessage(message, options); if (message !== void 0 && !(typeof message === "object" && message !== null && "messageId" in message && message.messageId != null)) protectStreamingAssistantTail(); return request; }, [sendMessage, protectStreamingAssistantTail]); const lastMessage = chatMessages[chatMessages.length - 1]; const pendingConfirmations = (() => { if (!lastMessage || lastMessage.role !== "assistant") return { messageId: void 0, toolCallIds: /* @__PURE__ */ new Set() }; const pendingIds = /* @__PURE__ */ new Set(); for (const part of lastMessage.parts ?? []) if (isToolUIPart(part) && part.state === "input-available" && toolsRequiringConfirmation.includes(getToolName(part))) pendingIds.add(part.toolCallId); return { messageId: lastMessage.id, toolCallIds: pendingIds }; })(); const pendingConfirmationsRef = useRef(pendingConfirmations); pendingConfirmationsRef.current = pendingConfirmations; const [pendingOnToolCallIds, setPendingOnToolCallIds] = useState(() => /* @__PURE__ */ new Set()); const finishOnToolCall = useCallback((toolCallId) => { setPendingOnToolCallIds((prev) => { if (!prev.has(toolCallId)) return prev; const next = new Set(prev); next.delete(toolCallId); return next; }); }, []); useEffect(() => { if (!experimental_automaticToolResolution) return; if (isResolvingToolsRef.current) return; const lastMsg = chatMessages[chatMessages.length - 1]; if (!lastMsg || lastMsg.role !== "assistant") return; const toolCalls = lastMsg.parts.filter((part) => isToolUIPart(part) && part.state === "input-available" && !processedToolCalls.current.has(part.toolCallId)); if (toolCalls.length > 0) { const currentTools = toolsRef.current; const toolCallsToResolve = toolCalls.filter((part) => isToolUIPart(part) && !toolsRequiringConfirmation.includes(getToolName(part)) && currentTools?.[getToolName(part)]?.execute); if (toolCallsToResolve.length > 0) { isResolvingToolsRef.current = true; (async () => { try { const toolResults = []; for (const part of toolCallsToResolve) if (isToolUIPart(part)) { let toolOutput = null; const toolName = getToolName(part); const tool = currentTools?.[toolName]; if (tool?.execute && part.input !== void 0) try { toolOutput = await tool.execute(part.input); } catch (error) { toolOutput = `Error executing tool: ${error instanceof Error ? error.message : String(error)}`; } processedToolCalls.current.add(part.toolCallId); toolResults.push({ toolCallId: part.toolCallId, toolName, output: toolOutput }); } if (toolResults.length > 0) { const clientToolSchemas = extractClientToolSchemas(currentTools); for (const result of toolResults) agentRef.current.send(JSON.stringify({ type: "cf_agent_tool_result", toolCallId: result.toolCallId, toolName: result.toolName, output: result.output, autoContinue: autoContinueAfterToolResult, clientTools: clientToolSchemas })); await Promise.all(toolResults.map((result) => addToolResult({ tool: result.toolName, toolCallId: result.toolCallId, output: result.output }))); setClientToolResults((prev) => { const newMap = new Map(prev); for (const result of toolResults) newMap.set(result.toolCallId, result.output); return newMap; }); startToolContinuation(); } } finally { isResolvingToolsRef.current = false; setToolResolutionTrigger((c) => c + 1); } })(); } } }, [ chatMessages, experimental_automaticToolResolution, addToolResult, toolsRequiringConfirmation, autoContinueAfterToolResult, startToolContinuation, toolResolutionTrigger ]); const sendToolOutputToServer = useCallback((toolCallId, toolName, output, state, errorText) => { const shouldAutoContinue = state === "output-error" ? false : autoContinueAfterToolResult; agentRef.current.send(JSON.stringify({ type: "cf_agent_tool_result", toolCallId, toolName, output, ...state ? { state } : {}, ...errorText !== void 0 ? { errorText } : {}, autoContinue: shouldAutoContinue, clientTools: toolsRef.current ? extractClientToolSchemas(toolsRef.current) : void 0 })); if (state !== "output-error") setClientToolResults((prev) => new Map(prev).set(toolCallId, output)); if (shouldAutoContinue) startToolContinuation(); }, [autoContinueAfterToolResult, startToolContinuation]); const sendToolApprovalToServer = useCallback((toolCallId, approved) => { agentRef.current.send(JSON.stringify({ type: "cf_agent_tool_approval", toolCallId, approved, autoContinue: autoContinueAfterToolResult })); if (autoContinueAfterToolResult) startToolContinuation(); }, [autoContinueAfterToolResult, startToolContinuation]); useEffect(() => { const currentOnToolCall = onToolCallRef.current; if (!currentOnToolCall) return; const lastMsg = chatMessages[chatMessages.length - 1]; if (!lastMsg || lastMsg.role !== "assistant") return; const pendingToolCalls = lastMsg.parts.filter((part) => isToolUIPart(part) && part.state === "input-available" && !processedToolCalls.current.has(part.toolCallId)); for (const part of pendingToolCalls) if (isToolUIPart(part)) { const toolCallId = part.toolCallId; const toolName = getToolName(part); processedToolCalls.current.add(toolCallId); setPendingOnToolCallIds((prev) => { if (prev.has(toolCallId)) return prev; const next = new Set(prev); next.add(toolCallId); return next; }); const addToolOutput = (opts) => { sendToolOutputToServer(opts.toolCallId, toolName, opts.output, opts.state, opts.errorText); addToolResult({ tool: toolName, toolCallId: opts.toolCallId, output: opts.state === "output-error" ? opts.errorText ?? "Tool execution denied by user" : opts.output }); }; let result; try { result = currentOnToolCall({ toolCall: { toolCallId, toolName, input: part.input }, addToolOutput }); } catch (error) { finishOnToolCall(toolCallId); throw error; } Promise.resolve(result).finally(() => { finishOnToolCall(toolCallId); }); } }, [ chatMessages, sendToolOutputToServer, addToolResult, finishOnToolCall ]); const streamStateRef = useRef({ status: "idle" }); const [isServerStreaming, setIsServerStreaming] = useState(false); const [isRecovering, setIsRecovering] = useState(false); useEffect(() => { const localResponseIds = localResponseMessageIdsRef.current; /** * Unified message handler that parses JSON once and dispatches based on type. * Avoids duplicate parsing overhead from separate listeners. */ function onAgentMessage(event) { if (typeof event.data !== "string") return; let data; try { data = JSON.parse(event.data); } catch (_error) { return; } switch (data.type) { case "cf_agent_chat_clear": streamStateRef.current = transition(streamStateRef.current, { type: "clear" }).state; setIsServerStreaming(false); setIsRecovering(false); resetLocalChatState(); break; case "cf_agent_chat_recovering": setIsRecovering(Boolean(data.recovering)); break; case "cf_agent_chat_messages": setMessages((currentMessages) => { let next = preserveProtectedStreamingAssistant(data.messages, currentMessages); const observed = streamStateRef.current; if (observed.status === "observing" && observed.accumulator.parts.length > 0) { const snapshotIdx = next.findIndex((m) => m.id === observed.accumulator.messageId); const snapshotParts = snapshotIdx >= 0 ? next[snapshotIdx].parts.length : 0; if (observed.accumulator.parts.length >= snapshotParts) next = observed.accumulator.mergeInto(next); } return next; }); break; case "cf_agent_message_updated": setMessages((prevMessages) => { const updatedMessage = data.message; let idx = prevMessages.findIndex((m) => m.id === updatedMessage.id); if (idx < 0) { const updatedToolCallIds = new Set(updatedMessage.parts.filter((p) => "toolCallId" in p && p.toolCallId).map((p) => p.toolCallId)); if (updatedToolCallIds.size > 0) idx = prevMessages.findIndex((m) => m.parts.some((p) => "toolCallId" in p && updatedToolCallIds.has(p.toolCallId))); } if (idx >= 0) { const updated = [...prevMessages]; updated[idx] = { ...updatedMessage, id: prevMessages[idx].id }; return updated; } return prevMessages; }); break; case "cf_agent_stream_resume_none": if (customTransport.handleStreamResumeNone(data) && data.reason === STREAM_RESUME_NONE_REASONS.IDLE && typeof data.probeId === "string") { const result = transition(streamStateRef.current, { type: "clear" }); streamStateRef.current = result.state; setIsServerStreaming(result.isStreaming); if (observedToolContinuationRequestIdRef.current !== null) resetToolContinuation(); } break; case "cf_agent_stream_pending": customTransport.handleStreamPending(); break; case "cf_agent_stream_resuming": { const isEarlyToolContinuation = resumingToolContinuationRef.current && !customTransport.isAwaitingResume(); if (!resume && !customTransport.isAwaitingResume()) { if (!isEarlyToolContinuation) return; } if (!resumingToolContinuationRef.current) pendingReplayResumeRequestIdsRef.current.add(data.id); if (customTransport.handleStreamResuming(data)) return; if (localRequestIdsRef.current.has(data.id)) return; if (fallbackAckedResumeRequestIdsRef.current.has(data.id)) return; if (isEarlyToolContinuation) { pendingToolContinuationRef.current = false; observedToolContinuationRequestIdRef.current = data.id; if (continuationLaunchTimerRef.current) { clearTimeout(continuationLaunchTimerRef.current); continuationLaunchTimerRef.current = null; } } streamStateRef.current = transition(streamStateRef.current, { type: "resume-fallback", streamId: data.id, messageId: nanoid() }).state; customTransport.observeServerTurn(data.id); setIsServerStreaming(true); setIsRecovering(false); fallbackAckedResumeRequestIdsRef.current.add(data.id); agentRef.current.send(JSON.stringify({ type: "cf_agent_stream_resume_ack", id: data.id })); break; } case "cf_agent_use_chat_response": { if (localRequestIdsRef.current.has(data.id)) { if (data.body?.trim()) try { const chunkData = JSON.parse(data.body); if (chunkData.type === "start" && typeof chunkData.messageId === "string") { const messageId = chunkData.messageId; localResponseIds.set(data.id, messageId); if (!data.continuation) { if (protectedStreamingAssistantRef.current?.assistantId !== messageId) setMessages((currentMessages) => { const idx = currentMessages.findIndex((message) => message.id === messageId); protectedStreamingAssistantRef.current = { assistantId: messageId, anchorMessageId: idx >= 0 ? currentMessages[idx - 1]?.id ?? null : currentMessages[currentMessages.length - 1]?.id ?? null }; return currentMessages; }); } if (data.replay && !data.continuation && !resumingToolContinuationRef.current && observedToolContinuationRequestIdRef.current !== data.id) { pendingReplayResumeRequestIdsRef.current.delete(data.id); resetMatchingHydratedAssistantForReplay(messageId); } } } catch {} if (data.done || data.error || data.replayComplete) pendingReplayResumeRequestIdsRef.current.delete(data.id); if (data.done || data.error) { if (streamStateRef.current.status === "observing" && streamStateRef.current.streamId === data.id) { streamStateRef.current = { status: "idle" }; setIsServerStreaming(false); } customTransport.handleServerTurnCompleted(data.id); restoreProtectedStreamingAssistant(localResponseIds.get(data.id)); localResponseIds.delete(data.id); localRequestIdsRef.current.delete(data.id); fallbackAckedResumeRequestIdsRef.current.delete(data.id); if (observedToolContinuationRequestIdRef.current === data.id) resetToolContinuation(); } return; } let chunkData; if (data.replay && streamStateRef.current.status !== "observing" && !pendingReplayResumeRequestIdsRef.current.has(data.id)) return; if (data.error) { pendingReplayResumeRequestIdsRef.current.delete(data.id); customTransport.handleServerTurnCompleted(data.id); fallbackAckedResumeRequestIdsRef.current.delete(data.id); setIsRecovering(false); if (streamStateRef.current.status === "idle" || streamStateRef.current.streamId === data.id) { const result = transition(streamStateRef.current, { type: "clear" }); streamStateRef.current = result.state; setIsServerStreaming(result.isStreaming); } if (observedToolContinuationRequestIdRef.current === data.id) resetToolContinuation(); break; } if (data.body?.trim()) try { chunkData = JSON.parse(data.body); if (data.replay && !data.continuation && !resumingToolContinuationRef.current && observedToolContinuationRequestIdRef.current !== data.id && typeof chunkData.messageId === "string" && chunkData.type === "start") { pendingReplayResumeRequestIdsRef.current.delete(data.id); resetMatchingHydratedAssistantForReplay(chunkData.messageId); } if (typeof chunkData.type === "string" && chunkData.type.startsWith("data-") && onDataRef.current) onDataRef.current(chunkData); } catch (parseError) { console.warn("[useAgentChat] Failed to parse stream chunk:", parseError instanceof Error ? parseError.message : parseError, "body:", data.body?.slice(0, 100)); } if (data.done || data.replayComplete) pendingReplayResumeRequestIdsRef.current.delete(data.id); if (data.done) { customTransport.handleServerTurnCompleted(data.id); fallbackAckedResumeRequestIdsRef.current.delete(data.id); setIsRecovering(false); } const completedObservedToolContinuation = data.done && observedToolContinuationRequestIdRef.current === data.id; const result = transition(streamStateRef.current, { type: "response", streamId: data.id, messageId: nanoid(), chunkData, done: data.done, error: data.error, replay: data.replay, replayComplete: data.replayComplete, continuation: data.continuation }); streamStateRef.current = result.state; if (result.messagesUpdate) setMessages(result.messagesUpdate); setIsServerStreaming(result.isStreaming); if (completedObservedToolContinuation) resetToolContinuation(); break; } } } const fallbackAckedResumeRequestIds = fallbackAckedResumeRequestIdsRef.current; let socketIsOpen = false; let sawClose = false; let disposed = false; const clearFallbackObserver = () => { const result = transition(streamStateRef.current, { type: "clear" }); streamStateRef.current = result.state; setIsServerStreaming(result.isStreaming); if (observedToolContinuationRequestIdRef.current !== null) resetToolContinuation(); }; const tryPendingReconnectProbe = () => { if (disposed || !socketIsOpen || !reconnectProbePendingRef.current) return; if (!resume) { reconnectProbePendingRef.current = false; return; } if (customTransport.retryPendingResume()) { reconnectProbePendingRef.current = false; return; } const canReconcileObservedContinuation = observedToolContinuationRequestIdRef.current !== null; if (statusRef.current !== "ready" && statusRef.current !== "error" || resumingToolContinuationRef.current && !canReconcileObservedContinuation || resumeOperationRef.current !== null) return; reconnectProbePendingRef.current = false; resumeStreamRef.current().catch(() => {}); }; reconnectProbeRunnerRef.current = tryPendingReconnectProbe; function onAgentClose() { socketIsOpen = false; sawClose = true; fallbackAckedResumeRequestIds.clear(); if (!resume) clearFallbackObserver(); } function onAgentOpen() { socketIsOpen = true; if (!sawClose) return; sawClose = false; reconnectProbePendingRef.current = true; tryPendingReconnectProbe(); } agent.addEventListener("message", onAgentMessage); agent.addEventListener("close", onAgentClose); agent.addEventListener("open", onAgentOpen); return () => { disposed = true; if (reconnectProbeRunnerRef.current === tryPendingReconnectProbe) reconnectProbeRunnerRef.current = null; reconnectProbePendingRef.current = false; agent.removeEventListener("message", onAgentMessage); agent.removeEventListener("close", onAgentClose); agent.removeEventListener("open", onAgentOpen); fallbackAckedResumeRequestIds.clear(); streamStateRef.current = { status: "idle" }; setIsServerStreaming(false); setIsRecovering(false); protectedStreamingAssistantRef.current = null; localResponseIds.clear(); customTransport.resetResumeState(); invalidateResumeGeneration(); }; }, [ agent, setMessages, resume, customTransport, preserveProtectedStreamingAssistant, resetToolContinuation, resetMatchingHydratedAssistantForReplay, restoreProtectedStreamingAssistant, resetLocalChatState, invalidateResumeGeneration ]); useEffect(() => { if (!resume) return; resumeStream().catch(() => {}); }, [resume, resumeStream]); useEffect(() => { reconnectProbeRunnerRef.current?.(); }, [isToolContinuation, status]); const addToolResultAndSendMessage = async (args) => { const { toolCallId } = args; const toolName = "tool" in args ? args.tool : ""; const output = "output" in args ? args.output : void 0; agentRef.current.send(JSON.stringify({ type: "cf_agent_tool_result", toolCallId, toolName, output, autoContinue: autoContinueAfterToolResult, clientTools: toolsRef.current ? extractClientToolSchemas(toolsRef.current) : void 0 })); setClientToolResults((prev) => new Map(prev).set(toolCallId, output)); addToolResult(args); if (autoContinueAfterToolResult) startToolContinuation(); if (!autoContinueAfterToolResult) { if (!autoSendAfterAllConfirmationsResolved) { sendMessage(); return; } const pending = pendingConfirmationsRef.current?.toolCallIds; if (!pending) { sendMessage(); return; } const wasLast = pending.size === 1 && pending.has(toolCallId); if (pending.has(toolCallId)) pending.delete(toolCallId); if (wasLast || pending.size === 0) sendMessage(); } }; const addToolApprovalResponseAndNotifyServer = (args) => { const { id: approvalId, approved } = args; let toolCallId; setMessages((currentMessages) => { for (const msg of currentMessages) for (const part of msg.parts) if ("toolCallId" in part && "approval" in part && part.approval?.id === approvalId) { toolCallId = part.toolCallId; return currentMessages; } return currentMessages; }); if (toolCallId) sendToolApprovalToServer(toolCallId, approved); else console.warn(`[useAgentChat] addToolApprovalResponse: Could not find toolCallId for approval ID "${approvalId}". Server will not be notified, which may cause duplicate messages.`); addToolApprovalResponse(args); }; const messagesWithToolResults = useMemo(() => { if (clientToolResults.size === 0) return chatMessages; return chatMessages.map((msg) => ({ ...msg, parts: msg.parts.map((p) => { if (!("toolCallId" in p) || !("state" in p) || p.state !== "input-available" || !clientToolResults.has(p.toolCallId)) return p; return { ...p, state: "output-available", output: clientToolResults.get(p.toolCallId) }; }) })); }, [chatMessages, clientToolResults]); useEffect(() => { const currentToolCallIds = /* @__PURE__ */ new Set(); for (const msg of chatMessages) for (const part of msg.parts) if ("toolCallId" in part && part.toolCallId) currentToolCallIds.add(part.toolCallId); setClientToolResults((prev) => { if (prev.size === 0) return prev; let hasStaleEntries = false; for (const toolCallId of prev.keys()) if (!currentToolCallIds.has(toolCallId)) { hasStaleEntries = true; break; } if (!hasStaleEntries) return prev; const newMap = /* @__PURE__ */ new Map(); for (const [id, output] of prev) if (currentToolCallIds.has(id)) newMap.set(id, output); return newMap; }); for (const toolCallId of processedToolCalls.current) if (!currentToolCallIds.has(toolCallId)) processedToolCalls.current.delete(toolCallId); }, [chatMessages]); const addToolOutput = useCallback((opts) => { const toolName = opts.toolName ?? ""; sendToolOutputToServer(opts.toolCallId, toolName, opts.output, opts.state, opts.errorText); addToolResult({ tool: toolName, toolCallId: opts.toolCallId, output: opts.state === "output-error" ? opts.errorText ?? "Tool execution denied by user" : opts.output }); }, [sendToolOutputToServer, addToolResult]); const lastAssistantMessage = messagesWithToolResults[messagesWithToolResults.length - 1]; const hasPendingClientToolCalls = (() => { if (pendingOnToolCallIds.size === 0 && !tools) return false; if (!lastAssistantMessage || lastAssistantMessage.role !== "assistant") return false; for (const part of lastAssistantMessage.parts) { if (!isToolUIPart(part)) continue; if (part.state !== "input-available") continue; const toolName = getToolName(part); if (toolsRequiringConfirmation.includes(toolName)) continue; if (pendingOnToolCallIds.has(part.toolCallId)) return true; if (tools?.[toolName]?.execute) return true; } return false; })(); const effectiveIsServerStreaming = isServerStreaming || hasPendingClientToolCalls; const isStreaming = status === "streaming" || effectiveIsServerStreaming; return { ...useChatHelpers, resumeStream, messages: messagesWithToolResults, isServerStreaming: effectiveIsServerStreaming, isStreaming, /** * True while a durable chat turn is being recovered (interrupted by a * deploy/eviction or a stream-stall watchdog abort and now resuming, #1620). * Distinct from `isStreaming` — a recovering turn isn't producing tokens * yet. Render a "recovering…" hint; most UIs treat `isStreaming || * isRecovering` as "busy". Cleared automatically on the next stream/terminal. */ isRecovering, isToolContinuation, connectionError: agent.connectionError ?? null, sendMessage: sendMessageWithStreamingProtection, stop: stopWithToolContinuationAbort, /** * Provide output for a tool call. Use this for tools that require user interaction * or client-side execution. */ addToolOutput, /** * @deprecated Use `addToolOutput` instead. */ addToolResult: addToolResultAndSendMessage, /** * Respond to a tool approval request. Use this for tools with `needsApproval`. * This wrapper notifies the server before updating local state, preventing * duplicate messages when sendMessage() is called afterward. */ addToolApprovalResponse: addToolApprovalResponseAndNotifyServer, clearHistory: () => { resetLocalChatState(); agent.send(JSON.stringify({ type: "cf_agent_chat_clear" })); }, setMessages: (messagesOrUpdater) => { setMessages((currentMessages) => { const resolvedMessages = typeof messagesOrUpdater === "function" ? messagesOrUpdater(currentMessages) : messagesOrUpdater; if (resolvedMessages.length === 0) markInitialMessagesSeeded(); if (syncMessagesToServer) agent.send(JSON.stringify({ messages: resolvedMessages, type: "cf_agent_chat_messages" })); return resolvedMessages; }); } }; } //#endregion export { WebSocketChatTransport, detectToolsRequiringConfirmation, extractClientToolSchemas, getAgentMessages, getToolApproval, getToolCallId, getToolInput, getToolOutput, getToolPartState, useAgentChat }; //# sourceMappingURL=react.js.map