Build a Multi-Agent Research Platform with LangGraph and A2A
Source: mortalapps.comThis guide details how to build a sophisticated multi agent research platform in Python using LangGraph. You will construct a supervisor agent that coordinates specialized sub-agents for distinct research tasks: web search, academic paper retrieval, and code execution. This platform addresses the common business problem of needing rapid, comprehensive, and verifiable insights from diverse data sources, often requiring complex, iterative reasoning beyond what a single LLM call can provide. By delegating tasks to expert agents and facilitating their communication via Redis Pub/Sub messaging (referred to here as A2A communication - note this is a custom inter-agent messaging pattern, not the official Google A2A interoperability protocol), the system efficiently gathers, synthesizes, and presents research findings.
This project is for AI engineers, data scientists, and developers looking to move beyond single-agent applications to robust, production-ready multi-agent systems. It emphasizes practical implementation, focusing on state management, tool integration, and inter-agent communication patterns essential for real-world scenarios. We'll leverage shared episodic memory to maintain context across agent interactions, ensuring continuity and reducing redundant work.
At the end of this guide, you will have a fully functional, deployable multi-agent research platform capable of executing complex research queries, synthesizing findings, and streaming results. The architecture is designed for extensibility, allowing for easy integration of new specialist agents or tools. This approach offers superior performance and reliability compared to monolithic agent designs, by breaking down complex problems into manageable, parallelizable sub-tasks.
What You Will Build
You will build a multi-agent research platform that acts as an intelligent coordinator for complex research tasks. The core system will feature a 'Supervisor' agent, responsible for understanding user queries, delegating work to specialist agents, and synthesizing their findings. These specialist agents include a 'Web Search Agent' for real-time information retrieval, an 'Academic Paper Agent' for accessing scholarly databases, and a 'Code Execution Agent' for data analysis or prototyping. Each agent will utilize specific tools relevant to its domain.
The agents will communicate using the A2A (Agent-to-Agent) protocol, allowing for structured and traceable information exchange. A shared episodic memory, backed by a vector database, will enable agents to recall past interactions and research findings, preventing redundant work and enriching context. The platform will run as a Python service, exposing a streaming API for real-time updates on research progress and final results. The output will be a structured research report, iteratively built by the collaborative efforts of the agents, delivered via a streaming interface. The final architecture consists of a central LangGraph state machine orchestrating a supervisor node, which conditionally invokes specialist agent subgraphs, each equipped with specific tools and all sharing a common episodic memory store, communicating through A2A messages.
Technology Stack
| Component | Technology | Why This Choice |
|---|---|---|
| LLM | OpenAI GPT-4o |
Chosen for its advanced reasoning capabilities, function calling, and broad availability, making it suitable for complex research tasks and agent coordination. |
| Orchestration | LangGraph |
Provides a robust framework for building stateful, multi-actor applications with cyclical graphs. Its ability to define nodes, edges, and conditional routing is ideal for the supervisor pattern and agentic workflows. |
| Memory | ChromaDB (local/embedded), Redis |
ChromaDB offers a lightweight, embedded vector store for episodic memory, suitable for local development and scalable to a client-server model. Redis is used for ephemeral working memory and caching A2A messages. |
| Tool Execution | LangChain Tools, Tavily Search, ArXiv API, Code Interpreter (local/sandbox) |
Leverages LangChain's extensive tool ecosystem for seamless integration of external capabilities. Tavily provides efficient web search, ArXiv for academic papers, and a local Python interpreter for code execution. |
| Observability | LangSmith, OpenTelemetry |
LangSmith provides excellent tracing and debugging for LangChain/LangGraph applications, crucial for understanding multi-agent interactions. OpenTelemetry offers a standardized way to collect and export traces, metrics, and logs for broader system observability. |
| Auth | API Key / OAuth (conceptual) |
For this project, API key authentication for LLMs and external tools is sufficient. For production, a robust OAuth 2.1 flow would be integrated, especially for A2A communication. |
| Deployment | Docker, FastAPI |
Docker ensures reproducible environments across development and production. FastAPI provides a high-performance asynchronous web framework for exposing the agent as a service with streaming capabilities. |
The choice of OpenAI's GPT-4o as the core LLM is driven by its exceptional performance in complex reasoning, instruction following, and function calling, which are critical for an effective research agent. While other LLMs exist, GPT-4o offers a strong balance of capability and cost for this type of demanding, multi-step reasoning task. We acknowledge alternatives like Anthropic's Claude or Google's Gemini, but GPT-4o's current lead in general intelligence and tool integration makes it the preferred choice for this project.
LangGraph is selected as the orchestration framework due to its native support for stateful, cyclical agent graphs, which is essential for implementing the supervisor pattern and allowing agents to iterate and self-correct. Unlike simpler sequential agent frameworks, LangGraph's explicit state management and conditional routing provide the flexibility needed for complex multi-agent workflows. Alternatives like CrewAI offer similar multi-agent capabilities, but LangGraph's lower-level control over graph execution and state transitions offers greater architectural flexibility for advanced patterns.
For memory, a hybrid approach with ChromaDB for episodic memory and Redis for working memory and caching is employed. ChromaDB is lightweight and easy to embed, making it ideal for managing long-term context and past research findings, and can scale to a client-server setup. Redis provides fast, in-memory storage for transient state and A2A message queues, crucial for performance. This combination balances persistence and speed effectively. Alternatives like FAISS or Pinecone could be used for the vector store, but ChromaDB's simplicity for initial setup is a benefit.
Tool integration leverages LangChain's extensive ecosystem, specifically for web search via Tavily and academic paper retrieval via ArXiv. For code execution, a secure local Python interpreter is used. This modular approach allows for easy swapping or addition of tools. The FastAPI framework is chosen for the service layer because of its modern asynchronous capabilities and built-in data validation, which are perfect for creating a high-performance, streaming API for the research platform. It provides a robust foundation for exposing the agent's capabilities to external systems and users, offering better performance and developer experience than Flask for this use case.
Project Structure
multi-agent-research-platform/
├── .env
├── pyproject.toml
├── poetry.lock
├── README.md
├── Dockerfile
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── api.py
│ ├── core/
│ │ ├── __init__.py
│ │ ├── agent_state.py
│ │ ├── supervisor.py
│ │ ├── specialist_agents.py
│ │ └── graph.py
│ ├── tools/
│ │ ├── __init__.py
│ │ ├── web_search.py
│ │ ├── academic_search.py
│ │ └── code_interpreter.py
│ └── memory/
│ ├── __init__.py
│ ├── episodic_memory.py
│ └── a2a_message_queue.py
├── tests/
│ ├── __init__.py
│ └── test_agent_flow.py
└── scripts/
└── run_agent.py
The multi-agent-research-platform/ root directory contains project configuration and the main application code. The .env file stores environment variables, including API keys and configurations, ensuring secrets are not hardcoded. pyproject.toml and poetry.lock manage Python dependencies using Poetry, providing reproducible environments. README.md will contain setup and usage instructions for the project.
The Dockerfile defines the containerization process for deploying the application. The app/ directory houses all core application logic. app/main.py is the entry point for the FastAPI application, while app/api.py defines the REST and streaming endpoints. The app/core/ directory contains the LangGraph-specific components: agent_state.py defines the shared state, supervisor.py implements the main supervisor agent logic, specialist_agents.py contains the definitions for the web search, academic, and code execution agents, and graph.py assembles the entire LangGraph workflow.
app/tools/ holds the implementations for external tools like web_search.py (Tavily), academic_search.py (ArXiv), and code_interpreter.py. The app/memory/ directory manages memory components, with episodic_memory.py handling ChromaDB interactions and a2a_message_queue.py managing Redis for A2A communication. Finally, tests/ contains unit and integration tests, and scripts/ provides utility scripts, such as run_agent.py for local testing.
Implementation
Phase 1: Environment Setup and Core Agent State
This phase establishes the project's foundational environment, including dependency management, environment variables, and the initial LangGraph agent state definition. It ensures all necessary libraries are installed and configured correctly before building the agent logic.
import os
import logging
from typing import TypedDict, List, Annotated, Union
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, END
import sqlite3
from langgraph.checkpoint.sqlite import SqliteSaver
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# --- Environment Configuration ---
# Ensure OPENAI_API_KEY is set in your .env file or environment variables
if not os.environ.get("OPENAI_API_KEY"): # Using os.environ.get for safety
logger.error("OPENAI_API_KEY environment variable not set. Please set it to proceed.")
raise ValueError("OPENAI_API_KEY not set")
# --- LangGraph Agent State Definition ---
# This defines the state of our graph. It is typed for clarity and error checking.
class AgentState(TypedDict):
input: str # The initial user query
chat_history: Annotated[List[BaseMessage], "append"] # Conversation history
research_results: Annotated[List[str], "append"] # Accumulated research findings
next_agent: str # The next agent to be invoked (e.g., 'web_search', 'academic', 'code', 'supervisor')
current_task: str # The specific task being worked on by an agent
# --- LLM Initialization (for supervisor) ---
def get_llm():
try:
return ChatOpenAI(model="gpt-4o", temperature=0.5, streaming=True)
except Exception as e:
logger.error(f"Failed to initialize LLM: {e}")
raise
# --- Main application entry point for testing setup ---
if __name__ == "__main__":
print("
--- Phase 1: Environment Setup and Core Agent State ---")
print("Checking environment variables...")
try:
llm = get_llm()
print(f"LLM initialized successfully with model: {llm.model_name}")
except ValueError as e:
print(f"Error: {e}")
print("Please ensure OPENAI_API_KEY is set in your environment or a .env file.")
exit(1)
print("
AgentState definition:")
for key, value in AgentState.__annotations__.items():
print(f" {key}: {value}")
print("
Phase 1 setup complete. Ready to define the graph.")
# Example of how to use SqliteSaver (will be used in later phases)
memory = SqliteSaver(conn=sqlite3.connect(":memory:", check_same_thread=False))
print(f"Initialized in-memory SqliteSaver for future use: {memory}")
OPENAI_API_KEY, demonstrating a production-ready practice of never hardcoding sensitive information and failing fast if required variables are missing. The os.environ.get() method is used for secure access to environment variables.
The core of this phase is the AgentState TypedDict definition. This TypedDict represents the shared state that will be passed between different nodes (agents) in our LangGraph. It includes input for the initial query, chat_history to maintain conversational context, research_results to accumulate findings from specialist agents, next_agent for routing decisions, and current_task for detailed task tracking. The Annotated type hint with operator.add is a LangGraph reducer that tells the state graph to append new values to these lists rather than overwriting them (a callable reducer is required - string literals like "append" are not valid), which is essential for accumulating history and results.
Finally, a utility function get_llm is provided to safely initialize the ChatOpenAI instance, including error handling for LLM initialization failures. The if __name__ == "__main__" block serves as a simple test to verify that the environment is correctly set up and the AgentState is properly defined, providing immediate feedback to the developer. The inclusion of SqliteSaver initialization, even if not fully used yet, foreshadows its role in persistent state management in later phases.
Run python app/core/agent_state.py (or the equivalent script if placed elsewhere) from your terminal. Ensure no ValueError related to OPENAI_API_KEY is raised and that the LLM initialization message appears, followed by the AgentState definition.
Phase 2: Supervisor Agent and Initial Graph Structure
This phase implements the central 'Supervisor' agent, responsible for interpreting the user's research query and delegating it to the appropriate specialist agent. It also establishes the basic LangGraph structure, including the initial entry point, the supervisor node, and conditional routing logic.
import os
import logging
from typing import TypedDict, List, Annotated, Union, Literal
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, END
import sqlite3
from langgraph.checkpoint.sqlite import SqliteSaver
from langchain_core.prompts import ChatPromptTemplate
from pydantic import BaseModel
from langchain_core.output_parsers import JsonOutputParser
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# --- Environment Configuration ---
if not os.environ.get("OPENAI_API_KEY"): # Using os.environ.get for safety
logger.error("OPENAI_API_KEY environment variable not set. Please set it to proceed.")
raise ValueError("OPENAI_API_KEY not set")
# --- LangGraph Agent State Definition ---
class AgentState(TypedDict):
input: str # The initial user query
chat_history: Annotated[List[BaseMessage], "append"] # Conversation history
research_results: Annotated[List[str], "append"] # Accumulated research findings
next_agent: str # The next agent to be invoked (e.g., 'web_search', 'academic', 'code', 'supervisor')
current_task: str # The specific task being worked on by an agent
# --- LLM Initialization ---
def get_llm():
try:
return ChatOpenAI(model="gpt-4o", temperature=0.5, streaming=True)
except Exception as e:
logger.error(f"Failed to initialize LLM: {e}")
raise
# --- Supervisor Agent Logic ---
class AgentDecision(BaseModel):
next_agent: str
current_task: str
class SupervisorAgent:
def __init__(self, llm: ChatOpenAI):
self.llm = llm
self.prompt = ChatPromptTemplate.from_messages([
("system", "You are a research supervisor. Your goal is to break down complex research queries into sub-tasks and delegate them to specialist agents. "
"You have access to 'web_search_agent', 'academic_paper_agent', and 'code_execution_agent'. "
"Based on the user's input, decide which agent is best suited to perform the next step. "
"If the research is complete or you need to synthesize results, choose 'FINISH'.
"
"Respond with a JSON object containing 'next_agent' and 'current_task'.
"
"Available agents: web_search_agent, academic_paper_agent, code_execution_agent, FINISH"),
("human", "{input}")
])
self.parser = JsonOutputParser(pydantic_object=AgentDecision)
self.chain = self.prompt | self.llm | self.parser
def route_agent(self, state: AgentState) -> AgentState:
logger.info(f"Supervisor: Routing agent for input: {state['input']}")
try:
# Use the latest input or the last message in chat_history if input is empty
current_input = state['input'] if state['input'] else state['chat_history'][-1].content
response = self.chain.invoke({"input": current_input})
next_agent = response.get('next_agent', 'FINISH')
current_task = response.get('current_task', 'Synthesize findings.')
logger.info(f"Supervisor decided: next_agent={next_agent}, current_task={current_task}")
return {**state, "next_agent": next_agent, "current_task": current_task}
except Exception as e:
logger.error(f"Supervisor routing failed: {e}")
# Fallback to FINISH in case of error to prevent infinite loops
return {**state, "next_agent": "FINISH", "current_task": f"Error during routing: {e}"}
def run_supervisor(self, state: AgentState) -> AgentState:
logger.info("Supervisor: Running supervisor logic.")
# In a more complex scenario, this might involve more reasoning or tool use
# For now, it primarily focuses on routing.
return self.route_agent(state)
# --- Define the graph --- (placeholder for specialist agents)
def web_search_agent_node(state: AgentState) -> AgentState:
logger.info("Web Search Agent: Executing placeholder.")
return {**state, "research_results": state["research_results"] + [f"Web search for: {state['current_task']}"], "next_agent": "supervisor"}
def academic_paper_agent_node(state: AgentState) -> AgentState:
logger.info("Academic Paper Agent: Executing placeholder.")
return {**state, "research_results": state["research_results"] + [f"Academic search for: {state['current_task']}"], "next_agent": "supervisor"}
def code_execution_agent_node(state: AgentState) -> AgentState:
logger.info("Code Execution Agent: Executing placeholder.")
return {**state, "research_results": state["research_results"] + [f"Code execution for: {state['current_task']}"], "next_agent": "supervisor"}
# --- Build the LangGraph ---
def create_research_graph(llm: ChatOpenAI, memory: SqliteSaver):
workflow = StateGraph(AgentState)
supervisor = SupervisorAgent(llm)
workflow.add_node("supervisor", supervisor.run_supervisor)
workflow.add_node("web_search_agent", web_search_agent_node)
workflow.add_node("academic_paper_agent", academic_paper_agent_node)
workflow.add_node("code_execution_agent", code_execution_agent_node)
workflow.set_entry_point("supervisor")
workflow.add_conditional_edges(
"supervisor",
lambda state: state['next_agent'],
{
"web_search_agent": "web_search_agent",
"academic_paper_agent": "academic_paper_agent",
"code_execution_agent": "code_execution_agent",
"FINISH": END
},
)
# After specialist agents, return control to supervisor
workflow.add_edge("web_search_agent", "supervisor")
workflow.add_edge("academic_paper_agent", "supervisor")
workflow.add_edge("code_execution_agent", "supervisor")
app = workflow.compile(checkpointer=memory)
return app
# --- Main application entry point for testing ---
if __name__ == "__main__":
print("
--- Phase 2: Supervisor Agent and Initial Graph Structure ---")
try:
llm = get_llm()
memory = SqliteSaver(conn=sqlite3.connect(":memory:", check_same_thread=False))
app = create_research_graph(llm, memory)
# Example invocation
initial_state = {"input": "Research the latest advancements in large language models for scientific discovery.", "chat_history": [], "research_results": [], "next_agent": "", "current_task": ""}
print(f"
Invoking graph with input: {initial_state['input']}")
for s in app.stream(initial_state):
print(s)
print("
Phase 2 setup complete. Supervisor can now route tasks.")
except ValueError as e:
print(f"Error: {e}")
print("Please ensure OPENAI_API_KEY is set.")
except Exception as e:
logger.exception("An unexpected error occurred during Phase 2 execution.")
SupervisorAgent and the initial LangGraph structure. The SupervisorAgent is a key component, leveraging an LLM to make intelligent routing decisions. Its prompt is carefully crafted to instruct the LLM on its role, available specialist agents, and expected JSON output format, which includes next_agent and current_task. The JsonOutputParser ensures the LLM's response is structured correctly, making it robust against minor formatting variations.
The route_agent method within the SupervisorAgent is responsible for invoking the LLM chain and extracting the routing decision. Crucially, it includes error handling to prevent the graph from crashing if the LLM response is malformed, falling back to a FINISH state to ensure graceful termination. The run_supervisor method acts as the LangGraph node, calling the routing logic.
Placeholder functions (web_search_agent_node, academic_paper_agent_node, code_execution_agent_node) are added for the specialist agents. These simply log their invocation, append a dummy result to research_results, and crucially, set next_agent back to supervisor to ensure the control flow returns to the supervisor after a specialist completes its task. This establishes the cyclical nature of the graph.
The create_research_graph function defines the LangGraph workflow. It adds the supervisor and specialist agent nodes. The set_entry_point("supervisor") ensures all queries start with the supervisor. The add_conditional_edges is central to the supervisor pattern, allowing the graph to dynamically route to different specialist agents or END based on the next_agent value returned by the supervisor. Edges from specialist agents back to the supervisor complete the loop, enabling iterative research. A SqliteSaver is passed to compile for state persistence, preparing for more complex interactions.
Run the if __name__ == "__main__" block. Observe the log messages indicating the supervisor's decision and the invocation of a placeholder specialist agent. The output should show the graph streaming through the supervisor, then one of the placeholder agents, and then back to the supervisor, eventually reaching FINISH.
Phase 3: Integrating Specialist Tools and Episodic Memory
This phase replaces the placeholder specialist agents with actual implementations that leverage external tools for web search, academic paper retrieval, and code execution. It also integrates a shared episodic memory using ChromaDB to store and retrieve past research findings, enhancing context and reducing redundant tool calls.
import os
import logging
import json
from typing import TypedDict, List, Annotated, Union, Literal
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, END
import sqlite3
from langgraph.checkpoint.sqlite import SqliteSaver
from langchain_core.prompts import ChatPromptTemplate
from pydantic import BaseModel
from langchain_core.output_parsers import JsonOutputParser
from langchain_community.tools.tavily_research import TavilySearchResults
from langchain_community.document_loaders import ArxivLoader
from langchain.tools import tool
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter
from functools import lru_cache
import subprocess
import sys
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# --- Environment Configuration ---
required_env_vars = ["OPENAI_API_KEY", "TAVILY_API_KEY"]
for var in required_env_vars:
if not os.environ.get(var):
logger.error(f"{var} environment variable not set. Please set it to proceed.")
raise ValueError(f"{var} not set")
# --- LangGraph Agent State Definition ---
class AgentState(TypedDict):
input: str # The initial user query
chat_history: Annotated[List[BaseMessage], "append"] # Conversation history
research_results: Annotated[List[str], "append"] # Accumulated research findings
next_agent: str # The next agent to be invoked (e.g., 'web_search', 'academic', 'code', 'supervisor')
current_task: str # The specific task being worked on by an agent
# --- LLM Initialization ---
def get_llm():
try:
return ChatOpenAI(model="gpt-4o", temperature=0.5, streaming=True)
except Exception as e:
logger.error(f"Failed to initialize LLM: {e}")
raise
# --- Episodic Memory Setup ---
@lru_cache(maxsize=1) # Cache the vector store instance
def get_episodic_memory():
try:
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
# Using a local ChromaDB instance
vectorstore = Chroma(persist_directory="./chroma_db", embedding_function=embeddings)
return vectorstore
except Exception as e:
logger.error(f"Failed to initialize ChromaDB for episodic memory: {e}")
raise
def add_to_episodic_memory(query: str, content: str):
try:
vectorstore = get_episodic_memory()
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
docs = text_splitter.create_documents([content], metadatas=[{"query": query}])
vectorstore.add_documents(docs)
logger.info(f"Added {len(docs)} documents to episodic memory for query: {query[:50]}...")
except Exception as e:
logger.error(f"Error adding to episodic memory: {e}")
def retrieve_from_episodic_memory(query: str, k: int = 3) -> List[str]:
try:
vectorstore = get_episodic_memory()
retrieved_docs = vectorstore.similarity_search(query, k=k)
logger.info(f"Retrieved {len(retrieved_docs)} documents from episodic memory for query: {query[:50]}...")
return [doc.page_content for doc in retrieved_docs]
except Exception as e:
logger.error(f"Error retrieving from episodic memory: {e}")
return []
# --- Supervisor Agent Logic (from Phase 2, updated to use memory) ---
class AgentDecision(BaseModel):
next_agent: str
current_task: str
class SupervisorAgent:
def __init__(self, llm: ChatOpenAI):
self.llm = llm
self.prompt = ChatPromptTemplate.from_messages([
("system", "You are a research supervisor. Your goal is to break down complex research queries into sub-tasks and delegate them to specialist agents. "
"You have access to 'web_search_agent', 'academic_paper_agent', and 'code_execution_agent'. "
"Based on the user's input and any existing research results, decide which agent is best suited to perform the next step. "
"If the research is complete or you need to synthesize results, choose 'FINISH'.
"
"Consider the existing research results and episodic memory before delegating. Focus on new, unaddressed aspects.
"
"Respond with a JSON object containing 'next_agent' and 'current_task'.
"
"Available agents: web_search_agent, academic_paper_agent, code_execution_agent, FINISH
"
"Existing Research Results: {research_results}
"
"Relevant Episodic Memory: {episodic_memory}"),
("human", "{input}")
])
self.parser = JsonOutputParser(pydantic_object=AgentDecision)
self.chain = self.prompt | self.llm | self.parser
def route_agent(self, state: AgentState) -> AgentState:
logger.info(f"Supervisor: Routing agent for input: {state['input']}")
try:
current_input = state['input'] if state['input'] else state['chat_history'][-1].content
# Retrieve relevant info from episodic memory
relevant_memory = retrieve_from_episodic_memory(current_input + " ".join(state['research_results']))
response = self.chain.invoke({
"input": current_input,
"research_results": "
".join(state['research_results']) if state['research_results'] else "None yet.",
"episodic_memory": "
".join(relevant_memory) if relevant_memory else "None found."
})
next_agent = response.get('next_agent', 'FINISH')
current_task = response.get('current_task', 'Synthesize findings.')
logger.info(f"Supervisor decided: next_agent={next_agent}, current_task={current_task}")
return {**state, "next_agent": next_agent, "current_task": current_task}
except Exception as e:
logger.error(f"Supervisor routing failed: {e}")
return {**state, "next_agent": "FINISH", "current_task": f"Error during routing: {e}"}
def run_supervisor(self, state: AgentState) -> AgentState:
logger.info("Supervisor: Running supervisor logic.")
return self.route_agent(state)
# --- Specialist Agent Implementations ---
# Web Search Agent
class WebSearchAgent:
def __init__(self):
self.tool = TavilySearchResults(max_results=5) # Reduced for faster execution
def run_web_search(self, state: AgentState) -> AgentState:
logger.info(f"Web Search Agent: Searching for '{state['current_task']}'")
try:
search_results = self.tool.invoke({"query": state['current_task']})
result_str = json.dumps(search_results, indent=2)
add_to_episodic_memory(state['current_task'], result_str)
return {**state, "research_results": state["research_results"] + [f"Web Search Results for '{state['current_task']}':
{result_str}"], "next_agent": "supervisor"}
except Exception as e:
logger.error(f"Web Search Agent failed: {e}")
return {**state, "research_results": state["research_results"] + [f"Web Search failed for '{state['current_task']}': {e}"], "next_agent": "supervisor"}
# Academic Paper Agent
class AcademicPaperAgent:
def run_academic_search(self, state: AgentState) -> AgentState:
logger.info(f"Academic Paper Agent: Searching ArXiv for '{state['current_task']}'")
try:
loader = ArxivLoader(query=state['current_task'], load_max_docs=2, load_all_available_meta=True)
docs = loader.load()
if not docs:
return {**state, "research_results": state["research_results"] + [f"No academic papers found for '{state['current_task']}'."], "next_agent": "supervisor"}
result_summary = []
for doc in docs:
summary = f"Title: {doc.metadata.get('Title', 'N/A')}
Authors: {doc.metadata.get('Authors', 'N/A')}
Published: {doc.metadata.get('Published', 'N/A')}
Summary: {doc.page_content[:500]}...
Link: {doc.metadata.get('entry_id', 'N/A')}"
result_summary.append(summary)
full_result_str = "
---
".join(result_summary)
add_to_episodic_memory(state['current_task'], full_result_str)
return {**state, "research_results": state["research_results"] + [f"Academic Research Results for '{state['current_task']}':
{full_result_str}"], "next_agent": "supervisor"}
except Exception as e:
logger.error(f"Academic Paper Agent failed: {e}")
return {**state, "research_results": state["research_results"] + [f"Academic Search failed for '{state['current_task']}': {e}"], "next_agent": "supervisor"}
# Code Execution Agent
class CodeExecutionAgent:
@tool("python_interpreter", return_direct=True)
def python_interpreter(self, code: str) -> str:
"Executes Python code in a sandboxed environment."
try:
# For production, use a secure sandbox like E2B or a Docker container
# For this example, we'll use a basic subprocess for demonstration.
# WARNING: Executing arbitrary code locally is a security risk.
logger.warning("Executing code locally without a proper sandbox. Use with caution.")
process = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
check=True,
timeout=30
)
if process.stderr:
logger.warning(f"Code execution stderr: {process.stderr}")
return process.stdout.strip()
except subprocess.CalledProcessError as e:
error_msg = f"Code execution failed with error: {e.stderr.strip()}"
logger.error(error_msg)
raise ValueError(error_msg)
except subprocess.TimeoutExpired:
error_msg = "Code execution timed out."
logger.error(error_msg)
raise ValueError(error_msg)
except Exception as e:
error_msg = f"An unexpected error occurred during code execution: {e}"
logger.error(error_msg)
raise ValueError(error_msg)
def run_code_execution(self, state: AgentState) -> AgentState:
logger.info(f"Code Execution Agent: Attempting to execute code for task '{state['current_task']}'")
try:
# LLM decides the code to execute based on current_task
code_generation_prompt = ChatPromptTemplate.from_messages([
("system", "You are a helpful assistant capable of writing and executing Python code to answer questions or perform data analysis. "
"Given a task, generate valid Python code that can be executed. Output ONLY the Python code, no explanations or markdown fences."
"Current Task: {current_task}
"
"Existing Research Results: {research_results}
"
"Relevant Episodic Memory: {episodic_memory}
"
"Example: print(2+2)"),
("human", "Generate Python code for the following task: {task}")
])
llm_code_chain = code_generation_prompt | get_llm()
relevant_memory = retrieve_from_episodic_memory(state['current_task'] + " ".join(state['research_results']))
code_to_execute = llm_code_chain.invoke({
"task": state['current_task'],
"research_results": "
".join(state['research_results']) if state['research_results'] else "None yet.",
"episodic_memory": "
".join(relevant_memory) if relevant_memory else "None found."
}).content
logger.info(f"Generated code: {code_to_execute[:100]}...")
execution_output = self.python_interpreter.invoke(code_to_execute)
add_to_episodic_memory(state['current_task'], f"Code Output: {execution_output}")
return {**state, "research_results": state["research_results"] + [f"Code Execution Results for '{state['current_task']}':
{execution_output}"], "next_agent": "supervisor"}
except Exception as e:
logger.error(f"Code Execution Agent failed: {e}")
return {**state, "research_results": state["research_results"] + [f"Code Execution failed for '{state['current_task']}': {e}"], "next_agent": "supervisor"}
# --- Build the LangGraph ---
def create_research_graph(llm: ChatOpenAI, memory: SqliteSaver):
workflow = StateGraph(AgentState)
supervisor = SupervisorAgent(llm)
web_search = WebSearchAgent()
academic_paper = AcademicPaperAgent()
code_execution = CodeExecutionAgent()
workflow.add_node("supervisor", supervisor.run_supervisor)
workflow.add_node("web_search_agent", web_search.run_web_search)
workflow.add_node("academic_paper_agent", academic_paper.run_academic_search)
workflow.add_node("code_execution_agent", code_execution.run_code_execution)
workflow.set_entry_point("supervisor")
workflow.add_conditional_edges(
"supervisor",
lambda state: state['next_agent'],
{
"web_search_agent": "web_search_agent",
"academic_paper_agent": "academic_paper_agent",
"code_execution_agent": "code_execution_agent",
"FINISH": END
},
)
workflow.add_edge("web_search_agent", "supervisor")
workflow.add_edge("academic_paper_agent", "supervisor")
workflow.add_edge("code_execution_agent", "supervisor")
app = workflow.compile(checkpointer=memory)
return app
# --- Main application entry point for testing ---
if __name__ == "__main__":
print("
--- Phase 3: Integrating Specialist Tools and Episodic Memory ---")
try:
llm = get_llm()
memory = SqliteSaver(conn=sqlite3.connect("./langgraph_checkpoints.sqlite", check_same_thread=False)) # Persist checkpoints
app = create_research_graph(llm, memory)
# Create chroma_db directory if it doesn't exist
if not os.path.exists("./chroma_db"):
os.makedirs("./chroma_db")
logger.info("Created ./chroma_db directory for episodic memory.")
# Example invocation
initial_state = {"input": "Find recent papers on graph neural networks for drug discovery and summarize their key findings. Then, write and execute a Python script to calculate the average number of citations for the top 3 papers.", "chat_history": [], "research_results": [], "next_agent": "", "current_task": ""}
print(f"
Invoking graph with input: {initial_state['input']}")
for s in app.stream(initial_state):
print(s)
print("
Phase 3 setup complete. Specialist agents are now functional with tools and episodic memory.")
except ValueError as e:
print(f"Error: {e}")
print("Please ensure all required API keys are set.")
except Exception as e:
logger.exception("An unexpected error occurred during Phase 3 execution.")
TAVILY_API_KEY is added to the environment variable check, as Tavily Search is now a critical external dependency. The get_episodic_memory function initializes ChromaDB, configured to store vector embeddings generated by OpenAIEmbeddings. This vector store is designed to persist data to a ./chroma_db directory, allowing agents to recall information across runs. add_to_episodic_memory and retrieve_from_episodic_memory functions provide a clean interface for agents to interact with this shared knowledge base, using RecursiveCharacterTextSplitter to manage document chunks.
The SupervisorAgent's prompt is updated to explicitly include Existing Research Results and Relevant Episodic Memory. This allows the supervisor to make more informed decisions, avoiding redundant tasks and building on previous findings. The route_agent method now retrieves relevant information from episodic memory and passes it to the LLM, enriching its context.
Each specialist agent (WebSearchAgent, AcademicPaperAgent, CodeExecutionAgent) is now fully implemented. The WebSearchAgent uses TavilySearchResults to perform web queries and stores the results in episodic memory. The AcademicPaperAgent uses ArxivLoader to fetch academic papers, processes their content, and also stores summaries in memory. Both agents include robust error handling for tool failures.
The CodeExecutionAgent is more complex. It features a python_interpreter tool that executes Python code using subprocess.run. A crucial security warning is included, emphasizing that for production, a secure sandboxing solution (like E2B or a dedicated Docker container) is mandatory to prevent arbitrary code execution vulnerabilities. The run_code_execution method first uses an LLM to generate Python code based on the current_task, then executes this code, and finally stores the output in episodic memory. This demonstrates an agent's ability to not just use tools, but to dynamically generate and execute logic.
Finally, the create_research_graph function is updated to instantiate these new agent classes and register their run_ methods as nodes. The compile step now uses a persistent SqliteSaver for LangGraph's internal state. The if __name__ == "__main__" block demonstrates a more complex research query, showcasing the multi-step, tool-augmented reasoning capabilities of the platform.
Run the if __name__ == "__main__" block. Observe the detailed logs showing the supervisor delegating tasks, specialist agents executing their respective tools (Tavily, ArXiv, code interpreter), and results being added to research_results and episodic memory. You should see chroma_db and langgraph_checkpoints.sqlite directories created. Expect multiple turns between supervisor and specialist agents.
Phase 4: A2A Protocol Integration and Streaming API
This phase integrates the A2A (Agent-to-Agent) protocol for structured communication between agents and implements a streaming API using FastAPI to provide real-time updates to the user. This moves the system closer to a production-ready, interactive research platform.
import os
import logging
import json
import time
from typing import TypedDict, List, Annotated, Union, Literal, AsyncIterator, Dict, Any
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, END
import sqlite3
from langgraph.checkpoint.sqlite import SqliteSaver
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import JsonOutputParser
from langchain_community.tools.tavily_research import TavilySearchResults
from langchain_community.document_loaders import ArxivLoader
from langchain.tools import tool
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter
from functools import lru_cache
import subprocess
import sys
import uuid
import asyncio
import uvicorn
from fastapi import FastAPI, Request, Response
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
import redis.asyncio as redis
from redis.asyncio import Redis
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# --- Environment Configuration ---
required_env_vars = ["OPENAI_API_KEY", "TAVILY_API_KEY", "REDIS_URL"]
for var in required_env_vars:
if not os.environ.get(var):
logger.error(f"{var} environment variable not set. Please set it to proceed.")
raise ValueError(f"{var} not set")
# --- LangGraph Agent State Definition (updated with session_id) ---
class AgentState(TypedDict):
input: str # The initial user query
chat_history: Annotated[List[BaseMessage], "append"] # Conversation history
research_results: Annotated[List[str], "append"] # Accumulated research findings
next_agent: str # The next agent to be invoked (e.g., 'web_search', 'academic', 'code', 'supervisor')
current_task: str # The specific task being worked on by an agent
session_id: str # Unique ID for the current research session
a2a_messages: Annotated[List[Dict[str, Any]], "append"] # A2A messages for inter-agent communication
# --- LLM Initialization ---
def get_llm():
try:
return ChatOpenAI(model="gpt-4o", temperature=0.5, streaming=True)
except Exception as e:
logger.error(f"Failed to initialize LLM: {e}")
raise
# --- Episodic Memory Setup (from Phase 3) ---
@lru_cache(maxsize=1)
def get_episodic_memory():
try:
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = Chroma(persist_directory="./chroma_db", embedding_function=embeddings)
return vectorstore
except Exception as e:
logger.error(f"Failed to initialize ChromaDB for episodic memory: {e}")
raise
def add_to_episodic_memory(query: str, content: str):
try:
vectorstore = get_episodic_memory()
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
docs = text_splitter.create_documents([content], metadatas=[{"query": query}])
vectorstore.add_documents(docs)
logger.info(f"Added {len(docs)} documents to episodic memory for query: {query[:50]}...")
except Exception as e:
logger.error(f"Error adding to episodic memory: {e}")
def retrieve_from_episodic_memory(query: str, k: int = 3) -> List[str]:
try:
vectorstore = get_episodic_memory()
retrieved_docs = vectorstore.similarity_search(query, k=k)
logger.info(f"Retrieved {len(retrieved_docs)} documents from episodic memory for query: {query[:50]}...")
return [doc.page_content for doc in retrieved_docs]
except Exception as e:
logger.error(f"Error retrieving from episodic memory: {e}")
return []
# --- A2A Message Queue (Redis) ---
@lru_cache(maxsize=1)
async def get_redis_client() -> Redis:
try:
redis_url = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
r = redis.from_url(redis_url)
await r.ping() # Test connection
logger.info(f"Connected to Redis at {redis_url}")
return r
except Exception as e:
logger.error(f"Failed to connect to Redis: {e}")
raise
async def publish_a2a_message(session_id: str, sender: str, recipient: str, message_type: str, content: Dict[str, Any]):
try:
r = await get_redis_client()
a2a_msg = {
"session_id": session_id,
"timestamp": time.time(),
"sender": sender,
"recipient": recipient,
"message_type": message_type,
"content": content
}
await r.publish(f"a2a_channel:{session_id}", json.dumps(a2a_msg))
logger.info(f"Published A2A message from {sender} to {recipient} ({message_type}) for session {session_id}")
except Exception as e:
logger.error(f"Error publishing A2A message: {e}")
# --- Supervisor Agent Logic (updated for A2A) ---
class AgentDecision(BaseModel):
next_agent: str
current_task: str
class SupervisorAgent:
def __init__(self, llm: ChatOpenAI):
self.llm = llm
self.prompt = ChatPromptTemplate.from_messages([
("system", "You are a research supervisor. Your goal is to break down complex research queries into sub-tasks and delegate them to specialist agents. "
"You have access to 'web_search_agent', 'academic_paper_agent', and 'code_execution_agent'. "
"Based on the user's input and any existing research results, decide which agent is best suited to perform the next step. "
"If the research is complete or you need to synthesize results, choose 'FINISH'.
"
"Consider the existing research results and episodic memory before delegating. Focus on new, unaddressed aspects.
"
"Respond with a JSON object containing 'next_agent' and 'current_task'.
"
"Available agents: web_search_agent, academic_paper_agent, code_execution_agent, FINISH
"
"Existing Research Results: {research_results}
"
"Relevant Episodic Memory: {episodic_memory}"),
("human", "{input}")
])
self.parser = JsonOutputParser(pydantic_object=AgentDecision)
self.chain = self.prompt | self.llm | self.parser
async def route_agent(self, state: AgentState) -> AgentState:
logger.info(f"Supervisor: Routing agent for input: {state['input']}")
try:
current_input = state['input'] if state['input'] else state['chat_history'][-1].content
relevant_memory = retrieve_from_episodic_memory(current_input + " ".join(state['research_results']))
response = self.chain.invoke({
"input": current_input,
"research_results": "
".join(state['research_results']) if state['research_results'] else "None yet.",
"episodic_memory": "
".join(relevant_memory) if relevant_memory else "None found."
})
next_agent = response.get('next_agent', 'FINISH')
current_task = response.get('current_task', 'Synthesize findings.')
logger.info(f"Supervisor decided: next_agent={next_agent}, current_task={current_task}")
# Publish A2A message about delegation
await publish_a2a_message(
state['session_id'], "supervisor", next_agent,
"task_delegation", {"task": current_task, "input": current_input}
)
return {**state, "next_agent": next_agent, "current_task": current_task}
except Exception as e:
logger.error(f"Supervisor routing failed: {e}")
await publish_a2a_message(
state['session_id'], "supervisor", "user",
"error", {"message": f"Supervisor routing failed: {e}"}
)
return {**state, "next_agent": "FINISH", "current_task": f"Error during routing: {e}"}
async def run_supervisor(self, state: AgentState) -> AgentState:
logger.info("Supervisor: Running supervisor logic.")
return await self.route_agent(state)
# --- Specialist Agent Implementations (updated for A2A) ---
class WebSearchAgent:
def __init__(self):
self.tool = TavilySearchResults(max_results=5)
async def run_web_search(self, state: AgentState) -> AgentState:
logger.info(f"Web Search Agent: Searching for '{state['current_task']}'")
try:
await publish_a2a_message(
state['session_id'], "web_search_agent", "supervisor",
"status_update", {"message": f"Starting web search for: {state['current_task']}"}
)
search_results = self.tool.invoke({"query": state['current_task']})
result_str = json.dumps(search_results, indent=2)
add_to_episodic_memory(state['current_task'], result_str)
await publish_a2a_message(
state['session_id'], "web_search_agent", "supervisor",
"result", {"task": state['current_task'], "output": result_str}
)
return {**state, "research_results": state["research_results"] + [f"Web Search Results for '{state['current_task']}':
{result_str}"], "next_agent": "supervisor"}
except Exception as e:
logger.error(f"Web Search Agent failed: {e}")
await publish_a2a_message(
state['session_id'], "web_search_agent", "supervisor",
"error", {"task": state['current_task'], "message": f"Web Search failed: {e}"}
)
return {**state, "research_results": state["research_results"] + [f"Web Search failed for '{state['current_task']}': {e}"], "next_agent": "supervisor"}
class AcademicPaperAgent:
async def run_academic_search(self, state: AgentState) -> AgentState:
logger.info(f"Academic Paper Agent: Searching ArXiv for '{state['current_task']}'")
try:
await publish_a2a_message(
state['session_id'], "academic_paper_agent", "supervisor",
"status_update", {"message": f"Starting academic search for: {state['current_task']}"}
)
loader = ArxivLoader(query=state['current_task'], load_max_docs=2, load_all_available_meta=True)
docs = loader.load()
if not docs:
await publish_a2a_message(
state['session_id'], "academic_paper_agent", "supervisor",
"result", {"task": state['current_task'], "output": f"No academic papers found for '{state['current_task']}'."}
)
return {**state, "research_results": state["research_results"] + [f"No academic papers found for '{state['current_task']}'."], "next_agent": "supervisor"}
result_summary = []
for doc in docs:
summary = f"Title: {doc.metadata.get('Title', 'N/A')}
Authors: {doc.metadata.get('Authors', 'N/A')}
Published: {doc.metadata.get('Published', 'N/A')}
Summary: {doc.page_content[:500]}...
Link: {doc.metadata.get('entry_id', 'N/A')}"
result_summary.append(summary)
full_result_str = "
---
".join(result_summary)
add_to_episodic_memory(state['current_task'], full_result_str)
await publish_a2a_message(
state['session_id'], "academic_paper_agent", "supervisor",
"result", {"task": state['current_task'], "output": full_result_str}
)
return {**state, "research_results": state["research_results"] + [f"Academic Research Results for '{state['current_task']}':
{full_result_str}"], "next_agent": "supervisor"}
except Exception as e:
logger.error(f"Academic Paper Agent failed: {e}")
await publish_a2a_message(
state['session_id'], "academic_paper_agent", "supervisor",
"error", {"task": state['current_task'], "message": f"Academic Search failed: {e}"}
)
return {**state, "research_results": state["research_results"] + [f"Academic Search failed for '{state['current_task']}': {e}"], "next_agent": "supervisor"}
class CodeExecutionAgent:
@tool("python_interpreter", return_direct=True)
def python_interpreter(self, code: str) -> str:
"Executes Python code in a sandboxed environment."
try:
logger.warning("Executing code locally without a proper sandbox. Use with caution.")
process = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
check=True,
timeout=30
)
if process.stderr:
logger.warning(f"Code execution stderr: {process.stderr}")
return process.stdout.strip()
except subprocess.CalledProcessError as e:
error_msg = f"Code execution failed with error: {e.stderr.strip()}"
logger.error(error_msg)
raise ValueError(error_msg)
except subprocess.TimeoutExpired:
error_msg = "Code execution timed out."
logger.error(error_msg)
raise ValueError(error_msg)
except Exception as e:
error_msg = f"An unexpected error occurred during code execution: {e}"
logger.error(error_msg)
raise ValueError(error_msg)
async def run_code_execution(self, state: AgentState) -> AgentState:
logger.info(f"Code Execution Agent: Attempting to execute code for task '{state['current_task']}'")
try:
await publish_a2a_message(
state['session_id'], "code_execution_agent", "supervisor",
"status_update", {"message": f"Starting code execution for: {state['current_task']}"}
)
code_generation_prompt = ChatPromptTemplate.from_messages([
("system", "You are a helpful assistant capable of writing and executing Python code to answer questions or perform data analysis. "
"Given a task, generate valid Python code that can be executed. Output ONLY the Python code, no explanations or markdown fences."
"Current Task: {current_task}
"
"Existing Research Results: {research_results}
"
"Relevant Episodic Memory: {episodic_memory}
"
"Example: print(2+2)"),
("human", "Generate Python code for the following task: {task}")
])
llm_code_chain = code_generation_prompt | get_llm()
relevant_memory = retrieve_from_episodic_memory(state['current_task'] + " ".join(state['research_results']))
code_to_execute = llm_code_chain.invoke({
"task": state['current_task'],
"research_results": "
".join(state['research_results']) if state['research_results'] else "None yet.",
"episodic_memory": "
".join(relevant_memory) if relevant_memory else "None found."
}).content
logger.info(f"Generated code: {code_to_execute[:100]}...")
execution_output = self.python_interpreter.invoke(code_to_execute)
add_to_episodic_memory(state['current_task'], f"Code Output: {execution_output}")
await publish_a2a_message(
state['session_id'], "code_execution_agent", "supervisor",
"result", {"task": state['current_task'], "output": execution_output}
)
return {**state, "research_results": state["research_results"] + [f"Code Execution Results for '{state['current_task']}':
{execution_output}"], "next_agent": "supervisor"}
except Exception as e:
logger.error(f"Code Execution Agent failed: {e}")
await publish_a2a_message(
state['session_id'], "code_execution_agent", "supervisor",
"error", {"task": state['current_task'], "message": f"Code Execution failed: {e}"}
)
return {**state, "research_results": state["research_results"] + [f"Code Execution failed for '{state['current_task']}': {e}"], "next_agent": "supervisor"}
# --- Build the LangGraph ---
def create_research_graph(llm: ChatOpenAI, memory: SqliteSaver):
workflow = StateGraph(AgentState)
supervisor = SupervisorAgent(llm)
web_search = WebSearchAgent()
academic_paper = AcademicPaperAgent()
code_execution = CodeExecutionAgent()
# Note: LangGraph nodes can be sync or async. For simplicity, we make all nodes async here.
# LangGraph will handle running async nodes correctly.
workflow.add_node("supervisor", supervisor.run_supervisor)
workflow.add_node("web_search_agent", web_search.run_web_search)
workflow.add_node("academic_paper_agent", academic_paper.run_academic_search)
workflow.add_node("code_execution_agent", code_execution.run_code_execution)
workflow.set_entry_point("supervisor")
workflow.add_conditional_edges(
"supervisor",
lambda state: state['next_agent'],
{
"web_search_agent": "web_search_agent",
"academic_paper_agent": "academic_paper_agent",
"code_execution_agent": "code_execution_agent",
"FINISH": END
},
)
workflow.add_edge("web_search_agent", "supervisor")
workflow.add_edge("academic_paper_agent", "supervisor")
workflow.add_edge("code_execution_agent", "supervisor")
app = workflow.compile(checkpointer=memory)
return app
# --- FastAPI Application for Streaming ---
app_fastapi = FastAPI(title="Multi-Agent Research Platform")
class ResearchRequest(BaseModel):
query: str
session_id: str = str(uuid.uuid4()) # Generate a new session_id if not provided
async def stream_a2a_messages(session_id: str) -> AsyncIterator[str]:
r = await get_redis_client()
pubsub = r.pubsub()
await pubsub.subscribe(f"a2a_channel:{session_id}")
logger.info(f"Subscribed to A2A channel: a2a_channel:{session_id}")
try:
while True:
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0)
if message:
data = json.loads(message['data'].decode('utf-8'))
yield f"data: {json.dumps(data)}
"
await asyncio.sleep(0.01) # Yield control
except asyncio.CancelledError:
logger.info(f"Streaming for session {session_id} cancelled.")
except Exception as e:
logger.error(f"Error in A2A message streaming for session {session_id}: {e}")
yield f"data: {json.dumps({'event': 'error', 'message': str(e)})}
"
finally:
await pubsub.unsubscribe(f"a2a_channel:{session_id}")
logger.info(f"Unsubscribed from A2A channel: a2a_channel:{session_id}")
@app_fastapi.post("/research/stream")
async def start_research_stream(request: ResearchRequest):
session_id = request.session_id
query = request.query
logger.info(f"Received research request for session {session_id} with query: {query}")
llm = get_llm()
memory = SqliteSaver(conn=sqlite3.connect(f"./langgraph_checkpoints_{session_id}.sqlite", check_same_thread=False)) # Per-session checkpoint
research_graph = create_research_graph(llm, memory)
async def research_task():
try:
initial_state = {"input": query, "chat_history": [HumanMessage(content=query)], "research_results": [], "next_agent": "", "current_task": "", "session_id": session_id}
final_state = None
async for s in research_graph.astream(initial_state, config={"configurable": {"thread_id": session_id}}):
# This loop processes LangGraph state changes but does not directly stream them out.
# A2A messages are published independently to Redis.
if "supervisor" in s:
current_state = s["supervisor"]
if current_state.get("next_agent") == "FINISH":
final_state = current_state
break
if final_state:
await publish_a2a_message(
session_id, "supervisor", "user",
"final_result", {"input": query, "final_results": final_state['research_results']}
)
else:
await publish_a2a_message(
session_id, "supervisor", "user",
"error", {"message": "Research task completed without a final state.", "input": query}
)
except Exception as e:
logger.error(f"Error during research task for session {session_id}: {e}")
await publish_a2a_message(
session_id, "supervisor", "user",
"error", {"message": f"Research task failed: {e}", "input": query}
)
# Run the research task in the background
asyncio.create_task(research_task())
# Stream A2A messages to the client
return StreamingResponse(stream_a2a_messages(session_id), media_type="text/event-stream")
# --- Main application entry point for testing (FastAPI) ---
if __name__ == "__main__":
print("
--- Phase 4: A2A Protocol Integration and Streaming API ---")
print("Starting FastAPI server... access at http://127.0.0.1:8000/docs")
print("Ensure Redis is running (e.g., `docker run -p 6379:6379 --name my-redis -d redis`).")
uvicorn.run(app_fastapi, host="0.0.0.0", port=8000)
AgentState TypedDict is extended with session_id to uniquely identify each research session and a2a_messages to potentially track A2A communication within the state (though we primarily use Redis for real-time streaming). Crucially, REDIS_URL is added to the environment variable checks, as Redis is now used for the A2A message queue.
The get_redis_client function provides an asynchronous, cached connection to Redis, with robust error handling. publish_a2a_message is the core function for sending structured A2A messages to a Redis Pub/Sub channel specific to the session_id. This allows external clients to subscribe to updates without direct polling. Each agent's run_ method is updated to be async and to publish A2A status_update, result, or error messages at key points in their execution. This provides granular, real-time feedback on the agent's progress, decisions, and outcomes.
The FastAPI application is introduced to serve as the external interface. A ResearchRequest Pydantic model defines the input for starting a research task, including an auto-generated session_id. The stream_a2a_messages function leverages Redis Pub/Sub to create an AsyncIterator that yields Server-Sent Events (SSE) to the client, pushing A2A messages as they occur. Error handling and asyncio.CancelledError are included for graceful stream termination.
The start_research_stream endpoint is the primary API for initiating research. It creates a new LangGraph instance with a per-session SqliteSaver checkpoint (using the session_id in the filename for isolation). The research_task runs the LangGraph in the background using asyncio.create_task, ensuring the API call returns immediately to start streaming. After the graph completes, a final_result A2A message is published. This architecture enables a responsive user experience where research progress is streamed as it happens, rather than waiting for the entire process to complete.
- Ensure Redis is running locally (e.g.,
docker run -p 6379:6379 --name my-redis -d redis). 2. SetREDIS_URL=redis://localhost:6379/0in your environment. 3. Runpython app/main.py(or the equivalent script if placed elsewhere) to start the FastAPI server. 4. Access the FastAPI docs athttp://127.0.0.1:8000/docsand use the/research/streamendpoint to send a query. Observe the streamed output in your browser or a tool likecurl(e.g.,curl -N -X POST -H "Content-Type: application/json" -d '{"query": "What are the latest breakthroughs in AI ethics?"}' http://localhost:8000/research/stream). You should see A2A messages streaming, indicating agent activity and results.
Phase 5: Observability with OpenTelemetry and LangSmith
This phase integrates OpenTelemetry for standardized tracing and metrics, and LangSmith for detailed LangChain/LangGraph specific debugging and monitoring. This provides deep visibility into the multi-agent workflow, essential for understanding agent behavior, performance, and debugging in production.
import os
import logging
import json
import time
from typing import TypedDict, List, Annotated, Union, Literal, AsyncIterator, Dict, Any
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, END
import sqlite3
from langgraph.checkpoint.sqlite import SqliteSaver
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import JsonOutputParser
from langchain_community.tools.tavily_research import TavilySearchResults
from langchain_community.document_loaders import ArxivLoader
from langchain.tools import tool
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter
from functools import lru_cache
import subprocess
import sys
import uuid
import asyncio
import uvicorn
from fastapi import FastAPI, Request, Response
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
import redis.asyncio as redis
from redis.asyncio import Redis
# OpenTelemetry imports
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.langchain import LangchainInstrumentor
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# --- Environment Configuration ---
required_env_vars = ["OPENAI_API_KEY", "TAVILY_API_KEY", "REDIS_URL"]
for var in required_env_vars:
if not os.environ.get(var):
logger.error(f"{var} environment variable not set. Please set it to proceed.")
raise ValueError(f"{var} not set")
# LangSmith specific environment variables (optional but recommended)
os.environ["LANGCHAIN_TRACING_V2"] = os.environ.get("LANGCHAIN_TRACING_V2", "true")
os.environ["LANGCHAIN_PROJECT"] = os.environ.get("LANGCHAIN_PROJECT", "Multi-Agent Research Platform")
# LANGCHAIN_API_KEY should be set in .env or environment variables
# --- OpenTelemetry Setup ---
def setup_opentelemetry():
resource = Resource.create({"service.name": "multi-agent-research-platform"})
provider = TracerProvider(resource=resource)
# Configure OTLP HTTP exporter for traces
exporter = OTLPSpanExporter(endpoint=os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318/v1/traces"))
span_processor = BatchSpanProcessor(exporter)
provider.add_span_processor(span_processor)
trace.set_tracer_provider(provider)
logger.info("OpenTelemetry configured.")
# Instrument LangChain/LangGraph
LangchainInstrumentor().instrument()
logger.info("LangChain/LangGraph instrumented for OpenTelemetry.")
# --- LangGraph Agent State Definition (updated with session_id) ---
class AgentState(TypedDict):
input: str
chat_history: Annotated[List[BaseMessage], "append"]
research_results: Annotated[List[str], "append"]
next_agent: str
current_task: str
session_id: str
a2a_messages: Annotated[List[Dict[str, Any]], "append"]
# --- LLM Initialization ---
def get_llm():
try:
return ChatOpenAI(model="gpt-4o", temperature=0.5, streaming=True)
except Exception as e:
logger.error(f"Failed to initialize LLM: {e}")
raise
# --- Episodic Memory Setup (from Phase 3) ---
@lru_cache(maxsize=1)
def get_episodic_memory():
try:
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = Chroma(persist_directory="./chroma_db", embedding_function=embeddings)
return vectorstore
except Exception as e:
logger.error(f"Failed to initialize ChromaDB for episodic memory: {e}")
raise
def add_to_episodic_memory(query: str, content: str):
try:
vectorstore = get_episodic_memory()
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
docs = text_splitter.create_documents([content], metadatas=[{"query": query}])
vectorstore.add_documents(docs)
logger.info(f"Added {len(docs)} documents to episodic memory for query: {query[:50]}...")
except Exception as e:
logger.error(f"Error adding to episodic memory: {e}")
def retrieve_from_episodic_memory(query: str, k: int = 3) -> List[str]:
try:
vectorstore = get_episodic_memory()
retrieved_docs = vectorstore.similarity_search(query, k=k)
logger.info(f"Retrieved {len(retrieved_docs)} documents from episodic memory for query: {query[:50]}...")
return [doc.page_content for doc in retrieved_docs]
except Exception as e:
logger.error(f"Error retrieving from episodic memory: {e}")
return []
# --- A2A Message Queue (Redis) ---
@lru_cache(maxsize=1)
async def get_redis_client() -> Redis:
try:
redis_url = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
r = redis.from_url(redis_url)
await r.ping() # Test connection
logger.info(f"Connected to Redis at {redis_url}")
return r
except Exception as e:
logger.error(f"Failed to connect to Redis: {e}")
raise
async def publish_a2a_message(session_id: str, sender: str, recipient: str, message_type: str, content: Dict[str, Any]):
try:
r = await get_redis_client()
a2a_msg = {
"session_id": session_id,
"timestamp": time.time(),
"sender": sender,
"recipient": recipient,
"message_type": message_type,
"content": content
}
await r.publish(f"a2a_channel:{session_id}", json.dumps(a2a_msg))
logger.info(f"Published A2A message from {sender} to {recipient} ({message_type}) for session {session_id}")
except Exception as e:
logger.error(f"Error publishing A2A message: {e}")
# --- Supervisor Agent Logic (from Phase 4) ---
class AgentDecision(BaseModel):
next_agent: str
current_task: str
class SupervisorAgent:
def __init__(self, llm: ChatOpenAI):
self.llm = llm
self.prompt = ChatPromptTemplate.from_messages([
("system", "You are a research supervisor. Your goal is to break down complex research queries into sub-tasks and delegate them to specialist agents. "
"You have access to 'web_search_agent', 'academic_paper_agent', and 'code_execution_agent'. "
"Based on the user's input and any existing research results, decide which agent is best suited to perform the next step. "
"If the research is complete or you need to synthesize results, choose 'FINISH'.
"
"Consider the existing research results and episodic memory before delegating. Focus on new, unaddressed aspects.
"
"Respond with a JSON object containing 'next_agent' and 'current_task'.
"
"Available agents: web_search_agent, academic_paper_agent, code_execution_agent, FINISH
"
"Existing Research Results: {research_results}
"
"Relevant Episodic Memory: {episodic_memory}"),
("human", "{input}")
])
self.parser = JsonOutputParser(pydantic_object=AgentDecision)
self.chain = self.prompt | self.llm | self.parser
async def route_agent(self, state: AgentState) -> AgentState:
logger.info(f"Supervisor: Routing agent for input: {state['input']}")
with trace.get_tracer(__name__).start_as_current_span("supervisor.route_agent") as span:
span.set_attribute("session_id", state['session_id'])
span.set_attribute("current_task", state['current_task'])
try:
current_input = state['input'] if state['input'] else state['chat_history'][-1].content
relevant_memory = retrieve_from_episodic_memory(current_input + " ".join(state['research_results']))
response = self.chain.invoke({
"input": current_input,
"research_results": "
".join(state['research_results']) if state['research_results'] else "None yet.",
"episodic_memory": "
".join(relevant_memory) if relevant_memory else "None found."
})
next_agent = response.get('next_agent', 'FINISH')
current_task = response.get('current_task', 'Synthesize findings.')
logger.info(f"Supervisor decided: next_agent={next_agent}, current_task={current_task}")
span.set_attribute("next_agent_decision", next_agent)
span.set_attribute("delegated_task", current_task)
await publish_a2a_message(
state['session_id'], "supervisor", next_agent,
"task_delegation", {"task": current_task, "input": current_input}
)
return {**state, "next_agent": next_agent, "current_task": current_task}
except Exception as e:
logger.error(f"Supervisor routing failed: {e}")
span.set_attribute("error", True)
span.set_attribute("error.message", str(e))
await publish_a2a_message(
state['session_id'], "supervisor", "user",
"error", {"message": f"Supervisor routing failed: {e}"}
)
return {**state, "next_agent": "FINISH", "current_task": f"Error during routing: {e}"}
async def run_supervisor(self, state: AgentState) -> AgentState:
logger.info("Supervisor: Running supervisor logic.")
return await self.route_agent(state)
# --- Specialist Agent Implementations (updated for A2A and tracing) ---
class WebSearchAgent:
def __init__(self):
self.tool = TavilySearchResults(max_results=5)
async def run_web_search(self, state: AgentState) -> AgentState:
logger.info(f"Web Search Agent: Searching for '{state['current_task']}'")
with trace.get_tracer(__name__).start_as_current_span("web_search_agent.run_web_search") as span:
span.set_attribute("session_id", state['session_id'])
span.set_attribute("search_query", state['current_task'])
try:
await publish_a2a_message(
state['session_id'], "web_search_agent", "supervisor",
"status_update", {"message": f"Starting web search for: {state['current_task']}"}
)
search_results = self.tool.invoke({"query": state['current_task']})
result_str = json.dumps(search_results, indent=2)
add_to_episodic_memory(state['current_task'], result_str)
await publish_a2a_message(
state['session_id'], "web_search_agent", "supervisor",
"result", {"task": state['current_task'], "output": result_str}
)
span.set_attribute("results_summary", result_str[:200])
return {**state, "research_results": state["research_results"] + [f"Web Search Results for '{state['current_task']}':
{result_str}"], "next_agent": "supervisor"}
except Exception as e:
logger.error(f"Web Search Agent failed: {e}")
span.set_attribute("error", True)
span.set_attribute("error.message", str(e))
await publish_a2a_message(
state['session_id'], "web_search_agent", "supervisor",
"error", {"task": state['current_task'], "message": f"Web Search failed: {e}"}
)
return {**state, "research_results": state["research_results"] + [f"Web Search failed for '{state['current_task']}': {e}"], "next_agent": "supervisor"}
class AcademicPaperAgent:
async def run_academic_search(self, state: AgentState) -> AgentState:
logger.info(f"Academic Paper Agent: Searching ArXiv for '{state['current_task']}'")
with trace.get_tracer(__name__).start_as_current_span("academic_paper_agent.run_academic_search") as span:
span.set_attribute("session_id", state['session_id'])
span.set_attribute("search_query", state['current_task'])
try:
await publish_a2a_message(
state['session_id'], "academic_paper_agent", "supervisor",
"status_update", {"message": f"Starting academic search for: {state['current_task']}"}
)
loader = ArxivLoader(query=state['current_task'], load_max_docs=2, load_all_available_meta=True)
docs = loader.load()
if not docs:
await publish_a2a_message(
state['session_id'], "academic_paper_agent", "supervisor",
"result", {"task": state['current_task'], "output": f"No academic papers found for '{state['current_task']}'."}
)
span.set_attribute("found_papers", 0)
return {**state, "research_results": state["research_results"] + [f"No academic papers found for '{state['current_task']}'."], "next_agent": "supervisor"}
result_summary = []
for doc in docs:
summary = f"Title: {doc.metadata.get('Title', 'N/A')}
Authors: {doc.metadata.get('Authors', 'N/A')}
Published: {doc.metadata.get('Published', 'N/A')}
Summary: {doc.page_content[:500]}...
Link: {doc.metadata.get('entry_id', 'N/A')}"
result_summary.append(summary)
full_result_str = "
---
".join(result_summary)
add_to_episodic_memory(state['current_task'], full_result_str)
await publish_a2a_message(
state['session_id'], "academic_paper_agent", "supervisor",
"result", {"task": state['current_task'], "output": full_result_str}
)
span.set_attribute("found_papers", len(docs))
span.set_attribute("results_summary", full_result_str[:200])
return {**state, "research_results": state["research_results"] + [f"Academic Research Results for '{state['current_task']}':
{full_result_str}"], "next_agent": "supervisor"}
except Exception as e:
logger.error(f"Academic Paper Agent failed: {e}")
span.set_attribute("error", True)
span.set_attribute("error.message", str(e))
await publish_a2a_message(
state['session_id'], "academic_paper_agent", "supervisor",
"error", {"task": state['current_task'], "message": f"Academic Search failed: {e}"}
)
return {**state, "research_results": state["research_results"] + [f"Academic Search failed for '{state['current_task']}': {e}"], "next_agent": "supervisor"}
class CodeExecutionAgent:
@tool("python_interpreter", return_direct=True)
def python_interpreter(self, code: str) -> str:
"Executes Python code in a sandboxed environment."
with trace.get_tracer(__name__).start_as_current_span("code_execution_agent.python_interpreter") as span:
span.set_attribute("code_snippet", code)
try:
logger.warning("Executing code locally without a proper sandbox. Use with caution.")
process = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
check=True,
timeout=30
)
if process.stderr:
logger.warning(f"Code execution stderr: {process.stderr}")
span.set_attribute("execution_output", process.stdout.strip()[:200])
return process.stdout.strip()
except subprocess.CalledProcessError as e:
error_msg = f"Code execution failed with error: {e.stderr.strip()}"
logger.error(error_msg)
span.set_attribute("error", True)
span.set_attribute("error.message", error_msg)
raise ValueError(error_msg)
except subprocess.TimeoutExpired:
error_msg = "Code execution timed out."
logger.error(error_msg)
span.set_attribute("error", True)
span.set_attribute("error.message", error_msg)
raise ValueError(error_msg)
except Exception as e:
error_msg = f"An unexpected error occurred during code execution: {e}"
logger.error(error_msg)
span.set_attribute("error", True)
span.set_attribute("error.message", error_msg)
raise ValueError(error_msg)
async def run_code_execution(self, state: AgentState) -> AgentState:
logger.info(f"Code Execution Agent: Attempting to execute code for task '{state['current_task']}'")
with trace.get_tracer(__name__).start_as_current_span("code_execution_agent.run_code_execution") as span:
span.set_attribute("session_id", state['session_id'])
span.set_attribute("task_description", state['current_task'])
try:
await publish_a2a_message(
state['session_id'], "code_execution_agent", "supervisor",
"status_update", {"message": f"Starting code execution for: {state['current_task']}"}
)
code_generation_prompt = ChatPromptTemplate.from_messages([
("system", "You are a helpful assistant capable of writing and executing Python code to answer questions or perform data analysis. "
"Given a task, generate valid Python code that can be executed. Output ONLY the Python code, no explanations or markdown fences."
"Current Task: {current_task}
"
"Existing Research Results: {research_results}
"
"Relevant Episodic Memory: {episodic_memory}
"
"Example: print(2+2)"),
("human", "Generate Python code for the following task: {task}")
])
llm_code_chain = code_generation_prompt | get_llm()
relevant_memory = retrieve_from_episodic_memory(state['current_task'] + " ".join(state['research_results']))
code_to_execute = llm_code_chain.invoke({
"task": state['current_task'],
"research_results": "
".join(state['research_results']) if state['research_results'] else "None yet.",
"episodic_memory": "
".join(relevant_memory) if relevant_memory else "None found."
}).content
logger.info(f"Generated code: {code_to_execute[:100]}...")
span.set_attribute("generated_code_snippet", code_to_execute[:200])
execution_output = self.python_interpreter.invoke(code_to_execute)
add_to_episodic_memory(state['current_task'], f"Code Output: {execution_output}")
await publish_a2a_message(
state['session_id'], "code_execution_agent", "supervisor",
"result", {"task": state['current_task'], "output": execution_output}
)
return {**state, "research_results": state["research_results"] + [f"Code Execution Results for '{state['current_task']}':
{execution_output}"], "next_agent": "supervisor"}
except Exception as e:
logger.error(f"Code Execution Agent failed: {e}")
span.set_attribute("error", True)
span.set_attribute("error.message", str(e))
await publish_a2a_message(
state['session_id'], "code_execution_agent", "supervisor",
"error", {"task": state['current_task'], "message": f"Code Execution failed: {e}"}
)
return {**state, "research_results": state["research_results"] + [f"Code Execution failed for '{state['current_task']}': {e}"], "next_agent": "supervisor"}
# --- Build the LangGraph (from Phase 4) ---
def create_research_graph(llm: ChatOpenAI, memory: SqliteSaver):
workflow = StateGraph(AgentState)
supervisor = SupervisorAgent(llm)
web_search = WebSearchAgent()
academic_paper = AcademicPaperAgent()
code_execution = CodeExecutionAgent()
workflow.add_node("supervisor", supervisor.run_supervisor)
workflow.add_node("web_search_agent", web_search.run_web_search)
workflow.add_node("academic_paper_agent", academic_paper.run_academic_search)
workflow.add_node("code_execution_agent", code_execution.run_code_execution)
workflow.set_entry_point("supervisor")
workflow.add_conditional_edges(
"supervisor",
lambda state: state['next_agent'],
{
"web_search_agent": "web_search_agent",
"academic_paper_agent": "academic_paper_agent",
"code_execution_agent": "code_execution_agent",
"FINISH": END
},
)
workflow.add_edge("web_search_agent", "supervisor")
workflow.add_edge("academic_paper_agent", "supervisor")
workflow.add_edge("code_execution_agent", "supervisor")
app = workflow.compile(checkpointer=memory)
return app
# --- FastAPI Application for Streaming (from Phase 4, updated for OpenTelemetry) ---
app_fastapi = FastAPI(title="Multi-Agent Research Platform")
class ResearchRequest(BaseModel):
query: str
session_id: str = str(uuid.uuid4())
async def stream_a2a_messages(session_id: str) -> AsyncIterator[str]:
r = await get_redis_client()
pubsub = r.pubsub()
await pubsub.subscribe(f"a2a_channel:{session_id}")
logger.info(f"Subscribed to A2A channel: a2a_channel:{session_id}")
try:
while True:
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0)
if message:
data = json.loads(message['data'].decode('utf-8'))
yield f"data: {json.dumps(data)}
"
await asyncio.sleep(0.01)
except asyncio.CancelledError:
logger.info(f"Streaming for session {session_id} cancelled.")
except Exception as e:
logger.error(f"Error in A2A message streaming for session {session_id}: {e}")
yield f"data: {json.dumps({'event': 'error', 'message': str(e)})}
"
finally:
await pubsub.unsubscribe(f"a2a_channel:{session_id}")
logger.info(f"Unsubscribed from A2A channel: a2a_channel:{session_id}")
@app_fastapi.post("/research/stream")
async def start_research_stream(request: ResearchRequest):
session_id = request.session_id
query = request.query
logger.info(f"Received research request for session {session_id} with query: {query}")
llm = get_llm()
memory = SqliteSaver(conn=sqlite3.connect(f"./langgraph_checkpoints_{session_id}.sqlite", check_same_thread=False))
research_graph = create_research_graph(llm, memory)
async def research_task():
try:
initial_state = {"input": query, "chat_history": [HumanMessage(content=query)], "research_results": [], "next_agent": "", "current_task": "", "session_id": session_id}
final_state = None
async for s in research_graph.astream(initial_state, config={"configurable": {"thread_id": session_id}}):
if "supervisor" in s:
current_state = s["supervisor"]
if current_state.get("next_agent") == "FINISH":
final_state = current_state
break
if final_state:
await publish_a2a_message(
session_id, "supervisor", "user",
"final_result", {"input": query, "final_results": final_state['research_results']}
)
else:
await publish_a2a_message(
session_id, "supervisor", "user",
"error", {"message": "Research task completed without a final state.", "input": query}
)
except Exception as e:
logger.error(f"Error during research task for session {session_id}: {e}")
await publish_a2a_message(
session_id, "supervisor", "user",
"error", {"message": f"Research task failed: {e}", "input": query}
)
asyncio.create_task(research_task())
return StreamingResponse(stream_a2a_messages(session_id), media_type="text/event-stream")
# --- Main application entry point for testing (FastAPI) ---
if __name__ == "__main__":
print("
--- Phase 5: Observability with OpenTelemetry and LangSmith ---")
setup_opentelemetry() # Initialize OpenTelemetry
FastAPIInstrumentor.instrument_app(app_fastapi) # Instrument FastAPI
print("Starting FastAPI server... access at http://127.0.0.1:8000/docs")
print("Ensure Redis is running (e.g., `docker run -p 6379:6379 --name my-redis -d redis`).")
print("To view traces, ensure an OpenTelemetry collector is running (e.g., Jaeger) or provide a LangSmith API Key.")
uvicorn.run(app_fastapi, host="0.0.0.0", port=8000)
LANGCHAIN_TRACING_V2, LANGCHAIN_PROJECT) are set, along with an explicit note that LANGCHAIN_API_KEY must be configured. These are crucial for LangSmith to collect traces.
The setup_opentelemetry function initializes the OpenTelemetry SDK. It configures a TracerProvider with a Resource identifying our service, and sets up an OTLPSpanExporter to send traces to an OpenTelemetry collector (e.g., Jaeger, DataDog, New Relic) via HTTP. The BatchSpanProcessor efficiently handles span export. Crucially, LangchainInstrumentor().instrument() is called to automatically generate OpenTelemetry spans for all LangChain and LangGraph operations, including LLM calls, tool invocations, and chain executions. This provides out-of-the-box tracing for the core agent logic.
Within each agent's primary run_ method (e.g., SupervisorAgent.route_agent, WebSearchAgent.run_web_search), with trace.get_tracer(__name__).start_as_current_span(...) as span: blocks are added. These manually create spans for the high-level agent actions, allowing us to add custom attributes like session_id, search_query, next_agent_decision, and error details. This enriches the traces with business-specific context, making it easier to understand the flow and debug failures across different agents.
Finally, in the if __name__ == "__main__" block, setup_opentelemetry() is called to initialize the tracing. Additionally, FastAPIInstrumentor.instrument_app(app_fastapi) is used to automatically instrument the FastAPI application, ensuring that incoming HTTP requests are also traced, and their context is propagated down to the agent workflow. This holistic tracing provides end-to-end visibility, from the user's API request through the entire multi-agent decision-making process, tool usage, and memory interactions, making it an indispensable part of production deployment.
- Ensure Redis is running. 2. Set
OPENAI_API_KEY,TAVILY_API_KEY,REDIS_URL, andLANGCHAIN_API_KEY(for LangSmith) in your environment. Optionally, run an OpenTelemetry Collector and Jaeger (e.g., via Docker Compose) and setOTEL_EXPORTER_OTLP_ENDPOINT. 3. Runpython app/main.py. 4. Send a research query via the FastAPI endpoint. 5. Check your LangSmith project dashboard or Jaeger UI for new traces. You should see spans for FastAPI requests, LangGraph nodes, LLM calls, and tool invocations, along with custom attributes.
Testing & Evaluation
Robust testing and evaluation are paramount for a multi-agent research platform due to its non-deterministic nature and reliance on external tools and LLMs. We adopt a multi-faceted approach encompassing functional, LLM-as-a-Judge, and failure scenario testing.
Functional Testing
Functional tests verify that the agents correctly perform their intended tasks. We use pytest to write integration tests that simulate user queries and assert on the final research_results and the sequence of a2a_messages. For example, a test might check if a query about "latest AI breakthroughs" correctly triggers the web_search_agent and eventually returns relevant information. These tests are deterministic where possible, focusing on tool invocation and state transitions.
LLM-as-a-Judge Evaluation
Given the qualitative nature of research, LLM-as-a-Judge is critical for evaluating the quality, completeness, and accuracy of the final research output. We use a separate, more powerful LLM (e.g., GPT-4o-turbo) to assess the research report generated by our platform against a reference answer or a set of criteria. The evaluation criteria include:
- Relevance: How well does the report address the original query?
- Completeness: Does it cover all necessary aspects? Are there gaps?
- Accuracy: Are the facts presented correct? (Requires ground truth or human review for initial dataset)
- Coherence: Is the report well-structured and easy to understand?
- Conciseness: Is there redundant information?
Here's an example of an LLM-as-a-Judge evaluation script:
import os
import json
import pytest
from typing import Dict, Any, List
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import JsonOutputParser
# Assume your agent's API is running at this URL
AGENT_API_URL = "http://localhost:8000/research/stream"
class EvaluationResult(TypedDict):
score: float
reason: str
def evaluate_research_report_with_llm(query: str, generated_report: str, evaluation_criteria: str) -> EvaluationResult:
eval_llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.0)
eval_prompt = ChatPromptTemplate.from_messages([
("system", "You are an impartial judge evaluating the quality of a research report based on a user query and specific criteria. "
"Rate the report on a scale of 0 to 1, where 1 is excellent and 0 is poor. Provide a detailed reason for your score.
"
"Evaluation Criteria: {criteria}
"
"Respond with a JSON object containing 'score' (float) and 'reason' (string)."),
("human", "User Query: {query}
Generated Research Report:
{report}")
])
eval_parser = JsonOutputParser(pydantic_object=type('EvaluationResult', (object,), {'score': float, 'reason': str}))
eval_chain = eval_prompt | eval_llm | eval_parser
try:
response = eval_chain.invoke({
"query": query,
"report": generated_report,
"criteria": evaluation_criteria
})
return EvaluationResult(score=response.get('score', 0.0), reason=response.get('reason', 'No reason provided.'))
except Exception as e:
print(f"LLM evaluation failed: {e}")
return EvaluationResult(score=0.0, reason=f"Evaluation system error: {e}")
def run_agent_and_get_final_result(query: str) -> str:
# This is a mock function. In a real scenario, you'd call your FastAPI endpoint
# and aggregate the streamed A2A messages to construct the final report.
# For testing, we'll return a dummy report.
print(f"[MOCK] Running agent for query: {query}")
time.sleep(5) # Simulate agent work
if "AI ethics" in query:
return "Recent advancements in AI ethics focus on algorithmic fairness, transparency in decision-making, and mitigating bias in large language models. Key research areas include explainable AI (XAI) and responsible AI development principles. Several papers by Google DeepMind and OpenAI discuss these topics, highlighting the need for interdisciplinary approaches. The average citation count for top papers is around 250."
return "No specific research found for your query. Please try again."
# Example usage in a pytest fixture or test
@pytest.mark.parametrize("query, expected_min_score, criteria", [
("Summarize recent advancements in AI ethics.", 0.7, "The report should cover algorithmic fairness, transparency, and bias mitigation. It must mention LLMs and provide a concise summary."),
("Find the capital of France.", 0.9, "The report should correctly identify Paris and provide relevant details.") # This might not trigger multi-agent, but tests basic functionality
])
def test_agent_research_quality(query: str, expected_min_score: float, criteria: str):
if not os.environ.get("OPENAI_API_KEY"):
pytest.skip("OPENAI_API_KEY not set, skipping LLM evaluation.")
generated_report = run_agent_and_get_final_result(query)
evaluation = evaluate_research_report_with_llm(query, generated_report, criteria)
print(f"
Query: {query}
Report: {generated_report}
Evaluation: Score={evaluation['score']}, Reason={evaluation['reason']}")
assert evaluation['score'] >= expected_min_score, f"Report quality too low: {evaluation['reason']}"Failure Scenario Testing
We simulate failures for each specialist agent and observe the supervisor's recovery or graceful degradation. This includes:
- Tool API failures: Mocking
TavilySearchResultsorArxivLoaderto raise exceptions (e.g.,rate_limit_exceeded,connection_error). The supervisor should ideally attempt retries or switch to an alternative strategy if available. - LLM generation failures: Inducing malformed JSON outputs from the supervisor LLM or specialist agents' code generation. The system should handle these gracefully, perhaps by retrying the LLM call or falling back to a default action.
- Code interpreter errors: Providing intentionally buggy code to the
CodeExecutionAgentto ensure errors are captured and reported, rather than crashing the system.
Edge Case Coverage
- Ambiguous queries: Queries that could map to multiple agents (e.g., "What's the latest on AI?") to test the supervisor's disambiguation capabilities.
- No results: Queries for which no information is found by any tool, ensuring the agent gracefully reports this.
- Long-running tasks: Testing the streaming API's resilience over extended research sessions.
- Empty episodic memory: Starting a session with a fresh vector store to ensure the agent can bootstrap its knowledge.
Performance Validation
While LLM-as-a-Judge focuses on quality, performance validation tracks latency and token usage. We use OpenTelemetry traces to monitor the duration of each agent's execution, LLM calls, and tool invocations. Metrics like P99 latency for end-to-end research queries are tracked. LangSmith provides detailed token usage for each LLM interaction, allowing for cost analysis and optimization. We establish baselines for typical queries and monitor for regressions.
Regression Testing
Automated tests, including functional and LLM-as-a-Judge evaluations, are integrated into a CI/CD pipeline. This ensures that new features or refactorings do not inadvertently degrade the agent's performance or output quality. A comprehensive suite of evaluation prompts and expected outcomes (or minimum LLM-as-a-Judge scores) is maintained and regularly run.
Deployment
Deploying a multi-agent research platform requires careful consideration of containerization, orchestration, and external service management. We will use Docker for containerization and a Kubernetes manifest for cloud deployment, ensuring scalability, reliability, and ease of management.
Dockerfile
Our Dockerfile will create a lightweight image for our FastAPI application, including all Python dependencies and a specified Python version. It will ensure that all secrets are passed via environment variables at runtime.
# Use a lightweight official Python image
FROM python:3.10-slim-buster
# Set working directory inside the container
WORKDIR /app
# Install Poetry
RUN pip install poetry==1.8.2
# Copy project files
COPY pyproject.toml poetry.lock ./
COPY app/ ./app/
# Install dependencies using Poetry
# Use --no-root to prevent installing the project itself as a package during build
# Use --no-dev to exclude development dependencies
RUN poetry install --no-root --no-dev
# Create directories for persistent data (ChromaDB, LangGraph checkpoints)
RUN mkdir -p /app/chroma_db
RUN mkdir -p /app/langgraph_checkpoints
# Expose the port FastAPI will run on
EXPOSE 8000
# Command to run the application using Uvicorn
# Use Gunicorn for production-ready ASGI server with multiple workers
CMD ["sh", "-c", "gunicorn app.main:app_fastapi --workers 4 --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000"]To build and run locally: docker build -t research-platform . docker run -p 8000:8000 --env-file ./.env --name research-platform-app research-platform (Ensure your .env file contains OPENAI_API_KEY, TAVILY_API_KEY, REDIS_URL, LANGCHAIN_API_KEY, and OTEL_EXPORTER_OTLP_ENDPOINT)
Kubernetes Deployment Configuration
For production, we deploy to Kubernetes. This deployment.yaml defines our application deployment, service, and persistent volume claims for ChromaDB.
apiVersion: apps/v1
kind: Deployment
metadata:
name: research-platform-deployment
labels:
app: research-platform
spec:
replicas: 2 # Start with 2 replicas for high availability
selector:
matchLabels:
app: research-platform
template:
metadata:
labels:
app: research-platform
spec:
containers:
- name: research-platform
image: your-docker-registry/research-platform:latest # Replace with your image
ports:
- containerPort: 8000
env:
- name: OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: research-platform-secrets
key: OPENAI_API_KEY
- name: TAVILY_API_KEY
valueFrom:
secretKeyRef:
name: research-platform-secrets
key: TAVILY_API_KEY
- name: LANGCHAIN_API_KEY
valueFrom:
secretKeyRef:
name: research-platform-secrets
key: LANGCHAIN_API_KEY
- name: REDIS_URL
value: "redis://redis-service.default.svc.cluster.local:6379/0" # Internal K8s service name for Redis
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "http://otel-collector.observability.svc.cluster.local:4318/v1/traces" # OTel Collector endpoint
volumeMounts:
- name: chroma-db-storage
mountPath: /app/chroma_db
- name: langgraph-checkpoints-storage
mountPath: /app/langgraph_checkpoints
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "2Gi"
cpu: "1"
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 15
periodSeconds: 20
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 5
periodSeconds: 10
volumes:
- name: chroma-db-storage
persistentVolumeClaim:
claimName: chroma-db-pvc
- name: langgraph-checkpoints-storage
persistentVolumeClaim:
claimName: langgraph-checkpoints-pvc
---
apiVersion: v1
kind: Service
metadata:
name: research-platform-service
labels:
app: research-platform
spec:
type: ClusterIP # Use LoadBalancer for external access
selector:
app: research-platform
ports:
- protocol: TCP
port: 80
targetPort: 8000
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: chroma-db-pvc
spec:
accessModes:
- ReadWriteOnce # Or ReadWriteMany if your storage class supports it and ChromaDB is configured for shared access
resources:
requests:
storage: 10Gi # Adjust storage size as needed
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: langgraph-checkpoints-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1GiSecrets Management
Secrets like OPENAI_API_KEY, TAVILY_API_KEY, and LANGCHAIN_API_KEY are never hardcoded. In Kubernetes, these are stored as Secret objects and injected into the container as environment variables using valueFrom.secretKeyRef. For local development, an .env file is used with tools like python-dotenv or docker run --env-file.
apiVersion: v1
kind: Secret
metadata:
name: research-platform-secrets
type: Opaque
data:
OPENAI_API_KEY: <base64-encoded-openai-key>
TAVILY_API_KEY: <base64-encoded-tavily-key>
LANGCHAIN_API_KEY: <base64-encoded-langchain-key>(Replace <base64-encoded-key> with echo -n 'your-key-here' | base64)
Health Check and Readiness Probe
The FastAPI app includes a simple /health endpoint to indicate its operational status. Kubernetes uses livenessProbe to detect if the application is still running and readinessProbe to determine if it's ready to serve traffic. This ensures that only healthy pods receive requests and unhealthy pods are restarted.
# In app/main.py
@app_fastapi.get("/health")
async def health_check():
return {"status": "ok", "message": "Multi-agent research platform is running."}Scaling Considerations
- Horizontal Pod Autoscaling (HPA): Configure HPA in Kubernetes to automatically scale the number of replicas based on CPU utilization or custom metrics (e.g., number of active research sessions in Redis).
- External Services: Ensure Redis, ChromaDB (if externalized), and your LLM provider can handle increased load. Redis can be clustered, and vector databases often have managed services for scalability.
- Statelessness (mostly): While LangGraph has state, our use of
SqliteSaver(per-session) and Redis for A2A messages allows individual pods to be largely stateless regarding shared application state, facilitating horizontal scaling. ChromaDB's persistence requires careful multi-writer consideration or a shared file system/managed service.
Rollback Strategy
Kubernetes deployments support rolling updates and rollbacks. If a new deployment introduces issues, you can quickly revert to a previous stable version using kubectl rollout undo deployment/research-platform-deployment. Version control for Docker images (e.g., research-platform:v1.0.1) and Kubernetes manifests is crucial. Automated monitoring (observability) and health checks will detect issues quickly, triggering alerts or automated rollbacks.
Production Hardening
Moving the multi-agent research platform to production requires addressing several critical areas beyond basic deployment to ensure security, reliability, and cost-effectiveness.
Security
- Sandbox Code Execution (OWASP ASI05): The
CodeExecutionAgent's localsubprocess.runis a significant security risk. For production, integrate a secure sandboxing solution like E2B.dev, a dedicated Docker container per execution, or a remote code execution service. This prevents malicious code from impacting the host system or accessing sensitive data. Each execution should be isolated, short-lived, and have strict resource limits. - Input Validation & Sanitization: Implement rigorous input validation on all API endpoints (
/research/stream). Sanitize user inputs before passing them to LLMs or tools to prevent prompt injection (OWASP ASI01) and other vulnerabilities. Use Pydantic for schema validation and consider libraries likeprompt-guardfor LLM-specific sanitization. - Secrets Management: Never store API keys or other sensitive credentials directly in code or configuration files. Use Kubernetes Secrets, Azure Key Vault, AWS Secrets Manager, or HashiCorp Vault for secure storage and retrieval. Restrict access to these secrets using RBAC.
- Network Segmentation: Deploy agents and their associated services (Redis, vector DB) in a private network segment. Control ingress/egress with firewalls and network policies, allowing only necessary communication between components and to external LLM/tool APIs.
Observability
- Comprehensive Logging: Implement structured logging (e.g., JSON logs) with appropriate log levels. Ensure logs capture agent decisions, tool invocations, A2A messages, errors, and performance metrics. Centralize logs using tools like ELK stack, Splunk, or cloud-native solutions (CloudWatch, Azure Monitor) for easy analysis and alerting.
- Distributed Tracing (OpenTelemetry): Leverage the OpenTelemetry integration to provide end-to-end visibility across the multi-agent workflow, LLM calls, and tool usage. Use a tracing backend (Jaeger, Grafana Tempo, DataDog) to visualize traces, identify bottlenecks, and debug complex interactions. Crucially, ensure trace context propagation is correctly configured across all services and asynchronous boundaries.
- Metrics & Alerting: Collect key metrics such as request latency (P99, P95), error rates, token usage per agent/session, number of active sessions, and tool success/failure rates. Use Prometheus/Grafana or cloud monitoring services to visualize these metrics and configure alerts for anomalies or critical thresholds.
- LangSmith Integration: Utilize LangSmith for fine-grained debugging of LLM interactions, prompt engineering, and tracking agent performance over time. Integrate LangSmith into your CI/CD for automated evaluation and regression testing of agent quality.
Reliability
- Retry Mechanisms & Exponential Backoff (Agent Failure Recovery): Implement robust retry logic with exponential backoff for all external API calls (LLMs, Tavily, ArXiv, Redis). This handles transient network issues and API rate limits gracefully, preventing cascading failures. Define clear retry limits to avoid infinite loops.
- Fallback Strategies: For critical LLM calls or tool invocations, consider fallback mechanisms. This could involve using a different, potentially less capable, LLM provider, or a simpler, cached response if a primary tool fails.
- Idempotency: Design agent actions to be idempotent where possible. If an operation is retried, it should produce the same result without unintended side effects.
- Resource Limits: Set appropriate CPU and memory limits for Kubernetes pods to prevent resource exhaustion and ensure fair resource allocation across replicas. Implement horizontal autoscaling based on observed load.
Rate Limiting
- External API Rate Limits: Implement client-side rate limiting for calls to LLMs and external tools (Tavily, ArXiv) to respect their API quotas and avoid being throttled or banned. Use libraries like
tenacityfor retries with rate limit handling. - Internal Rate Limiting: Implement rate limiting on your FastAPI endpoints to protect your backend services from abuse or overload, especially for the streaming API. This can be done using middleware or an API Gateway.
Authentication
- API Key / OAuth: For external access to your FastAPI service, implement secure authentication. API keys (managed securely) or OAuth 2.1 are common choices. For internal A2A communication, mutual TLS or service accounts can provide secure identity.
Governance
- Human-in-the-Loop (HITL) Escalation: For sensitive or high-impact research queries, implement a mechanism for human review or approval. If the agent detects uncertainty, ethical dilemmas, or requires external validation, it can pause and escalate to a human operator.
- Data Residency & Privacy: Ensure all data handled by the agents (user queries, research results, episodic memory) complies with relevant data residency and privacy regulations (e.g., GDPR, CCPA). Configure data storage locations and access controls accordingly.
Cost Optimisation
- Token Usage Monitoring: Continuously monitor token usage across all LLM calls using LangSmith and custom metrics. Identify and optimize prompts that are excessively verbose or lead to unnecessary LLM invocations.
- Caching: Implement robust caching for frequently accessed data or tool results (e.g., web search results for common queries) to reduce redundant LLM calls and external API usage.
- LLM Tiering: Use smaller, cheaper LLMs (e.g., GPT-4o-mini) for simpler tasks like parsing or summarization, reserving larger, more expensive models for complex reasoning or code generation.
- Design and implement a supervisor pattern for multi-agent orchestration using LangGraph's conditional routing capabilities
- Integrate diverse tools like web search, academic databases, and code interpreters into specialist agents
- Implement shared episodic memory using a vector database for persistent context across agent interactions
- Utilize the A2A protocol for structured, traceable communication between autonomous agents
- Develop a streaming API for real-time feedback and results from a multi-agent system
- Apply robust error handling, logging, and retry mechanisms for resilient agent operations