Skip to content

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, OBSERVATION or FINISH.
  • 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. phase is a record of where the loop is, not a dispatch table: the transitions are still written imperatively inside run_react_loop, so this is "explicit state and a bounded loop", not a formally table-driven state machine. Only the first tool_use block 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_sql call before accepting a final answer. failed is only ever set by the hop ceiling, the retry ceiling and a malformed model payload (see the code, above); a final_answer on hop 0 is a normal, failed=False exit even if the model never queried the database. eval/_common.py's harness additionally gates its own pass/recovery judging on n_queries >= 1/>= 2 (judge_task/judge_recovery), but that gate lives only in the eval scripts, not in this FSM or in run()/the CLI — so a well-known fact (or a confident wrong guess) can be answered from memory with failed=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). Instead AgentResult.n_queries (run.py) exposes the same len(sql_log) count the eval harness already computes, and the CLI (cli/main.py) prints a one-line stderr note when a successful run has n_queries == 0. This makes the gap visible rather than silent, without changing accept/reject behavior.