UNPKG

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