Code Agent Anatomy (02): How Does an Agent Think and Act Round by Round?

A deep dive into MyCodeAgent's ReAct main loop: immutable state machine, dual-layer loop structure, three-verdict completion gate, and model error recovery. Understand why an agent isn't just 'call the model once and get a result' — it's a control system with feedback, guarantees, and well-defined termination conditions.

·10 min read·AI Engineering

Why Agents Need a Loop

The simplest LLM invocation looks like this:

response = llm.invoke(messages)
print(response)

This works fine for simple Q&A, but it can't handle tasks like "refactor this function and run the tests" — because that involves multiple steps: reading files, modifying code, executing commands, and deciding what to do next based on the results. Each step's input depends on the previous step's output, and the model can't predict all the steps at once.

ReAct (Reasoning + Acting) is the pattern that solves this problem: let the model iterate through a "think → execute tool → observe result → think again" loop until the task is complete.

MyCodeAgent's RuntimeRunner._react_loop() is the concrete engineering implementation of this pattern. This article takes it apart.


Before the Loop: _prepare_run

_react_loop isn't the first thing RuntimeRunner.run() calls. There's a preceding step: _prepare_run():

# runtime/loop.py — RuntimeRunner.run()
def run(self, input_text, **kwargs):
    processed_input, trace_logger, run_id = self._prepare_run(input_text, show_raw)
    response_text = self._react_loop(pending_input=processed_input, ...)

_prepare_run does five things, each pointing to a different subsystem:

def _prepare_run(self, input_text, show_raw):
    # 1. Refresh Skills prompt (if the skills/ directory has changed)
    host._refresh_skills_prompt()
    host.context_builder.set_skills_prompt(host._skills_prompt)
 
    # 2. Preprocess user input: detect and expand @file references,
    #    inject a system-reminder telling the model to read them
    preprocess_result = preprocess_input(input_text)
    processed_input = preprocess_result.processed_input
 
    # 3. Clear trace events from the previous run, initialize run_id and transcript
    trace_logger.clear_current_run_events()
    host._run_id += 1
    host._active_transcript_run_id = f"run-{host._run_id}"
 
    # 4. Write the preprocessed user message into history_manager
    #    ← This is the only moment user input enters history
    self._append_user_message(processed_input)
 
    # 5. Emit run_start / user_input events to trace and transcript
    self._emit("run_start", {...}, step=0)
    self._emit("user_input", {...}, step=0)
 
    return processed_input, trace_logger, run_id

Where pending_input comes from: processed_input is pending_input — the preprocessed user input string. It's already been written to history inside _prepare_run; when passed to _react_loop, it's not appended again. It serves only two purposes: helping build_model_view() estimate how many tokens to reserve for this round, and letting the completion gate infer what the user is asking for.

What @file expansion does: When a user types take a look at @src/main.py, preprocess_input() detects the @file reference and appends a <system-reminder> to the message, instructing the model to read the file with the Read tool before answering — preventing the model from making up an answer from imagination.


Overall Structure: Dual-Layer Loop

_react_loop()

├─ outer for (step 1 → max_steps)      each iteration = one ReAct step
│   │
│   ├─ _prepare_step_context()          build Model View for this step
│   │
│   ├─ inner while True                 model invocation + error recovery
│   │   ├─ llm.invoke_raw()             call the model
│   │   ├─ model call exception?        classify and handle (compact/retry/abort)
│   │   ├─ parse response (text + tool_calls)
│   │   ├─ empty response?              inject prompt and retry
│   │   └─ break                        normal response, exit inner loop
│   │
│   ├─ has tool_calls?                  Acting branch
│   │   ├─ execute tools (ToolOrchestrator)
│   │   ├─ write results to history
│   │   └─ continue (next step)
│   │
│   └─ no tool_calls?                   Reasoning branch
│       ├─ completion gate verdict (PASS/FAIL/UNVERIFIED)
│       ├─ PASS → return final_text     normal exit
│       ├─ FAIL → inject feedback + continue
│       └─ UNVERIFIED → return          exit with uncertainty marker

└─ max_steps exceeded → return          fallback exit

The two loops have distinct responsibilities: the outer for advances ReAct steps, while the inner while handles model errors within a single step. Encapsulating error handling in the inner loop is the key design decision — otherwise retries would consume the outer loop's step budget.


Immutable State Machine

At the start of each step, state looks like this:

# runtime/state.py
@dataclass(frozen=True)          # frozen=True: immutable, any "mutation" produces a new object
class LoopState:
    messages: list[dict]         # current model view (not the full history)
    step: int                    # current step number
    tool_choice: str             # "auto" | "none" | specific tool
    transition: Transition|None  # most recent state transition record
    completion_block_count: int  # number of completion gate blocks
    model_recovery_counts: dict  # retry counts per error type
    last_error: str|None         # most recent error message
    # ...additional diagnostic fields
 
    def next(self, reason: TransitionReason, **changes) -> "LoopState":
        # produces a new object, records the transition reason — does not modify self
        return replace(self, transition=Transition(reason=reason), **changes)

frozen=True means:

state.step = 2      # ❌ raises FrozenInstanceError
state = state.next(TransitionReason.TOOLS_EXECUTED, step=2)  # ✅ produces a new object

Why immutability? Mutable state is the hardest thing to debug — "where exactly did this field get set to this value?" Immutable state paired with the TransitionReason enum means every state change has a documented reason. The trace system can reconstruct the complete execution path. This is functional programming thinking applied to engineering.

The TransitionReason enum records every possible direction the loop can take:

class TransitionReason(str, Enum):
    USER_INPUT = "user_input"                    # new run starts
    MODEL_RETURNED_TOOL_CALLS = "..."            # model wants to call tools (Acting)
    TOOLS_EXECUTED = "..."                       # tools have been executed
    MODEL_RETURNED_FINAL = "..."                 # model gives final answer (Reasoning)
    STOP_HOOK_BLOCKING = "..."                   # completion gate blocked, inject feedback and continue
    MODEL_RECOVERY_RETRY = "..."                 # model error, retry after recovery
    MAX_STEPS_EXCEEDED = "..."                   # terminated due to exceeding max steps
    TOKEN_BUDGET_EXCEEDED = "..."                # terminated due to token budget exhaustion

Model View: The Model Doesn't See Full History

At the start of each step, _prepare_step_context() is called. The messages it returns are not the full set of messages in history_manager — they're a bounded subset projected by context_engine.build_model_view().

history_manager (complete history, append-only, never deleted)

   build_model_view()

   model view (projection within token budget, sent to LLM)

The full history might contain 200 messages, but the token budget only allows 50. build_model_view() decides "which 50 to send," triggering compression (LLM summarization of older turns) when the budget is exceeded.

Why separate them? History is fact — it cannot be deleted. What the model sees is a view — it can be trimmed. Mixing these two concerns together corrupts the history, making crash recovery impossible. Article 09 in this series will cover context engineering in detail.


Acting Branch: Model Returns Tool Calls

# loop.py — Acting branch (simplified)
if tool_calls:
    # 1. Ensure every tool_call has an id (some models don't return one)
    for call in tool_calls:
        if not call.get("id"):
            call["id"] = f"call_{uuid.uuid4().hex}"
 
    # 2. Write the assistant message (including the tool_calls list) to history
    host.history_manager.append_assistant(
        content=response_text,
        metadata={"action_type": "tool_call", "tool_calls": tool_calls},
    )
 
    # 3. Execute tools (read-only tools can run concurrently, write ops are forced sequential)
    observations = host.tool_orchestrator.run(tool_calls, step=step)
 
    # 4. Write each tool's result to history so the model can see it in the next step
    for obs in observations:
        host.history_manager.append_tool(
            tool_name=obs.tool_name,
            observation=obs.observation,
        )
 
    continue  # outer for advances to the next step

Tool execution results are written to history via history_manager.append_tool(). In the next step, when build_model_view() constructs the model view, these results appear in the messages. The model "sees" the tool execution results — that's the "Observation" in ReAct.


Reasoning Branch: The Completion Gate

When there are no tool_calls, the model has given a text response. But the loop doesn't immediately return — it first passes through the completion gate:

# runtime/completion.py — completion gate, three steps
 
# Step 1: infer requirements (identify "needs verification" keywords from user input)
requirements = infer_completion_requirements(
    user_input=pending_input,          # scan for "pytest" / "run tests" etc.
    history_messages=history_messages, # read latest TodoWrite entries to check unfinished items
)
 
# Step 2: collect evidence (find verification behavior in historical Bash tool calls)
evidence = collect_verification_evidence(history_messages)
# Note: only verification evidence AFTER an Edit counts as valid;
#       evidence BEFORE an Edit is marked invalid
 
# Step 3: verdict
verdict = verifier.evaluate(candidate, requirements, evidence, ...)
# PASS:       all requirements satisfied → return final_text
# FAIL:       unfinished todos or missing verification → inject feedback, continue loop
# UNVERIFIED: user said "try to", evidence missing but skippable → return (with marker)

What problem does the completion gate solve? The model might say "I'm done" while the todo list isn't cleared, or forget to run the tests after the user asked for them. The completion gate catches both cases with rule-based checks, injects "why it isn't done" as a user message, and makes the model try again.

There's a counterintuitive detail about verification evidence: only a pytest run executed after an Edit counts as valid. If tests pass first, then code is modified (Edit), the earlier test results are invalidated — because the modified code hasn't been verified yet.

# completion.py — collect_verification_evidence()
# find the step of the most recent Edit
latest_mutation_step = max(step for edit in history if edit.tool == "Edit")
 
# mark verification evidence before that Edit as invalid
for evidence in evidences:
    if evidence.step < latest_mutation_step:
        evidence.valid = False  # invalidated by subsequent Edit

Model Error Recovery

The inner while True handles two categories of model problems:

Call exception (PROMPT_TOO_LONG):

invoke_raw() raises exception

classify_model_error() identifies PROMPT_TOO_LONG

context_engine.reactive_compact() compresses history

rebuild model_view, inner continue retries

still failing? → terminate

Response anomaly (EMPTY_RESPONSE):

call succeeds, but response_text is empty and there are no tool_calls

inject prompt: "please reply with your final answer in content, or use a tool call"

inner continue retries (at most once)

still empty? → terminate

Both recovery paths have attempt limits (_get_model_recovery_limit()). Once the limit is exceeded, the loop takes the termination path — no infinite retries.


Termination Conditions at a Glance

The loop has exactly the following exit points:

Normal exits:
  Completion gate PASS             → return final_text
  Completion gate UNVERIFIED       → return final_text (with marker)
 
Error exits:
  max_steps exceeded               → return error message
  token budget exhausted           → return error message
  model error unrecoverable        → return error message
  completion gate feedback retries exhausted → return error message

Note there's no "exit immediately on empty response" — empty responses are retried, and only terminate once retries are exhausted. This ensures the loop behaves predictably and doesn't silently fail on a single spurious empty response.


Summary

MechanismPurpose
Dual-layer loopOuter loop advances ReAct steps; inner loop handles model error recovery — clear separation of concerns
Immutable state machineEvery transition records its reason; trace can reconstruct the complete execution path
Model View separationHistory is preserved in full; what the model sees is a bounded projection — the two never mix
Three-verdict completion gatePASS/FAIL/UNVERIFIED — intercepts "false completion" from the model, injects feedback to continue
Verification evidence recencyOnly verification after an Edit counts; prevents "code changed but test results are stale"
Capped error recoveryEach error type retries at most N times; terminates when exceeded — behavior is predictable

Source Code for This Series

All analysis in this series is based on the open-source project MyCodeAgent.

The source code has been annotated at key positions following the explanation order of this article series — you can read the articles alongside the code, or clone it and run, modify, and extend it yourself to build your own agent.

git clone https://github.com/chendongqi/MyCodeAgent
cd MyCodeAgent
cp .env.example .env   # fill in your LLM API key
uv sync
uv run python main.py

Visit PrimeSkills — a carefully curated AI Agent and skills marketplace where all content is validated against real enterprise workflows. No hype, just things that actually work.

For more practical insights and interesting products, visit my personal homepage.