@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
952 lines • 43.7 kB
JavaScript
import { randomUUID } from 'node:crypto';
import { streamErrorFrame } from '../agent-loop.js';
import { isThreadTurn } from '../chat-queue-service.js';
import { RUN_ENDED_BEFORE_TOOL_CALL } from '../dangling-tool-calls.js';
import { buildMemoryBlock, offerMemories } from '../memory.js';
import { parseBedrockModelId } from '../pricing/bedrock.js';
import { ModelPriceResolver, resolveUsageCost } from '../pricing/resolve.js';
import { offerSkills } from '../skills.js';
import { DefaultApprovalPolicy } from '../spi/approval-policy.js';
import { isChatQueueStore, releaseThreadRun } from '../spi/chat-queue.js';
import { OpenCodeEventHub } from './event-hub.js';
import { errorText, openCodeLog } from './log.js';
import { emptyUsage, OpenCodeTurn, } from './turn.js';
const DEFAULT_TURN_TIMEOUT_MS = 30 * 60_000;
const DEFAULT_TOOLS_TTL_MS = 7 * 24 * 60 * 60_000;
/** Tool kinds the LOOP serves (delegation, `ask`, `skill`, `remember`): never served over MCP. */
const LOOP_SERVED_KINDS = new Set(['agent', 'ask', 'skill', 'memory']);
/**
* 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 class OpenCodeTurns {
events = new OpenCodeEventHub();
live = new Map();
/** Actions approved per run, and the approvals the MCP endpoint already spent. */
grants = new Map();
spent = new Set();
/** When the tools endpoint was last registered for a location (`server|boot|directory`). */
toolsIssued = new Map();
host;
sessions;
settings;
factory;
store;
sink;
registry;
queue;
mintToolsToken;
/** Starts the next queued message of the thread; set by the runner (it owns `start`). */
startNext;
constructor(deps) {
this.host = deps.host;
this.sessions = deps.sessions;
this.settings = deps.settings;
this.factory = deps.factory;
this.store = deps.store;
this.sink = deps.sink;
this.registry = deps.registry;
this.queue = deps.queue;
this.mintToolsToken = deps.mintToolsToken;
this.prices = new ModelPriceResolver({
pricingStore: deps.pricingStore,
catalog: deps.priceCatalog,
log: {
info: (message) => openCodeLog.info(message),
warn: (message) => openCodeLog.warn(message),
},
});
}
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}.
*/
async priceStep(step) {
const { model, usage, reportedCostUsd } = step;
const tokens = usage.inputTokens + usage.outputTokens;
const reported = reportedCostUsd !== undefined && (reportedCostUsd > 0 || tokens === 0);
if (reported && this.settings.cost !== 'estimate') {
return resolveUsageCost(usage, reportedCostUsd, undefined);
}
const price = model !== undefined && tokens > 0 ? await this.prices.priceFor(priceRef(model)) : undefined;
return resolveUsageCost(usage, price === undefined ? reportedCostUsd : undefined, price);
}
/** The tools server's name in OpenCode. */
get toolsServer() {
return this.settings.tools?.server ?? 'agora';
}
/** The app tool an OpenCode permission `action` names, and its presentation — `undefined` for a built-in. */
presentationOf(action) {
if (this.settings.tools === undefined)
return undefined;
const server = this.toolsServer;
for (const prefix of [`${server}.`, `${server}_`]) {
if (!action.startsWith(prefix))
continue;
const spec = this.registry.spec(action.slice(prefix.length));
if (spec !== undefined)
return {
name: spec.name,
...(spec.presentation !== undefined ? { presentation: spec.presentation } : {}),
};
}
return undefined;
}
/** The agent's deps (prompt, approval policy, allow-list, skills, memory) for a turn. */
depsFor(input) {
return this.factory.forAgent(input.agentName);
}
/** The tools a turn may reach: the agent's allow-list, narrowed by its persona's. */
allowedTools(input) {
const deps = this.depsFor(input);
const persona = personaOf(deps, input.persona);
const agent = deps.toolAllowList;
const own = persona?.allowedTools;
if (agent === undefined)
return own;
if (own === undefined)
return agent;
return agent.filter((name) => own.includes(name));
}
/** Whether the memory provider writes, so the endpoint serves `remember`. */
memoryWritable(input) {
return (this.settings.tools !== undefined && this.depsFor(input).memory?.provider.write !== undefined);
}
/** The runs this process is following now (an updater drains on it). */
liveRuns() {
return [...this.live.keys()];
}
/** Stop following every run, and close the event streams. */
close() {
for (const live of this.live.values())
live.stop();
this.live.clear();
this.events.close();
}
// ---- tools over MCP --------------------------------------------------------------------------
/**
* 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.
*/
async callContext(call) {
const sessionId = sessionOfMeta(call.meta);
if (sessionId === undefined)
return undefined;
const scope = (runId) => `${runId}:${call.requestId || randomUUID()}`;
for (const [runId, live] of this.live) {
if (live.handle.sessionId !== sessionId)
continue;
if (live.input.actor.id !== call.actor.id || live.handle.serverKey !== call.serverKey) {
return undefined;
}
return {
input: live.input,
runId,
ctx: {
threadId: live.input.threadId,
runId,
...(live.input.agentName !== undefined ? { agentName: live.input.agentName } : {}),
emitUi: uiEmitter(scope(runId), async (component) => {
await live.turn.pushUi(component);
await this.uiPushed(live.input, runId, component);
}),
},
};
}
const threadId = await this.threadOfSession(call.actor, sessionId);
if (threadId === undefined)
return undefined;
if ((await this.store.getThreadActorRef(threadId)) !== call.actor.id)
return undefined;
const known = await this.sessions.get(threadId);
if (known === null || known.sessionId !== sessionId || known.serverKey !== call.serverKey) {
return undefined;
}
const runId = await this.activeRun(threadId);
if (runId === null)
return undefined;
// Followed elsewhere: what this call knows of the run.
const input = {
threadId,
actor: call.actor,
userText: '',
...(known.agentName !== undefined ? { agentName: known.agentName } : {}),
...(known.persona !== undefined ? { persona: known.persona } : {}),
};
return {
input,
runId,
ctx: {
threadId,
runId,
...(known.agentName !== undefined ? { agentName: known.agentName } : {}),
emitUi: uiEmitter(scope(runId), async (component) => {
const writer = await this.sink.open(runId);
await writer.write({ t: 'event', event: { kind: 'ui', ...component } });
await this.store.appendMessage({
threadId,
role: 'assistant',
content: '',
runId,
ui: [component],
});
await this.uiPushed(input, runId, component);
}),
},
};
}
/**
* 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.
*/
async spendApproval(runId, threadId, tool) {
const names = new Set([tool, `${this.toolsServer}.${tool}`, `${this.toolsServer}_${tool}`]);
const local = (this.grants.get(runId) ?? []).find((grant) => names.has(grant.action) && !this.spent.has(grant.id));
if (local !== undefined) {
this.spent.add(local.id);
return true;
}
const thread = await this.store.getThread(threadId);
for (const message of thread?.messages ?? []) {
if (message.runId !== runId)
continue;
for (const call of message.toolCalls ?? []) {
if (!names.has(call.name) || this.spent.has(call.id))
continue;
const result = message.toolResults?.find((r) => r.id === call.id);
const approved = result?.output?.approved === true;
if (approved) {
this.spent.add(call.id);
return true;
}
}
}
return false;
}
/**
* 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.
*/
async pushToSession(sessionId, component) {
for (const [runId, live] of this.live) {
if (live.handle.sessionId !== sessionId)
continue;
await live.turn.pushUi(component);
await this.uiPushed(live.input, runId, component);
return true;
}
return false;
}
async uiPushed(input, runId, component) {
if (this.host.onUi === undefined)
return;
try {
await this.host.onUi({ input, runId, component });
}
catch (error) {
openCodeLog.warn(`onUi failed: ${errorText(error, 'error')}`);
}
}
async threadOfSession(actor, sessionId) {
try {
const server = await this.host.server(actor);
const info = await server.client.session.get?.({ sessionID: sessionId });
const threadId = info?.metadata?.threadId;
return typeof threadId === 'string' ? threadId : undefined;
}
catch {
return undefined;
}
}
async activeRun(threadId) {
if (isChatQueueStore(this.store))
return this.store.activeRunForThread(threadId);
return (await this.store.getThread(threadId))?.activeRunId ?? null;
}
// ---- begin -----------------------------------------------------------------------------------
/**
* 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.
*/
async begin(runId, input, durable = false) {
// A durable `begin` re-run after a crash finds its row already there.
if ((await this.store.getRunActorRef(runId)) === null) {
await this.store.recordRunStart({
runId,
threadId: input.threadId,
actor: input.actor,
durable,
...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
});
}
const earlier = await this.prepareThread(runId, input);
const server = await this.host.server(input.actor);
const known = await this.sessions.get(input.threadId);
const reusable = known !== null &&
known.serverKey === server.key &&
known.bootId === server.bootId &&
((await this.host.reuse?.({ input, runId, session: known })) ?? true);
let handle;
let created = false;
if (known !== null && reusable) {
handle = {
sessionId: known.sessionId,
serverKey: known.serverKey,
...(known.bootId !== undefined ? { bootId: known.bootId } : {}),
...(known.directory !== undefined ? { directory: known.directory } : {}),
};
if (input.regenerate === true)
await this.revertLastExchange(server.client, handle.sessionId);
await this.addTools(input, server, handle.directory, false);
}
else {
handle = await this.createSession(runId, input, server, earlier);
created = true;
}
const persona = typeof input.persona === 'string' ? input.persona : input.persona?.id;
if (created || known?.agentName !== input.agentName || known?.persona !== persona) {
await this.sessions.set(input.threadId, {
...handle,
...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
...(persona !== undefined ? { persona } : {}),
});
}
await this.host.beforePrompt?.({
input,
runId,
sessionId: handle.sessionId,
client: server.client,
created,
});
await this.refreshInstructions(runId, input, server.client, handle.sessionId);
return handle;
}
async createSession(runId, input, server, earlier, note) {
const { client } = server;
const context = { input, runId };
const spec = await this.host.session(context);
const directory = spec.location?.directory;
const skills = await this.writeSkills(input, client, directory);
const sessionId = (await client.session.create({
...spec,
permissions: [
...(spec.permissions ?? []),
...this.toolRules(input),
...skills.map((name) => ({
action: 'skill',
resource: name,
effect: 'allow',
})),
],
metadata: { ...(spec.metadata ?? {}), threadId: input.threadId },
})).id;
const handle = {
sessionId,
serverKey: server.key,
...(server.bootId !== undefined ? { bootId: server.bootId } : {}),
...(directory !== undefined ? { directory } : {}),
};
await this.sessions.set(input.threadId, handle);
await this.addTools(input, server, directory, true);
await this.host.prepare?.({ ...context, sessionId, client });
const transcript = transcriptOf(earlier, this.settings.historyMessages ?? 20);
if (transcript || note) {
await client.session.instructions.entry.put({
sessionID: sessionId,
key: 'agora.history',
value: [
transcript
? `The conversation so far (continue it; do not repeat it):\n${transcript}`
: '',
note ?? '',
]
.filter(Boolean)
.join('\n\n'),
});
}
return handle;
}
/** The user message, as the loop persists it (or, on a regenerate, the thread rewound to it). */
async prepareThread(runId, input) {
const thread = await this.store.getThread(input.threadId);
const messages = thread?.messages ?? [];
if (input.regenerate === true) {
let lastUser = -1;
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (messages[i]?.role === 'user') {
lastUser = i;
break;
}
}
const firstDropped = messages[lastUser + 1];
if (firstDropped !== undefined) {
await this.store.truncateFrom(input.threadId, firstDropped.id);
}
return messages.slice(0, Math.max(lastUser, 0));
}
await this.store.appendMessage({
threadId: input.threadId,
role: 'user',
content: input.userText,
runId,
...(typeof input.persona === 'string' ? { persona: input.persona } : {}),
...(input.attachments !== undefined ? { attachments: input.attachments } : {}),
});
if (thread !== null && (thread.title === '' || thread.title === 'New chat')) {
await this.store.setTitle(input.threadId, titleOf(input.userText));
}
return messages;
}
/** Rewind the session to before its last user message, so the regenerated answer replaces it. */
async revertLastExchange(client, sessionId) {
if (client.message === undefined || client.session.revert === undefined)
return;
const page = await client.message.list({ sessionID: sessionId, order: 'desc', limit: 50 });
const last = page.data.find((m) => m.type === 'user');
if (last === undefined)
return;
await client.session.revert.stage({ sessionID: sessionId, messageID: last.id, files: false });
await client.session.revert.commit({ sessionID: sessionId });
}
/** The agent's prompt, the memory block and the host's entries — refreshed every turn. */
async refreshInstructions(runId, input, client, sessionId) {
const memory = await this.memoryBlock(input);
const entries = {
[this.settings.systemKey ?? 'agora.system']: await this.systemPrompt(input),
...(memory !== undefined ? { 'agora.memory': memory } : {}),
...((await this.host.instructions?.({ input, runId, sessionId })) ?? {}),
};
for (const [key, value] of Object.entries(entries)) {
await client.session.instructions.entry.put({ sessionID: sessionId, key, value });
}
}
/**
* 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`.
*/
async systemPrompt(input) {
const deps = this.depsFor(input);
const persona = personaOf(deps, input.persona);
const ctx = {
actor: input.actor,
...(persona !== undefined ? { persona } : {}),
...(input.pageContext !== undefined ? { pageContext: input.pageContext } : {}),
...(input.uiCapabilities !== undefined ? { uiCapabilities: input.uiCapabilities } : {}),
};
const resolve = async (prompt, basePrompt) => typeof prompt === 'function' ? prompt({ ...ctx, basePrompt }) : prompt;
const own = persona?.systemPrompt;
if (own === undefined)
return resolve(deps.systemPrompt, '');
const base = typeof own === 'function' ? await resolve(deps.systemPrompt, '') : '';
return resolve(own, base);
}
scopeContext(input) {
return {
actor: input.actor,
threadId: input.threadId,
...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
...(input.pageContext !== undefined ? { pageContext: input.pageContext } : {}),
};
}
/** What is on file about the actor (`memory` in the config); writable when `remember` is served. */
async memoryBlock(input) {
const memory = this.depsFor(input).memory;
if (memory === undefined)
return undefined;
const digest = await offerMemories({
config: memory,
ctx: this.scopeContext(input),
query: input.userText,
});
if (digest.entries.length === 0)
return undefined;
return buildMemoryBlock({
entries: digest.entries,
writable: this.memoryWritable(input),
partial: digest.omitted > 0 || digest.recalled === true,
});
}
/**
* 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.
*/
async writeSkills(input, client, directory) {
const skills = this.depsFor(input).skills;
if (skills === undefined || client.file === undefined || directory === undefined)
return [];
const ctx = this.scopeContext(input);
const offer = await offerSkills(skills, ctx);
const dir = this.settings.skillsDir ?? '.opencode/skills';
const written = [];
for (const entry of offer.entries) {
const body = await skills.provider.load({ name: entry.name, scope: entry.scope, ctx });
if (body === null)
continue;
const file = `---\nname: ${entry.name}\ndescription: ${JSON.stringify(entry.description)}\n---\n\n${body}\n`;
await client.file.write({
location: { directory },
path: `${dir}/${entry.name}/SKILL.md`,
payload: new TextEncoder().encode(file),
});
written.push(entry.name);
}
return written;
}
/**
* 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.
*/
toolRules(input) {
if (this.settings.tools === undefined)
return [];
const server = this.toolsServer;
const allow = this.allowedTools(input);
const both = (name) => [`${server}.${name}`, `${server}_${name}`];
const rules = [
{ action: `${server}*`, resource: '*', effect: 'allow' },
];
for (const spec of this.registry.allSpecs()) {
const offered = (allow === undefined || allow.includes(spec.name)) && !LOOP_SERVED_KINDS.has(spec.kind);
if (offered && spec.kind === 'read')
continue;
const effect = offered && spec.kind === 'action' ? 'ask' : 'deny';
for (const action of both(spec.name))
rules.push({ action, resource: '*', effect });
}
return rules;
}
/**
* 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.
*/
async addTools(input, server, directory, fresh) {
const tools = this.settings.tools;
if (tools === undefined || this.mintToolsToken === undefined)
return;
const { client } = server;
if (client.mcp === undefined) {
openCodeLog.warn('`tools` is set but the OpenCode client cannot add MCP servers (`mcp.add`)');
return;
}
const key = `${server.key}|${server.bootId ?? ''}|${directory ?? ''}|${input.actor.id}`;
const issued = this.toolsIssued.get(key);
const ttl = tools.ttlMs ?? DEFAULT_TOOLS_TTL_MS;
if (!fresh && issued !== undefined && Date.now() - issued < ttl / 2)
return;
await client.mcp.add({
server: this.toolsServer,
...(directory !== undefined ? { location: { directory } } : {}),
config: {
type: 'remote',
url: tools.url,
headers: { Authorization: `Bearer ${this.mintToolsToken(input.actor, server.key)}` },
oauth: false,
},
});
this.toolsIssued.set(key, Date.now());
}
// ---- prompt / observe / reply ----------------------------------------------------------------
/** Start listening to the session (so nothing it says is missed), then send the user message. */
async prompt(runId, input, handle, spent) {
const live = await this.ensureLive(runId, input, handle, false, spent);
try {
const prompt = (await this.host.promptFor?.({
input,
runId,
sessionId: handle.sessionId,
})) ?? {
text: input.userText,
...(input.attachments?.length && this.host.files
? { files: await this.host.files({ input, runId }) }
: {}),
};
live.turn.setBaseline(await this.sessionUsage(live.client, handle.sessionId));
await live.client.session.prompt({
sessionID: handle.sessionId,
text: prompt.text,
...(prompt.files?.length ? { files: prompt.files } : {}),
});
}
catch (error) {
live.turn.fail(`OpenCode refused the message: ${errorText(error, 'unknown error')}`);
}
}
/**
* 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.
*/
async observe(runId, input, handle, spent) {
const live = await this.ensureLive(runId, input, handle, true, spent);
const { turn, client } = live;
if (turn.hasMilestone())
return this.reached(runId, input, handle, await turn.next());
// Set once this observation has its milestone: the safety nets below only act while it waits.
let done = false;
const next = turn.next().then((milestone) => {
done = true;
return milestone;
});
const timeoutMs = this.settings.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS;
const timer = setTimeout(() => {
if (!done)
turn.fail('Timed out waiting for OpenCode.');
}, timeoutMs);
timer.unref?.();
if (client.session.wait !== undefined) {
void client.session
.wait({ sessionID: handle.sessionId })
.then(() => new Promise((resolve) => setTimeout(resolve, 2_000)))
.then(async () => {
if (done || turn.finished)
return;
await turn.catchUp().catch(() => undefined);
// Let a milestone the catch-up reached settle this observation first.
await new Promise((resolve) => setImmediate(resolve));
// Idle with nothing open: the execution ended while its last events were lost.
if (!done && !turn.finished) {
turn.handle({
type: 'session.execution.succeeded',
data: { sessionID: handle.sessionId },
});
}
})
.catch(() => undefined);
}
try {
return await this.reached(runId, input, handle, await next);
}
finally {
clearTimeout(timer);
}
}
/**
* 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.
*/
async reply(runId, input, handle, ask, reply, spent) {
const server = await this.host.server(input.actor);
if (server.key !== handle.serverKey || server.bootId !== handle.bootId) {
return this.continueAfterRestart(runId, input, server, ask, reply, spent);
}
const live = await this.ensureLive(runId, input, handle, false, spent);
await live.turn.decide(ask, reply);
return handle;
}
async continueAfterRestart(runId, input, server, ask, reply, spent) {
const approved = 'approved' in reply && reply.approved === true;
const note = ask.kind === 'approval'
? approved
? `The user approved the action ${ask.action} you asked permission for. Continue the task.`
: `The user did not approve the action ${ask.action}. Continue without it and explain.`
: `The user answered your question: ${JSON.stringify('answers' in reply ? reply.answers : {})}. Continue.`;
const thread = await this.store.getThread(input.threadId);
const handle = await this.createSession(runId, input, server, thread?.messages ?? [], note);
await this.refreshInstructions(runId, input, server.client, handle.sessionId);
this.drop(runId);
const live = await this.ensureLive(runId, input, handle, false, spent);
// The old request's card settles as decided; OpenCode is told in the note.
await live.turn.decide(ask, reply, { tellOpenCode: false });
live.turn.setBaseline(await this.sessionUsage(live.client, handle.sessionId));
await live.client.session.prompt({ sessionID: handle.sessionId, text: note });
return handle;
}
/** What a session spent so far (`session.get`), or nothing when OpenCode does not say. */
async sessionUsage(client, sessionId) {
try {
return await client.session.get?.({ sessionID: sessionId });
}
catch {
return undefined;
}
}
/** Interrupt the thread's session (cancel). */
async interrupt(input) {
const known = await this.sessions.get(input.threadId);
if (known === null)
return;
const server = await this.host.server(input.actor);
if (server.key !== known.serverKey || server.bootId !== known.bootId)
return;
await server.client.session.interrupt({ sessionID: known.sessionId });
}
async ensureLive(runId, input, handle, catchUp, spent) {
const current = this.live.get(runId);
if (current !== undefined && current.handle.sessionId === handle.sessionId)
return current;
current?.stop();
const server = await this.host.server(input.actor);
const deps = this.depsFor(input);
const policy = deps.approvalPolicy ?? new DefaultApprovalPolicy();
const writer = await this.sink.open(runId);
const turn = new OpenCodeTurn({
runId,
input,
client: server.client,
sessionId: handle.sessionId,
writer,
store: this.store,
approvalFor: async (action) => policy.requirementFor({ name: action, kind: 'action' }, input.actor, {
threadId: input.threadId,
runId,
...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
}),
// An app tool reaches OpenCode as `<server>.<name>` / `<server>_<name>`: its own presentation
// words the approval card. OpenCode's built-ins (`bash`, `edit`) have none.
approvalPresentation: (action) => this.presentationOf(action),
modelLabel: input.model ?? 'opencode',
priceStep: (step) => this.priceStep(step),
...(spent !== undefined ? { carried: spent } : {}),
onGranted: (action, id) => {
const grants = this.grants.get(runId) ?? [];
grants.push({ action, id });
this.grants.set(runId, grants);
},
});
const stop = await this.events.listen(server.key, server.client, handle.sessionId, (event) => turn.handle(event));
const live = { turn, input, handle, client: server.client, stop };
this.live.set(runId, live);
if (catchUp && current === undefined)
await turn.catchUp().catch(() => undefined);
return live;
}
/** Stop following a run in this process. */
drop(runId) {
this.live.get(runId)?.stop();
this.live.delete(runId);
}
// ---- settle ----------------------------------------------------------------------------------
/** A person was asked something: tell the host (cards in other channels, wake-ups). */
async reached(runId, input, handle, milestone) {
if (milestone.kind === 'ask' && this.host.onAsk !== undefined) {
try {
await this.host.onAsk({ input, runId, sessionId: handle.sessionId, ask: milestone.ask });
}
catch (error) {
openCodeLog.warn(`onAsk failed: ${errorText(error, 'error')}`);
}
}
return milestone;
}
/**
* 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.
*/
async runResult(runId, input, outcome, durationMs, usage) {
const thread = await this.store.getThread(input.threadId);
const messages = (thread?.messages ?? []).filter((m) => m.role === 'assistant' && m.runId === runId);
return {
runId,
input,
outcome,
text: messages
.map((m) => m.content)
.filter(Boolean)
.join('\n\n'),
messages,
usage,
durationMs,
};
}
/**
* 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.
*/
async amend(writer, result) {
if (this.host.beforeSettle === undefined)
return {};
let amendment = {};
try {
amendment = (await this.host.beforeSettle(result)) ?? {};
}
catch (error) {
openCodeLog.warn(`beforeSettle failed: ${errorText(error, 'error')}`);
}
const ui = amendment.ui ?? [];
const last = result.messages.at(-1);
if (ui.length > 0) {
for (const component of ui) {
await writer.write({ t: 'event', event: { kind: 'ui', ...component } });
}
if (last !== undefined)
await this.store.setMessageUi?.(last.id, [...(last.ui ?? []), ...ui]);
}
return amendment;
}
/** Delivery, telemetry, spend: after the run settled. Errors are the host's, never the run's. */
async settled(result) {
try {
await this.host.onSettled?.(result);
}
catch (error) {
openCodeLog.warn(`onSettled failed: ${errorText(error, 'error')}`);
}
}
forget(runId) {
this.drop(runId);
for (const grant of this.grants.get(runId) ?? [])
this.spent.delete(grant.id);
this.grants.delete(runId);
}
/**
* 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.
*/
spentBy(runId, spent) {
return this.live.get(runId)?.turn.totalUsage() ?? spent ?? emptyUsage();
}
/**
* 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.
*/
async settle(runId, input, outcome, durationMs, spent) {
if (outcome.status === 'failed') {
await this.settleFailed(runId, input, outcome.error, durationMs, spent);
return;
}
const usage = this.spentBy(runId, spent);
this.forget(runId);
const writer = await this.sink.open(runId);
const result = await this.runResult(runId, input, outcome, durationMs, usage);
await this.amend(writer, result);
if (outcome.status === 'interrupted') {
await this.store.recordRunEnd({ runId, status: 'cancelled' });
await Promise.resolve(this.store.failUnsettledToolCalls?.(runId, RUN_ENDED_BEFORE_TOOL_CALL)).catch(() => 0);
await this.handoff(writer, input, runId, 'cancelled');
await releaseThreadRun(this.store, input.threadId, runId);
await writer.write({ t: 'event', event: { kind: 'cancelled' } });
await writer.end();
}
else {
await this.store.recordRunEnd({
runId,
status: 'completed',
finishedAt: Date.now(),
stepCount: usage.steps,
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
...(usage.costUsd > 0 ? { costUsd: usage.costUsd } : {}),
});
await this.handoff(writer, input, runId, 'completed');
await releaseThreadRun(this.store, input.threadId, runId);
await writer.end();
}
await this.settled(result);
}
async settleFailed(runId, input, error, durationMs = 0, spent) {
const usage = this.spentBy(runId, spent);
this.forget(runId);
const writer = await this.sink.open(runId);
const result = await this.runResult(runId, input, { status: 'failed', error }, durationMs, usage);
const worded = (await this.amend(writer, result)).error;
// OpenCode reports a failure as text, so it is coded the generic crash.
await this.store.recordRunEnd({ runId, status: 'failed', error, errorCode: 'run_failed' });
await Promise.resolve(this.store.failUnsettledToolCalls?.(runId, RUN_ENDED_BEFORE_TOOL_CALL)).catch(() => 0);
await this.handoff(writer, input, runId, 'failed', error);
await releaseThreadRun(this.store, input.threadId, runId);
// The host's wording is for the person and shown as is; OpenCode's own error follows the
// library's rule (shown outside production, logged everywhere).
await writer.write(worded !== undefined
? { t: 'error', code: 'run_failed', message: worded }
: streamErrorFrame(new Error(error), runId));
await writer.end();
await this.settled({ ...result, outcome: { status: 'failed', error: worded ?? error } });
}
/** As `InlineAgentRunner`: hand the thread to its next queued message, and say so on the stream. */
async handoff(writer, input, runId, outcome, error) {
const queue = this.queue;
const startNext = this.startNext;
if (queue === undefined || !queue.supported || startNext === undefined)
return;
if (!isThreadTurn(input))
return;
try {
const frame = await queue.handoff({ threadId: input.threadId, runId, outcome, ...(error !== undefined ? { error } : {}) }, (next, nextRunId) => startNext(next, nextRunId));
if (frame !== undefined)
await writer.write({ t: 'event', event: frame });
}
catch (failure) {
openCodeLog.error(`could not advance the queue of thread ${input.threadId} after run ${runId}: ${errorText(failure, 'error')}`);
}
}
}
/** The persona a turn names (an id, or the whole definition a direct caller handed over). */
function personaOf(deps, persona) {
if (persona === undefined)
return undefined;
return typeof persona === 'string' ? deps.personas.get(persona) : persona;
}
function titleOf(text) {
const trimmed = text.trim().replace(/\s+/g, ' ');
return trimmed.length > 60 ? `${trimmed.slice(0, 57)}...` : trimmed || 'New chat';
}
function transcriptOf(messages, limit) {
return messages
.filter((m) => (m.role === 'user' || m.role === 'assistant') && m.content.trim())
.slice(-limit)
.map((m) => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content.slice(0, 2000)}`)
.join('\n');
}
/** The `_meta` key OpenCode names its session with on every MCP call. */
export const SESSION_META_KEY = 'ai.opencode/sessionID';
function sessionOfMeta(meta) {
const value = meta?.[SESSION_META_KEY];
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
/** `ctx.emitUi` over `push`, with the library's id and snapshot rules. */
function uiEmitter(scope, push) {
let next = 0;
return async (component, props, options = {}) => {
if (typeof component !== 'string' || component.length === 0) {
throw new Error('emitUi: component must be a non-empty string');
}
if (typeof props !== 'object' || props === null || Array.isArray(props)) {
throw new Error('emitUi: props must be a JSON object');
}
const id = options.id ?? `${scope}:ui:${next++}`;
await push({
id,
component,
props: JSON.parse(JSON.stringify(props)),
...(options.version !== undefined ? { version: options.version } : {}),
...(options.fallbackText !== undefined ? { fallbackText: options.fallbackText } : {}),
});
return { id };
};
}
/**
* The ids a step's model is priced under: `provider/model` (what usage is recorded under), the bare
* model id, and — for a gateway that names a Bedrock model under its own path
* (`flip-gateway/<server>/us-gov.anthropic.…`) — the Bedrock id at its end.
*/
function priceRef(model) {
const aliases = [model.id];
const tail = model.id.slice(model.id.lastIndexOf('/') + 1);
if (tail !== model.id && parseBedrockModelId(tail) !== undefined)
aliases.push(tail);
return { modelId: `${model.providerID}/${model.id}`, provider: model.providerID, aliases };
}
//# sourceMappingURL=turns.js.map