UNPKG

@adonis-agora/agent

Version:

Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.

147 lines • 6.98 kB
import { useCallback, useEffect, useRef, useState } from 'react'; import { proposalNeedsPolling, reconcileProposalMessages } from './proposals.js'; /** Statuses meaning the server has no proposals route, rather than that this read failed. */ const UNSUPPORTED_STATUSES = new Set([404, 405, 501]); /** What the libraries' own route answers for a thread it does not know (yet): not "unsupported". */ const UNKNOWN_THREAD = new Set(['Thread not found', 'Unknown thread']); /** The longest wait between two reads after failed ones (the wait doubles per failure). */ const MAX_BACKOFF_MS = 30_000; /** Backends whose server answered that it serves no proposals — for the rest of the session. */ const unsupportedBackends = new WeakSet(); function statusOf(cause) { const status = cause?.status; return typeof status === 'number' ? status : undefined; } /** `cause` says the proposals route is not there at all (not a transient failure of one read). */ export function isProposalsUnsupported(cause) { const status = statusOf(cause); if (status === undefined || !UNSUPPORTED_STATUSES.has(status)) return false; return !(status === 404 && cause instanceof Error && UNKNOWN_THREAD.has(cause.message)); } export function useActionProposals(backend, threadId, setMessages, refreshKey, pollMs = 1000, /** * An outcome was newly written to the thread (not one already there when the thread was first * read): the server may continue the conversation from it with a turn this chat did not start. */ onAdmitted) { const [items, setItems] = useState([]); const [error, setError] = useState(null); const [unsupported, setUnsupported] = useState(() => unsupportedBackends.has(backend)); // Reads that failed in a row: each one doubles the wait before the next poll. const [failures, setFailures] = useState(0); const latest = useRef({ threadId, setMessages, onAdmitted }); latest.current = { threadId, setMessages, onAdmitted }; // Admitted outcomes already known per thread; `undefined` until the thread's first read. const knownAdmitted = useRef(new Map()); const mounted = useRef(true); const requests = useRef(new Map()); useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); const refresh = useCallback(async () => { if (threadId === undefined || backend.listActionProposals === undefined) return; if (unsupportedBackends.has(backend)) return; const pending = requests.current.get(threadId); if (pending) return pending; const request = (async () => { try { const proposals = await backend.listActionProposals?.({ threadId }); if (!mounted.current || latest.current.threadId !== threadId || !Array.isArray(proposals)) return; const admitted = proposals.some((proposal) => proposal.outcomeDelivery?.status === 'admitted'); const facts = admitted ? (await backend.getThread(threadId)).messages.filter((message) => message.actionProposalOutcome !== undefined) : []; if (!mounted.current || latest.current.threadId !== threadId) return; const admittedIds = proposals .filter((proposal) => proposal.outcomeDelivery?.status === 'admitted') .map((proposal) => proposal.id); const known = knownAdmitted.current.get(threadId); knownAdmitted.current.set(threadId, new Set(admittedIds)); const fresh = known === undefined ? [] : admittedIds.filter((id) => !known.has(id)); setItems(proposals); setError(null); setFailures(0); latest.current.setMessages((current) => reconcileProposalMessages(current, proposals, facts)); if (fresh.length > 0) latest.current.onAdmitted?.(fresh); } catch (cause) { if (isProposalsUnsupported(cause)) { // Not this read failing: the server has no such route. Stop asking, quietly. unsupportedBackends.add(backend); if (mounted.current) { setUnsupported(true); setError(null); } return; } if (mounted.current && latest.current.threadId === threadId) { setError(cause instanceof Error ? cause : new Error(String(cause))); setFailures((count) => count + 1); } } })(); requests.current.set(threadId, request); try { await request; } finally { requests.current.delete(threadId); } }, [backend, threadId]); // biome-ignore lint/correctness/useExhaustiveDependencies: A changed thread clears the prior scoped state. useEffect(() => { setItems([]); setError(null); setFailures(0); }, [threadId]); // A backend another chat already found unsupported (or a new backend that may not be). useEffect(() => { setUnsupported(unsupportedBackends.has(backend)); }, [backend]); // biome-ignore lint/correctness/useExhaustiveDependencies: Stream completion/reconnection triggers a scoped read. useEffect(() => { void refresh(); }, [refresh, refreshKey]); // One read per wait while a proposal is still moving; every read (its new `items`, or one more // failure) schedules the next. Failed reads back off: pollMs, ×2, ×4, … up to 30 s. useEffect(() => { if (unsupported || !items.some(proposalNeedsPolling) || pollMs <= 0) return; const wait = failures === 0 ? pollMs : Math.min(pollMs * 2 ** failures, MAX_BACKOFF_MS); const timer = setTimeout(() => { void refresh(); }, wait); return () => clearTimeout(timer); }, [items, failures, unsupported, pollMs, refresh]); useEffect(() => { if (unsupported) return; const refetch = () => { void refresh(); }; const visible = () => { if (document.visibilityState === 'visible') refetch(); }; window.addEventListener('focus', refetch); window.addEventListener('online', refetch); document.addEventListener('visibilitychange', visible); return () => { window.removeEventListener('focus', refetch); window.removeEventListener('online', refetch); document.removeEventListener('visibilitychange', visible); }; }, [refresh, unsupported]); return { items, error, unsupported, refresh }; } //# sourceMappingURL=use-action-proposals.js.map