UNPKG

@adonis-agora/agent

Version:

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

350 lines • 18 kB
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