ADR 0001: Explicit dataclass FSM for the ReAct loop
Status: accepted (v0.1.0)
Context
The agent answers a question by alternating between reasoning (the model choosing a tool),
acting (running the tool) and observing (reading the result or the error). The usual way to write
this is an open-ended while True: that keeps calling the model until it stops asking for tools.
That shape hides the two properties this project most needs to be true and testable: the loop
must stop, and a run that stops for the wrong reason must say so instead of inventing an answer.
Decision
The loop state lives in a plain dataclass, AgentState (src/db_action_agent/agent/state.py),
with no LLM or database dependency:
phase:THOUGHT,ACTION,OBSERVATIONorFINISH.hop_count: incremented once per ACTION (every tool call the model makes, including malformed ones).MAX_HOPS = 8;hop_ceiling_reached()uses>=.retry_count: the number of consecutive tool errors. A successful observation resets it to 0;MAX_RETRIES = 3;retry_ceiling_reached()uses>=.failed: an explicit boolean set on the two failure exits.
run_react_loop (agent/react_loop.py) is bounded by while not state.hop_ceiling_reached(),
not while True. Tool errors go through handle_tool_error (agent/self_correction.py), which
records the error text as the next OBSERVATION so the model can correct itself, and raises
RetryCeilingExceeded when the third consecutive error lands. Both ceilings end the run with
failed = True and a summary that says so (stopped after N steps without a final answer /
gave up: ...). run() reads state.failed; it does not infer failure by matching message text,
because that would fail open the moment someone rewords a message.
Model-supplied tool input is validated at the boundary (_run_tool): an unknown tool name or a
wrong argument key set becomes an error observation, not an exception. Errors raised inside a
tool are deliberately not caught wholesale, so real bugs are not misreported to the model.
Consequences
- The hop and retry bookkeeping is unit-testable with zero model calls (
tests/unit/test_agent_state.py,test_self_correction.py,test_react_loop.py), and both ceilings were mutation-tested (weakening the comparison turns the corresponding test red). - A run is bounded: at most 8 tool calls, and each loop iteration makes exactly one model call, so at most 8 model calls per question.
- Honest limits.
phaseis a record of where the loop is, not a dispatch table: the transitions are still written imperatively insiderun_react_loop, so this is "explicit state and a bounded loop", not a formally table-driven state machine. Only the firsttool_useblock in a model response is executed and echoed into history; parallel tool calls are not supported. The retry ceiling counts consecutive errors, so a model that alternates one error and one success can use all 8 hops without tripping it; only the hop ceiling stops that. - The FSM does not require any
execute_read_only_sqlcall before accepting a final answer.failedis only ever set by the hop ceiling, the retry ceiling and a malformed model payload (see the code, above); afinal_answeron hop 0 is a normal,failed=Falseexit even if the model never queried the database.eval/_common.py's harness additionally gates its own pass/recovery judging onn_queries >= 1/>= 2(judge_task/judge_recovery), but that gate lives only in the eval scripts, not in this FSM or inrun()/the CLI — so a well-known fact (or a confident wrong guess) can be answered from memory withfailed=False, and nothing in production distinguished that from a DB-grounded answer. This was deliberately not turned into a hard FSM gate here (forcing an extra hop would be a behavior change with its own trade-offs, e.g. breaking legitimate no-DB-needed replies, and is out of scope for a bugfix). InsteadAgentResult.n_queries(run.py) exposes the samelen(sql_log)count the eval harness already computes, and the CLI (cli/main.py) prints a one-line stderr note when a successful run hasn_queries == 0. This makes the gap visible rather than silent, without changing accept/reject behavior.