Skip to content

Repository files navigation

ReAct Agent from Scratch

CI Python

A ReAct (Reason + Act) LLM agent loop implemented from first principles - no LangChain, no hidden framework internals. Just the parsing, tool dispatch, and error handling that make an agent loop actually work, written out where you can read every line of it.


Why is it needed

ReAct is the pattern most agent frameworks - LangChain's AgentExecutor, LlamaIndex's agents, and others - are built on top of. Using one of those frameworks is the right call for production work, but it also means the actual mechanics (how the model's text gets parsed, how a tool call gets dispatched, what happens when the model outputs something malformed) are hidden a few layers down in someone else's code.

This project is that layer, written out directly: a model emits Thought / Action / Action Input text, application code parses it, runs the named tool, and feeds the result back in as an Observation before calling the model again. Nothing about the loop is magic - the effort here went into the parts that are easy to get wrong: regex correctness, not using eval(), making tool failures recoverable instead of fatal, and getting message roles right for providers (like Anthropic's) that enforce strict conversation structure.


Who It's For

  • Engineers who've used an agent framework and want to know what's actually happening inside it before reaching for one again.
  • Students and researchers who want a small, fully-readable reference implementation of ReAct rather than tracing it through a framework's abstraction layers.
  • Anyone evaluating whether they understand agent internals well enough to explain them in an interview, not just use them.

How This Compares to a Framework's Agent Executor

The concepts are the same ones LangChain (and most other agent frameworks) use internally - this table maps the vocabulary across:

ReAct Concept Typical Framework Equivalent This Repo
Model call AgentExecutor's configured LLM Injected as a plain callable (agent.LLMCall) - no SDK imported in the loop itself
Tool Tool / StructuredTool Plain Python functions in tools.py, registered in a TOOLS dict
Output parsing ReActSingleInputOutputParser (or similar) parser.py - line-anchored regex, zero dependencies
Conversation state agent_scratchpad The messages list threaded through run_agent
Loop / iteration cap max_iterations max_steps parameter on run_agent
Tool-call failure handling Framework-specific error handlers Caught in run_agent, converted to an Observation, fed back to the model

The mapping isn't decorative - the underlying problems are identical: turning unstructured model text into a real function call safely, keeping the loop from running forever, and recovering from a tool that fails mid-run.


Architecture

┌──────────────────────────────────────────────────────────┐
│                        User query                        │
└───────────────────────────┬──────────────────────────────┘
                             │
                             ▼
┌──────────────────────────────────────────────────────────┐
│                     run_agent()  (agent.py)               │
│                                                            │
│   messages = [system_prompt, user_query]                  │
│   loop (up to max_steps):                                 │
│     1. answer = llm_call(messages)   ◄── injected, not    │
│                                            hardcoded        │
│     2. parsed  = parse_step(answer)  (parser.py)           │
│     3. if parsed.is_final:      → return final_answer      │
│        if parsed.is_action:     → dispatch tool  ──┐       │
│        else:                    → "invalid format" │       │
│                                    retry prompt     │       │
└──────────────────────────────────────────────────────┼────┘
                                                          │
                             ┌────────────────────────────┘
                             ▼
                ┌─────────────────────────┐
                │   tools.py  -  TOOLS     │
                │   calculator()  (AST)    │
                │   search()  (Tavily/DDG) │
                │   recall() / remember()  │
                │   (memory.py, sqlite)    │
                └────────────┬─────────────┘
                             │  result, or caught exception
                             ▼
                 Observation appended to messages
                 (role="user" - see Key Design Decisions)
                             │
                             └──────────────► loop back to llm_call

┌──────────────────────────────────────────────────────────┐
│              providers.py  (only place an SDK             │
│              is imported - lazily, per function)           │
│                                                            │
│   make_openai_call()  → OpenAI() client                   │
│   make_anthropic_call() → Anthropic() client               │
│   make_xai_call()     → OpenAI() client, base_url=x.ai     │
└──────────────────────────────────────────────────────────┘

The split between agent.py and providers.py is the load-bearing design decision here - see below.

Not shown above: state.py / store.py / web/ are a separate, serializable-state version of the same loop (AgentState + a pure advance() step function, persisted to SQLite via RunStore, exposed over HTTP by a FastAPI app in web/app.py) built for a multi-worker web deployment rather than a single long-lived CLI process. See "Web API" below.


Key Design Decisions

LLM call is dependency-injected, not imported directly. run_agent takes llm_call: Callable[[list[dict]], str] instead of constructing an OpenAI or Anthropic client itself. That means the entire loop - tool dispatch, error recovery, step limits, message-role handling - is unit-tested with a fake that returns canned responses, with zero network access and zero API cost. providers.py is the only file that imports a real SDK, and it does so lazily inside each factory function so neither openai nor anthropic is a hard dependency of the core package.

No eval() in the calculator. eval(expr, {"__builtins__": {}}, {}) looks sandboxed but isn't - attribute-chain payloads like ().__class__.__base__.__subclasses__() escape it. The calculator instead parses to an AST and walks a whitelist of numeric/operator node types; anything outside that grammar raises ValueError before any code executes. Verified with a dedicated exploit-payload test, not just asserted.

Parsing is anchored to line start. Matching Action: anywhere in a string - not just at the start of a line - false-positives on a Thought that merely mentions the word "action" or discusses the output format itself. parser.py uses re.MULTILINE with ^Action: / ^Action Input: / ^Final Answer:. There's a regression test that reverts to the unanchored pattern against the same input and confirms it really would have misparsed it, so the fix is verified as meaningful rather than just present.

Tool exceptions become Observations, not crashes. A bad expression or a network failure now feeds back into the loop as Observation: Error: ..., which is also the mechanism ReAct depends on for a model to self-correct after a failed action.

Message roles: Observations use role="user", not role="system". Most chat APIs only treat the first message as system; the Anthropic Messages API specifically requires strict user/assistant alternation with system passed as a top-level argument, so a mid-conversation system message is a hard error there. There's a test asserting the transcript never has two consecutive same-role messages.

Final Answer takes priority over Action when a model outputs both. A model that hedges - reasoning toward an answer while also technically specifying a tool call - is treated as done rather than looped into an unnecessary tool call.


Security

Code injection prevention

The calculator's AST whitelist (see Key Design Decisions) is the primary attack surface in this project, since it's the one place freeform text from a model becomes executed logic. It's covered by tests using the exact class of payload that escapes a naive eval() sandbox - ().__class__.__base__.__subclasses__() style attribute-chain attacks - and confirmed to raise ValueError rather than execute.

Tool-error containment

A tool raising an exception (bad input, network failure, division by zero) is caught in run_agent and converted to a string Observation rather than propagating and crashing the process. This is deliberately narrow: only the tool call itself is wrapped in try/except, so a bug in the loop's own control flow still surfaces as a real exception rather than being silently swallowed.

Observation content is delimited, but model compliance isn't guaranteed

Content returned by search() is wrapped in explicit <<<TOOL_OUTPUT_START>>> / <<<TOOL_OUTPUT_END>>> markers (_wrap_observation in agent.py) before it re-enters the model's context, and SYSTEM_PROMPT instructs the model to treat anything between those markers as untrusted data, never as instructions. Attacker-controlled content that happens to contain the literal marker strings is neutralized first, so it can't forge a fake boundary and smuggle in fabricated "new" turns. That delimiting is a structural guarantee, independent of model behavior. Whether the model actually obeys the "treat this as data" instruction is not something code can guarantee - that's an alignment property of the underlying model. The real backstop is sensitive_tools (see Threat Model below): even a model that's talked into calling a tool the user didn't ask for still can't execute it without human approval, if that tool is gated - and it isn't gated by default.

Tool output is also length-bounded before it reaches messages (_MAX_RESULT_CHARS in tools.py), so a single oversized search() result can't unboundedly grow the conversation sent back to the model on every later turn.


Threat Model

Resolved architectural issue: agent.py and state.py used to each define their own AgentState/advance(). Not a naming collision - two structurally different, independently-maintained implementations of the same "resumable step function" idea (agent.py's used phase="await_llm"/"await_dispatch"/"await_approval"/"done"; state.py's uses status="running"/"awaiting_approval"/"done"). They were not interchangeable, and had drifted: agent.py's advance() retried a failing llm_call with backoff from early on; state.py's didn't, until a later pass added it. agent.py's copy has since been deleted; state.py is now the sole implementation, and agent.py's iter_agent_steps is a real wrapper that calls state.py's start_agent/advance in a loop rather than a second, competing state machine. __init__.py re-exports agent.py's names, which now transparently means state.py's AgentState/advance - no code change was needed there. web/app.py and store.py continue to import state.py's AgentState/advance directly, as they always did.

What this protects against

  • Code injection via the calculator (AST whitelist, verified with exploit-payload tests)
  • A non-terminating model looping forever (max_steps hard cap)
  • Process crashes from tool exceptions or malformed model output (caught, converted to Observations or retry prompts)
  • Process crashes from a failing LLM provider call, on both loop implementations now: run_agent/iter_agent_steps (agent.py) and advance()/web/app.py (state.py) each retry with exponential backoff (_call_llm_with_retry, shared between the two - see the architectural note above) before surfacing a clean llm_error terminal event (status="done", a normal HTTP response from the web API, not a 502) instead of an uncaught exception or a failed request.
  • Anthropic API request errors from incorrect message-role structure (strict alternation maintained and tested)
  • Forged closing markers in tool output smuggling fake conversation turns (see Security section above - this is structural, not model-dependent)
  • Unbounded growth of the conversation from a single oversized tool result (_MAX_RESULT_CHARS truncation in tools.py)
  • Accidental cross-tenant reads of remembered facts between callers that each honestly report their own identity (see the memory namespacing bullet below) - this is explicitly not the same as protecting against a caller that lies about who it is; see "What it explicitly does not protect against."

What it explicitly does not protect against

  • Model non-compliance with the "treat delimited content as data" instruction - the delimiting itself is structural and tested, but whether the model actually follows it is an alignment property, not something this codebase can enforce.
  • sensitive_tools being empty by default at the library level - calling start_agent() / run_agent() directly with no sensitive_tools argument gates nothing; that's still true, and is a footgun for any caller that imports react_agent directly rather than going through web/app.py. agent.RECOMMENDED_SENSITIVE_TOOLS (currently ("search",)) documents the recommended opt-in, but the library itself does not enforce it.
  • Per-tool rate limiting inside the agent loop itself - max_steps bounds total loop iterations, but there's no separate throttle on how many times a single run calls search() before finishing. (The HTTP layer's rate limiting, below, bounds requests per caller, not tool calls per run.)
  • Memory tool cross-tenant isolation isn't cryptographically enforced - recall()/remember() (memory.py) now partition storage by a namespace (web/app.py derives it from an X-Memory-Namespace request header; see "Web API" below), so callers that each honestly report their own namespace can no longer read each other's facts. But nothing in this codebase verifies that a namespace claim is genuine - a caller that can freely choose its own header value can still choose someone else's. Real, non-spoofable isolation requires a trusted authentication layer in front of this API (one that derives the namespace from a verified identity, not client-supplied input) - out of scope for this pass, since it requires a real per-user auth model this project doesn't have.
  • A distributed rate limiter - _RateLimiter (web/app.py) is per-process memory only; see "What would be added" below.
  • search()'s DuckDuckGo fallback resolving a query to the wrong entity - without TAVILY_API_KEY set, search() falls back to DDG's free Instant Answer API, which does keyword/topic matching against Wikipedia-style abstracts, not question-answering. A natural-language question like "What is the capital of Pakistan?" can resolve to a strongly-indexed related entity (Karachi, Pakistan's former capital and largest city) instead of the actually-correct answer (Islamabad) - confirmed both in production use and in the search screenshot above, which shows Tavily returning the correct answer for the same phrasing. This isn't a bug in this codebase's logic; it's a real limitation of the free fallback API being used, and it's the reason Tavily is the recommended path (see "Running" above) rather than an equal alternative.

What the web API (web/app.py) adds on top of the library defaults

These are HTTP-layer protections, opt-in via environment variables so the server still boots with zero configuration for local/demo use (see "Web API" below for the full list):

  • A server-side floor on sensitive_tools: POST /runs unions the caller-supplied list with RECOMMENDED_SENSITIVE_TOOLS before starting a run, so a caller can widen the human-approval gate but never narrow it below the server's own minimum. Direct library callers (start_agent()/run_agent()) are unaffected; see above.
  • Optional X-API-Key auth (REACT_AGENT_API_KEY) - the API has no authentication at all unless this is set.
  • CORS locked to an explicit origin allowlist (REACT_AGENT_CORS_ORIGINS) rather than the wildcard the prototype originally shipped with.
  • Per-IP rate limiting (REACT_AGENT_RATE_LIMIT, default 30/60) on the three endpoints that can trigger a paid LLM call or execute arbitrary tool input (POST /runs, POST /runs/{id}/advance, POST /tools/{name}/invoke). In-process only - see _RateLimiter's docstring in web/app.py for why this doesn't hold across multiple worker processes.
  • LLM provider calls (providers.py) now pass an explicit timeout (default 30s) to the underlying SDK client, instead of relying on the SDK's own default (the openai package's default is 10 minutes), so a hung provider response can't pin a FastAPI worker thread indefinitely.
  • advance() (state.py) retries a failing llm_call with backoff before giving up, matching agent.py's resilience - see the architectural note above.
  • AgentState.from_dict() (state.py) now filters to known dataclass fields before construction, so a stored run from an older/newer schema version doesn't make every load of that row raise TypeError.
  • GET /runs is paginated (limit/offset, limit capped at 200) instead of returning every stored run in one response.
  • recall/remember are namespace-partitioned (X-Memory-Namespace header, memory.py's namespace column) instead of one shared bucket - see "What it explicitly does not protect against" above for the honesty-not-cryptography caveat.

What would be added in a hardening pass

  • A real, non-spoofable identity source for memory namespacing (an authentication layer that derives X-Memory-Namespace from a verified caller, rather than trusting the header as given).
  • A distributed rate limiter (Redis-backed, or enforced at a gateway/load balancer) for any deployment running more than one worker process, since _RateLimiter is per-process memory only.
  • Move from freeform regex-parsed Action Input text to structured (JSON) tool-call arguments, closing off a class of injection that relies on malformed Action-block text.

Performance

Run the included benchmark yourself:

python benchmarks/bench.py

This measures only the parts of the project that are actually its own code - parsing and the calculator. It does not benchmark the LLM call or search(), since those numbers would mostly measure your network and the provider's API latency, not this codebase.

Results from a real run (Windows, Python 3.9.13, 50,000 iterations each - not a dedicated benchmarking machine, treat as an order-of-magnitude reference and run it on your own hardware for numbers that mean something for your setup):

Operation Throughput
parse_step (Action text) ~501,951 calls/sec (99.6 ms total)
parse_step (Final Answer text) ~1,162,091 calls/sec (43.0 ms total)
calculator ~85,342 calls/sec (585.9 ms total)

(Ran on Python 3.9.13 despite the 3.10+ badge/Prerequisites above - the core loop has no 3.10-only syntax, match/case isn't used anywhere, and every module opts into from __future__ import annotations, so this particular script happens to work below the stated floor. That's not a claim the whole test suite is verified on 3.9 - it isn't, only this benchmark script was actually run there - so 3.10+ stays the documented requirement until the full suite is confirmed on 3.9 too.)

The takeaway isn't the specific numbers so much as the shape: parsing and arithmetic are effectively free next to a network round-trip to an LLM, which is milliseconds-to-seconds per call. The loop's own logic was never going to be the bottleneck - that's expected, and worth stating plainly rather than presenting a benchmark that implies otherwise.


Stack

Component Technology
Core loop Python 3.10+, standard library only (ast, re, dataclasses)
Search tool requests → Tavily /search (if TAVILY_API_KEY is set) → falls back to DuckDuckGo Instant Answer API (no key required)
Testing pytest, dependency-injected fakes (no live API calls in the test suite)
CI GitHub Actions: pytest -v, matrix across Python 3.10-3.13 (no lint step currently configured - see .github/workflows/ci.yml)
LLM providers (optional, install separately) openai (OpenAI, xAI's Grok, or Hugging Face's inference router - all OpenAI-compatible), anthropic

Prerequisites

  • Python 3.10+
  • (optional) An API key from OpenAI, Anthropic, xAI, or a Hugging Face token - only needed to run the loop against a real model. The full test suite requires none of these.

Setup

git clone https://github.com/imann128/react-agent-from-scratch.git
cd react-agent-from-scratch

python -m venv venv
venv\Scripts\activate          # Windows
# source venv/bin/activate     # macOS / Linux

pip install -r requirements-dev.txt

Running

pytest -v

227 tests, no API key or network access required - this is the correctness check that actually matters; everything below is optional. (Run pytest -v --collect-only to reproduce this count yourself if the suite grows - the number above will drift as tests are added, so don't treat it as load-bearing.)

To run the loop against a real model:

pip install openai          # or: pip install anthropic

OPENAI_API_KEY=sk-...    python examples/run_example.py
ANTHROPIC_API_KEY=sk-... python examples/run_example.py --provider anthropic
XAI_API_KEY=xai-...      python examples/run_example.py --provider xai
HF_TOKEN=hf_...           python examples/run_example.py --provider huggingface

Pass --model to override a provider's default model, e.g. to pin a specific Hugging Face backend instead of its :auto routing:

HF_TOKEN=hf_... python examples/run_example.py --provider huggingface \
    --model deepseek-ai/DeepSeek-V4-Flash-0731:novita

HF_TOKEN doesn't authenticate against one specific model host - it authenticates against Hugging Face's Inference Providers router (https://router.huggingface.co/v1, see providers.py), which itself forwards the request to whichever backend the :provider suffix names (:novita, :auto, etc.). DeepSeek is one model family reachable this way - an open-weight reasoning model that emits its chain-of-thought inside literal <think>...</think> tags before its actual response. That's not incidental to this project: parser.py strips <think> blocks before matching Action/Final Answer (a reasoning model's preamble would otherwise break the parser), and the bundled UI (see Screenshots above) renders that reasoning in a separate collapsible toggle instead of dumping it into the main chat bubble.

The web API reads the equivalent override from REACT_AGENT_HF_MODEL (see web/llm.py), since it has no per-request model parameter to accept (see "Web API" below for why).

To use Tavily instead of the default DuckDuckGo fallback for the search tool (see the Stack table above), set TAVILY_API_KEY in the environment - read at call time in react_agent/tools.py's search(), so it's a pure opt-in with no code change and no effect on anyone who doesn't set it:

TAVILY_API_KEY=tvly-... python examples/run_example.py --provider openai

or, for the web server, set it the same way you'd set HF_TOKEN/OPENAI_API_KEY before running uvicorn. Tavily is a search API built specifically for feeding LLM agents (it returns a short synthesized answer plus a few source snippets, not a page of ranked links for a human to click through), which is why it's the recommended path over the DDG fallback: DDG's Instant Answer API is a free, no-key Wikipedia-abstract lookup, not general search, and can resolve a natural-language question to the wrong entity entirely (see the Threat Model section and the search screenshot above for a concrete before/after).


Example Run

Illustrative trace - constructed by hand to show the expected shape of a run, not a captured transcript. Actual model wording will vary between runs and providers.

--- Step 1 ---
Thought: I need to compute 12 * 7 first.
Action: calculator
Action Input: 12 * 7
Observation: 84

--- Step 2 ---
Thought: Now I need the capital of the country Marie Curie was born in - Poland.
Action: search
Action Input: capital of Poland
Observation: Warsaw is the capital and largest city of Poland.

--- Step 3 ---
Thought: I have both pieces of information now.
Final Answer: 12 times 7 is 84, and the capital of Poland (where Marie Curie was born) is Warsaw.

Screenshots

Captured from the bundled UI (web/static/index.html, see "Web API" below), running against a real DeepSeek model via Hugging Face's Inference Providers.

Human-in-the-loop approval - search is a server-enforced sensitive tool (RECOMMENDED_SENSITIVE_TOOLS in agent.py), so the run pauses at awaiting_approval and the UI blocks on an Approve/Deny modal before the tool actually executes. This particular capture also happens to show the model missing the required Thought/Action/Action Input format on its first attempt (the "Model output didn't match the required format; retrying automatically" line) and self-correcting on retry - real model behavior, not staged.

Human-in-the-loop approval modal

Tools tab - search - the interactive sandbox calling the real search tool directly (POST /tools/search/invoke, outside of any run). This capture is post-Tavily-integration: "What is the capital of Pakistan" correctly returns Islamabad. Compare against the DuckDuckGo-fallback behavior described in the Threat Model section, where the same free-text phrasing resolved to the wrong city (Karachi) - this is the concrete fix that produced.

Tools tab: search schema and sandbox result

Tools tab - recall - the recall schema and a live query against long-term memory, returning a previously-stored preference.

Tools tab: recall schema and sandbox result

Tools tab - remember - the remember schema and a live call storing a new fact, which the recall capture above then successfully retrieves.

Tools tab: remember schema and sandbox result


Adding a Tool

  1. Add a function to tools.py - signature def your_tool(input_str: str) -> object, raising exceptions rather than swallowing them (the loop handles that).
  2. Register it in the TOOLS dict at the bottom of tools.py.
  3. Add a one-line description to the tool list in SYSTEM_PROMPT (state.py) - it's the sole copy since the state-machine consolidation (see "Resolved architectural issue" above); agent.py imports and re-exports it rather than defining its own. Before that consolidation there were two independently-maintained copies and they drifted once (recall/remember were dispatchable through TOOLS in both loops before either prompt mentioned them to the model) - that failure mode is gone now that there's only one prompt to update, not a second thing to remember.

Web API

web/app.py exposes the loop over HTTP for a multi-worker deployment (see state.py's module docstring for why a paused generator can't do this, and what AgentState/advance()/RunStore replace it with). Every handler is load state -> do one unit of work -> save state -> return, so any worker process behind a load balancer can serve any request for any run_id.

pip install -r web/requirements.txt
uvicorn web.app:app --reload

Then open http://127.0.0.1:8000/ in a browser: GET / serves a bundled single-page UI (web/static/index.html, not gated by REACT_AGENT_API_KEY) that drives the playground/approval/traces flow below directly against the same API, same-origin, no CORS configuration needed. It's a local dev console, not a production frontend - single HTML file, no build step, no auth of its own beyond whatever the API itself enforces.

Four tabs, all wired to real endpoints, none of it mocked or hardcoded: Playground (chat against /runs//runs/{id}/advance, with a client-side loop that keeps calling /advance until the run reaches done/awaiting_approval - see the design note above about that being the caller's responsibility, not the server's) with human-in-the-loop approval and markdown rendering (bold/headers/lists/inline-code) for assistant and final-answer text; Traces (GET /runs, with a per-run drawer against GET /runs/{id}); Tools (GET /tools + POST /tools/{name}/invoke, an interactive sandbox for calculator/search/recall/remember); and Evals (POST /evals/run, running eval/eval_agent.py's CASES against the server's own configured provider - with the default fake provider every case is expected to fail, since it echoes the query instead of answering it, which is correct behavior, not a bug). If REACT_AGENT_API_KEY is set server-side, the gear icon next to the Model box opens a settings modal to enter the matching X-API-Key value; it's stored in this browser's localStorage and sent only to this same-origin API.

With no configuration, it boots with the fake provider (no API key needed) and no auth, CORS, or rate limiting - fine for local exploration, not for anything reachable off localhost. Configure via environment variables:

Variable Default Effect
REACT_AGENT_PROVIDER fake openai | anthropic | xai | huggingface | fake
REACT_AGENT_RUNS_DB agent_runs.db SQLite file RunStore persists to
REACT_AGENT_MEMORY_DB agent_memory.db SQLite file recall/remember persist to
REACT_AGENT_API_KEY unset (no auth) If set, every endpoint except /health requires a matching X-API-Key header
REACT_AGENT_CORS_ORIGINS unset (no cross-origin access) Comma-separated allowlist of origins for CORS
REACT_AGENT_RATE_LIMIT 30/60 <requests>/<window_seconds> per client IP, applied to POST /runs, POST /runs/{id}/advance, POST /tools/{name}/invoke. 0/0 disables it. In-process only - see the Threat Model section for the multi-worker caveat.

X-Memory-Namespace (request header, not an env var, sent with POST /runs, POST /runs/{id}/advance, or POST /tools/{name}/invoke): scopes that request's recall/remember calls to a namespace. Omitted, every caller shares memory.py's DEFAULT_NAMESPACE - the same one-bucket behavior this had before namespacing existed. See the Threat Model section for what this does and doesn't guarantee (it is caller-supplied, not a verified identity).

Endpoints:

Method & path Does
POST /runs Starts a run and advances it one step
POST /runs/{run_id}/advance Resumes a run ({"approval": true/false} if it's awaiting_approval)
GET /runs/{run_id} Full transcript for one run
GET /runs?status=&limit=&offset= Paginated run summaries, limit capped at 200
DELETE /runs/{run_id} Deletes a run
GET /tools Tool name -> JSON Schema, for a UI to introspect what's callable
POST /tools/{name}/invoke Calls a tool directly, outside any run
POST /evals/run Runs eval/eval_agent.py's CASES against this server's own configured provider - not caller-selectable, one call can trigger many real LLM calls (see the bundled UI's Evals tab, below)
GET /health Liveness + which provider is configured; never requires auth
GET / Serves the bundled single-page UI (web/static/index.html); not gated by REACT_AGENT_API_KEY - see the bundled-UI paragraph below

POST /runs's sensitive_tools field is additive-only: the server unions whatever the caller passes with agent.RECOMMENDED_SENSITIVE_TOOLS before starting the run, so a caller can widen the human-approval gate but not narrow it below the server's own floor (see Threat Model).


Testing

pytest -v

tests/test_parser.py - regex extraction correctness for Action/Action Input pairs and Final Answer text; that Final Answer takes priority when a model outputs both; that malformed text doesn't crash; and a regression pair specifically proving the line-anchoring fix is meaningful (one test with the anchored parser, one showing the old unanchored pattern really would have misparsed the same input).

tests/test_tools.py - calculator arithmetic correctness (including ^ as an accepted alias for **, translated before parsing rather than added to the AST whitelist - see the module docstring), rejection of code-injection payloads and syntactically invalid input, and that division-by-zero is deliberately not caught inside the tool (that's agent.py's job). Search is tested on both branches without touching the real network: DDG tests mock requests.get (abstract/fallback/no-result/network-failure/oversized-result), and a separate TestSearchViaTavily class mocks requests.post with TAVILY_API_KEY set (answer/fallback-to-first-result/no-result/network-failure/oversized-result, that the key travels only as a Bearer header and never in the URL, and that the DDG path is provably never reached once a Tavily key is present).

tests/test_agent.py - iter_agent_steps's control flow against a fake LLM returning canned responses in sequence: tool dispatch happens correctly, an unknown tool name doesn't crash, a tool exception becomes an Observation instead of propagating, malformed output triggers a retry rather than a crash, the loop actually stops at max_steps, the real computed tool value reaches the Observation message content, the message transcript never has two consecutive same-role entries, and human-in-the-loop approval and prompt-injection delimiting behave as documented. AgentState/advance() themselves - the serializable step-function iter_agent_steps now delegates to - are exercised directly in tests/test_state.py, including their JSON round-trip and retry/llm_error behavior, rather than duplicated here.

tests/test_providers.py - each provider adapter (OpenAI, xAI, Hugging Face, Anthropic) is wired to the right base URL/env var and correctly translates its SDK's response shape back into a plain string, against a fake SDK module (no real network calls).

tests/test_reflection.py - run_agent_with_reflection only calls the critique prompt after a failed attempt, retries with the critique appended to the original query, stops as soon as any attempt succeeds, and gives up after max_reflections is exhausted.

tests/test_tracing.py - iter_agent_steps_traced yields the exact same events as iter_agent_steps, writes one JSON record per resolved step with correct status/latency, composes correctly with HITL approval, and doesn't crash the run if the trace write fails.

tests/test_eval_harness.py - the eval harness's own plumbing (turn counting, pass/fail aggregation, the fake provider smoke test) is correct, independent of any real model's actual reasoning ability.

tests/test_state.py - AgentState.to_dict()/from_dict() round-trip through JSON, including the approval flow surviving a simulated handoff to a different worker; from_dict() ignoring an unknown stored key rather than raising; advance()'s retry-with-backoff and llm_error terminal event when a provider call keeps failing; and (in TestEquivalenceWithGeneratorWrapper) that iter_agent_steps produces the exact same event stream as calling this module's advance()/start_agent() directly - expected now that iter_agent_steps is a real wrapper around them, not a coincidence between two separate implementations (see the Threat Model's architectural note).

tests/test_store.py - RunStore save/load/delete/list against a real SQLite file, including the cross-thread and concurrent-write cases FastAPI's worker-thread pool requires, and a full human-in-the-loop run split across three separate RunStore instances to prove state genuinely crosses a process boundary.

tests/test_memory.py - memory.remember()/recall() persistence and lexical-similarity retrieval against a real SQLite file, independent of the tools.py wrapper; namespace isolation between callers, the unspecified-namespace-defaults-to-shared backward-compatibility case, and the in-place schema migration for a memories.db written before namespacing existed.

tests/test_web_app.py - the FastAPI layer end-to-end via TestClient: run creation/advance/approval flows, tool introspection and invocation (including recall/remember), listing/filtering/pagination, X-API-Key auth (both unconfigured and configured), CORS origin handling, per-IP rate limiting on the cost-bearing endpoints, the server-side sensitive_tools floor, that a failing LLM provider call retries and then ends the run cleanly with an llm_error event (not a 500, and no longer a 502 for the ordinary retries-exhausted case - only for a genuinely unexpected failure), and X-Memory-Namespace isolation both for direct tool invocation and for recall/remember calls made by the model mid-run.


Project Structure

react-agent-from-scratch/
├── react_agent/
│   ├── parser.py         # text -> ParsedStep. Zero dependencies, zero I/O.
│   ├── tools.py          # calculator (AST-based, supports ^ and **), search (Tavily if TAVILY_API_KEY set, else DuckDuckGo), recall/remember wrappers
│   ├── memory.py         # long-term fact storage/retrieval backing recall()/remember() (sqlite)
│   ├── agent.py          # the generator-based loop; LLM call is dependency-injected
│   ├── state.py          # AgentState + advance() - the serializable, step-function form of the loop
│   ├── store.py          # RunStore - persists AgentState across requests/processes (sqlite)
│   ├── providers.py      # OpenAI / Anthropic / xAI / Hugging Face adapters (lazy imports)
│   ├── reflection.py     # Reflexion-style self-critique retry, layered on iter_agent_steps
│   └── tracing.py        # JSONL execution tracing, layered on iter_agent_steps
├── web/
│   ├── app.py             # FastAPI backend: start_agent()/advance()/RunStore over HTTP
│   ├── llm.py             # REACT_AGENT_PROVIDER -> a concrete LLMCall for the server
│   ├── requirements.txt   # fastapi, uvicorn - only needed to run the web server, not the core loop
│   └── static/
│       └── index.html    # bundled single-page UI ("Pulse Console"), served by GET / - single file, no build step
├── tests/
│   ├── test_parser.py
│   ├── test_tools.py
│   ├── test_agent.py
│   ├── test_state.py
│   ├── test_store.py
│   ├── test_memory.py
│   ├── test_providers.py
│   ├── test_reflection.py
│   ├── test_tracing.py
│   ├── test_eval_harness.py
│   └── test_web_app.py
├── benchmarks/
│   └── bench.py          # reproduce the Performance numbers above
├── examples/
│   └── run_example.py    # wires providers.py into agent.py end-to-end
├── eval/
│   └── eval_agent.py     # task-completion eval against a real (or fake) provider
├── .github/workflows/
│   └── ci.yml             # pytest across Python 3.10-3.13 (no lint step)
├── requirements.txt
├── requirements-dev.txt   # also installs web/requirements.txt (fastapi, uvicorn) + httpx
└── pyproject.toml

About

A ReAct (Reasoning + Acting) agent framework built from scratch in Python. It has no LangChain or agent SDK. It implements the Thought/Action/Observation loop, tool dispatch, and a resumable state machine as plain, testable code. Supports human-in-the-loop approval gates, retry/backoff on LLM failures, execution tracing, and pluggable providers.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages