Files
deepseek-harness/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.md

7.3 KiB
Raw Blame History

Agent Note: Collapse agent-loop events around the observable state machine

Status: implemented

English | 中文

Problem

The agent loop exposed its control flow as a large set of Cordis events. Separate pre-step and post-step checkpoints bracketed a step, session-prefix and step-result transformed request and response messages, request-error decided whether a failed request retried inside its turn, and turn-continuation plus turn-stop composed competing continuation decisions.

Those events made internal phases public even when the durable session log already owned the corresponding turn and step facts. They also mixed two extension models: some listeners observed a boundary and issued an agent command, while others returned control decisions that the loop interpreted. Understanding the public machine therefore required reconstructing event order, waterfall precedence, and special terminal overrides together.

Agent lifetime, whole-agent activity, inbox-item progress, and per-turn settlement are independent state dimensions. Treating them as one status or one linear callback sequence makes ordinary questions ambiguous: an agent can remain running across several turns, an accepted item can be discarded without opening a turn, and one turn can settle while later work keeps the agent active.

Decision

The public contract exposes four orthogonal state dimensions:

  • Registration lifetime is the agent/created to agent/disposed interval. Disposal is the terminal registry edge, not an AgentStatus.
  • Whole-agent activity is AgentStatus = 'idle' | 'running'. Consecutive turns may share one running interval.
  • A FIFO-backed message progresses from agent/inbox/enqueue to exactly one agent/inbox/dequeue or agent/inbox/discard, correlated by AgentMessageId. The inbox events describe acceptance, claim, and removal rather than turn completion.
  • A claimed turn passes through prompt admission and zero or more request steps. An automatic retry closes the failed turn and immediately opens another; agent/idle reports only the terminal turn in that chain and remains distinct from the whole-agent transition to status === 'idle'.

The loop keeps five machine extension events. agent/prompt-submit admits, rewrites, or blocks a claimed prompt. agent/step is the single awaited between-steps checkpoint and runs before every request is derived. agent/request is the waterfall for the frozen call configuration; the configuration comes only from await next(), not from a duplicate positional argument. agent/request-error serializes ownership of awaited model-request recovery. agent/stopping runs when the turn otherwise has no work left; a listener that needs another step records real steering with agent.steer(), and the loop decides from that data after all listeners settle.

Continuation and termination are data rather than returned control enums. Tool calls and accepted steering require another step. A tool result carrying concludesTurn ends the tool loop at its step. The loop does not expose general ContinuationDecision or terminal-stop return channels.

A model-request failure closes its step, then enters agent/request-error with the exact error, normalized LlmFailure, and live turn signal. A listener that owns recovery repairs state, calls agent.retry(), and returns without delegating. The loop closes the failed turn and opens one retry turn over that state without an intervening idle notification; retry is not another step inside the failed turn. agent/idle reports the terminal outcome, and agent/error remains the live error notification for consumers that report failures independently of turn settlement.

The event taxonomy removes agent/pre-step, agent/post-step, agent/session-prefix, agent/step-result, agent/turn-continuation, and agent/turn-stop. Durable turn and step boundaries remain session events. Model-facing additions use logged message channels, request configuration uses agent/request, response content is recorded as assembled, failed-request recovery uses agent/request-error plus agent.retry(), and end-of-turn continuation uses agent/stopping plus steering.

Alternatives considered

Keep the fine-grained event sequence. This preserves a dedicated interception point for every internal phase, including request-only prefixes, assistant-message rewriting, post-step work, in-turn request recovery, and terminal stop overrides. It also makes the loop's private sequencing a permanent public contract and lets overlapping seams express conflicting decisions. The decision accepts the lost interception points in exchange for one boundary per supported extension responsibility.

Represent disposal as a third AgentStatus. This gives retained handles a terminal status value but duplicates the registry lifecycle already expressed by agent/disposed. The decision keeps AgentStatus about live activity and makes registration lifetime a separate dimension.

Return a retry decision from agent/request-error. A returned instruction duplicates the existing agent.retry() command and requires the loop to carry policy history across attempts. The waterfall remains useful for ordered ownership: an unhandled listener delegates, while a handling listener performs its awaited repair, calls agent.retry(), and stops delegation.

Mirror durable turn and step boundaries as agent events. This gives live consumers a second event stream for the same facts. The decision keeps the session log as the source of truth and exposes only extension checkpoints or live-only facts that the durable stream cannot carry.

Consequences

The observable machine is smaller and compositional: registration lifetime, activity, item progress, and terminal settlement can be followed independently. In particular, agent/idle does not imply agent.status === 'idle'; it reports the terminal turn of one drain chain, while agent/status reports whether the whole agent is active.

Plugins no longer rewrite every phase of the loop. There is no request-only message prefix, assistant-message transform, post-step checkpoint, generic continuation enum, generic terminal-stop result, or in-turn request retry. Extensions use the remaining owned channels instead of recreating those phases.

Continuation plugins publish durable steering rather than returning an unlogged reason. Recovery plugins act after the failed step and explicitly schedule another turn through agent.retry(). This makes every attempt a complete turn while keeping asynchronous repair and policy ownership at one narrow waterfall boundary.

The inbox lifecycle complements, rather than replaces, the durable session log. AgentMessageId correlates acceptance with claim or discard; turn and step numbers, messages, tool activity, and terminal reasons remain session facts.