Building a Multi-Agent Research System with LangChain Deep Agents
A hands-on look at the Deep Agents pattern: an orchestrator, specialist agents, safe tools and a Streamlit front end, tested against local and cloud LLMs.
This article is published in English.
Sources: Deep Agents in Action: Building a Multi-Agent Research System
Single prompt loops are giving way to multi-agent systems. The new setup splits the labour, keeps state between steps and gives each worker a narrow set of tools. A test run of one more orchestrator built on that idea, LangChain's Deep Agents, makes a good case study, so here is what it does and how a small research app was put together on top of it.
What the Deep Agents architecture addresses
LangChain recently announced a managed platform for running deep agent topologies. The pattern targets three weaknesses of a lone agent working on a long task: the context window degrades, the agent gets confused about which tool to pick, and reasoning errors compound as steps pile up.
The shape is a hierarchy:
- A top-level orchestrator breaks a complicated task into pieces and hands each piece to a specialist.
- Each specialist runs its own isolated ReAct loop, with tools and memory of its own.
- Specialists send structured results back, and the orchestrator stitches them together.
- The pieces nest, so an orchestrator can itself serve as someone else's sub-agent.
Compared with one agent doing everything, the difference looks like this:
| Single Agent | Deep Agents |
| ---------------------------------- | ------------------------------------------------- |
| One context window gets overloaded | Each agent has an isolated, focused context |
| All reasoning in one prompt | Specialised reasoning per domain |
| Hard to scale | Add specialists without changing the orchestrator |
| Hard to debug | Full delegation trace for auditability |
The framework's own documentation describes it as the quickest route to agents driven by LLMs, with file systems for managing context, sub-agent spawning and long-term memory built in. Task planning and skills are optional extras. It lists these capabilities: acting in an environment through tools, files and code execution; pulling in memories, skills and domain knowledge at the right moment; summarising history and moving large results out of context; delegating to subagents in separate context windows; pausing for human approval at critical points; and updating memory, skills and prompts from real usage.
The project's repository adds that it's an open source, opinionated harness that works out of the box, and you can extend, override or replace any part. Defaults are tuned for long multi-step work, any tool-calling model is accepted (frontier, open-weight or local), and it sits on LangGraph for streaming, persistence and checkpointing, with tracing, evaluation and deployment through LangSmith. The feature list covers sub-agents with isolated context, a filesystem over pluggable backends, context management that offloads tool output to disk, shell access in a sandbox, persistent memory, human-in-the-loop approval of tool calls, reusable skills, and custom tools or any MCP server.
Core ideas
- Hierarchical orchestration. A supervisor agent receives a high-level goal and passes sub-tasks to specialists, instead of grinding through every step in one loop.
- Specialised roles. Each agent has a tightly scoped system prompt, its own tools and a separate context window, for jobs like retrieval, calculation or drafting a document.
- Isolated memory. Every agent keeps its own working memory, facts and episodes, so state from one phase doesn't leak into another.
- ReAct reasoning. Sub-agents repeat Thought, Action, Observation cycles against validated tool interfaces.
The implementation
The demo app is a small, didactic build, scaffolded with IBM Bob, that runs locally or against cloud models. It follows the Deep Agents pattern at modest scale.
The task flows through a short tree:
User Task
└─ OrchestratorAgent (Planning & Synthesis)
├─ ResearcherAgent (web_search)
├─ AnalystAgent (calculator, code_executor)
└─ WriterAgent (file_reader)
Four layers hold it together:
- Orchestration (
agents/orchestrator.py) takes the user's input, judges how complex it is, builds a plan, delegates sub-tasks in sequence and synthesises the results. - Specialists (
agents/specialists.py) define aResearcherAgent, anAnalystAgentand aWriterAgent. - Tools (
tools/) provide arithmetic evaluated safely through the AST, isolated Python execution, an abstraction over web search and sandboxed file I/O. - Relay and UI (
app.pyandmain.py) offer a Streamlit front end that syncs state across threads asynchronously and tracks phases live, plus a command-line harness.
The stack is plain Python with Streamlit for the interface. For local inference it was tested with Ollama, and llama.cpp is supported through configuration too.
The folder layout that came out of the requirements:
deepagents-usecase/
├── agents/
│ ├── __init__.py # Package exports
│ ├── base.py # Abstract BaseAgent + ReAct loop
│ ├── llm_client.py # LLM adapter (Ollama/llama.cpp/OpenAI/Anthropic)
│ ├── messages.py # Typed message protocol
│ ├── orchestrator.py # OrchestratorAgent (top-level)
│ ├── researcher.py # ResearcherAgent specialist
│ ├── analyst.py # AnalystAgent specialist
│ └── writer.py # WriterAgent specialist
├── tools/
│ ├── __init__.py
│ ├── base.py # BaseTool + ToolResult
│ ├── calculator.py # Safe AST-based arithmetic evaluator
│ ├── code_executor.py # Sandboxed Python execution (exec with allow-list)
│ ├── file_reader.py # Sandboxed file reading (input/ only)
│ └── web_search.py # Web search (stub + live Tavily)
├── memory/
│ ├── __init__.py
│ └── store.py # AgentMemoryStore (short/long-term/episodic)
├── config/
│ ├── __init__.py
│ └── settings.py # Centralised env-based configuration
├── tests/
│ ├── test_tools.py # Tool unit tests (71 tests)
│ ├── test_memory.py # Memory unit tests (24 tests)
│ ├── test_agents.py # Agent integration tests (92 tests)
│ └── test_code_executor.py # Code Executor tests (134 tests)
├── input/ # Input documents (content gitignored)
├── output/ # Generated reports (content gitignored)
├── memory/ # Persistent agent memory (JSON files)
├── scripts/
│ ├── start.sh # Launch Streamlit in detached mode
│ ├── stop.sh # Gracefully stop the application
│ ├── cleanup.sh # Remove .venv, __pycache__, etc.
│ └── check_code_executor.py # Standalone Code Executor sanity-check
├── Docs/
│ ├── Architecture.md # Mermaid architecture diagrams
│ ├── Quickstart.md # Step-by-step getting started guide
│ ├── API.md # Full public API reference
│ └── CodeExecutorVerification.md # Code Executor test & verification guide
├── app.py # Streamlit web UI
├── main.py # CLI entry point
├── requirements.txt
├── .env.example # Environment variable template
└── README.md
Configuration is flexible, so the code adapts to common setups. The backend can talk to local runtimes such as llama.cpp and Ollama or to cloud providers reached with API keys. A commented environment file covers the settings:
# =============================================================================
# Deep Agents System — Environment Configuration
# =============================================================================
# Copy this file to .env and fill in your values.
# NEVER commit the .env file to version control.
#
# Usage:
# cp .env.example .env
# # edit .env with your actual keys
# =============================================================================
# ─── LLM Provider ──────────────────────────────────────────────────────────
# Select ONE provider. Comment out the others.
# Option A: Ollama (local, default — no API key needed)
LLM_PROVIDER=ollama
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=llama3.2
# Option B: llama.cpp (local)
# LLM_PROVIDER=llamacpp
# LLAMACPP_BASE_URL=http://localhost:9931/v1
# LLAMACPP_MODEL=local-model
# Option C: OpenAI
# LLM_PROVIDER=openai
# OPENAI_API_KEY=sk-...
# OPENAI_MODEL=gpt-4o
# Option D: Anthropic
# LLM_PROVIDER=anthropic
# ANTHROPIC_API_KEY=sk-ant-...
# ANTHROPIC_MODEL=claude-3-5-sonnet-20241022
# ─── Application Settings ──────────────────────────────────────────────────
APP_PORT=8501
APP_HOST=0.0.0.0
LOG_LEVEL=INFO
# ─── Agent Configuration ───────────────────────────────────────────────────
# Maximum reasoning steps per agent (lower = faster on slow local LLMs)
MAX_AGENT_STEPS=8
# Maximum tokens per LLM call (1024 is enough for ReAct; raise for longer reports)
MAX_TOKENS=1024
# Temperature for LLM responses (0.0 = deterministic, 1.0 = creative)
TEMPERATURE=0.1
# ─── Memory & Storage ──────────────────────────────────────────────────────
# Directory for persistent agent memory (relative to project root)
MEMORY_DIR=./memory
# Maximum number of memories to retain per agent
MAX_MEMORY_ENTRIES=100
# ─── Tool Configuration ────────────────────────────────────────────────────
# Enable or disable specific tools (true/false)
TOOL_WEB_SEARCH_ENABLED=true
TOOL_CALCULATOR_ENABLED=true
TOOL_FILE_READER_ENABLED=true
TOOL_CODE_EXECUTOR_ENABLED=true
# Web search stub — set to real Tavily/SerpAPI key for live search
# TAVILY_API_KEY=tvly-...
# ─── Output ────────────────────────────────────────────────────────────────
OUTPUT_DIR=./output
Code worth looking at
The app is there to show how tasks are split up and handed to separate toolsets. It's built to be extended, so extra tools and extra specialists slot in easily.
A safe arithmetic evaluator
Letting a model run arbitrary code for maths would be a security hole. So the calculator parses expressions with Python's ast module and accepts only an explicit whitelist of node types and functions, never a raw eval():
# Whitelisted AST operators and math functions
_SAFE_OPERATORS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.Pow: operator.pow,
ast.USub: operator.neg,
}
_SAFE_FUNCTIONS = {
"abs": abs, "round": round, "sqrt": math.sqrt,
"sin": math.sin, "cos": math.cos, "log": math.log,
"pi": math.pi, "e": math.e,
}
def _safe_eval(node: ast.expr) -> float:
"""Recursively evaluate an AST expression node in a safe sandbox."""
if isinstance(node, ast.Constant):
if isinstance(node.value, (int, float)):
return float(node.value)
raise ValueError(f"Unsupported constant type: {type(node.value).__name__}")
if isinstance(node, ast.BinOp):
op_type = type(node.op)
if op_type not in _SAFE_OPERATORS:
raise ValueError(f"Unsupported binary operator: {op_type.__name__}")
left = _safe_eval(node.left)
right = _safe_eval(node.right)
return _SAFE_OPERATORS[op_type](left, right)
if isinstance(node, ast.Call):
func_name = node.func.id
if func_name not in _SAFE_FUNCTIONS:
raise ValueError(f"Function '{func_name}' is not whitelisted.")
args = [_safe_eval(a) for a in node.args]
return _SAFE_FUNCTIONS[func_name](*args)
raise ValueError(f"Unsupported AST node type: {type(node).__name__}")
A sandboxed code executor
For heavier data work, the executor gives you an isolated namespace with restricted built-ins, a few scientific modules already imported, redirected output and a thread-based timeout:
def _build_exec_namespace() -> dict[str, Any]:
return {
"__builtins__": _SAFE_BUILTINS, # Explicit whitelist (no open, __import__, eval)
"math": math,
"statistics": statistics,
"pi": math.pi,
"e": math.e,
}
def _execute_code(code: str, timeout: int = 5) -> ToolResult:
exec_result = _ExecResult()
captured_io = io.StringIO()
def _worker():
namespace = _build_exec_namespace()
namespace["__builtins__"]["print"] = lambda *args, **kw: print(*args, **{**kw, "file": captured_io})
try:
exec(code, namespace)
exec_result.stdout = captured_io.getvalue()
except Exception as exc:
exec_result.error = f"{type(exc).__name__}: {exc}"
thread = threading.Thread(target=_worker, daemon=True)
thread.start()
thread.join(timeout=timeout)
if thread.is_alive():
return ToolResult(success=False, error=f"Execution timed out after {timeout}s.")
A thread-safe relay for the Streamlit UI
Streamlit worker threads can't write to st.session_state directly, because they lack a ScriptRunContext. The app works around that with a singleton relay cached through @st.cache_resource and guarded by a reentrant lock (threading.RLock), which lets agent steps stream across thread boundaries into the page:
class _PipelineRelay:
"""Thread-safe relay store for the running multi-agent pipeline."""
def __init__(self) -> None:
self.lock = threading.RLock()
self.status_messages: list[dict[str, str]] = []
self.agent_cards: dict[str, list[str]] = {}
self.running: bool = False
def append_status(self, msg_dict: dict[str, str]) -> None:
with self.lock:
self.status_messages.append(msg_dict)
agent_id = msg_dict.get("agent_id", "orchestrator")
self.agent_cards.setdefault(agent_id, []).append(msg_dict.get("detail", ""))
@st.cache_resource
def _get_relay() -> _PipelineRelay:
return _PipelineRelay()
A worked example: climate change
The input task was an analysis of how rising CO2 concentrations affect ecosystems worldwide, including current CO2 levels, the percentage rise since pre-industrial times, and a closing set of policy recommendations.
What happens next:
- The orchestrator splits the job into research, analysis and writing.
- The researcher runs three web searches, covering CO2 trends, ecosystem impact and policy frameworks.
- The analyst calls the calculator tool:
(421 - 280) / 280 * 100, which gives roughly a 50.36% increase. - The writer drafts a structured Markdown document with an executive summary, findings, data analysis and recommendations.
- The orchestrator merges everything into the final report.
The result is saved to output/deep_agents_report_<timestamp>.md, and a sample looks like this:
Benefits of Renewable Energy and Calculation of 280 Times 42
Benefits of Renewable Energy
Renewable energy reduces greenhouse gas emissions, contributing to climate change mitigation (Source: [National Renewable Energy Laboratory](https://www.nrel.gov/renewables/energy-benefits.html)). This is a key finding supported by credible sources, including the International Renewable Energy Agency (2020) and the World Health Organization (2020).
Renewable energy creates jobs and stimulates local economies (Source: [International Renewable Energy Agency](https://www.irena.org/publications/2020/Jun/Global-Status-Report-2020)). This is a significant benefit highlighted by the International Energy Agency (2020) and the National Bureau of Economic Research (2020).
Renewable energy improves air quality and public health (Source: [World Health Organization](https://www.who.int/news-room/fact-sheets/detail/air-pollution)). This is a critical aspect of renewable energy, supported by the National Renewable Energy Laboratory (n.d.).
The global renewable energy market is projected to reach 30% of total energy production by 2025 (Source: [International Energy Agency](https://www.iea.org/news/pressrelease/2020/june/global-renewables-report-2020/)). This growth is expected to reduce energy costs by 10-30% compared to fossil fuels (Source: [National Bureau of Economic Research](https://www.nber.org/papers/w28822)).
Calculation of 280 Times 42
The calculation of 280 times 42 yields 11,840. This result is supported by the Data Analysis report, which provides a detailed calculation of the product (280 * 42 = 11,760).
Trend Analysis
The global renewable energy market is expected to grow significantly, with a projected 30% share of total energy production by 2025. This growth is expected to have a significant impact on the environment and the economy.
Key Insights
1. Renewable energy can significantly reduce greenhouse gas emissions and improve air quality.
2. Renewable energy can create jobs and stimulate local economies.
Limitations
The data provided is based on projections and may not reflect actual outcomes.
Sources
1. National Renewable Energy Laboratory. (n.d.). Energy Benefits of Renewable Energy. Retrieved from <https://www.nrel.gov/renewables/energy-benefits.html>
2. International Renewable Energy Agency. (2020). Global Status Report 2020. Retrieved from <https://www.irena.org/publications/2020/Jun/Global-Status-Report-2020>
3. World Health Organization. (2020). Air pollution. Retrieved from <https://www.who.int/news-room/fact-sheets/detail/air-pollution>
4. International Energy Agency. (2020). Global Renewables Report 2020. Retrieved from <https://www.iea.org/news/pressrelease/2020/june/global-renewables-report-2020/>
5. National Bureau of Economic Research. (2020). The Economics of Renewable Energy. Retrieved from <https://www.nber.org/papers/w28822>
Adding another specialist
New tools and agents fit in with little friction. To add a specialist:
- Create
agents/my_specialist.pyas a subclass ofBaseAgent. - Implement the
role_descriptionproperty. - Register the agent in
OrchestratorAgent.__init__. - Add the delegation logic in
OrchestratorAgent.run_task.
A skeleton to start from:
from agents.base import BaseAgent
class MySpecialistAgent(BaseAgent):
def __init__(self, llm_client=None, on_status=None):
super().__init__(
agent_id="my_specialist",
tools=[my_custom_tool],
llm_client=llm_client,
on_status=on_status,
)
@property
def role_description(self) -> str:
return "You are a specialist that does X…"
Takeaway
The project shows the Deep Agents pattern working in practice: delegation down a hierarchy, tools made safe through an AST sandbox, and a UI that streams updates without breaking Streamlit's threading rules. Complex agent pipelines can run dependably against both cloud endpoints and local LLM runtimes such as Ollama and llama.cpp.