UNPKG

@adonis-agora/agent

Version:

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

941 lines • 40.1 kB
import { ASK_TOOL_NAME, isHumanDecision, normalizeElicitationReply, settleElicitation, } from '../elicitation.js'; import { resolveToolConfirmation } from '../tool-presentation.js'; import { toElicitation, toFormAnswer } from './forms.js'; import { errorText, openCodeLog } from './log.js'; export function emptyUsage() { return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0, costUsd: 0, steps: 0, }; } /** `a + b`; a missing side (a milestone journaled before usage travelled) counts as nothing. */ export function addUsage(a, b) { const x = a ?? emptyUsage(); const y = b ?? {}; return { inputTokens: x.inputTokens + (y.inputTokens ?? 0), outputTokens: x.outputTokens + (y.outputTokens ?? 0), cacheReadTokens: x.cacheReadTokens + (y.cacheReadTokens ?? 0), cacheWriteTokens: x.cacheWriteTokens + (y.cacheWriteTokens ?? 0), reasoningTokens: x.reasoningTokens + (y.reasoningTokens ?? 0), costUsd: x.costUsd + (y.costUsd ?? 0), steps: x.steps + (y.steps ?? 0), }; } function subUsage(a, b) { return { inputTokens: a.inputTokens - b.inputTokens, outputTokens: a.outputTokens - b.outputTokens, cacheReadTokens: a.cacheReadTokens - b.cacheReadTokens, cacheWriteTokens: a.cacheWriteTokens - b.cacheWriteTokens, reasoningTokens: a.reasoningTokens - b.reasoningTokens, costUsd: a.costUsd - b.costUsd, steps: a.steps - b.steps, }; } const FAILED_STATUSES = new Set(['error', 'failed', 'denied', 'cancelled']); /** * OpenCode tools the stream does not show as calls: the question tool IS the form, which streams as * an `elicitation` of its own. */ const HIDDEN_TOOLS = new Set(['question']); function emptySegment() { return { text: '', reasoning: '', reasoningMs: 0, calls: [], results: [], ui: [] }; } /** * The arguments of the call a permission is about. OpenCode puts a built-in tool's arguments * straight in `metadata` (`webfetch` → `{ url, format }`); calls that wrap others nest them under * `input` or `args`. */ function permissionArgs(request) { const metadata = request.metadata ?? {}; const nested = metadata.input ?? metadata.args; if (nested !== null && typeof nested === 'object') return nested; return metadata; } /** * One turn of an OpenCode session, as the library's stream and store see it. Fed the session's * events in order, it writes the protocol's frames and persists what the loop would have — the * assistant messages with their tool calls and results, the calls put to a person (so the * approve/answer routes find their run), usage per model step — and reports {@link Milestone}s: a * person was asked something, or the execution ended. * * The turn does not wait for people itself. Whoever drives it (`OpenCodeTurns`, under an in-memory * or a durable runner) takes the `ask` milestone, waits however it waits, and hands the answer back * with {@link decide} — which is what lets a durable run wait across a restart and reply from * another process, with a fresh turn object fed the journaled {@link PendingAsk}. */ export class OpenCodeTurn { a; /** Serializes the handling of events, so frames and rows land in the order OpenCode sent them. */ chain = Promise.resolve(); segment = emptySegment(); tools = new Map(); /** Results already persisted per message, so a late settlement adds to them rather than replacing. */ messageResults = new Map(); asked = new Set(); /** Calls of {@link HIDDEN_TOOLS}, by id. */ hidden = new Set(); milestones = []; waiting; /** What this turn object saw spent, and how much of it a milestone already reported. */ spent = emptyUsage(); reported = emptyUsage(); priced = false; model; /** What the session had spent when this turn prompted it; what OpenCode reported since. */ baseline; seen = { cost: 0, input: 0, output: 0, reasoning: 0, read: 0, write: 0 }; compacted = false; stepOpen = false; sawText = false; separator = false; reasoningSince; wroteMessage = false; ended = false; stepError; constructor(a) { this.a = a; this.model = modelOfLabel(a.input.model); } /** * What the session had spent before this turn prompted it (`session.get`). When the execution * ends, what the session spent since that this turn's events did not report — the title OpenCode * generates, a compaction: OpenCode records those calls on the session without streaming them — is * recorded as a usage row of its own. */ setBaseline(info) { if (info?.tokens === undefined) return; this.baseline = { cost: Number(info.cost) || 0, tokens: info.tokens }; } /** What the run spent so far: carried from before this turn object, plus what it saw. */ totalUsage() { return addUsage(this.a.carried, this.spent); } get sessionId() { return this.a.sessionId; } get finished() { return this.ended; } /** The run's totals so far (see {@link totalUsage}): tokens, cost, model steps. */ totals() { const total = this.totalUsage(); return { inputTokens: total.inputTokens, outputTokens: total.outputTokens, ...(this.isPriced() ? { costUsd: total.costUsd } : {}), steps: total.steps, }; } isPriced() { return this.priced || (this.a.carried?.costUsd ?? 0) > 0; } /** Feed one of the session's events. */ handle(event) { this.enqueue(() => this.onEvent(event)); } /** End the turn as failed without an event (the prompt was refused, the stream is gone). */ fail(error) { this.enqueue(() => this.end({ status: 'failed', error })); } /** * A component a tool pushed (`ctx.emitUi`, through the engine's MCP endpoint): streamed as a `ui` * frame and persisted on the message being written, in the order it arrived. Pushing the same id * again replaces it. */ pushUi(component) { return new Promise((resolve, reject) => { this.chain = this.chain .then(async () => { await this.step(); const ui = this.segment.ui; const at = ui.findIndex((existing) => existing.id === component.id); if (at >= 0) ui[at] = component; else ui.push(component); await this.event({ kind: 'ui', ...component }); }) .then(resolve, reject); }); } /** The next milestone: one already reached, or the next one to come. */ next() { const ready = this.milestones.shift(); if (ready !== undefined) return Promise.resolve(ready); return new Promise((resolve) => { this.waiting = resolve; }); } /** Whether a milestone is waiting to be taken. */ hasMilestone() { return this.milestones.length > 0; } /** * Requests OpenCode raised while nobody listened (this process started after them, or the event * stream dropped): its open permissions and forms, handled as if their events had just arrived. * One already recorded in the store is reported without being recorded or streamed again. */ async catchUp() { const { client, sessionId } = this.a; const permissions = (await client.permission.list?.({ sessionID: sessionId })) ?? []; const forms = (await client.session.form.list?.({ sessionID: sessionId })) ?? []; await new Promise((resolve) => { this.enqueue(async () => { for (const permission of permissions) await this.onPermission(permission, true); for (const form of forms) await this.onForm(form, true); resolve(); }); }); } /** * Hand a person's answer to what the turn asked, and let OpenCode go on. `tellOpenCode: false` * only settles the call (stream and store): the request died with a restarted server, and the * new session is told the answer in its prompt instead. */ decide(ask, reply, opts = {}) { const tell = opts.tellOpenCode ?? true; return new Promise((resolve, reject) => { this.chain = this.chain .then(() => ask.kind === 'approval' ? this.decided(ask, reply, tell) : this.answered(ask, reply, tell)) .then(resolve, reject); }); } reach(reached) { const usage = subUsage(this.spent, this.reported); this.reported = { ...this.spent }; const milestone = { ...reached, usage }; const waiting = this.waiting; if (waiting !== undefined) { this.waiting = undefined; waiting(milestone); } else { this.milestones.push(milestone); } } enqueue(work) { this.chain = this.chain.then(work).catch((error) => { openCodeLog.error(`run ${this.a.runId}: ${errorText(error, 'event handling failed')}`); // A turn whose bookkeeping broke can't be trusted to finish on its own. if (!this.ended) { this.ended = true; this.reach({ kind: 'finished', outcome: { status: 'failed', error: errorText(error, 'event handling failed') }, }); } }); } async write(frame) { await this.a.writer.write(frame); } async event(event) { await this.write({ t: 'event', event }); } async step() { if (this.stepOpen) return; this.stepOpen = true; await this.event({ kind: 'step-start' }); } endReasoning() { if (this.reasoningSince === undefined) return; this.segment.reasoningMs += Date.now() - this.reasoningSince; this.reasoningSince = undefined; } tool(id, name = 'tool') { let state = this.tools.get(id); if (state === undefined) { state = { name, announced: false, available: false, settled: false, input: {}, innerAnnounced: new Set(), innerSettled: new Set(), }; this.tools.set(id, state); this.segment.calls.push({ id, name, input: state.input, kind: 'read' }); } return state; } async announce(id, name) { const state = this.tool(id, name); if (!state.announced) { state.announced = true; await this.event({ kind: 'tool-input-start', id, name: state.name, toolKind: 'read' }); } return state; } async makeAvailable(id) { const state = await this.announce(id, this.tools.get(id)?.name ?? 'tool'); if (!state.available) { state.available = true; await this.event({ kind: 'tool-input-available', id, name: state.name, input: state.input, toolKind: 'read', }); } return state; } setInput(id, input, name) { const state = this.tool(id, name); if (input === null || typeof input !== 'object') return; Object.assign(state.input, input); } /** Code-mode calls made inside a tool (`metadata.toolCalls`), nested under it by `parentId`. */ async innerCalls(parentId, raw) { if (!Array.isArray(raw)) return; const parent = this.tool(parentId); for (const [index, call] of raw.entries()) { const id = `${parentId}.${index}`; const name = String(call?.tool ?? 'tool'); const input = call?.input !== null && typeof call?.input === 'object' ? call.input : {}; if (!parent.innerAnnounced.has(index)) { parent.innerAnnounced.add(index); this.segment.calls.push({ id, name, input, kind: 'read', parentId }); await this.event({ kind: 'tool-input-available', id, name, input, toolKind: 'read', parentId, }); } const status = String(call?.status ?? '').toLowerCase(); if (parent.innerSettled.has(index) || status === 'running' || status === 'pending' || !status) { continue; } parent.innerSettled.add(index); if (FAILED_STATUSES.has(status)) { this.segment.results.push({ id, name, output: { status }, error: status }); await this.event({ kind: 'tool-output-error', id, error: status }); } else { this.segment.results.push({ id, name, output: { status } }); await this.event({ kind: 'tool-output', id, output: { status } }); } } } async onEvent(e) { const d = e.data ?? {}; // A title or a compaction OpenCode ran is spent even when it lands after the execution ended. if (e.type === 'session.usage.recorded') { await this.spend(d, d.source === 'title' ? 'title' : 'summary'); return; } if (this.ended) return; switch (e.type) { case 'session.compaction.started': case 'session.compaction.ended': this.compacted = true; return; case 'session.step.started': case 'session.model.selected': { const model = d.model; if (typeof model?.id === 'string' && typeof model.providerID === 'string') this.model = { providerID: model.providerID, id: model.id }; return; } case 'session.text.delta': { const delta = typeof d.delta === 'string' ? d.delta : ''; if (!delta) return; this.endReasoning(); // Whitespace before anything was said is noise. if (!this.sawText && !delta.trim()) return; await this.step(); const text = this.separator && this.sawText ? `\n\n${delta}` : delta; this.separator = false; this.sawText = true; this.segment.text += text; await this.write({ t: 'text', v: text }); return; } case 'session.text.ended': // The next step's text starts a new paragraph. this.separator = true; return; case 'session.reasoning.delta': { const delta = typeof d.delta === 'string' ? d.delta : ''; if (!delta) return; this.reasoningSince ??= Date.now(); await this.step(); this.segment.reasoning += delta; await this.event({ kind: 'reasoning', text: delta }); return; } case 'session.tool.input.started': this.endReasoning(); if (HIDDEN_TOOLS.has(String(d.name))) { this.hidden.add(String(d.id)); return; } await this.step(); await this.announce(String(d.id), String(d.name ?? 'tool')); return; case 'session.tool.called': if (this.hidden.has(String(d.id))) return; await this.step(); this.setInput(String(d.id), d.input, typeof d.name === 'string' ? d.name : undefined); await this.makeAvailable(String(d.id)); return; case 'session.skill.activated': { const name = String(d.id ?? d.name ?? 'skill'); const id = `skill:${name}:${this.tools.size}`; await this.step(); await this.announce(id, 'skill'); this.setInput(id, { name }); await this.makeAvailable(id); await this.settleTool(id, true); return; } case 'session.tool.progress': if (this.hidden.has(String(d.id))) return; await this.step(); await this.makeAvailable(String(d.id)); await this.innerCalls(String(d.id), d.metadata?.toolCalls); return; case 'session.tool.success': case 'session.tool.failed': { const id = String(d.id); if (this.hidden.has(id)) return; await this.step(); await this.makeAvailable(id); await this.innerCalls(id, d.metadata?.toolCalls); await this.settleTool(id, e.type === 'session.tool.success', errorText(d.error, 'tool failed')); return; } case 'session.step.ended': this.endReasoning(); await this.stepEnded(d); return; case 'session.step.failed': this.stepError = errorText(d.error, 'the model call failed'); return; case 'session.renamed': if (typeof d.title === 'string' && d.title.trim()) { await this.a.store.setTitle(this.a.input.threadId, d.title.trim()); await this.event({ kind: 'title', title: d.title.trim() }); } return; case 'permission.asked': await this.onPermission(d, false); return; case 'form.created': await this.onForm((d.form ?? d), false); return; case 'session.execution.succeeded': await this.end({ status: 'succeeded' }); return; case 'session.execution.failed': await this.end({ status: 'failed', error: errorText(d.error, this.stepError ?? 'the execution failed'), }); return; case 'session.execution.interrupted': await this.end({ status: 'interrupted' }); return; default: return; } } async settleTool(id, ok, error = 'failed') { const state = this.tool(id); if (state.settled) return; state.settled = true; if (ok) { this.segment.results.push({ id, name: state.name, output: { ok: true } }); await this.event({ kind: 'tool-output', id, output: { ok: true } }); } else { this.segment.results.push({ id, name: state.name, output: { ok: false }, error }); await this.event({ kind: 'tool-output-error', id, error }); } } modelId() { return this.model !== undefined ? `${this.model.providerID}/${this.model.id}` : this.a.modelLabel; } /** One model call's usage and cost, into the ledger and the run's total. */ async spend( // biome-ignore lint/suspicious/noExplicitAny: an OpenCode event payload, read defensively. d, purpose) { const usage = usageOf(d.tokens); const reportedCostUsd = typeof d.cost === 'number' && Number.isFinite(d.cost) && d.cost >= 0 ? d.cost : undefined; const n = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0); this.seen.cost += reportedCostUsd ?? 0; this.seen.input += n(d.tokens?.input); this.seen.output += n(d.tokens?.output); this.seen.reasoning += n(d.tokens?.reasoning); this.seen.read += n(d.tokens?.cache?.read); this.seen.write += n(d.tokens?.cache?.write); const fallback = reportedCostUsd !== undefined ? { costUsd: reportedCostUsd, costSource: 'provider' } : {}; let cost = fallback; if (this.a.priceStep !== undefined) { cost = await this.a .priceStep({ model: this.model, usage, reportedCostUsd }) .catch(() => fallback); } this.spent = addUsage(this.spent, { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, cacheReadTokens: usage.cacheReadTokens ?? 0, cacheWriteTokens: usage.cacheWriteTokens ?? 0, reasoningTokens: usage.reasoningTokens ?? 0, costUsd: cost.costUsd ?? 0, steps: purpose === 'chat' ? 1 : 0, }); if (cost.costUsd !== undefined) this.priced = true; await this.a.store.recordUsage({ threadId: this.a.input.threadId, actorRef: this.a.input.actor.id, runId: this.a.runId, modelId: this.modelId(), purpose, usage, ...cost, }); return { usage, cost }; } // biome-ignore lint/suspicious/noExplicitAny: an OpenCode event payload, read defensively. async stepEnded(d) { const { usage, cost } = await this.spend(d, 'chat'); if (!this.stepOpen) return; this.stepOpen = false; await this.event({ kind: 'step-finish', usage, costUsd: cost.costUsd ?? null, ...(this.segment.reasoningMs > 0 ? { reasoningMs: this.segment.reasoningMs } : {}), model: this.modelId(), }); } /** The run's usage as its last message carries it: the whole run, cache tokens included. */ messageUsage() { const total = this.totalUsage(); return { inputTokens: total.inputTokens, outputTokens: total.outputTokens, ...(total.cacheReadTokens > 0 ? { cacheReadTokens: total.cacheReadTokens } : {}), ...(total.cacheWriteTokens > 0 ? { cacheWriteTokens: total.cacheWriteTokens } : {}), ...(total.reasoningTokens > 0 ? { reasoningTokens: total.reasoningTokens } : {}), ...(this.isPriced() ? { costUsd: total.costUsd } : {}), }; } /** * Persist what the turn produced since the last flush as one assistant message — the message a * call put to a person hangs on, or the turn's last one. Returns its id. */ async flush(final) { this.endReasoning(); const s = this.segment; const empty = !s.text && !s.reasoning && s.calls.length === 0 && s.ui.length === 0; // A turn always leaves an answer behind, even an empty one, so the thread reads as answered. if (empty && !(final && !this.wroteMessage)) return undefined; this.segment = emptySegment(); const { input } = this.a; const message = await this.a.store.appendMessage({ threadId: input.threadId, role: 'assistant', content: s.text, runId: this.a.runId, ...(final ? { usage: this.messageUsage() } : {}), ...(typeof input.persona === 'string' ? { persona: input.persona } : {}), ...(s.reasoning ? { reasoning: s.reasoning, reasoningMs: s.reasoningMs } : {}), ...(s.calls.length > 0 ? { toolCalls: s.calls } : {}), ...(s.results.length > 0 ? { toolResults: s.results } : {}), ...(s.ui.length > 0 ? { ui: s.ui } : {}), }); this.wroteMessage = true; this.messageResults.set(message.id, [...s.results]); return message.id; } /** A request recorded by an earlier process (a turn resumed after a restart). */ async alreadyRecorded(id) { return ((await this.a.store.toolCallApproval?.(id, this.a.runId)) ?? null) !== null; } /** The persisted assistant message carrying call `id`, so its result lands on the same message. */ async messageOf(id) { const thread = await this.a.store.getThread(this.a.input.threadId); return thread?.messages.find((m) => m.toolCalls?.some((call) => call.id === id))?.id; } async onPermission(request, recovering) { if (this.asked.has(request.id)) return; this.asked.add(request.id); const { client, sessionId, input, store } = this.a; const action = String(request.action); const args = permissionArgs(request); if (recovering && (await this.alreadyRecorded(request.id))) { const state = await store.toolCallApproval?.(request.id, this.a.runId); const messageId = await this.messageOf(request.id); this.reach({ kind: 'ask', ask: { kind: 'approval', id: request.id, action, approver: state?.approver ?? 'requester', ...(state?.expiresAt ? { expiresAt: state.expiresAt } : {}), ...(messageId !== undefined ? { messageId } : {}), }, ...(state?.expiresAt ? { timeoutMs: Math.max(0, Date.parse(state.expiresAt) - Date.now()) } : {}), }); return; } await this.step(); const requirement = await this.a.approvalFor(action); const remembered = requirement.required ? ((await store.rememberedApprovals?.(input.threadId)) ?? []).includes(action) : false; // No one needs to decide: the policy does not require it, or it was approved "for this // conversation" earlier. OpenCode asked because a rule said `ask`; the answer is yes. if (!requirement.required || remembered) { this.a.onGranted?.(action, request.id); await client.permission.reply({ sessionID: sessionId, requestID: request.id, decision: 'once', }); this.segment.calls.push({ id: request.id, name: action, input: args, kind: 'action' }); this.segment.results.push({ id: request.id, name: action, output: { approved: true } }); await this.event({ kind: 'tool-input-available', id: request.id, name: action, input: args, toolKind: 'action', }); await this.event({ kind: 'approval-settled', id: request.id, status: 'approved', approver: requirement.approver, decidedVia: remembered ? 'remembered' : 'policy', ...(remembered ? { remember: true } : {}), }); await this.event({ kind: 'tool-output', id: request.id, output: { approved: true } }); return; } const ttlMs = requirement.ttlMs !== undefined && Number.isFinite(requirement.ttlMs) && requirement.ttlMs > 0 ? Math.floor(requirement.ttlMs) : undefined; const expiresAt = ttlMs !== undefined ? new Date(Date.now() + ttlMs).toISOString() : undefined; // An app tool's own prompt, as the loop would carry it ("Refund order #7?") — else its label or // name in words: the call reaches clients under OpenCode's prefixed name (`agora_refund_order`), // which no tool catalog knows. A built-in (`bash`) carries none; adapters word it from its name. const tool = this.a.approvalPresentation?.(action); const confirmation = tool !== undefined ? resolveToolConfirmation(tool.name, args, tool.presentation) : undefined; this.segment.calls.push({ id: request.id, name: action, input: args, kind: 'action' }); const messageId = (await this.flush(false)); await store.recordToolCall({ toolCallId: request.id, messageId, toolName: action, toolType: 'action', input: args, status: 'pending_approval', ...(confirmation !== undefined ? { confirmation } : {}), runId: this.a.runId, approver: requirement.approver, ...(expiresAt !== undefined ? { expiresAt } : {}), }); await this.event({ kind: 'tool-input-available', id: request.id, name: action, input: args, toolKind: 'action', }); await this.write({ t: 'approval', runId: this.a.runId, id: request.id, toolName: action, input: args, approver: requirement.approver, ...(confirmation !== undefined ? { confirmation } : {}), ...(expiresAt !== undefined ? { expiresAt } : {}), }); this.reach({ kind: 'ask', ask: { kind: 'approval', id: request.id, action, approver: requirement.approver, messageId, ...(expiresAt !== undefined ? { expiresAt } : {}), }, ...(ttlMs !== undefined ? { timeoutMs: ttlMs } : {}), }); } async decided(ask, reply, tell) { // Answers to a form are not a yes: an approval only ever settles on a decision. const decision = isHumanDecision(reply) ? reply : { approved: false }; const { client, sessionId, store } = this.a; const { id, action } = ask; const status = decision.approved ? 'approved' : decision.expired ? 'expired' : 'rejected'; if (decision.approved) this.a.onGranted?.(action, id); if (tell) { await client.permission.reply({ sessionID: sessionId, requestID: id, decision: decision.approved ? 'once' : 'reject', ...(decision.approved ? {} : { message: decision.expired ? 'Nobody approved this action in time, so it was not run.' : `The user denied this action${decision.reason ? `: ${decision.reason}` : ''}. Do not retry it; tell the user what you would have done.`, }), }); } const result = decision.approved ? { id, name: action, output: { approved: true } } : { id, name: action, output: { rejected: true, ...(decision.reason ? { reason: decision.reason } : {}) }, error: decision.expired ? 'approval expired' : 'rejected by the user', denied: true, ...(decision.expired ? { expired: true } : {}), }; await store.updateToolCall({ toolCallId: id, runId: this.a.runId, status: decision.approved ? 'executed' : status === 'expired' ? 'expired' : 'rejected', output: result.output, ...(result.error !== undefined ? { error: result.error } : {}), ...(decision.executedByRef !== undefined ? { executedByRef: decision.executedByRef } : {}), ...(decision.remember === true ? { remember: true } : {}), ...(decision.decidedVia !== undefined ? { decidedVia: decision.decidedVia } : {}), }); await this.addResult(ask.messageId, result); await this.event({ kind: 'approval-settled', id, status, approver: ask.approver, ...(decision.executedByRef !== undefined ? { decidedBy: decision.executedByRef } : {}), ...(decision.decidedVia !== undefined ? { decidedVia: decision.decidedVia } : {}), ...(decision.remember === true ? { remember: true } : {}), ...(decision.reason !== undefined ? { reason: decision.reason } : {}), }); if (decision.approved) { await this.event({ kind: 'tool-output', id, output: { approved: true } }); } else { await this.event({ kind: 'tool-output-denied', id, ...(decision.expired ? { reason: 'approval expired' } : decision.reason ? { reason: decision.reason } : {}), }); } } async onForm(form, recovering) { if (this.asked.has(form.id)) return; this.asked.add(form.id); const request = toElicitation(form.id, form); if (recovering && (await this.alreadyRecorded(form.id))) { const messageId = await this.messageOf(form.id); this.reach({ kind: 'ask', ask: { kind: 'form', id: form.id, form, request, ...(messageId !== undefined ? { messageId } : {}), }, }); return; } const input = { ...(request.preamble !== undefined ? { preamble: request.preamble } : {}), questions: request.questions, }; await this.step(); this.segment.calls.push({ id: form.id, name: ASK_TOOL_NAME, input, kind: 'ask' }); const messageId = (await this.flush(false)); // A question waiting on a person is what `action` + `pending_approval` already mean — the loop // records its own questions the same way, so the answer route finds the run. await this.a.store.recordToolCall({ toolCallId: form.id, messageId, toolName: ASK_TOOL_NAME, toolType: 'action', input, status: 'pending_approval', runId: this.a.runId, }); await this.write({ t: 'elicitation', runId: this.a.runId, id: form.id, request }); this.reach({ kind: 'ask', ask: { kind: 'form', id: form.id, form, request, messageId } }); } async answered(ask, reply, tell) { const { client, sessionId } = this.a; const { form, request } = ask; // An Approve pressed on a form confirms its defaults; a Reject skips it. const answer = normalizeElicitationReply(reply); if (!tell) { // The form died with the server; the new session is told the answer. } else if (answer.skipped === true) { await client.session.form.cancel({ sessionID: sessionId, formID: form.id }); } else { await client.session.form.reply({ sessionID: sessionId, formID: form.id, answer: toFormAnswer(form, answer.answers), }); } const result = settleElicitation({ request, reply: answer }); await this.a.store.updateToolCall({ toolCallId: form.id, runId: this.a.runId, status: result.skipped ? 'rejected' : 'executed', output: result, ...(result.skipped ? { error: 'skipped by the user' } : {}), ...(answer.answeredByRef !== undefined ? { executedByRef: answer.answeredByRef } : {}), ...(answer.answeredVia !== undefined ? { decidedVia: answer.answeredVia } : {}), }); await this.addResult(ask.messageId, { id: form.id, name: ASK_TOOL_NAME, output: result }); await this.event({ kind: 'tool-output', id: form.id, output: result }); } async addResult(messageId, result) { if (messageId === undefined) return; // A message persisted by an earlier process: its results are in the store, not in this map. let known = this.messageResults.get(messageId); if (known === undefined) { const thread = await this.a.store.getThread(this.a.input.threadId); known = [...(thread?.messages.find((m) => m.id === messageId)?.toolResults ?? [])]; } const results = [...known.filter((r) => r.id !== result.id), result]; this.messageResults.set(messageId, results); await this.a.store.setMessageToolResults(messageId, results); } /** What the session spent since the baseline that no event of this turn reported. */ async unreported() { const baseline = this.baseline; if (baseline === undefined) return; this.baseline = undefined; const info = await this.a.client.session .get?.({ sessionID: this.a.sessionId }) .catch(() => undefined); const after = info?.tokens; if (after === undefined) return; const before = baseline.tokens; const left = (now, then, seen) => Math.max(0, (Number(now) || 0) - (Number(then) || 0) - seen); const tokens = { input: left(after.input, before.input, this.seen.input), output: left(after.output, before.output, this.seen.output), reasoning: left(after.reasoning, before.reasoning, this.seen.reasoning), cache: { read: left(after.cache?.read, before.cache?.read, this.seen.read), write: left(after.cache?.write, before.cache?.write, this.seen.write), }, }; const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write; if (total === 0) return; // Below a millionth of a cent is float noise from the subtraction, not a price. const cost = left(info?.cost, baseline.cost, this.seen.cost); await this.spend({ tokens, cost: cost < 1e-9 ? 0 : cost }, this.compacted ? 'summary' : 'title'); } async end(outcome) { if (this.ended) return; this.ended = true; await this.unreported().catch((error) => openCodeLog.warn(`run ${this.a.runId}: ${errorText(error, 'could not read the session usage')}`)); if (outcome.status !== 'failed' || this.segment.text || this.segment.calls.length > 0 || this.segment.ui.length > 0) { await this.flush(true); } if (this.stepOpen) { this.stepOpen = false; await this.event({ kind: 'step-finish' }); } this.reach({ kind: 'finished', outcome }); } } /** `provider/model` (the send's `model`) as a step model; `undefined` for a bare label. */ function modelOfLabel(label) { const slash = label?.indexOf('/') ?? -1; if (label === undefined || slash <= 0 || slash === label.length - 1) return undefined; return { providerID: label.slice(0, slash), id: label.slice(slash + 1) }; } /** * OpenCode's token counts as the library's `MessageUsage`. OpenCode reports the uncached input in * `input` and the cache reads and writes beside it; the library counts the whole input side in * `inputTokens`, the cache counts being subsets of it. */ // biome-ignore lint/suspicious/noExplicitAny: an OpenCode event payload, read defensively. export function usageOf(tokens) { const n = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0); const read = n(tokens?.cache?.read); const write = n(tokens?.cache?.write); const reasoning = n(tokens?.reasoning); return { inputTokens: n(tokens?.input) + read + write, outputTokens: n(tokens?.output), ...(read > 0 ? { cacheReadTokens: read } : {}), ...(write > 0 ? { cacheWriteTokens: write } : {}), ...(reasoning > 0 ? { reasoningTokens: reasoning } : {}), }; } //# sourceMappingURL=turn.js.map