CoolFace
Apppublic

28Shekhar/m3_react_agent

sourceHugging Faceupdated 3mo agoView on Hugging Face
0likes
App README

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

FilePurpose
m3_react_agent.pyThe ReAct loop, LLM client abstraction, parsing/self-correction, autonomy bounds, final synthesis, CLI.
tools.pyWeb search, page fetch, content extraction, citation tracker.
memory_integration.pyShort-term session buffer (with pruning) + long-term Upstash Redis memory.
config.pyEnv-var loading, provider selection, autonomy/memory/tool limits.
requirements.txtPython dependencies.
.env.exampleTemplate for required environment variables (local dev only).
DockerfileDocker Space build for Hugging Face (CLI-only, see below).
entrypoint.shRuns one research query on container start (reads QUERY), prints to stdout/Logs, then keeps the Space alive.
.dockerignoreKeeps the image lean (excludes .env, caches, logs).
sample_run_raptor_vs_hyde.mdFull trace + final cited summary from the validation run.
trace_raptor_vs_hyde.jsonThe same run, as structured JSON.

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 scratchrun_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 to search(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 CitationTracker and 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)

  1. 1.`WebSearchTool` — Tavily or SerpAPI over plain HTTPS (requests, no SDK dependency). Returns ranked (title, url, snippet) results.
  2. 2.`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 a FetchedPage with .error set.
  3. 3.`ContentExtractor` — parses HTML with BeautifulSoup, strips nav/script/style/footer, extracts the densest <article>/<main>/<div> text block, and pulls title + domain metadata.
  4. 4.`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 condensed summary entry 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.pyAutonomyLimits)

Checked before every iteration (and again before running a tool):

LimitDefaultEnforced by
Max ReAct iterations10check_limits() in the main loop
Max tokens (LLM prompt+completion, cumulative)100,000state.total_tokens, incremented after every LLM call
Max wall-clock time300s (5 min)state.elapsed_seconds()

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

bash
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:

bash
GROQ_API_KEY=gsk_...
GROQ_MODEL=openai/gpt-oss-120b   # default; llama-3.3-70b-versatile also works

No 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.

  1. 1.Create the Space as SDK type "Docker" and upload this repo's files (the Dockerfile and the YAML frontmatter at the top of this README are what HF reads to configure the Space card).
  2. 2.Add your secret: Space page → Settings → "Variables and secrets" → New secret → name GROQ_API_KEY, value your Groq key. Optionally add TAVILY_API_KEY/SERPAPI_API_KEY (real web search) and UPSTASH_REDIS_REST_URL/UPSTASH_REDIS_REST_TOKEN (long-term memory) the same way. Secrets are injected as plain environment variables at runtime — config.py reads them like any other env var; no `.env` file is used on Spaces.
  3. 3.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.
  4. 4.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.
  5. 5.Ask a new question: edit the QUERY variable, then Settings → "Restart this Space". The container reruns entrypoint.sh against 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:

  1. 1.Space page → Settings → enable "Dev Mode".
  2. 2.Connect via the VS Code Web button in the Dev Mode panel, or from a local terminal: ssh <space-subdomain>@ssh.hf.space (or hf spaces ssh <namespace>/<space> using the huggingface_hub CLI).
  3. 3.Run the agent as many times as you like without restarting the Space:
bash
   python m3_react_agent.py --query "Compare Raptor RAG vs HyDE" \
       --trace-out trace.json --summary-out summary.md

The 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

bash
python m3_react_agent.py --query "Compare Raptor RAG vs HyDE" \
    --trace-out trace.json --summary-out summary.md

Programmatic use:

python
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.