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