5.2.29. Local Deep Researcher¶
LangGraph-based multi-step research agent. The user submits a topic, LDR runs a search-summarize-reflect-search loop (default 3 iterations), and returns a Markdown report citing the sources it found. Upstream is langchain-ai/local-deep-researcher; the stack runs it via the LangGraph dev server (langgraph dev) listening on port 2024 inside the container, exposed at LOCAL_DEEP_RESEARCHER_PORT on the host.
LDR is completely local by design — it relies on the stack's LiteLLM gateway (so any registered local Ollama model works) and SearXNG for web search. No outbound API keys required for the default loop. The backend exposes a typed /research/start|status|result|cancel|logs|sessions|health surface while its upstream client now speaks the stock LangGraph dev-server API: /ok, /threads, and /threads/{id}/runs/stream. The LDR endpoint itself is reachable directly and via Kong's research.localhost alias.
1. Overview¶
Image: python:3.11.15-slim. At startup, the managed repo volume is checked out at the manifest-pinned upstream commit, verified, and installed with pip install -e .; restarts never pull a mutable branch. Source variants are minimal — container or disabled. There is no GPU path; LDR doesn't run inference itself, it orchestrates LiteLLM.
2. Access¶
| Path | URL | Notes |
|---|---|---|
| Direct | http://localhost:${LOCAL_DEEP_RESEARCHER_PORT} (default 63097) |
LangGraph dev-server REST API. |
| Kong | http://research.localhost:63000 |
Route generated from LOCAL_DEEP_RESEARCHER_SOURCE (needs the --setup-hosts entries). |
| LangGraph API | POST /threads, POST /threads/{id}/runs/stream |
Standard LangGraph dev-server endpoints. |
Canonical port table: Ports and Routes.
3. Configuration¶
LOCAL_DEEP_RESEARCHER_SOURCE=container # container | disabled
LOCAL_DEEP_RESEARCHER_PORT=63097 # computed by topology.py
LOCAL_DEEP_RESEARCHER_REF=38f769f84380f2065de76021ac7c5215f88aa39e
LOCAL_DEEP_RESEARCHER_LANGGRAPH_CLI_VERSION=0.4.31
LOCAL_DEEP_RESEARCHER_UPSTREAM_LOCK_SHA256=26fc35ac377836de6628e5f7b180944c4d4bd50a5e9f0200bd6e663f20e35c1a
LOCAL_DEEP_RESEARCHER_LOOPS=3 # max research iterations
LOCAL_DEEP_RESEARCHER_SEARCH_API=searxng # only searxng is wired today; tavily/perplexity supported upstream
LOCAL_DEEP_RESEARCHER_WORKERS=3 # reserved; the entrypoint does not currently pass --n-workers to langgraph dev
Adaptive env (auto-injected):
LITELLM_BASE_URL=http://litellm:4000
LITELLM_API_KEY=${LITELLM_MASTER_KEY}
# STT_ENDPOINT / TTS_ENDPOINT / DOCLING_ENDPOINT are NOT injected — the LDR
# research-agent path is text-only today (see service.yml note). Those
# provider endpoints are owned by the stt/tts/docling manifests.
Required hard dependencies (depends_on.required): searxng, litellm. Without SearXNG, LDR has no search backend; without LiteLLM, no LLM to summarize. LDR is DB-free — it does not connect to Supabase. Research sessions are persisted to public.research_* by the backend (research_service.py), which calls this LangGraph server over HTTP; that supabase dependency belongs to the backend, not LDR.
LOCAL_DEEP_RESEARCHER_REF must remain a full commit SHA. Its upstream uv.lock is verified against LOCAL_DEEP_RESEARCHER_UPSTREAM_LOCK_SHA256, while Atlas' committed build/config/runtime-requirements.lock combines that graph with the exact serving CLI and build-tool versions. The lock records all three manifest pins as provenance metadata, and startup rejects mismatches before replacing the prior source tree, synchronizing a private virtual environment with mandatory hashes, and installing the checked-out project without dependency resolution.
To upgrade, update the three manifest pins, then run uv run --project bootstrapper python scripts/refresh-local-deep-researcher-lock.py. The refresh command checks out the exact revision, verifies its upstream lock digest, adds the exact CLI, build-tool, and security-floor pins without installing them, and commits both combined resolver inputs under services/local-deep-researcher/locks/ plus the exported runtime lock. The current security floors keep aiohttp, Click, langchain-classic, LangSmith, and Soup Sieve on patched releases; the exported graph passes pip-audit. Run the same command with --check for the network-free byte-equivalence gate, then validate the LDR patches and backend contract in the same change.
Optional adaptive (runtime_deps.local-deep-researcher.optional): neo4j-graph-db, n8n, weaviate, the media providers. Wiring exists in the manifest but nothing in the LDR code consumes them today — these are forward-looking hooks.
4. Architecture & wiring¶
Research loop:
- Client calls
POST /threadsto create a thread. - Client calls
POST /threads/{id}/runs/streamwith the research topic. - LangGraph executes the StateGraph defined upstream:
generate_query— LiteLLM produces a search query.web_research— calls SearXNG athttp://searxng:8080/search?format=json.summarize_sources— LiteLLM summarizes the gathered snippets into arunning_summary.reflect_on_summary— LiteLLM produces a follow-up query if loop count <LOCAL_DEEP_RESEARCHER_LOOPS.finalize_summary— outputs the final Markdown report.- State (running_summary, sources_gathered, loop_count) lives in the LangGraph dev-server's in-memory checkpointer.
Checkpointer caveat. The dev-server's default in-memory checkpointer drops thread state on container restart, so resumable research isn't possible today. A Redis-backed checkpointer is a documented future pair (a fresh db index, e.g. /4; /3 belongs to JupyterHub).
Search backend. LOCAL_DEEP_RESEARCHER_SEARCH_API=searxng calls http://searxng:8080/search?q=…&format=json. SearXNG must have formats: [json] enabled (it does, in services/searxng/config/settings.yml).
LLM gateway. Every LangGraph node that needs an LLM goes through LiteLLM at http://litellm:4000/v1/chat/completions. The model id used at each step is configured in the upstream repo's init-config.py; the stack pins it to whatever LiteLLM advertises by default.
Backend integration. services/backend/app/app/research_client.py targets http://local-deep-researcher:2024 with a ResearchRequest/ResearchResult schema, exposed through the backend's /research/* routes (sessions persist to public.research_sessions). The client checks /ok, creates a thread with POST /threads, and the backend background task executes POST /threads/{thread_id}/runs/stream with assistant_id=ollama_deep_researcher, on_disconnect=cancel, and stream_mode=["values"].
5. Dependencies & Integrations¶
5.1. Current — Upstream (this service calls)¶
| Service | Category |
|---|---|
| litellm | llm |
| crawl4ai | media |
| searxng | media |
5.2. Current — Downstream (services that call this)¶
| Service | Category |
|---|---|
| kong | infra |
| backend | apps |
| open-webui | apps |
5.3. Architecture diagram¶
Open the full-size diagram for a full-screen view.
5.4. Future — Missing pair integrations¶
- local-deep-researcher ↔ redis — Why: LDR runs
langgraph devwith the in-memory checkpointer, so thread state is lost on restart; no Redis checkpointer is wired today. Mechanism: swap checkpointer tolanggraph.checkpoint.redis.RedisSaverpointed at a fresh index (redis://:${REDIS_PASSWORD}@redis:6379/4—/3is JupyterHub's); addREDIS_URLto LDR env. Effort: small. Confidence: medium. - local-deep-researcher ↔ neo4j — Why: each research run yields
sources_gathered+ arunning_summary. Writing these as(Topic)-[CITES]->(Source)triples lets later runs detect overlap and reuse evidence. Mechanism: post-finalize_summarycallback writes CypherMERGEviabolt://neo4j-graph-db:7687. Effort: medium. Confidence: medium. - local-deep-researcher ↔ minio — Why: the final markdown report lives only in
/app/datainside the container; no other service can consume it. Mechanism: onfinalize_summary, S3PutObjectto${MINIO_ENDPOINT}bucketresearch-reportskeyed bysession_id. Effort: small. Confidence: medium. - local-deep-researcher ↔ hermes — Why: Hermes has no path to invoke multi-step web research today. Exposing LDR as a Hermes tool turns "deep research" into a single tool call. Mechanism: Hermes custom tool POSTs to
http://local-deep-researcher:2024/threads/{id}/runs/streamand returns the final summary; configured inservices/hermes/init/templates/config.yaml.tmpl. Effort: medium. Confidence: medium.
5.5. Future — Candidate new services¶
- open_deep_research (langchain-ai) — Headline: multi-agent deep-research engine (supervisor + parallel sub-researchers) evaluated as a disabled-by-default opt-in second research engine complementing LDR (LDR stays the fast/local/key-free tier). GO-conditional per
docs/strategy/langchain-stack-evaluation.md(#532) — gated on a key-free LiteLLM+SearXNG boot at acceptable cost; itsmessages/final_reportschema needs a per-engine branch in the backend research client. - Firecrawl (details) — Headline: self-hosted JS-rendering scraper that returns clean markdown, replacing LDR's
FETCH_FULL_PAGEDuckDuckGo path with structured extraction. Wires into: n8n, backend, hermes.
5.6. Future — Unused features in this service¶
- Persistent LangGraph checkpointer — Why pursue: dev-server inmem checkpointer drops thread history on restart, so resumable research is impossible. Effort: small.
- Tavily / Perplexity search backends — Why pursue: upstream supports both via
SEARCH_API=tavily|perplexity+ API keys; manifest only exposes searxng/duckduckgo. Effort: small. USE_TOOL_CALLINGfor gpt-oss models — Why pursue: enables structured tool calls instead of JSON mode for gpt-oss family, improving reliability with LiteLLM-routed local models. Effort: small.STRIP_THINKING_TOKENStoggle — Why pursue: Hermes-style reasoning models leak<think>blocks into the report; upstream env var hides them. Effort: small.- LangSmith tracing — Why pursue:
LANGSMITH_API_KEYships upstream; superseded if Langfuse lands but useful as a stopgap. Effort: small.
6. Troubleshooting¶
Container restarts every 30s. Usually the upstream repo clone or pip install -e . failed on first run. docker logs <project>-local-deep-researcher shows the failing step. Network or PyPI mirror issues are the most common root cause.
Research returns empty / "no sources gathered". SearXNG returned no JSON results. Check curl 'http://localhost:${SEARXNG_PORT}/search?q=test&format=json'; if the JSON format is disabled, fix services/searxng/config/settings.yml.
Runs hang at summarize_sources. LiteLLM is unreachable or the configured model is overloaded. docker logs <project>-litellm -f and confirm the model in use is registered.
State lost on restart. Expected — see the in-memory checkpointer note above. The fix is the Redis-checkpointer integration listed under Future.
Kong route 404 for research.localhost. The route IS generated when LOCAL_DEEP_RESEARCHER_SOURCE=container; a 404 here usually means the *.localhost hosts entries are missing (./start.sh --setup-hosts) or the service is disabled.
docker compose ps local-deep-researcher
docker compose logs -f local-deep-researcher
curl -s http://localhost:${LOCAL_DEEP_RESEARCHER_PORT}/threads -X POST -H 'content-type: application/json' -d '{}'
For general startup and routing issues, see Troubleshooting.
7. Operations¶
Run a research session from the CLI.
# 1. Create a thread
THREAD=$(curl -s -X POST http://localhost:${LOCAL_DEEP_RESEARCHER_PORT}/threads \
-H 'content-type: application/json' -d '{}' | jq -r .thread_id)
# 2. Stream a run (SSE)
curl -N -X POST http://localhost:${LOCAL_DEEP_RESEARCHER_PORT}/threads/$THREAD/runs/stream \
-H 'content-type: application/json' \
-d '{"assistant_id":"ollama_deep_researcher","on_disconnect":"cancel","input":{"research_topic":"vector database trade-offs 2026"}}'
The stream emits LangGraph node events; the final finalize_summary event contains the Markdown report.
Inspect thread state.
curl -s http://localhost:${LOCAL_DEEP_RESEARCHER_PORT}/threads/$THREAD/state | jq '.values'
Returns running_summary, sources_gathered, loop_count, current node — useful for debugging stalls.
Tune the research depth. LOCAL_DEEP_RESEARCHER_LOOPS=3 is a balance between report quality and cost. Bump to 5+ for thorough surveys; drop to 1 for fast lookups.
Configure the LLM used per step. The upstream repo's init-config.py hard-pins models; to swap them, edit that file in the clone (inside the container) and restart, or override via LANGCHAIN_* env vars supported upstream.
8. Performance notes¶
- Cost per run. ~5 LLM calls per loop ×
LOOPSloops = 15 calls for the default. Plus one SearXNG call per loop. Local Ollama → free + slow (~30-90s/loop); cloud APIs via LiteLLM → fast + metered. - No streaming back to backend. The
/runs/streamSSE channel exists but the backend'sresearch_client.pyconsumes it synchronously; fanning events out to Open WebUI via Supabase Realtime remains future work. - Thread state size. A 3-loop run produces ~30-60 KB of state (summary + sources). The in-memory checkpointer holds the last N threads in process; under load it can grow unbounded — restart cleans it.