Exercise 3: ReAct from Scratch (no framework)¶
Corresponds to Stage 3 — Tool Use & Your First Agent Loop Exercise 3.
🎓 How to use this: First run the provided
starter.py(python starter.py), then change exactly one small thing and run the existing test again:python test.py. If the test fails, undo or fix that one change and try again. You do not need to rename the file or rewrite the whole solution. Seedocs/HOW_TO_USE.mdfor the full method.📚 Want the chapter-length version? The starter in this folder is a 70-150 line illustrative build focused on
the core pattern + two SDK paths— it is not in-depth teaching material. Recommended for depth: -datawhalechina/hello-agents⭐ the most complete Chinese-language course out there — chapter-based, covering 16 production capabilities. this exercise maps to hello-agents' ReAct chapter (paired with thelearn_versionbranch) - The original ReAct paper (Yao et al. 2022, Section 3) + pguso/ai-agents-from-scratch (from-scratch implementation on a local LLM) - Full references in Stage 3 Curated Projects
Why write it from scratch¶
ReAct (Reasoning + Acting) is the foundational pattern of modern agents:
while not done:
thought = LLM reads current context and verbalizes the next step
action = LLM calls a tool
observation = tool result, fed back to the LLM
LangGraph / CrewAI hide this loop from you. Writing it once yourself is what teaches you:
- Why the
messagesarray keeps growing - How
tool_use_idpairs withtool_result - Why
stop_reasonistool_usevsend_turn - Why
max_iteris a mandatory safety net
All of that is covered in 70 lines of Python.
How to run — two paths¶
Path A (default, free, local)¶
pip install -r requirements.txt
ollama pull qwen2.5:3b
ollama serve
python starter.py
Budget: $0 API cost; hardware, memory, and electricity are excluded.
Path B (Anthropic, cloud comparison)¶
pip install -r requirements.txt
$env:ANTHROPIC_API_KEY = "your-key"
python starter_anthropic.py
Budget: reserve $0.05 per run. Actual cost is input tokens × $1 / 1,000,000 + output tokens × $5 / 1,000,000; Tool Use also adds prompt tokens. Prices checked on 2026-08-27.
Expected output (Path A, local):
❓ Question: Divide 'Taipei population' by 'NYC population', 4 decimal places.
------------------------------------------------------------
[step 0] thought: Let me look up Taipei's population...
tool: lookup_fact({'query': '台北人口'}) → 2602000
[step 1] thought: Now NYC's...
tool: lookup_fact({'query': '紐約人口'}) → 8336000
[step 2] thought: Compute the ratio...
tool: calculator({'expression': '2602000 / 8336000'}) → 0.3121...
[step 3] thought: The answer is 0.3122.
------------------------------------------------------------
✅ Final answer: Taipei / NYC ≈ 0.3122
Took 4 rounds.
✅ Exercise 3 passed — the ReAct loop chained lookup_fact and calculator on its own.
Validate the logic without spending API credits¶
python test.py # validates Path A (Ollama) starter.py logic
python test_anthropic.py # validates Path B (Anthropic) starter_anthropic.py logic
Both test suites use unittest.mock, no real API call, $0/run. Path A uses the OpenAI-compat response shape; Path B uses Anthropic content blocks.
test.py uses unittest.mock.MagicMock to replace the Anthropic client and feed canned responses, validating your loop logic. Expected:
✅ test_calculator_basic
✅ test_calculator_rejects_eval_injection
✅ test_lookup_fact
✅ test_react_loop_single_tool_call
✅ test_react_loop_multi_step
✅ test_react_loop_respects_max_iter
🎉 All tests passed — your ReAct loop logic is correct.
Program structure walkthrough¶
| Section | Lines | What it does |
|---|---|---|
tool_calculator |
~30-40 | Safe calculator (whitelist filter, avoids eval injection) |
tool_lookup_fact |
~42-50 | Fake fact lookup (teaching-only, avoids external API dep) |
TOOLS_SPEC |
~52-75 | Tool schema that the LLM sees |
TOOL_IMPL |
~77-80 | name → callable dispatch table |
react_loop |
~85-130 | Main loop, with max_iter safety, messages accumulation, tool_result wiring |
Common pitfalls¶
- Forgetting to append the assistant response to messages — next round the LLM can't see what it just said, leading to infinite loops
- Not passing
tool_use_idwith tool_result — the LLM can't pair results to calls while Truewithoutmax_iter— if a tool returns garbage the LLM may call it forever; safety net is mandatory- Unfiltered eval: never send model text to
eval, or it can become an RCE; do not rely on a character whitelist orast.literal_evalfor arithmetic. Use an explicit AST operator allowlist with input-length, AST-depth, node-count, and number/result bounds.
Want smarter answers?¶
Default model is the pinned ID claude-haiku-4-5-20251001. To compare Sonnet:
$env:MODEL = "claude-sonnet-5"; python starter_anthropic.py
Or change MODEL = ... in starter_anthropic.py.
Extensions¶
- Add more tools — append one entry each to
TOOLS_SPEC+TOOL_IMPL - Add streaming — swap
client.messages.create(...)forwith client.messages.stream(...) as s:, print as it goes - Add prompt cache — pass
cache_control={"type":"ephemeral"}onsystem=ortools=to save 90% on repeat calls - Plug into LangGraph or Pydantic AI to see how frameworks hide these 70 lines