SPB Git

Repository Public

logic.ts 9.3 KB 191 lines typescript

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
Files main
Menu

Theme