UNPKG

@adonis-agora/agent

Version:

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

129 lines • 5.5 kB
import { RunCancelledError } from '../agent-loop.js'; import { HumanReplyMismatchError, isHumanDecision } from '../elicitation.js'; import { errorText } from './log.js'; import { addUsage, emptyUsage } from './turn.js'; /** * Runs OpenCode turns in this process: `begin → prompt → [observe → wait for a person → reply]* → * settle`, with the waits on people held in memory. Like `InlineAgentRunner`, a run parked on a * person lives in this process, so deploy it single-replica — or use `openCodeDurable()` * (`@adonis-agora/agent/opencode/durable`), which checkpoints every step and waits on a durable * signal. */ export class OpenCodeAgentRunner { turns; store; live = new Map(); cancelled = new Set(); pending = new Map(); constructor(turns, store) { this.turns = turns; this.store = store; turns.startNext = (next, runId) => this.start(next, { runId }); } runIdFor(input) { return this.turns.settings.runId?.(input) ?? crypto.randomUUID(); } /** In-process, so exact: a run this process is not running is not running anywhere. */ async isRunActive(runId) { if (this.live.has(runId)) return true; for (const key of this.pending.keys()) if (key.startsWith(`${runId}:`)) return true; return false; } async start(input, options = {}) { const runId = options.runId ?? this.runIdFor(input); // Before the turn starts: a fast turn could otherwise end (and clear it) before it was set. await this.store.setActiveStream(input.threadId, runId); this.live.set(runId, input); void this.run(runId, input).finally(() => { this.live.delete(runId); this.cancelled.delete(runId); for (const key of [...this.pending.keys()]) { if (key.startsWith(`${runId}:`)) this.pending.delete(key); } }); return { runId }; } /** * Deliver a person's reply. Answers addressed at an approval are refused, as the inline runner * refuses them: they say nothing about whether the action should run. */ async signal(runId, toolCallId, reply) { const key = `${runId}:${toolCallId}`; const parked = this.pending.get(key); if (parked === undefined) return; if (parked.on === 'approval' && !isHumanDecision(reply)) { throw new HumanReplyMismatchError(runId, toolCallId); } this.pending.delete(key); parked.resolve(reply); } /** * Stop the run: what it waits on a person for is dropped, and OpenCode is interrupted — it answers * `session.execution.interrupted`, which settles the run as cancelled. */ async cancel(runId) { this.cancelled.add(runId); for (const [key, parked] of [...this.pending]) { if (!key.startsWith(`${runId}:`)) continue; this.pending.delete(key); parked.reject(new RunCancelledError()); } const input = this.live.get(runId); if (input !== undefined) await this.turns.interrupt(input).catch(() => undefined); } async run(runId, input) { const started = Date.now(); // What the run spent, milestone by milestone. let spent = emptyUsage(); try { let handle = await this.turns.begin(runId, input); if (this.cancelled.has(runId)) throw new RunCancelledError(); await this.turns.prompt(runId, input, handle, spent); for (;;) { const milestone = await this.turns.observe(runId, input, handle, spent); spent = addUsage(spent, milestone.usage); if (milestone.kind === 'finished') { await this.turns.settle(runId, input, milestone.outcome, Date.now() - started, spent); return; } const reply = await this.park(runId, milestone.ask.id, milestone.ask.kind === 'approval' ? 'approval' : 'answers', milestone.timeoutMs); handle = await this.turns.reply(runId, input, handle, milestone.ask, reply, spent); } } catch (error) { if (error instanceof RunCancelledError) { await this.turns.interrupt(input).catch(() => undefined); await this.turns.settle(runId, input, { status: 'interrupted' }, Date.now() - started); return; } await this.turns.settleFailed(runId, input, errorText(error, 'the turn failed'), Date.now() - started, spent); } } /** Wait for a person's answer; a lapsed approval answers itself as expired. */ park(runId, toolCallId, on, timeoutMs) { const key = `${runId}:${toolCallId}`; const waiting = new Promise((resolve, reject) => { this.pending.set(key, { on, resolve, reject }); }); if (timeoutMs === undefined) return waiting; let timer; const lapse = new Promise((resolve) => { timer = setTimeout(() => { if (this.pending.delete(key)) resolve({ approved: false, expired: true }); }, timeoutMs); timer.unref?.(); }); return Promise.race([waiting, lapse]).finally(() => clearTimeout(timer)); } } //# sourceMappingURL=runner.js.map