Module 3: Agents with create_agent

Project: Research Agent with Tools

Project overview

In the seven previous capsules you learned to create autonomous agents with create_agent, configure static and dynamic system prompts, manage agent state with TypedDict, stream the reasoning process, get structured output with Pydantic models, and map legacy APIs to modern ones. You saw each concept on its own. Now you're going to combine all of it into a real system: an autonomous research agent.

The agent takes a research question — "What are the main advantages of Rust over C++?" or "How does TypeScript's type system work?" — and automatically searches for information, extracts relevant data, runs calculations if it needs them, and generates a structured report with findings, sources, and a confidence level. The whole process is visible through streaming: you can watch in real time how the agent reasons, which tools it decides to call, and how it builds its conclusions.

What makes this project interesting isn't just that the agent calls tools — you already did that in Module 2. What's different is that the agent operates autonomously: you hand it the question and it decides how many times it needs to search, what data to extract, whether it needs to calculate something, and when it has enough information to generate the final report. The ReAct loop you learned in capsule 02 handles all the orchestration.

The result is a terminal research system that demonstrates everything you learned in this module: create_agent for orchestration, a system prompt to guide the behavior, streaming for visibility, and structured output for the final report.


Project goal

Build an autonomous research agent that takes questions, searches for information using multiple tools, and generates structured reports — showing the whole reasoning process via streaming.

When you complete this project:

  • 🔧 You'll know how to create autonomous agents with create_agent and multiple tools
  • 🔧 You'll design system prompts that guide the agent's research behavior
  • 🔧 You'll implement streaming to see the reasoning process step by step
  • 🔧 You'll use structured output (Pydantic) to generate reports in a defined format
  • 🔧 You'll have a working research system that integrates the whole module

Technical specifications

Tech stack

ComponentVersionPurpose
Python3.11+Runtime
LangChainv1.2+LLM framework
LangGraphv1.0+create_agent
langchain-openailatestModel provider
python-dotenvlatestEnvironment variables
pydanticv2+Structured output

Initial setup

pip install langchain langgraph langchain-openai python-dotenv pydantic

Create a .env file at the root of your project:

# .env
OPENAI_API_KEY=sk-...

Project structure

research-agent/
├── .env                    # API key
├── research_agent.py       # Main code (everything in one file)
└── requirements.txt        # Dependencies
# requirements.txt
langchain>=0.3.0
langgraph>=0.3.0
langchain-openai>=0.3.0
python-dotenv>=1.0.0
pydantic>=2.0.0

Step 1: Create the 3 research tools

The agent needs three tools that cover the pillars of any investigation: search for information, extract key data, and compute metrics.

Tool 1: web_search (mock web search)

Simulates a web search that returns results about technology, science, and general topics. In a real system, this would connect to Tavily, DuckDuckGo, or Brave Search.

from langchain_core.tools import tool

KNOWLEDGE_BASE = {
    "python": {
        "title": "Python Programming Language",
        "content": (
            "Python is a high-level, interpreted, general-purpose programming language. "
            "Created by Guido van Rossum in 1991. Used in AI/ML, data science, web development, "
            "automation and scripting. It has the largest library ecosystem for ML (PyTorch, "
            "TensorFlow, scikit-learn). Current version: 3.12. Dynamic typing with optional type hints. "
            "Community: >8M active developers."
        ),
        "source": "https://python.org",
    },
    "rust": {
        "title": "Rust Programming Language",
        "content": (
            "Rust is a systems programming language focused on safety, speed and "
            "concurrency. Created by Mozilla in 2010, v1.0 in 2015. Its ownership system eliminates "
            "null pointer errors and data races at compile time. Performance comparable to C/C++ with no "
            "garbage collector. Used in operating systems, browsers (Firefox, Chrome), CLI tools, "
            "and WebAssembly. Voted 'most loved language' 8 years in a row on Stack Overflow."
        ),
        "source": "https://rust-lang.org",
    },
    "typescript": {
        "title": "TypeScript Language",
        "content": (
            "TypeScript is a typed superset of JavaScript developed by Microsoft. It adds optional "
            "static types, interfaces, enums, and generics. It compiles to plain JavaScript. Adopted "
            "by Angular, Vue 3, Deno, and most modern frameworks. The type system is "
            "structural (not nominal). 78% of professional JS developers use TypeScript in 2025."
        ),
        "source": "https://typescriptlang.org",
    },
    "langchain": {
        "title": "LangChain Framework",
        "content": (
            "LangChain is the most widely adopted open-source framework for building applications with LLMs. "
            "It provides standardized interfaces for models, tools, agents, and workflows. Ecosystem: "
            "LangChain (high level), LangGraph (orchestration), LangSmith (observability). Modern API "
            "v1.2+ with create_agent, middleware system, and Functional API. >80K stars on GitHub."
        ),
        "source": "https://python.langchain.com",
    },
    "kubernetes": {
        "title": "Kubernetes Container Orchestration",
        "content": (
            "Kubernetes (K8s) is an open-source container orchestration system originally designed "
            "by Google. It automates deployment, scaling, and management of containerized "
            "applications. Features: auto-scaling, rolling updates, service discovery, load "
            "balancing, self-healing. Cloud-native foundation (CNCF). Used by >60% of Fortune 500 "
            "companies. Alternatives: Docker Swarm, ECS, Nomad."
        ),
        "source": "https://kubernetes.io",
    },
    "machine learning": {
        "title": "Machine Learning Overview",
        "content": (
            "Machine Learning is a branch of artificial intelligence that lets systems "
            "learn from data without being explicitly programmed. Types: supervised (classification, "
            "regression), unsupervised (clustering, dimensionality reduction), reinforcement "
            "learning. Main frameworks: PyTorch, TensorFlow, scikit-learn, JAX. The global ML "
            "market is estimated at $209B by 2029 (CAGR 38.8%)."
        ),
        "source": "https://en.wikipedia.org/wiki/Machine_learning",
    },
}


@tool
def web_search(query: str) -> str:
    """Search the web for information on any topic.
    Returns results with a title, content and source.
    Useful for getting data, definitions, comparisons, and general context.
    """
    if not query or len(query.strip()) < 3:
        return "Error: the search needs at least 3 characters."

    query_lower = query.lower()
    results = []

    for keyword, data in KNOWLEDGE_BASE.items():
        if keyword in query_lower:
            results.append(
                f"📄 {data['title']}\n"
                f"   {data['content']}\n"
                f"   Source: {data['source']}"
            )

    if results:
        return f"Found {len(results)} result(s) for '{query}':\n\n" + "\n\n".join(results)

    return (
        f"No specific results for '{query}'. "
        f"Available topics: Python, Rust, TypeScript, LangChain, Kubernetes, Machine Learning. "
        f"Try rephrasing the search with one of these topics."
    )

Tool 2: extract_info (mock data extraction)

Simulates extracting key data from a source. In a real system, this could use a scraper or a document parser.

@tool
def extract_info(topic: str, aspect: str) -> str:
    """Extract specific information about one aspect of a topic.
    Parameters:
    - topic: the main topic (e.g. 'Python', 'Rust')
    - aspect: which aspect to extract (e.g. 'advantages', 'disadvantages', 'use cases', 'comparison')
    """
    if not topic or not aspect:
        return "Error: provide both the topic and the aspect."

    topic_lower = topic.lower().strip()
    aspect_lower = aspect.lower().strip()

    extractions = {
        "python": {
            "advantages": (
                "Advantages of Python:\n"
                "1. Simple, readable syntax — ideal for beginners\n"
                "2. Massive ecosystem — PyPI has >400K packages\n"
                "3. Huge community — >8M active developers\n"
                "4. Versatility — web, AI/ML, scripting, automation\n"
                "5. High productivity — fast prototyping"
            ),
            "disadvantages": (
                "Disadvantages of Python:\n"
                "1. Execution speed — 10-100x slower than C/Rust\n"
                "2. GIL (Global Interpreter Lock) — limits real concurrency\n"
                "3. Memory usage — high compared to compiled languages\n"
                "4. Mobile development — limited support\n"
                "5. Runtime errors — dynamic typing lets bugs slip through to runtime"
            ),
            "use cases": (
                "Main use cases for Python:\n"
                "1. Machine Learning / AI (PyTorch, TensorFlow)\n"
                "2. Data Science (pandas, numpy, matplotlib)\n"
                "3. Web backend (Django, FastAPI, Flask)\n"
                "4. Automation and scripting\n"
                "5. DevOps and tooling"
            ),
        },
        "rust": {
            "advantages": (
                "Advantages of Rust:\n"
                "1. Memory safety with no garbage collector — ownership system\n"
                "2. C/C++ performance — zero-cost abstractions\n"
                "3. Safe concurrency — data races impossible at compile time\n"
                "4. Excellent tooling — cargo, clippy, rustfmt\n"
                "5. Interoperability — FFI with C, native WebAssembly"
            ),
            "disadvantages": (
                "Disadvantages of Rust:\n"
                "1. Steep learning curve — the borrow checker\n"
                "2. Long compile times\n"
                "3. Smaller ecosystem than Python/JS\n"
                "4. Fewer developers available on the market\n"
                "5. Verbosity in simple code"
            ),
            "use cases": (
                "Main use cases for Rust:\n"
                "1. Operating systems and kernels\n"
                "2. Browsers and rendering engines\n"
                "3. High-performance CLI tools\n"
                "4. WebAssembly\n"
                "5. Cloud infrastructure (Firecracker, TiKV)"
            ),
        },
        "typescript": {
            "advantages": (
                "Advantages of TypeScript:\n"
                "1. Static types — errors caught at compile time\n"
                "2. Better IDE support — autocomplete, refactoring\n"
                "3. Compatible with existing JavaScript\n"
                "4. Interfaces and generics — powerful abstraction\n"
                "5. Adopted by the industry — the de facto standard"
            ),
            "disadvantages": (
                "Disadvantages of TypeScript:\n"
                "1. Complexity of the advanced type system\n"
                "2. An extra compilation step\n"
                "3. Third-party library types can be wrong\n"
                "4. Initial configuration (tsconfig.json)\n"
                "5. False sense of safety — the runtime is still JS"
            ),
            "use cases": (
                "Main use cases for TypeScript:\n"
                "1. Frontend (React, Angular, Vue)\n"
                "2. Backend (Node.js, Deno, Bun)\n"
                "3. Full-stack frameworks (Next.js, Nuxt)\n"
                "4. CLI tools\n"
                "5. Libraries and SDKs"
            ),
        },
    }

    if topic_lower in extractions:
        topic_data = extractions[topic_lower]
        for key in topic_data:
            if key in aspect_lower:
                return topic_data[key]
        available = ", ".join(topic_data.keys())
        return f"I couldn't find the aspect '{aspect}' for {topic}. Available aspects: {available}"

    available_topics = ", ".join(extractions.keys())
    return f"I have no extraction data for '{topic}'. Available topics: {available_topics}"

Tool 3: calculator (calculations and metrics)

import math

@tool
def calculator(expression: str) -> str:
    """Evaluate math expressions. Useful for computing metrics,
    percentages, numeric comparisons, and statistics.
    Supports: +, -, *, /, **, (), sqrt(), abs(), round().
    Examples: '400000 / 8000000 * 100', 'sqrt(144)', '2**10'.
    """
    if not expression or not expression.strip():
        return "Error: empty expression."

    safe_dict = {
        "sqrt": math.sqrt,
        "abs": abs,
        "round": round,
        "pow": pow,
        "pi": math.pi,
        "e": math.e,
        "log": math.log,
        "log10": math.log10,
    }

    allowed_chars = set("0123456789+-*/.() ,epiabsqrtoundwlg")
    if not all(c in allowed_chars for c in expression.lower().replace(" ", "")):
        return f"Error: characters not allowed in '{expression}'."

    try:
        result = eval(expression, {"__builtins__": {}}, safe_dict)
        if isinstance(result, float):
            if result == int(result) and abs(result) < 1e15:
                return str(int(result))
            return str(round(result, 4))
        return str(result)
    except ZeroDivisionError:
        return "Error: division by zero."
    except Exception as e:
        return f"Error evaluating '{expression}': {e}"

Let's try the three tools:

print(web_search.invoke({"query": "Python programming"}))
print()
print(extract_info.invoke({"topic": "Rust", "aspect": "advantages"}))
print()
print(calculator.invoke({"expression": "400000 / 8000000 * 100"}))
# Expected output:
# Found 1 result(s) for 'Python programming':
#
# 📄 Python Programming Language
#    Python is a high-level programming language...
#    Source: https://python.org
#
# Advantages of Rust:
# 1. Memory safety with no garbage collector — ownership system
# ...
#
# 5

Step 2: Create the agent with create_agent and a system prompt

The system prompt is crucial. It tells the agent how to behave as a researcher: search exhaustively, extract specific data, and generate a report once it has enough information.

from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langgraph.prebuilt import create_agent

RESEARCH_PROMPT = """You are a specialized research agent. Your job is to research topics exhaustively and generate informative reports.

RESEARCH PROCESS:
1. First, use web_search to find general information about the topic
2. Then, use extract_info to get specific data (advantages, disadvantages, use cases)
3. If you need to compute metrics or percentages, use calculator
4. Once you have enough information, generate your final report

RULES:
- Always check at least 2 different sources or aspects before concluding
- If a search returns no results, rephrase the query and try again
- Include concrete data (numbers, percentages, years) whenever it's available
- Cite the sources you got the information from
- If you can't find enough information, say so honestly

Always answer in English."""

model = init_chat_model("openai:gpt-4.1-mini")

tools = [web_search, extract_info, calculator]

agent = create_agent(model, tools, prompt=RESEARCH_PROMPT)

Let's try it with a simple question:

result = agent.invoke(
    {"messages": [{"role": "user", "content": "Research Rust"}]}
)
print(result["messages"][-1].content)
# Expected output: A detailed report about Rust with data from web_search
# and extract_info, including advantages, use cases, and statistics.

Step 3: Add streaming to watch the reasoning process

Streaming lets you watch in real time how the agent thinks, which tools it decides to call, and how it builds its answer. This is fundamental for a research agent — you want to see the process, not just the result.

def stream_research(question: str) -> str:
    """Run a research pass with streaming of the whole process."""
    print(f"\n{'=' * 60}")
    print(f"  RESEARCH: {question}")
    print(f"{'=' * 60}\n")

    final_content = ""

    for chunk in agent.stream(
        {"messages": [{"role": "user", "content": question}]},
        stream_mode="updates",
    ):
        for node_name, node_output in chunk.items():
            if node_name == "agent":
                messages = node_output.get("messages", [])
                for msg in messages:
                    if msg.content:
                        print(f"\n💭 Agent reasoning:")
                        print(f"   {msg.content[:200]}")
                        final_content = msg.content

                    if hasattr(msg, "tool_calls") and msg.tool_calls:
                        for tc in msg.tool_calls:
                            args_preview = str(tc["args"])[:80]
                            print(f"\n🔧 Calling tool: {tc['name']}")
                            print(f"   Args: {args_preview}")

            elif node_name == "tools":
                messages = node_output.get("messages", [])
                for msg in messages:
                    preview = msg.content[:100] + "..." if len(msg.content) > 100 else msg.content
                    print(f"   📥 Result: {preview}")

    print(f"\n{'=' * 60}")
    print(f"  RESEARCH COMPLETE")
    print(f"{'=' * 60}\n")

    return final_content

Let's try it:

report = stream_research("What are the advantages and disadvantages of Python?")
print(f"\n📋 FINAL REPORT:\n{report}")
# Expected output:
# ============================================================
#   RESEARCH: What are the advantages and disadvantages of Python?
# ============================================================
#
# 🔧 Calling tool: web_search
#    Args: {'query': 'Python programming advantages disadvantages'}
#    📥 Result: Found 1 result(s) for 'Python programming advantages disadvantages':...
#
# 🔧 Calling tool: extract_info
#    Args: {'topic': 'Python', 'aspect': 'advantages'}
#    📥 Result: Advantages of Python: 1. Simple, readable syntax...
#
# 🔧 Calling tool: extract_info
#    Args: {'topic': 'Python', 'aspect': 'disadvantages'}
#    📥 Result: Disadvantages of Python: 1. Execution speed...
#
# 💭 Agent reasoning:
#    [Full report about Python]
#
# ============================================================
#   RESEARCH COMPLETE
# ============================================================

Step 4: Add structured output for the final report

The agent generates reports as free-form text. To make it professional, we define a Pydantic model that structures the report with specific fields: topic, summary, key findings, sources, and a confidence score.

from pydantic import BaseModel, Field

class ResearchReport(BaseModel):
    topic: str = Field(description="The topic researched")
    summary: str = Field(description="Executive summary in 2-3 sentences")
    key_findings: list[str] = Field(
        description="List of key findings (3-7 items)"
    )
    sources: list[str] = Field(
        description="URLs or references for the sources consulted"
    )
    confidence: float = Field(
        description="Confidence level in the results (0.0 to 1.0)",
        ge=0.0,
        le=1.0,
    )

Now we create a second model with structured output to generate the report:

report_model = init_chat_model("openai:gpt-4.1-mini")
structured_report_model = report_model.with_structured_output(ResearchReport)


def generate_structured_report(raw_report: str, question: str) -> ResearchReport:
    """Turn the free-form text report into a structured ResearchReport."""
    prompt = (
        f"Based on this research about '{question}', "
        f"generate a structured report.\n\n"
        f"Research:\n{raw_report}"
    )
    return structured_report_model.invoke(prompt)

Step 5: Interactive research loop

Let's tie it all together in an interactive loop that lets you research multiple topics.

def research_loop():
    """Interactive research loop."""
    print("=" * 60)
    print("  🔬 Research Agent with Tools")
    print("  Type a topic to research")
    print("  Type 'exit' to quit")
    print("=" * 60)

    while True:
        try:
            question = input("\n🔎 Research question: ").strip()
        except (KeyboardInterrupt, EOFError):
            print("\n\nSee you next time!")
            break

        if not question:
            continue

        if question.lower() in ("exit", "quit", "q"):
            print("\nSee you next time!")
            break

        raw_report = stream_research(question)

        if not raw_report:
            print("❌ The agent didn't generate a report. Try another question.")
            continue

        print("\n⏳ Generating structured report...")
        try:
            report = generate_structured_report(raw_report, question)

            print(f"\n{'=' * 60}")
            print(f"  📋 STRUCTURED REPORT")
            print(f"{'=' * 60}")
            print(f"\n📌 Topic: {report.topic}")
            print(f"\n📝 Summary: {report.summary}")
            print(f"\n🔑 Key findings:")
            for i, finding in enumerate(report.key_findings, 1):
                print(f"   {i}. {finding}")
            print(f"\n📚 Sources:")
            for source in report.sources:
                print(f"   - {source}")
            print(f"\n📊 Confidence: {report.confidence:.0%}")
            print(f"{'=' * 60}")

        except Exception as e:
            print(f"\n⚠️ Error generating the structured report: {e}")
            print(f"Free-form text report:\n{raw_report}")

Complete code

This is the full research_agent.py file. Copy it, set up your .env, and run it with python research_agent.py:

"""
Research Agent with Tools
Module 3 — LangChain & LangGraph: From Chains to Agents

Requires: pip install langchain langgraph langchain-openai python-dotenv pydantic
"""

import math

from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langgraph.prebuilt import create_agent
from pydantic import BaseModel, Field


# --- Knowledge Base (mock data) ---

KNOWLEDGE_BASE = {
    "python": {
        "title": "Python Programming Language",
        "content": (
            "Python is a high-level, interpreted, general-purpose programming language. "
            "Created by Guido van Rossum in 1991. Used in AI/ML, data science, web development, "
            "automation and scripting. It has the largest library ecosystem for ML (PyTorch, "
            "TensorFlow, scikit-learn). Current version: 3.12. Dynamic typing with optional type hints. "
            "Community: >8M active developers."
        ),
        "source": "https://python.org",
    },
    "rust": {
        "title": "Rust Programming Language",
        "content": (
            "Rust is a systems programming language focused on safety, speed and "
            "concurrency. Created by Mozilla in 2010, v1.0 in 2015. Its ownership system eliminates "
            "null pointer errors and data races at compile time. Performance comparable to C/C++ with no "
            "garbage collector. Used in operating systems, browsers (Firefox, Chrome), CLI tools, "
            "and WebAssembly. Voted 'most loved language' 8 years in a row on Stack Overflow."
        ),
        "source": "https://rust-lang.org",
    },
    "typescript": {
        "title": "TypeScript Language",
        "content": (
            "TypeScript is a typed superset of JavaScript developed by Microsoft. It adds optional "
            "static types, interfaces, enums, and generics. It compiles to plain JavaScript. Adopted "
            "by Angular, Vue 3, Deno, and most modern frameworks. The type system is "
            "structural (not nominal). 78% of professional JS developers use TypeScript in 2025."
        ),
        "source": "https://typescriptlang.org",
    },
    "langchain": {
        "title": "LangChain Framework",
        "content": (
            "LangChain is the most widely adopted open-source framework for building applications with LLMs. "
            "It provides standardized interfaces for models, tools, agents, and workflows. Ecosystem: "
            "LangChain (high level), LangGraph (orchestration), LangSmith (observability). Modern API "
            "v1.2+ with create_agent, middleware system, and Functional API. >80K stars on GitHub."
        ),
        "source": "https://python.langchain.com",
    },
    "kubernetes": {
        "title": "Kubernetes Container Orchestration",
        "content": (
            "Kubernetes (K8s) is an open-source container orchestration system originally designed "
            "by Google. It automates deployment, scaling, and management of containerized "
            "applications. Features: auto-scaling, rolling updates, service discovery, load "
            "balancing, self-healing. Cloud-native foundation (CNCF). Used by >60% of Fortune 500 "
            "companies. Alternatives: Docker Swarm, ECS, Nomad."
        ),
        "source": "https://kubernetes.io",
    },
    "machine learning": {
        "title": "Machine Learning Overview",
        "content": (
            "Machine Learning is a branch of artificial intelligence that lets systems "
            "learn from data without being explicitly programmed. Types: supervised (classification, "
            "regression), unsupervised (clustering, dimensionality reduction), reinforcement "
            "learning. Main frameworks: PyTorch, TensorFlow, scikit-learn, JAX. The global ML "
            "market is estimated at $209B by 2029 (CAGR 38.8%)."
        ),
        "source": "https://en.wikipedia.org/wiki/Machine_learning",
    },
}

EXTRACTIONS = {
    "python": {
        "advantages": (
            "Advantages of Python:\n"
            "1. Simple, readable syntax — ideal for beginners\n"
            "2. Massive ecosystem — PyPI has >400K packages\n"
            "3. Huge community — >8M active developers\n"
            "4. Versatility — web, AI/ML, scripting, automation\n"
            "5. High productivity — fast prototyping"
        ),
        "disadvantages": (
            "Disadvantages of Python:\n"
            "1. Execution speed — 10-100x slower than C/Rust\n"
            "2. GIL (Global Interpreter Lock) — limits real concurrency\n"
            "3. Memory usage — high compared to compiled languages\n"
            "4. Mobile development — limited support\n"
            "5. Runtime errors — dynamic typing lets bugs slip through to runtime"
        ),
        "use cases": (
            "Main use cases for Python:\n"
            "1. Machine Learning / AI (PyTorch, TensorFlow)\n"
            "2. Data Science (pandas, numpy, matplotlib)\n"
            "3. Web backend (Django, FastAPI, Flask)\n"
            "4. Automation and scripting\n"
            "5. DevOps and tooling"
        ),
    },
    "rust": {
        "advantages": (
            "Advantages of Rust:\n"
            "1. Memory safety with no garbage collector — ownership system\n"
            "2. C/C++ performance — zero-cost abstractions\n"
            "3. Safe concurrency — data races impossible at compile time\n"
            "4. Excellent tooling — cargo, clippy, rustfmt\n"
            "5. Interoperability — FFI with C, native WebAssembly"
        ),
        "disadvantages": (
            "Disadvantages of Rust:\n"
            "1. Steep learning curve — the borrow checker\n"
            "2. Long compile times\n"
            "3. Smaller ecosystem than Python/JS\n"
            "4. Fewer developers available on the market\n"
            "5. Verbosity in simple code"
        ),
        "use cases": (
            "Main use cases for Rust:\n"
            "1. Operating systems and kernels\n"
            "2. Browsers and rendering engines\n"
            "3. High-performance CLI tools\n"
            "4. WebAssembly\n"
            "5. Cloud infrastructure (Firecracker, TiKV)"
        ),
    },
    "typescript": {
        "advantages": (
            "Advantages of TypeScript:\n"
            "1. Static types — errors caught at compile time\n"
            "2. Better IDE support — autocomplete, refactoring\n"
            "3. Compatible with existing JavaScript\n"
            "4. Interfaces and generics — powerful abstraction\n"
            "5. Adopted by the industry — the de facto standard"
        ),
        "disadvantages": (
            "Disadvantages of TypeScript:\n"
            "1. Complexity of the advanced type system\n"
            "2. An extra compilation step\n"
            "3. Third-party library types can be wrong\n"
            "4. Initial configuration (tsconfig.json)\n"
            "5. False sense of safety — the runtime is still JS"
        ),
        "use cases": (
            "Main use cases for TypeScript:\n"
            "1. Frontend (React, Angular, Vue)\n"
            "2. Backend (Node.js, Deno, Bun)\n"
            "3. Full-stack frameworks (Next.js, Nuxt)\n"
            "4. CLI tools\n"
            "5. Libraries and SDKs"
        ),
    },
}


# --- Tools ---

@tool
def web_search(query: str) -> str:
    """Search the web for information on any topic.
    Returns results with a title, content and source.
    Useful for getting data, definitions, comparisons, and general context.
    """
    if not query or len(query.strip()) < 3:
        return "Error: the search needs at least 3 characters."

    query_lower = query.lower()
    results = []

    for keyword, data in KNOWLEDGE_BASE.items():
        if keyword in query_lower:
            results.append(
                f"📄 {data['title']}\n"
                f"   {data['content']}\n"
                f"   Source: {data['source']}"
            )

    if results:
        return f"Found {len(results)} result(s) for '{query}':\n\n" + "\n\n".join(results)

    return (
        f"No specific results for '{query}'. "
        f"Available topics: Python, Rust, TypeScript, LangChain, Kubernetes, Machine Learning. "
        f"Try rephrasing the search with one of these topics."
    )


@tool
def extract_info(topic: str, aspect: str) -> str:
    """Extract specific information about one aspect of a topic.
    Parameters:
    - topic: the main topic (e.g. 'Python', 'Rust')
    - aspect: which aspect to extract (e.g. 'advantages', 'disadvantages', 'use cases')
    """
    if not topic or not aspect:
        return "Error: provide both the topic and the aspect."

    topic_lower = topic.lower().strip()
    aspect_lower = aspect.lower().strip()

    if topic_lower in EXTRACTIONS:
        topic_data = EXTRACTIONS[topic_lower]
        for key in topic_data:
            if key in aspect_lower:
                return topic_data[key]
        available = ", ".join(topic_data.keys())
        return f"I couldn't find the aspect '{aspect}' for {topic}. Available aspects: {available}"

    available_topics = ", ".join(EXTRACTIONS.keys())
    return f"I have no extraction data for '{topic}'. Available topics: {available_topics}"


@tool
def calculator(expression: str) -> str:
    """Evaluate math expressions. Useful for computing metrics,
    percentages, numeric comparisons, and statistics.
    Supports: +, -, *, /, **, (), sqrt(), abs(), round().
    Examples: '400000 / 8000000 * 100', 'sqrt(144)', '2**10'.
    """
    if not expression or not expression.strip():
        return "Error: empty expression."

    safe_dict = {
        "sqrt": math.sqrt,
        "abs": abs,
        "round": round,
        "pow": pow,
        "pi": math.pi,
        "e": math.e,
        "log": math.log,
        "log10": math.log10,
    }

    allowed_chars = set("0123456789+-*/.() ,epiabsqrtoundwlg")
    if not all(c in allowed_chars for c in expression.lower().replace(" ", "")):
        return f"Error: characters not allowed in '{expression}'."

    try:
        result = eval(expression, {"__builtins__": {}}, safe_dict)
        if isinstance(result, float):
            if result == int(result) and abs(result) < 1e15:
                return str(int(result))
            return str(round(result, 4))
        return str(result)
    except ZeroDivisionError:
        return "Error: division by zero."
    except Exception as e:
        return f"Error evaluating '{expression}': {e}"


# --- Structured Output Model ---

class ResearchReport(BaseModel):
    topic: str = Field(description="The topic researched")
    summary: str = Field(description="Executive summary in 2-3 sentences")
    key_findings: list[str] = Field(
        description="List of key findings (3-7 items)"
    )
    sources: list[str] = Field(
        description="URLs or references for the sources consulted"
    )
    confidence: float = Field(
        description="Confidence level in the results (0.0 to 1.0)",
        ge=0.0,
        le=1.0,
    )


# --- Agent Setup ---

RESEARCH_PROMPT = """You are a specialized research agent. Your job is to research topics exhaustively and generate informative reports.

RESEARCH PROCESS:
1. First, use web_search to find general information about the topic
2. Then, use extract_info to get specific data (advantages, disadvantages, use cases)
3. If you need to compute metrics or percentages, use calculator
4. Once you have enough information, generate your final report

RULES:
- Always check at least 2 different sources or aspects before concluding
- If a search returns no results, rephrase the query and try again
- Include concrete data (numbers, percentages, years) whenever it's available
- Cite the sources you got the information from
- If you can't find enough information, say so honestly

Always answer in English."""

model = init_chat_model("openai:gpt-4.1-mini")

tools = [web_search, extract_info, calculator]

agent = create_agent(model, tools, prompt=RESEARCH_PROMPT)

report_model = init_chat_model("openai:gpt-4.1-mini")
structured_report_model = report_model.with_structured_output(ResearchReport)


# --- Streaming ---

def stream_research(question: str) -> str:
    """Run a research pass with streaming of the whole process."""
    print(f"\n{'=' * 60}")
    print(f"  RESEARCH: {question}")
    print(f"{'=' * 60}\n")

    final_content = ""

    for chunk in agent.stream(
        {"messages": [{"role": "user", "content": question}]},
        stream_mode="updates",
    ):
        for node_name, node_output in chunk.items():
            if node_name == "agent":
                messages = node_output.get("messages", [])
                for msg in messages:
                    if msg.content:
                        print(f"\n💭 Agent reasoning:")
                        print(f"   {msg.content[:200]}")
                        final_content = msg.content

                    if hasattr(msg, "tool_calls") and msg.tool_calls:
                        for tc in msg.tool_calls:
                            args_preview = str(tc["args"])[:80]
                            print(f"\n🔧 Calling tool: {tc['name']}")
                            print(f"   Args: {args_preview}")

            elif node_name == "tools":
                messages = node_output.get("messages", [])
                for msg in messages:
                    preview = msg.content[:100] + "..." if len(msg.content) > 100 else msg.content
                    print(f"   📥 Result: {preview}")

    print(f"\n{'=' * 60}")
    print(f"  RESEARCH COMPLETE")
    print(f"{'=' * 60}\n")

    return final_content


def generate_structured_report(raw_report: str, question: str) -> ResearchReport:
    """Turn the free-form text report into a structured ResearchReport."""
    prompt = (
        f"Based on this research about '{question}', "
        f"generate a structured report.\n\n"
        f"Research:\n{raw_report}"
    )
    return structured_report_model.invoke(prompt)


# --- Interactive Loop ---

def research_loop():
    """Interactive research loop."""
    print("=" * 60)
    print("  🔬 Research Agent with Tools")
    print("  Type a topic to research")
    print("  Type 'exit' to quit")
    print("=" * 60)

    while True:
        try:
            question = input("\n🔎 Research question: ").strip()
        except (KeyboardInterrupt, EOFError):
            print("\n\nSee you next time!")
            break

        if not question:
            continue

        if question.lower() in ("exit", "quit", "q"):
            print("\nSee you next time!")
            break

        raw_report = stream_research(question)

        if not raw_report:
            print("❌ The agent didn't generate a report. Try another question.")
            continue

        print("\n⏳ Generating structured report...")
        try:
            report = generate_structured_report(raw_report, question)

            print(f"\n{'=' * 60}")
            print(f"  📋 STRUCTURED REPORT")
            print(f"{'=' * 60}")
            print(f"\n📌 Topic: {report.topic}")
            print(f"\n📝 Summary: {report.summary}")
            print(f"\n🔑 Key findings:")
            for i, finding in enumerate(report.key_findings, 1):
                print(f"   {i}. {finding}")
            print(f"\n📚 Sources:")
            for source in report.sources:
                print(f"   - {source}")
            print(f"\n📊 Confidence: {report.confidence:.0%}")
            print(f"{'=' * 60}")

        except Exception as e:
            print(f"\n⚠️ Error generating the structured report: {e}")
            print(f"Free-form text report:\n{raw_report}")


if __name__ == "__main__":
    research_loop()

Run it:

python research_agent.py

Success criteria

Your project is complete when you meet all four criteria:

  • The agent runs multiple tool calls in an autonomous sequence — the agent decides on its own when to search, when to extract, and when to calculate. You don't tell it which tools to use; it decides
  • Streaming shows the thinking process — you can see in real time which tools the agent calls, with which arguments, and what results it gets back
  • The final report has a defined structure (Pydantic model) — the ResearchReport holds topic, summary, key_findings, sources, and confidence as typed fields
  • The agent stops after gathering enough information — it doesn't fall into an infinite loop; once it has enough data, it generates the report and finishes

How to test it with different topics

Test 1: Researching a language

🔎 Research question: What are the advantages of Rust over other languages?

🔧 Calling tool: web_search
   Args: {'query': 'Rust programming language advantages'}
   📥 Result: Found 1 result(s) for 'Rust programming language advantages': 📄 Rust Progr...

🔧 Calling tool: extract_info
   Args: {'topic': 'Rust', 'aspect': 'advantages'}
   📥 Result: Advantages of Rust: 1. Memory safety with no garbage collector...

🔧 Calling tool: extract_info
   Args: {'topic': 'Rust', 'aspect': 'use cases'}
   📥 Result: Main use cases for Rust: 1. Operating systems and kernels...

💭 Agent reasoning:
   [Detailed report about Rust]

📋 STRUCTURED REPORT
📌 Topic: Rust Programming Language
📝 Summary: Rust is a systems language that combines...
🔑 Key findings:
   1. Memory safety with no garbage collector...
   2. Performance comparable to C/C++...
   3. ...
📚 Sources:
   - https://rust-lang.org
📊 Confidence: 85%

Test 2: Research with calculations

🔎 Research question: Compare the adoption of Python vs TypeScript in the industry

🔧 Calling tool: web_search
   Args: {'query': 'Python programming adoption'}
   📥 Result: ...

🔧 Calling tool: web_search
   Args: {'query': 'TypeScript adoption'}
   📥 Result: ...

🔧 Calling tool: calculator
   Args: {'expression': '8000000 / 78 * 100'}
   📥 Result: ...

💭 Agent reasoning:
   [Detailed comparison with numbers]

Test 3: A topic with no data available

🔎 Research question: How does photosynthesis work?

🔧 Calling tool: web_search
   Args: {'query': 'photosynthesis'}
   📥 Result: No specific results for 'photosynthesis'...

💭 Agent reasoning:
   I couldn't find enough information about photosynthesis in my sources.
   The available topics are technology-related...

Test 4: Research that needs multiple aspects

🔎 Research question: Give me a full analysis of Python: advantages, disadvantages and use cases

🔧 Calling tool: web_search
   Args: {'query': 'Python programming language'}
🔧 Calling tool: extract_info
   Args: {'topic': 'Python', 'aspect': 'advantages'}
🔧 Calling tool: extract_info
   Args: {'topic': 'Python', 'aspect': 'disadvantages'}
🔧 Calling tool: extract_info
   Args: {'topic': 'Python', 'aspect': 'use cases'}

💭 Agent reasoning:
   [Full analysis with 4 data sources]

Common errors

1. ModuleNotFoundError: No module named 'langgraph'

Cause: You didn't install langgraph, which is a separate package from langchain.

pip install langgraph

2. ImportError: cannot import name 'create_agent' from 'langgraph.prebuilt'

Cause: An old version of langgraph. create_agent requires langgraph>=0.3.0.

pip install --upgrade langgraph

3. The agent doesn't call tools and answers directly

Cause: The system prompt isn't directive enough, or the question is too general. The model decides it can answer without tools.

Fix: Make the system prompt more explicit about when to use tools. The line "Always check at least 2 different sources or aspects before concluding" helps, but you can reinforce it with "ALWAYS use web_search as the first step".

4. ValidationError in the ResearchReport

pydantic.ValidationError: 1 validation error for ResearchReport
confidence: Input should be less than or equal to 1

Cause: The model generated a confidence greater than 1.0 (for example, 85 instead of 0.85).

Fix: The Field already has ge=0.0, le=1.0. If the error persists, add an instruction to the generate_structured_report prompt: "confidence must be a float between 0.0 and 1.0 (not a percentage)".

5. Streaming shows nothing and then prints everything at once

Cause: You're using stream_mode="values", which waits for each node to finish. Use stream_mode="updates" to see incremental updates.

Fix: Check that the code uses stream_mode="updates":

for chunk in agent.stream(
    {"messages": [...]},
    stream_mode="updates",  # ← not "values"
):

6. The agent falls into an infinite loop of tool calls

Cause: The agent can't find enough information and keeps trying rephrased queries. This is more common with topics that aren't in the knowledge base.

Fix: create_agent has a default iteration limit. If you need to adjust it, pass max_iterations when creating the agent:

agent = create_agent(model, tools, prompt=RESEARCH_PROMPT)
result = agent.invoke(
    {"messages": [...]},
    config={"recursion_limit": 25},
)

7. with_structured_output returns None

Cause: The model couldn't generate output that satisfies the schema. This can happen if the input report is very short or doesn't hold enough information to fill in every field.

Fix: Add a try/except and use the raw report as a fallback:

try:
    report = generate_structured_report(raw_report, question)
except Exception:
    print("Falling back to the free-form text report (structured output failed)")
    print(raw_report)

8. AuthenticationError or RateLimitError

Cause: An invalid API key, or you went over your usage limit. The agent makes multiple calls to the model (one per iteration of the ReAct loop + one for the structured output).

Fix: Check your API key and your usage. The agent can make 5-10 model calls per research pass, so each investigation burns more tokens than a single call.


Ideas to extend it

If you finished the project and want to go further:

  • 🚀 Real APIs — Replace the mock web_search with the Tavily Search API (tavily-python) or DuckDuckGo Search (duckduckgo-search). Replace extract_info with a real scraper using beautifulsoup4
  • 🚀 Memory across investigations — Add checkpointer=MemorySaver() to the agent so it remembers previous research. You can ask "What did you research about Python earlier?" and the agent remembers
  • 🚀 Export reports — Add a tool that writes the ResearchReport out as a Markdown or JSON file. Use json.dumps(report.model_dump(), indent=2) to serialize it
  • 🚀 Multiple models — Use a fast model (gpt-4.1-mini) for the tool-calling iterations and a powerful model (gpt-4.1) to generate the final structured report
  • 🚀 Report validation — Add a step where another model reviews the report and suggests corrections or areas that still need research
  • 🚀 Metrics dashboard — Track how many tools were called, how many tokens were consumed, and how long each investigation took

Connection with the next module

In this project you created a working agent with create_agent, but all the customization was limited to the system prompt and the tools. What if you want the agent to use a cheap model for searching and a powerful model to generate the report? Or to filter tools depending on the type of question? Or to log every model call for monitoring?

In Module 4: Middleware and Customization, you'll learn to intercept and modify an agent's behavior without rewriting it. With @wrap_model_call you can swap the model at runtime, with @wrap_tool_call you can add logging to every tool call, and with @dynamic_prompt you can change the system prompt based on context. The very research agent you built here could benefit enormously from middleware: dynamic model routing (mini for search, powerful for the report), logging of every tool call, and prompts that adapt to the research topic.


Resources for the project

  1. LangGraph create_agent — Reference for the create_agent API
  2. Streaming in LangGraph — Official guide to agent streaming
  3. Structured Output — Guide to with_structured_output with Pydantic
  4. LangChain Tools — Tool concepts in LangChain
  5. ReAct Pattern Paper — The original ReAct paper (Reason + Act)
  6. Pydantic v2 Documentation — Pydantic reference for structured models

Module 3 — LangChain & LangGraph: From Chains to Agents