Repository Public
1/**2 * The agent loop as kernel run logic (CONTRACT §5).3 *4 * Determinism: this function is re-run from the top on every claim. Every interaction with the outside5 * world is a kernel call (`ctx.step`, `ctx.wait`, `ctx.sleep`, `ctx.now`, `ctx.ownerMessages`, `ctx.emit`)6 * made in the same order on every replay, and every branch below depends only on values those calls7 * returned. There is no clock, random number or filesystem read outside a step.8 */9import { isControlError, type RunContext, type RunLogic } from '@aos/kernel';10import { promptTokens } from '@aos/providers';11import type { ResultEnvelope } from '@aos/shared';12import { cleanupRunTmp } from '@aos/tools';13import { criteriaStatusLine } from './criteria.js';14import { resolveDeps, type EngineDeps, type ResolvedDeps } from './deps.js';15import { budgetView, CONTEXT_WARN_NOTE, contextLevel, FINAL_TURN_NOTE, finalTurnDue, type BudgetView } from './governors.js';16import { callModel, decorate, engineEnvelope } from './model.js';17import { buildFirstMessage, toolDefs } from './prompt.js';18import { buildPrefix, ensureMissionBranch, readRunMeta } from './setup.js';19import { STALL_CONTINUE, STALL_NOTE, STALL_STOP, StallDetector, stallQuestion } from './stall.js';20import { isExpired, ownerWait, type RunState } from './state.js';21import { runToolBatch, type ToolCall } from './tool-exec.js';22import { Transcript } from './transcript.js';2324const NUDGE_NOTE = (n: number, max: number): string =>25 `Your last reply contained no tool call, which does not end the run (reminder ${n} of ${max}). ` +26 'If work remains, continue with the next tool call. If the mission is complete or you cannot go further, call `finish` with the right status and a factual summary.';27const CONTINUE_NOTE =28 'Your previous output was cut off by the output-token limit. Continue from where you stopped, in smaller steps: shorter messages, one tool call at a time, smaller file writes.';2930function view(st: RunState): BudgetView {31 return budgetView({ budget: st.meta.budget, left: st.ctx.budgetLeft(), usage: st.ctx.usage(), waitedSec: st.waitedMs / 1000 });32}3334/** The stall protocol after a turn: nothing, an operator note, or a question to the owner. Returns an envelope when the owner stops the mission. */35async function handleStall(st: RunState, lastText: string): Promise<ResultEnvelope | null> {36 const action = st.stall.check();37 if (action === 'none') return null;38 const reason = st.stall.reason;39 if (action === 'note') {40 st.transcript.note(STALL_NOTE(reason));41 return null;42 }43 const r = await ownerWait<{ answer?: unknown }>(st, 'question', stallQuestion(reason, lastText));44 const answer = isExpired(r) || typeof r.answer !== 'string' ? '' : r.answer.trim();45 st.stall.progress();46 if (answer === STALL_STOP) {47 return engineEnvelope(st, 'partial', `Stopped by the owner after the agent appeared stuck (${reason}). ${criteriaStatusLine(st.criteria, st.criteriaConfirmed)}`, lastText);48 }49 if (answer.length === 0 || answer === STALL_CONTINUE) st.transcript.note('The owner was told you appear stuck and chose to let you continue. Re-plan before your next action.');50 else st.transcript.owner(answer);51 return null;52}5354async function runAgent(ctx: RunContext, deps: ResolvedDeps): Promise<ResultEnvelope> {55 const L = deps.limits;56 const meta = await readRunMeta(ctx, deps);57 const prefix = await buildPrefix(ctx, deps, meta);58 const git = await ensureMissionBranch(ctx);59 const adapter = deps.provider(meta.provider);60 const st: RunState = {61 ctx, deps, meta, adapter,62 tools: new Map(prefix.tools.map((t) => [t.def.name, t])),63 transcript: new Transcript(adapter, buildFirstMessage(ctx.contract, meta.criteria, git)),64 criteria: meta.criteria,65 criteriaConfirmed: meta.criteria.length > 0,66 fileState: {},67 batchRules: [],68 stall: new StallDetector(L.stallRepeat, L.stallTurns),69 waitedMs: 0,70 artifacts: [],71 finalTurn: false,72 };73 const defs = toolDefs(prefix.tools);74 const window = deps.contextWindow(meta.provider, meta.model);75 let turn = 0;76 let nudges = 0;77 let continuations = 0;78 let contextWarned = false;79 let nextPromptTokens = 0;80 let lastText = '';8182 for (;;) {83 // 1. Steering: owner messages posted since the last turn are read as soon as the tool batch is over.84 for (const m of await ctx.ownerMessages()) {85 st.transcript.owner(m.text);86 st.stall.progress();87 nudges = 0;88 }89 // 2. Governors, evaluated before every model call.90 const cx = contextLevel(nextPromptTokens, window, L.contextWarnFraction, L.contextStopFraction);91 if (cx.level === 'stop') {92 const summary = `Stopped: the conversation fills about ${Math.round(cx.fraction * 100)} % of the model's context window and this version cannot compact it. ` +93 `${criteriaStatusLine(st.criteria, st.criteriaConfirmed)} Start a follow-up mission to continue from the committed state.`;94 return engineEnvelope(st, 'partial', summary, lastText);95 }96 const budget = view(st);97 if (finalTurnDue(budget, ctx.budgetLeft(), L.finalTurnFraction)) {98 st.finalTurn = true;99 st.transcript.note(FINAL_TURN_NOTE(budget));100 } else if (cx.level === 'warn' && !contextWarned) {101 contextWarned = true;102 st.transcript.note(CONTEXT_WARN_NOTE(cx.fraction));103 }104 const exhausted = (text: string): ResultEnvelope =>105 engineEnvelope(st, 'budget_exhausted', `Budget exhausted — ${budget.describe}. ${criteriaStatusLine(st.criteria, st.criteriaConfirmed)}`, text);106107 // 3. The model turn.108 turn++;109 const out = await callModel(st, prefix.system, defs, turn, lastText);110 if (out.t === 'end') return out.envelope;111 const res = out.res;112 st.transcript.assistant(res.native);113 st.stall.recordTurn();114 nextPromptTokens = promptTokens(res.usage) + res.usage.outputTokens;115 const text = res.blocks.filter((b) => b.type === 'text').map((b) => b.text).join('\n\n').trim();116 if (text.length > 0) lastText = text;117118 if (res.stop === 'refusal') {119 const why = [res.refusal?.category ? `category: ${res.refusal.category}` : null, res.refusal?.explanation ?? null].filter(Boolean).join(' — ');120 return engineEnvelope(st, 'blocked', `The model declined to continue this mission${why ? ` (${why})` : ''}. Nothing further was executed. ${criteriaStatusLine(st.criteria, st.criteriaConfirmed)}`, text);121 }122123 // 4. Tool calls of the turn.124 const calls = res.blocks.filter((b): b is ToolCall => b.type === 'tool_call');125 if (calls.length > 0) {126 nudges = 0;127 continuations = 0;128 const batch = await runToolBatch(st, calls);129 st.transcript.toolResults(batch.results);130 if (batch.finish) {131 const env = decorate(st, batch.finish.envelope);132 // The final turn exists to summarise: only a verified "done" keeps its status.133 return st.finalTurn && env.status !== 'done' ? { ...env, status: 'budget_exhausted', summary: `Budget exhausted — ${budget.describe}.\n\n${env.summary}`.slice(0, 8000) } : env;134 }135 if (st.finalTurn) return exhausted(lastText);136 const stalled = await handleStall(st, lastText);137 if (stalled) return stalled;138 continue;139 }140141 // 5. A turn without tool calls.142 if (st.finalTurn) return exhausted(lastText);143 if (res.stop === 'max_tokens' || res.stop === 'pause') {144 if (++continuations > L.maxContinuations) {145 return engineEnvelope(st, 'partial', `Stopped: the model hit its output limit ${continuations} times in a row without acting. ${criteriaStatusLine(st.criteria, st.criteriaConfirmed)}`, lastText);146 }147 st.transcript.note(CONTINUE_NOTE);148 continue;149 }150 if (res.stop === 'error') {151 return engineEnvelope(st, 'failed', `The model stopped unexpectedly (${res.nativeStop ?? 'unknown stop reason'}). ${criteriaStatusLine(st.criteria, st.criteriaConfirmed)}`, lastText);152 }153 if (nudges >= L.maxNudges) {154 return engineEnvelope(st, 'partial', `The agent stopped replying with tool calls and never called finish (after ${nudges} reminders). ${criteriaStatusLine(st.criteria, st.criteriaConfirmed)}`, lastText);155 }156 nudges++;157 st.transcript.note(NUDGE_NOTE(nudges, L.maxNudges));158 const stalled = await handleStall(st, lastText);159 if (stalled) return stalled;160 }161}162163/** The run is over for good (as opposed to suspended, released or handed to another worker). */164function endsRun(ctx: RunContext, e: unknown): boolean {165 const name = (x: unknown): string => (x instanceof Error ? x.name : '');166 if (name(e) === 'RunCancelled' || name(e) === 'ReconcileAborted') return true;167 if (ctx.signal.aborted && name(ctx.signal.reason) === 'RunCancelled') return true;168 return !isControlError(e) && !ctx.signal.aborted;169}170171/**172 * `agentLogic(deps)` — the kernel `RunLogic` of an agent run. When the run ends (envelope returned,173 * failure, cancel) the per-run temp directory and edit journal are removed; a suspended or released run174 * keeps them.175 */176export function agentLogic(deps: EngineDeps = {}): RunLogic {177 const d = resolveDeps(deps);178 const cleanup = (ctx: RunContext): Promise<void> => cleanupRunTmp(ctx.runId, d.kernel.dataDir).catch(() => undefined);179 return async (ctx) => {180 let env: ResultEnvelope;181 try {182 env = await runAgent(ctx, d);183 } catch (e) {184 if (endsRun(ctx, e)) await cleanup(ctx);185 throw e;186 }187 await cleanup(ctx);188 return env;189 };190}191