@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
JavaScript
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