@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
114 lines • 5.31 kB
JavaScript
import { BaseWorkflow } from '@adonis-agora/durable';
import { PACKAGE_ORIGIN } from '../../package-origin.js';
import { errorText } from '../log.js';
import { addUsage, emptyUsage } from '../turn.js';
export const OPENCODE_RUN_WORKFLOW = 'agora.agent.opencode.run';
/** The token a person's answer to `toolCallId` is signalled under — the library's own convention. */
export function decisionToken(runId, toolCallId) {
return `tool:${runId}:${toolCallId}`;
}
/**
* A suspend / continue-as-new the runtime throws THROUGH the body — control flow, not a failure.
* Recognised by name, so a second copy of `@adonis-agora/durable` (another class identity) still is.
*/
export function isControlFlowSignal(error) {
const name = error?.name;
return name === 'WorkflowSuspended' || name === 'ContinueAsNew';
}
/** The runtime's `SignalTimeoutError`, recognised by name for the same reason. */
function isSignalTimeout(error) {
return error instanceof Error && error.name === 'SignalTimeoutError';
}
/**
* An OpenCode turn as a durable workflow:
*
* begin → prompt → observe:0 → [ wait for a person (signal `tool:<run>:<call>`) → reply:n → observe:n+1 ]* → finish
*
* Every step is checkpointed. A turn parked on a person is a suspended run with a signal waiter: an
* app restart loses nothing, and the decision resumes it on whichever process takes it — replaying
* the steps from their checkpoints and replying to OpenCode from there (`OpenCodeTurns` rebuilds the
* live turn, and re-prompts a fresh session when OpenCode itself restarted meanwhile). A process
* that dies while OpenCode works re-runs that `observe` step, which catches up on what OpenCode
* asked while nobody listened.
*
* Built by the engine's factory with the turn steps (see `registerOpenCodeWorkflow`), not by the
* container.
*/
export class OpenCodeRunWorkflow extends BaseWorkflow {
turns;
engine;
static workflow = { name: OPENCODE_RUN_WORKFLOW, version: '1', origin: PACKAGE_ORIGIN };
constructor(turns, engine) {
super();
this.turns = turns;
this.engine = engine;
}
async run(ctx, input) {
const turns = this.turns;
// `@adonis-agora/durable` >= 0.43.3 never runs one run twice at once in a process, so a step
// that talks to OpenCode (a `reply`, an `observe`) runs once per execution of the run.
const step = (name, body) => ctx.localStep(name, body);
// What the run spent: every milestone carries its share, journaled with it, so a run resumed in
// another process adds up the same figure (a milestone journaled before usage travelled adds 0).
let spent = emptyUsage();
try {
const begun = await step('begin', async () => ({
handle: await turns.begin(ctx.runId, input, true),
startedAt: Date.now(),
}));
let handle = begun.handle;
await step('prompt', async () => {
await turns.prompt(ctx.runId, input, handle, spent);
return true;
});
for (let n = 0;; n += 1) {
const before = spent;
const milestone = await step(`observe:${n}`, () => turns.observe(ctx.runId, input, handle, before));
spent = addUsage(spent, milestone.usage);
const total = spent;
if (milestone.kind === 'finished') {
await step('finish', async () => {
// A cancel settles the run itself (it may be parked where no step runs again).
if (await this.cancelled(ctx.runId))
turns.drop(ctx.runId);
else {
await turns.settle(ctx.runId, input, milestone.outcome, Date.now() - begun.startedAt, total);
}
return true;
});
return { outcome: milestone.outcome.status };
}
let reply;
try {
reply = await ctx.waitForSignal(decisionToken(ctx.runId, milestone.ask.id), milestone.timeoutMs !== undefined ? { timeoutMs: milestone.timeoutMs } : undefined);
}
catch (error) {
if (!isSignalTimeout(error))
throw error;
reply = { approved: false, expired: true };
}
const ask = milestone.ask;
handle = await step(`reply:${n}`, () => turns.reply(ctx.runId, input, handle, ask, reply, total));
}
}
catch (error) {
if (isControlFlowSignal(error))
throw error;
await step('fail', async () => {
await turns.settleFailed(ctx.runId, input, errorText(error, 'the turn failed'), 0, spent);
return true;
});
return { outcome: 'failed' };
}
}
async cancelled(runId) {
try {
const status = (await this.engine?.getRun(runId))?.status;
return status === 'cancelling' || status === 'cancelled';
}
catch {
return false;
}
}
}
//# sourceMappingURL=workflow.js.map