A minimal, hackable AI agent built on the ReAct reasoning loop.
A clean implementation of ReAct (Reasoning + Acting) with Plan-and-Execute capabilities. No bloated frameworks — just a transparent loop you can read, modify, and learn from in an afternoon.
Create a .env file in the project root:
OPENROUTER_API_KEY=sk-or-...
LLM_MODEL=google/gemini-2.0-flash-001Swap the LLM provider by editing
react_agent/core/llm_client.py— anything OpenAI-compatible works.
pip install uv # if you don't have uv yet
uv syncuv run react-agent ./your-project-directoryThe agent will start an interactive session, reasoning about your files and executing tool calls inside the directory you point it to.
ReAct (Yao et al., 2022) interleaves reasoning and acting in a single loop so the model can observe real-world feedback before deciding its next step.
| Step | What happens |
|---|---|
| Thought | The LLM reasons about the current state and decides what to do next. |
| Action | A tool call is dispatched — read a file, write code, run a shell command. |
| Observation | The tool's output is fed back into the conversation as new context. |
| Final answer | When no more tools are needed, the agent returns its conclusion. |
The loop continues until the model produces a <final_answer> tag or an error halts execution.
env = Environment()
tools = Tools(env)
prompt = "Goals, constraints, and how to act"
while True:
action = llm.run(prompt + env.state)
env.state = tools.run(action)
The ReActAgent class handles four things and nothing else:
- Rendering the system prompt (injecting available tools, OS info, and file listings).
- Orchestrating the message history sent to the LLM.
- Parsing structured
<thought>,<action>, and<final_answer>tags from model output. - Dispatching tool calls and feeding observations back into the loop.
| Tool | Description |
|---|---|
read_file(path) |
Read a text file from disk. |
write_to_file(path, content) |
Write text content to a file. |
run_terminal_command(cmd) |
Execute a shell command and return output. |
Terminal commands require explicit user confirmation before execution — all other tools run automatically. Extend the agent by adding entries to get_default_tools() in tools.py.
The parser uses a finite-state machine to split LLM output into function name + arguments, handling:
- Quoted strings with escaped characters (
"hello \"world\"") - Nested parentheses (
(1, (2, 3))) - Mixed argument types (strings, ints, lists)
It only splits on commas that are outside any quoted string and at parenthesis depth zero — similar to a simplified pushdown automaton.
write_to_file("tests/index.html", "<html><body>Hello</body></html>")
↓ parse
func = "write_to_file"
args = ["tests/index.html", "<html><body>Hello</body></html>"]
The system prompt is the contract — it tells the model the rules. The code is the firewall — it validates, repairs, or rejects bad actions.
Both matter. We instruct the model and assume it will sometimes break the rules.
Reference: LangGraph — Plan-and-Execute
Plan-and-Execute decomposes a high-level goal into a sequence of sub-tasks, then executes them one by one using the ReAct loop. This enables longer-horizon reasoning for complex tasks.
react_agent/
├── core/
│ ├── agent.py # ReAct loop orchestration
│ ├── llm_client.py # OpenAI-compatible LLM adapter
│ ├── parser.py # FSM-based action parser
│ ├── policy.py # Permission rules for sensitive tools
│ └── tools.py # Tool definitions and registry
├── prompt_template.py # System prompt template
└── global_utils.py # OS detection, logging, API key loading
A few hard-won insights from building and debugging this agent:
Observation design matters. Observations are generated by the system, never by the model. Always send back tool output, error messages, and command results — without them, the model can't self-correct.
LLM output is messy. Models sometimes use echo for file writes, split strings across multiple arguments, or forget to quote content. The _sanitize_action method exists specifically to catch and fix these cases.
Tool permissions are essential. Letting an LLM run arbitrary shell commands without confirmation is a recipe for disaster. The policy.py module gates dangerous operations behind explicit user approval.