UNPKG

@adonis-agora/agent

Version:

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

404 lines • 22.3 kB
import type { ToolCallOutcome } from '../dangling-tool-calls.js'; import type { RecordGenuiCallInput } from '../genui-telemetry.js'; import type { AgentUiComponent } from '../stream-events.js'; import type { ToolConfirmation } from '../tool-presentation.js'; import type { Actor, MessageAttachment, MessageFeedback, MessageUsage, StoredMessage, ThreadDetail, ThreadSummary, ToolCallRequest, ToolCallStatus, ToolResult, UsagePurpose } from '../types.js'; import type { ActionProposalOutcome } from './action-proposal-outcome-store.js'; import type { ToolCallApprovalState } from './approval-policy.js'; export interface CreateThreadInput { actor: Actor; /** * The persona pinned on the thread from its first turn — the one that send named. Omitted (or * empty) → none pinned. See {@link ThreadSummary.persona}. */ persona?: string; /** A scratch thread: left out of `listThreads` until {@link AgentStore.promoteThread} keeps it. */ transient?: boolean; title?: string; /** * Create the thread under THIS id instead of a generated one — for a caller whose protocol names * the conversation itself (AG-UI's `threadId`). OPTIONAL to honour: a store that ignores it still * creates a thread, under an id of its own, and the caller reads the id off the result. A store * that honours it rejects an id already taken (soft-deleted threads included). */ id?: string; } /** Patch applied by {@link AgentStore.updateThread}. An omitted key leaves that field untouched. */ export interface UpdateThreadInput { title?: string; /** `null` unpins the thread's model (turns run on the provider default). */ model?: string | null; /** The agent a send that names none runs as; `null` → the configured default agent. */ defaultAgent?: string | null; /** The persona a send that names none runs under; `null` clears it (the agent's default applies). */ persona?: string | null; } export interface AppendMessageInput { actionProposalOutcome?: ActionProposalOutcome; threadId: string; role: StoredMessage['role']; content: string; persona?: string; toolCalls?: ToolCallRequest[]; toolResults?: ToolResult[]; /** Files the user attached to this message (image/PDF); persisted with it and replayed to the model. */ attachments?: MessageAttachment[]; followUps?: string[]; usage?: MessageUsage; /** The run (turn) this message belongs to, for run-detail assembly + trace deep-links. */ runId?: string; /** The agent that wrote it, when it is not the thread's own turn. See {@link StoredMessage.agentName}. */ agentName?: string; /** The step's streamed thinking. See {@link StoredMessage.reasoning}. */ reasoning?: string; /** Time spent thinking in this step, in ms. See {@link StoredMessage.reasoningMs}. */ reasoningMs?: number; /** Components pushed during this step. See {@link StoredMessage.ui}. */ ui?: AgentUiComponent[]; /** When the person sent it (ISO 8601). See {@link StoredMessage.sentAt}. */ sentAt?: string; } export interface RecordToolCallInput { proposalId?: string; confirmation?: ToolConfirmation; /** * The id the model gave the call. A provider only promises it is unique within one response — * another response can be handed the same one — so a store keys the call by this id AND * {@link messageId} (one assistant message per response), and reads it back by id and run. */ toolCallId: string; messageId: string; toolName: string; toolType: 'read' | 'action'; input: unknown; status: ToolCallStatus; /** The run (turn) this tool call belongs to, for run-detail assembly + trace deep-links. */ runId?: string; /** * Who has to approve this call (`'requester'` or a role), from the turn's `ApprovalPolicy`. Set on * an action call that was put to a person — or approved by a remembered decision — and never * otherwise; persisted as `null` when absent. */ approver?: string; /** ISO-8601 instant the approval request lapses. Absent → it never does. */ expiresAt?: string; } export interface UpdateToolCallInput { toolCallId: string; /** * The run that recorded the call, which tells it apart from another run's call under the same * model-supplied id. Absent → the newest call recorded under {@link toolCallId}. */ runId?: string; status: ToolCallStatus; output?: unknown; error?: string; executionMs?: number; executedByRef?: string; /** The approval asked for later calls of this tool in this thread to run without asking. */ remember?: boolean; /** The surface the decision came through. See {@link import('../types.js').Decision.decidedVia}. */ decidedVia?: string; } export interface RecordUsageInput { threadId: string; actorRef: string; messageId?: string; modelId: string; purpose: UsagePurpose; usage: MessageUsage; /** * The turn's USD cost, when known: reported by the provider (a gateway), or estimated by the loop * from the pricing table. {@link costSource} says which. Absent → unpriced (`cost_usd` NULL). */ costUsd?: number; /** * Where {@link costUsd} came from: `'provider'` (the provider reported it; the real figure) or * `'estimate'` (tokens × the price row in effect for the turn, frozen at write time). Absent with a * `costUsd` → treat as `'provider'` (rows written before the column existed only held reported cost). */ costSource?: CostSource; /** The run (turn) this usage row belongs to, for run-detail assembly + trace deep-links. */ runId?: string; } /** Where a usage row's `cost_usd` came from. See {@link RecordUsageInput.costSource}. */ export type CostSource = 'provider' | 'estimate'; /** * An actor's spend over a window. `costUsd` sums every row's `cost_usd`, provider-reported AND * estimated; `estimatedCostUsd` is the estimated share of it (`0` when none), so a reader that wants * reported cost only takes `costUsd - estimatedCostUsd`. A store written before estimates were * persisted may omit it (read as `0`). */ export interface UsageTotals { usedTokens: number; costUsd: number; estimatedCostUsd?: number; } /** {@link UsageTotals} over usage rows: the one summing rule every store shares. */ export declare function sumUsage(rows: readonly { inputTokens: number; outputTokens: number; costUsd?: number | null; costSource?: string | null; }[]): UsageTotals; /** A run's lifecycle status: created `running`, then settled once (terminal). */ export type AgentRunStatus = 'running' | 'completed' | 'failed' | 'cancelled'; /** Opens a run (turn) row at start — status `running`, `started_at` stamped by the store. */ export interface RecordRunStartInput { runId: string; threadId: string; /** Acting identity; the store persists `actor.id` as `actor_ref` and `actor.tenantRef` as `tenant_ref`. */ actor: Actor; /** The agent that handled the run; `null`/omitted for the default agent. */ agentName?: string; /** * The run that started this one, for a delegation's child run. The parent->child edge exists in * the durable runtime's own journal, but only there: a governance surface reading run rows alone * cannot roll a delegation's cost up to the turn that asked for it. * * Optional, and a store that persists nothing for it still works — it loses the tree, not the run. */ parentRunId?: string; /** True when the run executes as a replay-safe durable workflow, false for the inline runner. */ durable?: boolean; } /** * Settles a run's outcome (terminal). A store MUST apply this only while the run is still `running` * (first terminal wins) so a late `completed` from the loop can never overwrite a `failed`/`cancelled` * already recorded by the runner. `error` is set only for `failed`. Token/step/cost totals are the * loop's run-level rollup on `completed`; a runner-recorded failure/cancel may omit them. */ export interface RecordRunEndInput { runId: string; status: Exclude<AgentRunStatus, 'running'>; /** Epoch-ms finish time; defaults to now inside the store when omitted. */ finishedAt?: number; stepCount?: number; inputTokens?: number; outputTokens?: number; costUsd?: number; error?: string; /** * Turn start → first non-empty `text` frame, on `completed`. The clock is the finishing process's, * so a durable run resumed elsewhere measures from its resume. Omitted when no text went out. */ ttftMs?: number; /** The stable code a failure's stream `error` frame carries (`quota_exceeded`, `run_failed`…). */ errorCode?: string; /** The last model call's id, on `completed`. */ modelId?: string; } /** Which thread, and how many of its newest messages, {@link ThreadTurnReader.loadThreadForTurn} reads. */ export interface ThreadTurnQuery { threadId: string; /** Omitted reads every message; `0` reads none. */ messageLimit?: number; } /** What a turn reads off a thread — a bounded window, not the transcript. */ export interface ThreadTurnPage { title: string; /** Whether the THREAD has ever been answered, not whether {@link messages} holds an answer. */ hasAssistantMessage: boolean; /** Oldest first, carrying only the fields a model turn reads. */ messages: StoredMessage[]; } /** * A store that can hand a turn the WINDOW it is about to send, instead of the thread's transcript. * * {@link AgentStore.getThread} materializes every message row, every attachment and every tool * output a thread ever recorded, and `load:thread` then journals what it loaded — so a long thread * pays for its whole history on every turn and again on every replay, to send a prompt bounded to * its last few messages. This read is bounded by the database (`order by created_at desc limit ?`), * projected to the columns a model turn actually reads. * * `hasAssistantMessage` is answered over the WHOLE thread, never the page: it answers "has this * conversation been answered before?" — what a `thread-start` intake asks — and a thread whose * window happens to hold only the user's last questions has still been answered. `null` for a thread * that is unknown or soft-deleted, matching `getThread`. * * Probed STRUCTURALLY rather than declared on {@link AgentStore}: it is an optimization a store * either offers or does not, and one that offers none still answers correctly through the full read. */ export interface ThreadTurnReader { loadThreadForTurn(query: ThreadTurnQuery): Promise<ThreadTurnPage | null>; } /** ORM-agnostic persistence. Refs are string ids; adapters may add real relations. */ export interface AgentStore { createThread(input: CreateThreadInput): Promise<ThreadSummary>; getThread(threadId: string): Promise<ThreadDetail | null>; /** * The owning `actor_ref` of a thread, or `null` when it is unknown (or soft-deleted). Backs the * per-actor ownership check on the thread routes (`threads/:id` read/delete/fork), so a caller can * only act on threads it owns. */ getThreadActorRef(threadId: string): Promise<string | null>; listThreads(actorRef: string, limit?: number): Promise<ThreadSummary[]>; softDeleteThread(threadId: string): Promise<void>; forkThread(threadId: string, fromMessageId: string): Promise<ThreadSummary>; setTitle(threadId: string, title: string): Promise<void>; /** * OPTIONAL: patch a thread's settings. Needed to pin a model on a thread (`PATCH <path>/threads/:id * { model }` answers `501` without it); a title alone still goes through {@link setTitle}. */ updateThread?(threadId: string, patch: UpdateThreadInput): Promise<void>; /** * OPTIONAL: the thread's own default agent (`UpdateThreadInput.defaultAgent`), or `null` when it * has none or does not exist. Read on every send that names no agent, so it is one scalar rather * than {@link getThread}'s whole transcript; a store without it is read through `getThread`. */ defaultAgentForThread?(threadId: string): Promise<string | null>; /** * OPTIONAL: the thread's pinned persona (`UpdateThreadInput.persona`), or `null` when it has none * or does not exist — one scalar, like {@link defaultAgentForThread}, read on a send that names no * persona to an agent that has some. A store without it is read through `getThread`. */ personaForThread?(threadId: string): Promise<string | null>; /** * OPTIONAL: make a transient thread a regular one, listed by `listThreads` from then on. A thread * that is not transient is left as it is. Absent → `POST <path>/threads/:id/promote` answers `501`. */ promoteThread?(threadId: string): Promise<void>; setActiveStream(threadId: string, runId: string | null): Promise<void>; /** * OPTIONAL: clear the thread's active run only if it is still `runId` — so a turn that ends after * a newer one started on the same thread does not blank the newer one's pointer. Absent → * `setActiveStream(threadId, null)`. */ clearActiveStream?(threadId: string, runId: string): Promise<void>; appendMessage(input: AppendMessageInput): Promise<StoredMessage>; /** * Attach a turn's settled tool RESULTS to a message already appended, replacing whatever it held. * A message's tool calls are known when it is written and their outputs are not, but a thread * reader pairs the two off THAT MESSAGE — so an output that only ever reaches the tool-call table * leaves every call on a reopened thread looking like a tool still running. * * Required rather than optional: a store that silently declines this renders a finished turn as a * permanently in-flight one, with nothing logged and nothing to notice. A missing method should * fail to compile instead. */ setMessageToolResults(messageId: string, results: ToolResult[]): Promise<void>; /** * OPTIONAL: replace the components persisted on a message (`StoredMessage.ui`) — what the step's * tools pushed through `ctx.emitUi`, merged after what the model turn streamed. Written once the * step's tools settle, from values its checkpoints hold, so a replay writes the same list. Absent → * tool-pushed components stream live but are not persisted. */ setMessageUi?(messageId: string, ui: AgentUiComponent[]): Promise<void>; truncateFrom(threadId: string, messageId: string): Promise<void>; /** * OPTIONAL: of `mediaIds`, the ones a message that still exists in one of `actorRef`'s threads * carries as an attachment — each id at most once, in the order asked. * * DERIVED, not tracked: {@link truncateFrom} deletes messages (which is what regenerating a turn * does), so a reference can disappear, and a flag set at send time would never be unset. Scoped * to one actor, like every read on this surface: a reference only ANOTHER actor's thread holds is * reported as none, so this can never probe someone else's conversation. * * What lets `attachmentStores.media()` serve a file a message in the actor's own thread already * carries (a fork, a regenerate) even when the actor did not upload it. Absent → only the * uploader (and `canAccess`) may use a file. */ referencedMediaIds?(actorRef: string, mediaIds: readonly string[]): Promise<string[]>; /** * OPTIONAL: the thread a message belongs to, or `null` when there is no such message. The * authorization seam for message-scoped routes (feedback): the route resolves the thread's owner * from it. Implement it together with {@link setMessageFeedback}. */ threadOfMessage?(messageId: string): Promise<string | null>; /** * OPTIONAL: set (or, with `null`, clear) the rating on a message — see * {@link import('../types.js').StoredMessage.feedback}. Absent → `POST <path>/messages/:id/feedback` * answers `501`. */ setMessageFeedback?(messageId: string, feedback: MessageFeedback | null): Promise<void>; recordToolCall(input: RecordToolCallInput): Promise<void>; updateToolCall(input: UpdateToolCallInput): Promise<void>; /** * The run a tool call belongs to, or `null` when the call is unknown. Lets the approve / reject / * answer / skip routes accept a body naming the call alone (`{ toolCallId }`), which is what the * shared React client sends. Optional: without it those routes still take an explicit `runId`. * When several runs were handed the same id, the newest call's run. */ getToolCallRunId?(toolCallId: string): Promise<string | null>; /** * What is recorded about each of these calls — its status, and the output or error it settled * with. Read when a thread's history holds a tool call its message has no result for (the turn * died mid-step), so the next turn can be told what actually happened instead of being handed a * call answered by nothing. Unknown ids are left out, and an id several runs were handed answers * with the newest call's outcome. Absent → such a call is put to the model as never completed. */ toolCallOutcomes?(toolCallIds: string[]): Promise<ToolCallOutcome[]>; /** * Settle every call of `runId` still awaiting a decision as `failed` with `error`, and answer how * many there were. Called when the run ends without settling them — it failed, or it was found * dead — so an approval card never waits on a run that will not come back. Calls already settled * are left alone. */ failUnsettledToolCalls?(runId: string, error: string): Promise<number>; /** * OPTIONAL: the names of the tools whose approval someone asked to REMEMBER in this thread — an * approved call persisted with `remember: true`. The loop approves a later call of one of them * without asking, inside that call's own `persist:toolcall` checkpoint. Absent → nothing is ever * remembered, and every call asks. */ rememberedApprovals?(threadId: string): Promise<string[]>; /** * OPTIONAL: a call's approval state, or `null` when the call is unknown. Read by the approve/reject * routes to enforce the recorded approver and refuse a decision on a request that already * expired. Absent → every call is treated as the requester's, with no expiry (the old behaviour). * `runId` picks that run's call; without it, the newest call recorded under the id. */ toolCallApproval?(toolCallId: string, runId?: string): Promise<ToolCallApprovalState | null>; /** * OPTIONAL: the input a call was recorded with, or `null` when the call is unknown. Read by the * answer route to check a reply against the questions it answers (a typed question's rules, a * `required` one left empty) before it is signalled. Absent → answers are checked for shape only, * and the loop drops what it cannot settle. `runId` as in {@link toolCallApproval}. */ toolCallInput?(toolCallId: string, runId?: string): Promise<unknown>; recordUsage(input: RecordUsageInput): Promise<void>; /** * OPTIONAL: record generative-UI telemetry — one row per UI tool call (see `genui-telemetry`), * keyed by `toolCallId` (recording the same call again replaces its row). Metadata only unless * the tool samples payloads. The loop calls it best-effort: a failure is swallowed, never the * turn's. A store without it records nothing. */ recordGenuiCalls?(rows: readonly RecordGenuiCallInput[]): Promise<void>; quotaToday(actorRef: string, day: string): Promise<{ usedTokens: number; }>; /** * OPTIONAL: the actor's usage over the UTC days `fromDay`..`toDay` (`YYYY-MM-DD`, inclusive) — * tokens and recorded spend, provider-reported and estimated (see {@link UsageTotals}). Feeds the * month window (and the day window's spend) of `GET <path>/quota`; absent → the month window is left * out and spend reads `0`. */ usageBetween?(actorRef: string, fromDay: string, toDay: string): Promise<UsageTotals>; /** Open a run (turn) row at start. Replay-safe: the loop calls it under a durable step. */ recordRunStart(input: RecordRunStartInput): Promise<void>; /** * The owning `actor_ref` of a run (turn), or `null` when the run is unknown. The loop opens the run * row as its FIRST step (before the quota gate), so this is populated for the whole life of a run. * Backs the per-actor ownership check on the run routes (stream re-attach, cancel, tool-call * approve/reject), so a caller can only act on runs it owns. */ getRunActorRef(runId: string): Promise<string | null>; /** * OPTIONAL: the thread whose active run is `runId`, or `null` — the reverse of the admission * pointer (`activeRunId` on a thread read). * * A run is admitted to its thread BEFORE it starts executing: a send claims the thread and then * starts the run; a queue drain claims it for the next message and then starts that. Under a * durable runner the body runs in a worker, so there is a window — the time a worker takes to * pick the run up — in which the run holds a thread and has no run row yet. A client attaches in * exactly that window (it was just told the run started), so the routes that address a run read * its owner off the thread it holds when there is no row. Absent → those routes answer `404` * until the run has started. */ threadHeldByRun?(runId: string): Promise<string | null>; /** Settle a run's outcome (terminal, first-wins). A no-op when the run is unknown or already settled. */ recordRunEnd(input: RecordRunEndInput): Promise<void>; } /** Clear `threadId`'s active run if it is still `runId` (see {@link AgentStore.clearActiveStream}). */ export declare function clearActiveRun(store: AgentStore, threadId: string, runId: string): Promise<void>; //# sourceMappingURL=agent-store.d.ts.map