The loop was rewritten in the naive-agent shape. packages/core/agent-loop/src/agent.ts
is the single source of truth — read it before migrating a spec. Key changes:
| Old seam | Replacement |
|---|---|
agent/pre-step (serial, before step/start) |
agent/step (serial, before EVERY request derives; same position) |
agent/post-step (serial, after tools, before step/end) |
REMOVED — use agent/step of the next step, or agent/idle after the turn |
agent/session-prefix (waterfall, request-only prefix) |
REMOVED — requests carry no unlogged prefix; durable context via agent.inject() at agent/session-start |
agent/step-result (waterfall, rewrite assistant msg) |
REMOVED — the assembled message is recorded as-is |
agent/request-error (waterfall, retry/fail decision) |
REMOVED — observe agent/idle with reason.kind === 'error', repair, then agent.retry() |
agent/turn-continuation (waterfall, ContinuationDecision) |
agent/continue (waterfall of boolean; handler (agent, turn, signal, next)) |
agent/turn-stop (serial, terminal stop) |
REMOVED — agent/continue returning false stops the turn |
agent/request (agent, turn, step, config, signal, next) |
(agent, turn, step, signal, next) — the config comes only from await next() |
agent/prompt-submit |
unchanged |
New emit: agent/idle (agent, turn, reason: IdleReason) fires once per closed turn
(after turn/end + flush, with busy already false, so listeners may synchronously
retry()/send()). IdleReason = completed | aborted | { kind: 'error', error, failure? }.
send() — unchanged (queued FIFO, one turn each).steer() while running — enters the outbox; taken whole at the next step
boundary. A turn failure leaves untaken steering staged without waking the
agent; retry() or a later prompt takes it.inject() while the machine is busy — enters the outbox (a context/message
appears at the NEXT step boundary, not immediately). While idle — writes a
one-shot turn (turn/start(injection) + context/message + turn/end) and
requests a flush. Enclosure is decided by busy, NOT by scanning the log for
an open turn.retry() — NEW verb: re-opens a turn on the current log with trigger
{ kind: 'retry' }. Throws while busy ("cannot retry while busy") and after
disposal. Legal from a synchronous agent/idle listener.cancel() — unchanged surface. No more "pre-run cancelled" bookkeeping:
clearing the queue before a run starts simply means no run starts.kick() runs SYNCHRONOUSLY from send() when idle: status flips to
running inside the send() call. There is no parked driver loop, no
waitForQueued, no microtask collection window.run() = one turn. After turn/end, it emits agent/idle, then either
starts the next waking queued prompt or flips status to idle. Residual
outbox input does not wake the agent. Status stays running continuously
across queued turns.step/end is appended INSIDE the step (after tools + the in-step outbox
drain), before agent/continue runs. The old post-step → step/end
window no longer exists.session.deriveMessages() snapshot taken right before
step/start — no messagePrefix. request/header events no longer carry
a messagePrefix field.agent/request) runs INSIDE the step
(after step/start), seeded from agent options (first request) or the folded
logged header (later requests).{ kind:'error', step, failure } +
agent/error emit + agent/idle { kind:'error', error, failure }.
There are no in-turn recovery steps.user/parent → turn/end
aborted; disposal → disposed. IdleReason for both is aborted.prompt-submit → block) records prompt/blocked, closes
a zero-step turn rejected in turn/end, and emits agent/idle
{ kind: 'completed' } (rejection is a policy outcome, not an error).dispose() (the prepared disposer / factory teardown) returns undefined
when the machine is not busy — do not .resolves it unconditionally; use
await Promise.resolve(dispose()).agent/continue returning
false; request-error retry tests → agent/idle + retry() flows).