28Shekhar/m3_react_agent
M3 Researcher-and-Summarizer ReAct Agent
A from-scratch ReAct (Thought → Action → Observation) research agent. It takes a research question, autonomously searches the web, reads pages, tracks citations, and produces a structured, inline-cited summary — bounded by hard limits on iterations, tokens, and wall-clock time.
Files
Architecture
┌─────────────────────────────────────────────┐
│ run_react_loop() [while True] │
│ │
query ──────▶ │ check_limits() ──▶ Thought (LLM call, │
│ │ structured prompt) │
│ │ no limit hit │ │
│ ▼ ▼ │
│ stop & synthesize parse_thought_action() │
│ ▲ (self-correct ≤2 retries)│
│ │ │ │
│ │ tool == finish? ───yes───┐│
│ │ │ no ││
│ │ ▼ ││
│ │ Action (search/fetch) ││
│ │ │ ││
│ │ ▼ ▼│
│ │ Observation (tool result, │
│ │ citation registered) ────┘
│ │ │
│ │ short-term memory.add()
│ │ prune_if_needed() (LLM-condensed)
│ └──────────────loop back to Thought
└─────────────────────────────────────────────┘
│
▼
synthesis_phase() → CitationTracker.finalize()
│
▼
FinalOutput (summary + sources + metadata)
│
▼
LongTermMemory.store_research() (Upstash Redis)The loop is built from scratch — run_react_loop() in m3_react_agent.py is a plain Python while loop. There is no AgentExecutor, no create_react_agent, no hidden LangChain agent abstraction driving control flow. Every Thought/Action/Observation is an explicit, inspectable step.
An optional build_langgraph_app() wraps the same node functions (thought_and_action_phase, act_and_observe_phase, synthesis_phase) in a LangGraph StateGraph, for teams that want LangGraph's tracing/checkpointing in production. It requires langgraph to be installed and is not on the critical path — run_react_loop() works with zero orchestration dependencies.
Thought / Action / Observation
- Thought: one LLM call per iteration, prompted with the tool spec, the full short-term trace so far, remaining budget (iterations/tokens/time), and any related past research recalled from long-term memory. The model must respond in a strict
Thought: / Action: / Action Input:format. - Action:
parse_thought_action()extracts the tool + JSON args via regex. If parsing fails, the agent re-prompts with a corrective message (up to 2 retries); if it still can't parse a valid action, it self-corrects by defaulting tosearch(original query)rather than crashing. - Observation: the dispatched tool's result (search results, fetched page text, or an error message) is folded into short-term memory. Fetched pages are registered with the
CitationTrackerand returned to the model as[Source N] ...so the model can cite them directly in later reasoning and in the final summary.
Tool suite (tools.py)
- `WebSearchTool` — Tavily or SerpAPI over plain HTTPS (
requests, no SDK dependency). Returns ranked(title, url, snippet)results. - `PageFetchTool` — fetches a URL with
requests, handling timeouts, connection errors, HTTP error codes, non-HTML content types, and oversized responses gracefully — it never raises, it returns aFetchedPagewith.errorset. - `ContentExtractor` — parses HTML with BeautifulSoup, strips nav/script/style/footer, extracts the densest
<article>/<main>/<div>text block, and pulls title + domain metadata. - `CitationTracker` — de-duplicates sources by URL, and at the end of the run
finalize(summary_text)scans for[Source N]tags, keeps only the sources actually cited, renumbers them sequentially in order of first appearance, and rewrites the tags to match — so the sources list never contains anything the summary doesn't reference.
Memory (memory_integration.py)
- `ShortTermMemory` — the full in-session trace. When its estimated token size crosses
MEMORY.short_term_token_budget(default 12,000), the oldest raw entries are collapsed into one condensedsummaryentry via an LLM call, keeping the working context bounded instead of growing forever. - `LongTermMemory` — Upstash Redis via its REST API (plain HTTPS POST with
["CMD", ...]bodies — no TCP client / redis-py dependency, so it works from any sandboxed environment that can only make outbound HTTPS calls). Every completed session is stored (query,summary,sources, timestamp); before starting new research, the agent retrieves related past sessions via keyword-overlap similarity over stored queries/topics. If Redis is unreachable or not configured, every method degrades to a logged no-op — the agent keeps working, it just loses cross-session recall.
Autonomy bounds (config.py → AutonomyLimits)
Checked before every iteration (and again before running a tool):
The instant any bound is crossed, the loop breaks and jumps straight to synthesis_phase(), which writes the best possible cited summary from whatever was gathered so far. FinalOutput.stop_reason records exactly why the loop ended (agent_finished, max_iterations, max_tokens, or max_wall_clock_time).
Setup
pip install -r requirements.txt
cp .env.example .env
# edit .env: set GROQ_API_KEY (or ANTHROPIC_API_KEY / OPENAI_API_KEY),
# TAVILY_API_KEY (or SERPAPI_API_KEY), and optionally
# UPSTASH_REDIS_REST_URL/TOKEN.Every secret is read from the environment (via python-dotenv loading .env) — nothing is hardcoded, and .env should never be committed.
Provider fallback behavior
config.py resolves providers in this order: explicit LLM_PROVIDER / SEARCH_PROVIDER env var → whichever API key is present (LLM check order: GROQ_API_KEY, then ANTHROPIC_API_KEY, then OPENAI_API_KEY) → a built-in deterministic mock provider as a last resort, with a loud WARNING log so a mock run is never mistaken for a live one. This means the agent is always runnable (e.g. in CI, or this sandbox, which has no outbound access to pip's index or paid APIs) while making the production path the default whenever real credentials are supplied.
LLM provider: Groq
GroqLLMClient in m3_react_agent.py uses GroqCloud's OpenAI-compatible chat.completions.create API via the official groq Python package. Set:
GROQ_API_KEY=gsk_...
GROQ_MODEL=openai/gpt-oss-120b # default; llama-3.3-70b-versatile also worksNo other code changes are needed — LLM_PROVIDER auto-resolves to groq the moment GROQ_API_KEY is present (see resolution order above), and build_llm_client() picks GroqLLMClient automatically.
Deploying on Hugging Face Spaces (Docker, CLI-only)
This repo includes a Dockerfile + entrypoint.sh for a Hugging Face Docker Space. The Space has no web UI. There are two ways to get output out of it, depending on your HF plan:
Path A — free tier (any plan, no upgrade needed)
entrypoint.sh runs the agent once, automatically, every time the container starts or restarts, against a QUERY variable — and prints the full Thought/Action/Observation trace plus the final cited summary to stdout, which Hugging Face shows on the Space's "Logs" tab on every plan (free included). To ask a different question, you don't need a terminal at all.
- Create the Space as SDK type "Docker" and upload this repo's files (the
Dockerfileand the YAML frontmatter at the top of this README are what HF reads to configure the Space card). - Add your secret: Space page → Settings → "Variables and secrets" → New secret → name
GROQ_API_KEY, value your Groq key. Optionally addTAVILY_API_KEY/SERPAPI_API_KEY(real web search) andUPSTASH_REDIS_REST_URL/UPSTASH_REDIS_REST_TOKEN(long-term memory) the same way. Secrets are injected as plain environment variables at runtime —config.pyreads them like any other env var; no `.env` file is used on Spaces. - Set the question: same Settings page → "Variables and secrets" → New variable (not secret, since it's not sensitive) → name
QUERY, value e.g.Compare Raptor RAG vs HyDE. - Build and watch: once the Space shows "Running", open the Logs tab — you'll see the full live trace (every Thought/Action/Observation) followed by the final structured, cited summary, exactly like running it locally.
- Ask a new question: edit the
QUERYvariable, then Settings → "Restart this Space". The container rerunsentrypoint.shagainst the new query and the new trace appears in Logs.
Path B — interactive terminal (requires HF PRO / Team / Enterprise)
Hugging Face's interactive shell into a Space ("Dev Mode": SSH + VS Code Web) is gated to paid plans. If you have one:
- Space page → Settings → enable "Dev Mode".
- Connect via the VS Code Web button in the Dev Mode panel, or from a local terminal:
ssh <space-subdomain>@ssh.hf.space(orhf spaces ssh <namespace>/<space>using thehuggingface_hubCLI). - Run the agent as many times as you like without restarting the Space:
python m3_react_agent.py --query "Compare Raptor RAG vs HyDE" \
--trace-out trace.json --summary-out summary.mdThe Dockerfile installs bash, git, git-lfs, curl, wget, and procps and runs as uid 1000 specifically so it satisfies Dev Mode's requirements, in case you have access to it.
Usage
python m3_react_agent.py --query "Compare Raptor RAG vs HyDE" \
--trace-out trace.json --summary-out summary.mdProgrammatic use:
from config import load_config
from m3_react_agent import run_react_loop, build_llm_client
config = load_config()
llm = build_llm_client(config)
output = run_react_loop("Compare Raptor RAG vs HyDE", config=config, llm=llm)
print(output.to_markdown()) # summary + numbered sources + metadata
print(output.iterations_used, output.tokens_consumed, output.stop_reason)Sample run
sample_run_raptor_vs_hyde.md and trace_raptor_vs_hyde.json contain a full, real execution of python m3_react_agent.py --query "Compare Raptor RAG vs HyDE" captured in this environment, showing every Thought/Action/Observation step and the final cited summary.
Note on that specific run: this sandbox has no outbound access to PyPI or to paid LLM/search APIs (only an HTTP allowlist proxy), so no GROQ_API_KEY / TAVILY_API_KEY could be installed or exercised here. Per the fallback behavior described above, the sample run therefore used the built-in MockLLMClient and the mock search/fetch provider — clearly labeled llm_provider: mock, search_provider: mock in the run's own metadata. The mock LLM implements a generic, non-hardcoded scripted policy (search → fetch top candidates → refine search if under-covered → finish), so it still exercises the real control flow: parsing, tool dispatch, citation tracking, memory pruning, and autonomy-bound checks all ran for real. Swapping in a real GROQ_API_KEY (as on the Hugging Face Spaces deployment) plus TAVILY_API_KEY (and optionally Upstash credentials) switches every one of those steps to live calls with zero code changes — build_llm_client() and WebSearchTool pick the real providers automatically whenever the corresponding key is present.
Autonomy bounds were separately verified by forcing each limit low (MAX_ITERATIONS=3, MAX_TOKENS=500, MAX_WALL_CLOCK_SECONDS=0.01) and confirming the loop stopped immediately with the correct stop_reason and still produced a FinalOutput from partial findings in each case.
Design notes / trade-offs
- Self-correction: malformed LLM output triggers up to 2 corrective re-prompts before falling back to a safe default action, so one bad generation never crashes the run.
- Graceful degradation: a failed fetch or empty search becomes an Observation the model reasons over (e.g. "try a different query") instead of an exception; a down/unreachable Upstash instance disables long-term memory for that run without affecting the research itself.
- Citation integrity: the sources list is generated after the summary is written, by scanning the summary text for
[Source N]tags — sources gathered but never cited are silently dropped, and cited ones are renumbered in order of first appearance. - LangChain/LangGraph: used as an orchestration option (
build_langgraph_app), not as the mechanism that performs the reasoning — the Thought/Action parsing and tool dispatch are hand-written so the ReAct behavior stays fully inspectable and dependency-light.
