@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
300 lines • 16.3 kB
TypeScript
import type { AgentDeps } from '../agent-deps.js';
import type { AgentDepsFactory } from '../agent-deps-factory.js';
import { type ChatQueueService } from '../chat-queue-service.js';
import type { HumanReply } from '../elicitation.js';
import type { PriceCatalogOptions } from '../pricing/boot-pricing.js';
import type { AgentStore } from '../spi/agent-store.js';
import type { AgentPricingStore } from '../spi/pricing-store.js';
import type { TokenStreamSink } from '../spi/token-stream-sink.js';
import type { AiToolCtx } from '../spi/tool.js';
import type { AgentUiComponent } from '../stream-events.js';
import type { ToolPresentation } from '../tool-presentation.js';
import type { ToolRegistry } from '../tool-registry.js';
import type { Actor, AgentRunInput, MessageUsage } from '../types.js';
import type { OpenCodeHost, OpenCodeSessionStore } from './host.js';
import { type Milestone, type OpenCodeUsage, type PendingAsk, type StepCost, type StepModel, type TurnOutcome } from './turn.js';
/**
* The app's tools, served to OpenCode sessions over the engine's own MCP endpoint
* (`POST <agent path>/opencode/mcp`, mounted by the engine).
*/
export interface OpenCodeToolsOptions {
/**
* The URL the OpenCode server calls that endpoint at, passed to it verbatim (no default). It is
* resolved from where OpenCode runs, not from the app: e.g. `http://127.0.0.1:3333/agent/opencode/mcp`
* on the same machine, the app's Compose service or Kubernetes Service name otherwise.
* `/agent` is the agent's route prefix (`path`).
*/
url: string;
/** The MCP server's name in OpenCode; its tools are `<server>.<tool>` / `<server>_<tool>`. Default `'agora'`. */
server?: string;
/**
* The secret the endpoint's bearer tokens are signed with. Default: derived from the app's
* `APP_KEY`; without one, a random per-process secret (which only works with one process).
*/
secret?: string;
/**
* How long a token OpenCode is given stays valid. A token only ever reaches a turn of its own
* actor that is running on the session that calls, and it is re-issued on the turns of a session
* once it is half-way through. Default 7 days.
*/
ttlMs?: number;
}
export interface OpenCodeEngineSettings {
/** The id a run gets (e.g. a tenant prefix your durable store partitions by). Default: a random UUID. */
runId?: (input: AgentRunInput) => string;
/**
* `openCodeDurable()` only: how each turn's workflow run starts — tags, search attributes, a
* concurrency quota (`WorkflowEngine.start` options) — and what a refused start becomes (e.g. a
* concurrency limit → a 429 for the person sending).
*/
durable?: {
start?: (input: AgentRunInput, runId: string) => Record<string, unknown> | Promise<Record<string, unknown>>;
startError?: (error: unknown, input: AgentRunInput) => unknown;
};
/** How many earlier messages a NEW session is told about (a thread whose session was lost). Default 20. */
historyMessages?: number;
/** The instructions key the agent's own prompt is put under. Default `'agora.system'`. */
systemKey?: string;
/** Serve the app's tools to the sessions. Omit → the session has the host's tools only. */
tools?: OpenCodeToolsOptions;
/** Where skills are written, relative to the session's directory. Default `'.opencode/skills'`. */
skillsDir?: string;
/**
* How long `observe` waits for a milestone before failing the turn. Default 30 minutes. A turn
* parked on a person is not observing: this bounds one stretch of OpenCode working on its own.
*/
turnTimeoutMs?: number;
/**
* Whose figure a model call is recorded at. OpenCode prices each call itself, off its model
* catalog (the `cost` its config declares, else models.dev's list price):
*
* - `'reported'` (default) — OpenCode's figure when it has one (`cost_source` `'provider'`); a call
* OpenCode priced at 0 (no price for the model) is estimated from the library's price instead
* (`priceCatalog`, the pricing store, the built-in GovCloud Bedrock table, models.dev;
* `cost_source` `'estimate'`).
* - `'estimate'` — the library's price whenever it has one, OpenCode's figure otherwise. For
* models OpenCode prices at a list price that is not yours (Bedrock in GovCloud, a negotiated
* rate) when you would rather not declare `cost` in OpenCode's config.
*/
cost?: 'reported' | 'estimate';
}
/** A thread's session, as the steps of a turn pass it along (and a durable run journals it). */
export interface SessionHandle {
sessionId: string;
serverKey: string;
bootId?: string;
directory?: string;
}
/** What the engine's MCP endpoint tells the turns about the call it is serving. */
export interface OpenCodeToolCall {
actor: Actor;
/** The OpenCode server the endpoint's token was issued for. */
serverKey: string;
/** The MCP request id, so two calls' unnamed components stay apart. */
requestId: string;
/** The call's `_meta` — OpenCode names its session there (`ai.opencode/sessionID`). */
meta: Readonly<Record<string, unknown>> | undefined;
}
/** The turn a tool call over MCP belongs to — see {@link OpenCodeTurns.callContext}. */
export interface OpenCodeCallContext {
input: AgentRunInput;
runId: string;
ctx: Pick<AiToolCtx, 'threadId' | 'runId' | 'emitUi'> & {
agentName?: string;
};
}
/** The context the engine's runners and endpoint are built from. */
export interface OpenCodeTurnsDeps {
host: OpenCodeHost;
sessions: OpenCodeSessionStore;
settings: OpenCodeEngineSettings;
factory: AgentDepsFactory;
store: AgentStore;
sink: TokenStreamSink;
registry: ToolRegistry;
queue?: ChatQueueService;
/** Mints the bearer token a session's tools endpoint is registered with (`settings.tools`). */
mintToolsToken?: (actor: Actor, serverKey: string) => string;
/** Where a call OpenCode did not price gets its price from (`pricingStore`, `priceCatalog`). */
pricingStore?: AgentPricingStore;
priceCatalog?: PriceCatalogOptions | false;
}
/**
* The steps of an OpenCode turn — `begin` (thread, session, instructions), `prompt`, `observe`
* (until a person is asked something or the execution ends), `reply`, `settle` — so an in-memory
* runner can run them in a row and a durable one can checkpoint each. A step may run in a process
* that did not run the one before (a durable run resumed elsewhere): the live turn is rebuilt from
* the session, catching up on what OpenCode asked while nobody listened.
*/
export declare class OpenCodeTurns {
private readonly events;
private readonly live;
/** Actions approved per run, and the approvals the MCP endpoint already spent. */
private readonly grants;
private readonly spent;
/** When the tools endpoint was last registered for a location (`server|boot|directory`). */
private readonly toolsIssued;
readonly host: OpenCodeHost;
private readonly sessions;
readonly settings: OpenCodeEngineSettings;
private readonly factory;
private readonly store;
private readonly sink;
readonly registry: ToolRegistry;
private readonly queue;
private readonly mintToolsToken;
/** Starts the next queued message of the thread; set by the runner (it owns `start`). */
startNext: ((next: AgentRunInput, runId: string) => Promise<unknown>) | undefined;
constructor(deps: OpenCodeTurnsDeps);
private readonly prices;
/**
* What a model call OpenCode made cost, by the same rule the library's loop records its own steps
* at: a reported figure wins, else an estimate off the model's price — see
* {@link OpenCodeEngineSettings.cost}.
*/
priceStep(step: {
model: StepModel | undefined;
usage: MessageUsage;
reportedCostUsd: number | undefined;
}): Promise<StepCost>;
/** The tools server's name in OpenCode. */
get toolsServer(): string;
/** The app tool an OpenCode permission `action` names, and its presentation — `undefined` for a built-in. */
presentationOf(action: string): {
name: string;
presentation?: ToolPresentation;
} | undefined;
/** The agent's deps (prompt, approval policy, allow-list, skills, memory) for a turn. */
depsFor(input: Pick<AgentRunInput, 'agentName'>): AgentDeps;
/** The tools a turn may reach: the agent's allow-list, narrowed by its persona's. */
allowedTools(input: Pick<AgentRunInput, 'agentName' | 'persona'>): string[] | undefined;
/** Whether the memory provider writes, so the endpoint serves `remember`. */
memoryWritable(input: Pick<AgentRunInput, 'agentName'>): boolean;
/** The runs this process is following now (an updater drains on it). */
liveRuns(): string[];
/** Stop following every run, and close the event streams. */
close(): void;
/**
* The turn a tool call over the engine's MCP endpoint belongs to, or `undefined` when it belongs
* to none — which the endpoint refuses: it exists only to serve turns. OpenCode names its session in
* every call's `_meta` (`ai.opencode/sessionID`); the call is that session's running turn's, and
* only when the endpoint's caller is the person that turn runs for, on the server the token was
* issued for. A session this process is not following is found through OpenCode (`session.get` →
* the thread it was created for → the thread's running turn); its components still reach the
* stream, as a message of their own.
*/
callContext(call: OpenCodeToolCall): Promise<OpenCodeCallContext | undefined>;
/**
* Spend one approval of `tool` in `runId` — what the MCP endpoint asks before it runs an `action`.
* OpenCode asks the person (or the policy answers) before it calls an action tool; the endpoint
* runs it only against an approval this engine saw granted, so a caller that skipped OpenCode's
* permission rules (anything that got hold of the token) cannot run an action unapproved. The
* approval is the run's own: live in this process, else read off the run's persisted calls.
*/
spendApproval(runId: string, threadId: string, tool: string): Promise<boolean>;
/**
* Push a component into the run an OpenCode session is serving, for the host's own trusted
* callers (no caller check — the MCP endpoint checks its own). `false` when no run is live on
* that session in this process.
*/
pushToSession(sessionId: string, component: AgentUiComponent): Promise<boolean>;
private uiPushed;
private threadOfSession;
private activeRun;
/**
* Open the run's row, persist the user message (or rewind the thread for a regenerate), find or
* create the thread's session, and refresh what the session is told this turn.
*/
begin(runId: string, input: AgentRunInput, durable?: boolean): Promise<SessionHandle>;
private createSession;
/** The user message, as the loop persists it (or, on a regenerate, the thread rewound to it). */
private prepareThread;
/** Rewind the session to before its last user message, so the regenerated answer replaces it. */
private revertLastExchange;
/** The agent's prompt, the memory block and the host's entries — refreshed every turn. */
private refreshInstructions;
/**
* The agent's prompt as the loop resolves it: a persona's prompt stands in for the base (a
* builder is handed the base as `basePrompt`), else the agent's `systemPrompt`.
*/
private systemPrompt;
private scopeContext;
/** What is on file about the actor (`memory` in the config); writable when `remember` is served. */
private memoryBlock;
/**
* The agent's skills (`skills` in the config) as `SKILL.md` files under the session's directory,
* where OpenCode's own `skill` tool finds them. Returns the names written.
*/
private writeSkills;
/**
* Permission rules for the app's tools: the MCP server allowed as a whole (OpenCode offers a
* server's tools only when the server itself is allowed), then each `action` tool asked — the last
* matching rule wins, so an action still lands on an approval card. Tools outside the agent's
* allow-list, and the kinds only the loop serves, are denied.
*/
private toolRules;
/**
* Register the tools endpoint at the session's location, with a bearer token for the turn's
* actor. On a new session always; on a kept one when this process has not registered it yet or
* its token is half-way through its life.
*/
private addTools;
/** Start listening to the session (so nothing it says is missed), then send the user message. */
prompt(runId: string, input: AgentRunInput, handle: SessionHandle, spent?: OpenCodeUsage): Promise<void>;
/**
* Wait for the turn's next milestone. In a process that was not following the turn (a resumed
* durable run), the turn is rebuilt and first catches up on requests OpenCode raised meanwhile;
* `session.wait` is the safety net for a terminal event that was missed.
*
* `spent` is what the run spent before (the sum of the `usage` of its earlier milestones): a turn
* rebuilt here starts from it, so the answer it persists carries the whole run's usage.
*/
observe(runId: string, input: AgentRunInput, handle: SessionHandle, spent?: OpenCodeUsage): Promise<Milestone>;
/**
* Hand a person's answer back to OpenCode. When the server restarted while the turn waited, the
* request is gone with it: a new session is opened, told what the person decided, and prompted
* to go on.
*/
reply(runId: string, input: AgentRunInput, handle: SessionHandle, ask: PendingAsk, reply: HumanReply, spent?: OpenCodeUsage): Promise<SessionHandle>;
private continueAfterRestart;
/** What a session spent so far (`session.get`), or nothing when OpenCode does not say. */
private sessionUsage;
/** Interrupt the thread's session (cancel). */
interrupt(input: AgentRunInput): Promise<void>;
private ensureLive;
/** Stop following a run in this process. */
drop(runId: string): void;
/** A person was asked something: tell the host (cards in other channels, wake-ups). */
private reached;
/**
* What the run produced, as the host's settle hooks see it. `usage` is what the run spent — the
* runner's sum of its milestones' usage, which a resumed run reads back from its journal — else
* what the turn this process follows saw.
*/
private runResult;
/**
* The host's last word on the answer before the stream ends: components to append (a guardrail
* notice), or, for a failed run, the error the person reads instead of OpenCode's.
*/
private amend;
/** Delivery, telemetry, spend: after the run settled. Errors are the host's, never the run's. */
private settled;
private forget;
/**
* What the run spent. The turn this process follows starts from the runner's sum of the earlier
* milestones and adds every call it saw since, so it is never behind that sum — and ahead of it for
* a run that failed or was cancelled between two milestones. Without one, the runner's sum.
*/
private spentBy;
/**
* End the run's stream and bookkeeping for how its execution ended. `spent` is what the run spent
* (the sum of its milestones' `usage`); omit → what the turn this process follows saw.
*/
settle(runId: string, input: AgentRunInput, outcome: TurnOutcome, durationMs: number, spent?: OpenCodeUsage): Promise<void>;
settleFailed(runId: string, input: AgentRunInput, error: string, durationMs?: number, spent?: OpenCodeUsage): Promise<void>;
/** As `InlineAgentRunner`: hand the thread to its next queued message, and say so on the stream. */
private handoff;
}
/** The `_meta` key OpenCode names its session with on every MCP call. */
export declare const SESSION_META_KEY = "ai.opencode/sessionID";
//# sourceMappingURL=turns.d.ts.map