A minimal ReAct agent (Reason → Act → Observe) that calls Groq's chat API.
No framework, no tool-calling schemas, no SDK — the whole project is a small
set of plain modules, and every piece of wiring lives in one function:
build_agent().
Everything you need to understand the agent is in build_agent() at
src/main.py:25. It does exactly four things:
def build_agent() -> ReActAgent:
tools = ToolRegistry() # 1. a registry of capabilities
tools.register(CalculatorTool())
tools.register(EchoTool())
actions = ActionRegistry() # 2. a registry of self-actions
actions.register(ReflectAction())
actions.register(SaveMemoryAction())
actions.register(AskUserAction())
config = ReActConfig(max_llm_calls=10, max_tool_calls=8, debug=False)
return ReActAgent(llm_call=call_groq, tool_registry=tools,
action_registry=actions, config=config)- Inputs: nothing. Everything the agent needs is constructed inline.
- Output: a ready-to-use
ReActAgentwhose only public entry point isawait agent.run(query: str) -> str. - To change the agent, you change this one function — swap
call_groqfor anyasync (system_prompt, user_turn) -> strcallable, register a new tool or action, or tweak the config.
- The agent only decides; it never executes.
_run_loopinsrc/react/agent.pydelegates every call toToolExecutor/ActionExecutor. - Tools vs. Actions. A tool reaches into the outside world
(
CalculatorTool,EchoTool); an action operates on the agent itself and receivesAgentState(reflect,save_memory,ask_user). - Errors are data, not exceptions. Every failure becomes a
success: Falseresult rendered as an observation, so the model can recover — an unknown tool is a graceful retry, not a crash. - A parser that never raises.
src/react/parser.pytolerates fenced JSON, preamble/postamble text, and trailing commas, and feeds parse errors back to the model with a bounded retry. - Loop detection that nudges before it kills. The same
(kind, name, sorted-args)call seen repeatedly is first fed back as a hint to stop, then hard-stops the run. - Human-in-the-loop. The
ask_useraction pauses the run, reads a reply from the terminal, and feeds it back as the next observation. - LLM-agnostic core. Any
async (system, user) -> strcallable works, and the test suite drives the full loop with aScriptedLLM— no network.
# one-shot
.\venv\Scripts\python.exe src\main.py "What is 12 * (7 + 3)?"
# interactive session
.\venv\Scripts\python.exe src\main.pyYou need a Groq API key in the GROQ_API_KEY (or API_KEY) environment
variable (or in a .env file). The key is never hardcoded in the source.
src/
main.py entrypoint; build_agent() and the CLI loop
api/
request.py Groq client (stdlib urllib only)
react/
agent.py the ReAct loop — decides, never executes
parser.py never-raises JSON -> Decision parser
models.py Decision/Result dataclasses (all have success: bool)
state.py AgentState: steps, budgets, scratch, session turns
config.py every tunable (budgets, retry, loop detection)
exceptions.py typed errors for recoverable vs fatal failures
prompt/ system prompt + user-turn rendering
runtime/ loop_detector, retry
tool/ BaseTool, registry, executor, builtins
action/ BaseAction, registry, executor, builtins
tests/ unit tests (scripted LLM, no API calls)
See src/react/config.py. Highlights:
| Field | Default | Purpose |
|---|---|---|
max_llm_calls |
15 | hard budget on LLM calls per turn |
max_tool_calls |
10 | hard budget on tool calls per turn |
retry_limit |
2 | retries per failed step (parse or execute) |
enable_loop_detection |
True | stop repeating identical calls |
loop_window / loop_repeat_threshold |
4 / 3 | repeat signature window & cutoff |
loop_nudge_limit |
2 | hints to the model before hard-stop |
.\venv\Scripts\python.exe -m pytestThe suite runs fully offline: the agent loop is driven by a ScriptedLLM
that returns canned JSON responses, so no API key or network is needed.