@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
350 lines • 18 kB
TypeScript
import type { DataUIPart, UIDataTypes, UIMessage } from 'ai';
import { type UiAction } from '../../genui/actions.js';
import type { UiCapabilities } from '../../genui/index.js';
import type { QuotaBlock, ThreadSummary } from '../../index.js';
import { type ReconnectOptions, type StreamConnectionState } from './agent-chat-transport.js';
import { type AttachmentsState, type UseAttachmentsOptions } from './attachments/use-attachments.js';
import { type AgentBackend } from './backend.js';
import { type BackgroundRun } from './background-runs.js';
import { type ModelOption, type ModelsState } from './catalog/use-models.js';
import type { ChatQueue, SendWhileRunning, WhileRunning } from './queue/model.js';
import { type QuotaState } from './quota/use-quota.js';
import type { AgentRunFailure } from './run-errors.js';
import type { ChatStatus } from './transcript/model.js';
import { type AnswerInput, type ApproveInput, type ChatTranscript, type MessageActionInput, type RejectInput, type SkipInput, type UseChatTranscriptOptions } from './transcript/use-chat-transcript.js';
export interface UseAgentChatOptions<B extends AgentBackend = AgentBackend> {
uiCapabilities?: UiCapabilities;
proposalPollMs?: number;
/** Enable proposal reads even when history and resume are both disabled. */
proposals?: boolean;
/**
* What the chat talks to — see {@link AgentBackend}. Omitted → the enclosing `<AgentProvider>`'s,
* else a same-origin client on `/agent`. Must be stable across renders.
*/
backend?: B;
/**
* Retry a stream that dropped mid-run from its last frame (`GET <base>/chat/:runId/stream?after=`),
* with exponential backoff. `status` reads `'reconnecting'` meanwhile. Default on; `false` turns
* it off.
*/
reconnect?: ReconnectOptions | false;
/** Named agent to run each turn. */
agent?: string;
/**
* One of the agent's personas (`GET <base>/agents` lists them) to answer as. Read per send, so a
* picker can switch it between messages; the server pins it on the thread, and `threadPersona`
* reports it back. Omitted → the thread's pinned persona, else the agent's default. A single send
* can name its own: `sendMessage(message, { body: { persona } })`.
*/
persona?: string;
/**
* Thread this chat is bound to. Omitted → the backend creates one on the first send
* ({@link UseAgentChatOptions.onThreadCreated}). Changing it switches the chat to that thread:
* its history loads and a turn still streaming on it is re-attached. Switching to the thread this
* chat just created (syncing a URL) keeps the live conversation as it is.
*/
threadId?: string;
/**
* Load a `threadId`'s persisted history into the chat (`GET <base>/threads/:id`). Default `true`;
* skipped when {@link UseAgentChatOptions.initialMessages} is given. `false` opts out.
*/
history?: boolean;
/** Persisted history you already have — seeds the chat instead of loading it (read per thread). */
initialMessages?: UIMessage[];
/**
* Re-attach to a turn that was still streaming on `threadId` when the page (re)loaded — read off
* the thread's `activeRunId`, so a reload mid-answer keeps streaming it. Default `true`; `false`
* opts out.
*/
resume?: boolean;
/**
* A run id you already know is streaming — attached to on mount without reading the thread first.
* Rarely needed: {@link UseAgentChatOptions.resume} finds it by itself.
*/
resumeRunId?: string;
/**
* The model every turn runs on, controlled by you. Omitted → `chat.models.selected` (what
* `chat.models.select(id)` picked), else the thread's pinned model, else the server default. A
* single send can still override it with `sendMessage(msg, { body: { model } })`. Sent as the
* turn's `model`, which the server applies to that turn only — pinning a model on the thread is
* `chat.models.pinToThread(id)`.
*/
model?: string;
/**
* Read the caller's budget (`GET <base>/quota`) and refuse sends while a window is exhausted.
* Default `true` when the backend implements `getQuota`; `false` skips the request.
*/
quota?: boolean;
/**
* Override the quota gate: `null` never blocks, a {@link QuotaBlock} always does. Omitted → the
* gate follows {@link UseAgentChatOptions.quota}'s report. While blocked, `sendMessage` and
* `regenerate` refuse with {@link QuotaBlockedError}.
*/
blocked?: QuotaBlock | null;
/** Validation and upload for `chat.composer.files` — see `useAttachments`. */
composer?: Omit<UseAttachmentsOptions, 'backend'>;
/**
* What `composer.submit()` and `sendMessage` do while a turn is still running. Default `'queue'`:
* the message waits in the thread's queue (server-side — it survives a reload) and runs when the
* turn settles; `chat.queue` lists and edits what is waiting. `'interrupt'` cancels the running
* turn and runs the message next. `'block'` refuses the send (`composer.blockedBy === 'busy'`).
* A backend without `enqueueMessage` behaves as `'block'`. One send can answer differently:
* `composer.submit({ mode })`, `sendMessage(message, { mode })`.
*/
whileRunning?: WhileRunning;
/**
* Overrides for `chat.transcript` — anything `useChatTranscript` takes (`sources`, `editable` +
* `onEditSubmit`, `followUps`, `getUsage`, a `toolCatalog` of your own, `onApprove: null`, …).
* Yours win over the chat's own wiring.
*/
transcript?: Partial<Omit<UseChatTranscriptOptions, 'messages' | 'status'>>;
/** Read at every send to capture a page snapshot for the page-assistant. */
getPageContext?: () => Record<string, unknown> | null;
/** Fired after each streamed turn finishes (e.g. to refetch the sidebar). */
onFinish?: () => void;
/**
* Fired when this chat moves to a thread it created: the first send of a threadless chat (the
* backend created one), or `chat.fork(…)`. Carries the id so the host can sync its URL/router.
* The hook already remembers a created thread, so every later send reuses it even before the
* host navigates.
*/
onThreadCreated?: (threadId: string) => void;
/**
* Fired exactly once per run when its stream settles — normally (`'completed'`) or via a thrown
* failure / the backend's `event: error` frame (`'failed'`) — including a RESUMED stream's own
* completion. By the time this fires, the server has already derived and persisted the thread's
* title (and the run's terminal state).
*
* Skipped when the attempt never got far enough to learn a run id. A user-initiated `cancel()`
* reports `'completed'` (the server-side cancel records its own terminal state).
*/
onRunSettled?: (outcome: {
runId: string;
status: 'completed' | 'failed';
}) => void;
/**
* Every data part the stream delivers, as it arrives — including transient ones that are never
* stored on a message (`data-title`, `data-cancelled`). Pushed components arrive as `data-ui`,
* approval metadata as `data-approval-requested`, and a frame kind this version does not map as
* `data-<kind>`. See `AgentChatTransport` for the full mapping.
*/
onData?: (part: DataUIPart<UIDataTypes>) => void;
/** The server set or changed the thread's title while a turn streamed. */
onTitle?: (title: string) => void;
/**
* Track DETACHED sub-agents this conversation started: which are still working, and their answers
* as they land (see {@link ChatBackground}). Default `false`.
*/
background?: boolean;
/**
* How often to re-read the thread while a detached sub-agent is still working, in ms. Default
* 5000; `0` turns the interval off, leaving {@link ChatBackground.refresh} as the only trigger.
*/
backgroundPollMs?: number;
}
/** Sub-agents this conversation started and did not wait for. See {@link BackgroundRun}. */
export interface ChatBackground {
runs: BackgroundRun[];
/** True while any of them is still working — what a "2 agents running" affordance reads. */
isWorking: boolean;
/** Re-read the thread now, instead of waiting for the next poll. */
refresh: () => Promise<void>;
}
/**
* The chat's model picker state. Loaded the first time `list`/`providers`/`defaultModel`/`locked`
* is read.
*
* Two different choices: `select(id)` picks the model this chat's following sends name — each send
* carries it as `model`, which the server applies to that turn only and never stores; the pick is
* this conversation's, so switching threads drops it. `pinToThread(id)` stores a model on the
* thread (`PATCH threads/:id { model }`), which every later turn without a `model` of its own runs
* on, across reloads and devices.
*/
export interface ChatModels {
/** Every model the caller may pick, flattened (`[]` until loaded). */
readonly list: ModelOption[];
/** The same, grouped by provider. */
readonly providers: ModelsState['providers'];
/**
* What the next turn runs on: the lock, the `model` option, the pick, the thread's pin, else the
* default.
*/
selected: string | null;
/** The model pinned on the thread (`null` when none, or before the thread is read). */
pinned: string | null;
/** The catalog's default — what a turn runs on when nothing is picked or pinned (`null` until loaded). */
readonly defaultModel: string | null;
/**
* The agent always runs on one model (`{ model, reason? }`), or `null`. While locked, `select`
* does nothing and `selected` is the locked model.
*/
readonly locked: ModelsState['locked'];
/** Run this chat's following sends on `id` — each send's `model`, that turn only. */
select: (id: string) => void;
/**
* Pin `id` on the thread (`null` unpins) — every later turn without its own `model` runs on it,
* across reloads. Replaces the pick, so the next send runs on the pin. On a chat with no thread
* yet, the pin lands when the first send creates one.
*/
pinToThread: (id: string | null) => Promise<void>;
isLoading: boolean;
error: Error | null;
}
/** Why the composer cannot send right now. */
export type ComposerBlock = 'empty' | 'busy' | 'uploading' | 'quota';
/** A headless composer: the draft, its files, and whether (and why not) it can send. */
export interface ChatComposer {
text: string;
setText: (text: string) => void;
/** The staged attachments — `useAttachments` state on the chat's backend. */
files: AttachmentsState;
canSend: boolean;
/** Why `canSend` is false, or `null`. */
blockedBy: ComposerBlock | null;
/**
* Send the draft with the ready files attached, then clear both. A no-op while blocked.
*
* `submit({ mode })` says what to do if a turn is running, for this send only — `'interrupt'`
* for a "send now" button next to a plain send that queues. It overrides `whileRunning`
* (`'block'` included) and clears the draft like any other submit.
*/
submit: (options?: SendWhileRunning) => Promise<void>;
}
interface AddToolResultArgs {
tool: string;
toolCallId: string;
output: unknown;
}
/**
* The agent chat, wired: the AI SDK v7 `useChat` over `AgentChatTransport`, a thread's history
* loaded and a streaming turn re-attached on its own, a quota send gate, a model picker, a headless
* composer with attachments, and a transcript whose approve/reject/answer/skip/stop/fork/regenerate
* are already bound. Every piece is also a hook of its own (`useChatTranscript`, `useAttachments`,
* `useModels`, `useQuota`, `useThreads`) for a host that wants to wire it differently.
*/
export declare function useAgentChat<B extends AgentBackend = AgentBackend>(options?: UseAgentChatOptions<B>): {
messages: UIMessage<unknown, UIDataTypes, import("ai").UITools>[];
resumeStream: (options?: import("ai").ChatRequestOptions) => Promise<void>;
clearError: () => void;
addToolApprovalResponse: import("ai").ChatAddToolApproveResponseFunction;
addToolOutput: import("ai").ChatAddToolOutputFunction<UIMessage<unknown, UIDataTypes, import("ai").UITools>>;
stop: () => Promise<void>;
id: string;
setMessages: (messages: UIMessage<unknown, UIDataTypes, import("ai").UITools>[] | ((messages: UIMessage<unknown, UIDataTypes, import("ai").UITools>[]) => UIMessage<unknown, UIDataTypes, import("ai").UITools>[])) => void;
error: Error | undefined;
status: ChatStatus;
/** Where the live stream stands — see {@link StreamConnectionState}. */
connection: StreamConnectionState;
sendMessage: (message?: Parameters<(message?: {
text: string;
files?: FileList | import("ai").FileUIPart[];
metadata?: unknown;
parts?: never;
messageId?: string;
} | {
files: FileList | import("ai").FileUIPart[];
metadata?: unknown;
parts?: never;
messageId?: string;
} | (Omit<UIMessage<unknown, UIDataTypes, import("ai").UITools>, "id" | "role"> & {
id?: string;
role?: "assistant" | "system" | "user";
} & {
text?: never;
files?: never;
messageId?: string;
}) | undefined, options?: import("ai").ChatRequestOptions) => Promise<void>>[0], options?: Parameters<(message?: {
text: string;
files?: FileList | import("ai").FileUIPart[];
metadata?: unknown;
parts?: never;
messageId?: string;
} | {
files: FileList | import("ai").FileUIPart[];
metadata?: unknown;
parts?: never;
messageId?: string;
} | (Omit<UIMessage<unknown, UIDataTypes, import("ai").UITools>, "id" | "role"> & {
id?: string;
role?: "assistant" | "system" | "user";
} & {
text?: never;
files?: never;
messageId?: string;
}) | undefined, options?: import("ai").ChatRequestOptions) => Promise<void>>[1] & SendWhileRunning) => Promise<void>;
/** Send a UI action as the next user message (see `GenuiActionProvider`). */
sendUiAction: (action: UiAction) => Promise<void>;
addToolResult: ({ tool, toolCallId, output }: AddToolResultArgs) => void | PromiseLike<void>;
/** Sub-agents this conversation started and did not wait for. */
background: ChatBackground;
proposals: import("./approvals/use-action-proposals.js").ActionProposalsState;
/**
* Look for a turn the server started on this thread (after an action's outcome — a sign-in that
* finished elsewhere continues the request) and stream it. Called on its own when this chat's
* proposals see a new outcome; an app with its own proposals list calls it when it does.
*/
followServerTurn: () => Promise<void>;
runId: string | undefined;
/** The loaded thread's running turn, or `null` once read with none. */
activeRunId: string | null;
/** The thread's history is being read. */
isLoadingHistory: boolean;
/** Reading the thread's history failed. */
historyError: Error | null;
/**
* How the last run's stream said it failed — the `event: error` frame's `code` and `message` —
* or `null`. Cleared when the next attempt starts and when the chat moves to another thread.
* `chat.error` still carries the message; this is where the `code` is, so the app can word each
* failure itself (`replay_diverged`, `model_no_output`, `run_failed`, `quota_exceeded`, …).
*/
runError: AgentRunFailure | null;
/**
* The thread this chat is on right now: the `threadId` option, else the one the backend created
* on the first send. A getter, because the created id is learned mid-stream.
*/
getThreadId: () => string | undefined;
/** The backend this chat talks to — the same one every other hook under the provider uses. */
backend: B;
/** The transcript model, with every action already bound to this chat. */
transcript: ChatTranscript;
/** A headless composer: draft, files, send gate, `submit()`. */
composer: ChatComposer;
/**
* Messages sent while a turn was running, waiting their turn (server-side): list, edit, move,
* remove, clear, and resume a paused queue.
*/
queue: ChatQueue;
/** The model picker: `list`, `selected`, `select(id)`, `pinToThread(id)`. */
models: ChatModels;
/**
* The persona pinned on this thread (`ThreadSummary.persona`), or `null` when none is — what a
* persona picker on a reopened thread starts from (`?? agent.defaultPersona`).
*/
threadPersona: string | null;
/** The caller's budget (`GET <base>/quota`); `quota.blocked` gates sends. */
quota: QuotaState;
/** The window blocking sends right now (the `blocked` option, else the quota report's). */
blocked: QuotaBlock | null;
cancel: () => Promise<void>;
approve: ({ toolCallId, target, remember, via }: ApproveInput) => Promise<void>;
reject: ({ toolCallId, target, reason, via }: RejectInput) => Promise<void>;
answer: ({ toolCallId, answers, via }: AnswerInput) => Promise<void>;
skip: ({ toolCallId, via }: SkipInput) => Promise<void>;
regenerate: (_input?: Partial<MessageActionInput>) => void;
fork: ({ messageId, threadId, }: MessageActionInput & {
threadId?: string;
}) => Promise<ThreadSummary>;
truncateFrom: ({ messageId, threadId }: MessageActionInput & {
threadId?: string;
}) => Promise<void>;
promote: ({ threadId }?: {
threadId?: string;
}) => Promise<void>;
};
/** A send refused on the client because a quota window is exhausted. */
export declare class QuotaBlockedError extends Error {
readonly block: QuotaBlock;
constructor(block: QuotaBlock);
}
export {};
//# sourceMappingURL=use-agent-chat.d.ts.map