From 4b04deee7b4a2cb8390ab5a1cf22d924de148f4c Mon Sep 17 00:00:00 2001 From: Assaf Elovic Date: Fri, 7 Mar 2025 17:39:07 +0200 Subject: [PATCH 1/9] added base files for new deep research langgraph --- multi_agents/deep_research/README.md | 95 ++++++ multi_agents/deep_research/__init__.py | 15 + multi_agents/deep_research/agents/README.md | 41 +++ multi_agents/deep_research/agents/__init__.py | 14 + multi_agents/deep_research/agents/base.py | 71 ++++ multi_agents/deep_research/agents/explorer.py | 74 +++++ multi_agents/deep_research/agents/reviewer.py | 86 +++++ .../deep_research/agents/synthesizer.py | 58 ++++ .../deep_research_workflow.excalidraw | 0 multi_agents/deep_research/main.py | 175 ++++++++++ multi_agents/deep_research/memory.py | 104 ++++++ multi_agents/deep_research/orchestrator.py | 303 ++++++++++++++++++ 12 files changed, 1036 insertions(+) create mode 100644 multi_agents/deep_research/README.md create mode 100644 multi_agents/deep_research/__init__.py create mode 100644 multi_agents/deep_research/agents/README.md create mode 100644 multi_agents/deep_research/agents/__init__.py create mode 100644 multi_agents/deep_research/agents/base.py create mode 100644 multi_agents/deep_research/agents/explorer.py create mode 100644 multi_agents/deep_research/agents/reviewer.py create mode 100644 multi_agents/deep_research/agents/synthesizer.py create mode 100644 multi_agents/deep_research/deep_research_workflow.excalidraw create mode 100644 multi_agents/deep_research/main.py create mode 100644 multi_agents/deep_research/memory.py create mode 100644 multi_agents/deep_research/orchestrator.py diff --git a/multi_agents/deep_research/README.md b/multi_agents/deep_research/README.md new file mode 100644 index 000000000..99f414b88 --- /dev/null +++ b/multi_agents/deep_research/README.md @@ -0,0 +1,95 @@ +# Deep Research with LangGraph + +This module implements a deep research capability using LangGraph, inspired by the recursive deep research approach in GPT Researcher's `deep_research.py`. + +## Overview + +The Deep Research implementation uses LangGraph to create a recursive research workflow that: + +1. Generates search queries based on the initial query +2. Creates a research plan with follow-up questions +3. Conducts parallel research on each search query +4. Synthesizes and reviews the research results +5. Recursively explores deeper based on follow-up questions +6. Finalizes the research with citations and context + +## Architecture + +The deep research system is built with the following components: + +- **DeepResearchOrchestrator**: Coordinates the entire research process using LangGraph +- **DeepExplorerAgent**: Generates search queries and research plans +- **DeepResearchAgent**: Conducts basic research using GPTResearcher +- **DeepSynthesizerAgent**: Processes and synthesizes research results +- **DeepReviewerAgent**: Reviews research quality and completeness +- **DeepResearchState**: Manages the state of the research process + +The LangGraph workflow is structured as follows: + +``` +generate_queries → generate_plan → process_queries → review_research → [recursive_research] → finalize_research +``` + +The recursive research step is conditionally executed based on the current depth and creates a recursive loop back to the review step. + +## How It Works + +1. **Query Generation**: The system generates multiple search queries based on the initial query +2. **Research Planning**: Follow-up questions are generated to guide the research +3. **Parallel Research**: Each search query is processed in parallel with a concurrency limit +4. **Research Synthesis**: Results are synthesized to extract key learnings with citations +5. **Research Review**: The quality and completeness of the research is evaluated +6. **Recursive Exploration**: If the maximum depth hasn't been reached, the system generates new queries based on follow-up questions and continues the research +7. **Finalization**: The research is finalized with a comprehensive context that includes all learnings with citations + +## Usage + +You can use the deep research implementation in two ways: + +### 1. From the command line: + +```bash +python -m multi_agents.main --mode deep --query "Your research query" --depth 2 --breadth 4 +``` + +### 2. Programmatically: + +```python +from multi_agents.deep_research.main import run_deep_research + +results = await run_deep_research( + query="Your research query", + depth=2, + breadth=4, + concurrency=2 +) +``` + +## Configuration + +You can configure the deep research process through the `task.json` file by adding these parameters: + +```json +{ + "query": "Your research query", + "deep_research_depth": 2, + "deep_research_breadth": 4, + "deep_research_concurrency": 2, + "source": "web", + "verbose": true, + "publish_formats": { + "markdown": true, + "pdf": true + } +} +``` + +## Output + +The deep research process produces: + +1. A comprehensive research context with citations +2. A list of key learnings from the research +3. A quality review of the research +4. A final report generated by the Writer agent +5. Published files in the specified formats \ No newline at end of file diff --git a/multi_agents/deep_research/__init__.py b/multi_agents/deep_research/__init__.py new file mode 100644 index 000000000..d22e0bfbc --- /dev/null +++ b/multi_agents/deep_research/__init__.py @@ -0,0 +1,15 @@ +from .orchestrator import DeepResearchOrchestrator +from .agents import ( + DeepResearchAgent, + DeepExplorerAgent, + DeepSynthesizerAgent, + DeepReviewerAgent +) + +__all__ = [ + "DeepResearchOrchestrator", + "DeepResearchAgent", + "DeepExplorerAgent", + "DeepSynthesizerAgent", + "DeepReviewerAgent" +] \ No newline at end of file diff --git a/multi_agents/deep_research/agents/README.md b/multi_agents/deep_research/agents/README.md new file mode 100644 index 000000000..9f165e4ac --- /dev/null +++ b/multi_agents/deep_research/agents/README.md @@ -0,0 +1,41 @@ +# Deep Research Agents + +This directory contains the specialized agents used in the deep research process. Each agent has a specific role in the research workflow. + +## Agent Structure + +- **base.py**: Contains the base `DeepResearchAgent` class and common utilities + - `count_words()`: Utility function to count words in text + - `trim_context_to_word_limit()`: Utility to trim context to stay within token limits + - `ResearchProgress`: Class to track research progress + - `DeepResearchAgent`: Base agent class with common functionality + +- **explorer.py**: Contains the `DeepExplorerAgent` class + - Responsible for generating search queries and research plans + - Methods: + - `generate_search_queries()`: Generates search queries based on the initial query + - `generate_research_plan()`: Generates follow-up questions to guide research + +- **synthesizer.py**: Contains the `DeepSynthesizerAgent` class + - Responsible for processing and synthesizing research results + - Methods: + - `process_research_results()`: Extracts key learnings and follow-up questions from research results + +- **reviewer.py**: Contains the `DeepReviewerAgent` class + - Responsible for reviewing and validating research results + - Methods: + - `review_research()`: Evaluates research quality and completeness + +## Agent Workflow + +The agents work together in the following workflow: + +1. `DeepExplorerAgent` generates search queries and a research plan +2. `DeepResearchAgent` conducts basic research on each query +3. `DeepSynthesizerAgent` processes the research results +4. `DeepReviewerAgent` reviews the research quality +5. The process repeats recursively for deeper research levels + +## Usage + +The agents are orchestrated by the `DeepResearchOrchestrator` class, which creates a LangGraph workflow to coordinate their actions. You don't need to instantiate these agents directly in most cases. \ No newline at end of file diff --git a/multi_agents/deep_research/agents/__init__.py b/multi_agents/deep_research/agents/__init__.py new file mode 100644 index 000000000..54691be7c --- /dev/null +++ b/multi_agents/deep_research/agents/__init__.py @@ -0,0 +1,14 @@ +from .base import count_words, trim_context_to_word_limit, ResearchProgress, DeepResearchAgent +from .explorer import DeepExplorerAgent +from .synthesizer import DeepSynthesizerAgent +from .reviewer import DeepReviewerAgent + +__all__ = [ + "count_words", + "trim_context_to_word_limit", + "ResearchProgress", + "DeepResearchAgent", + "DeepExplorerAgent", + "DeepSynthesizerAgent", + "DeepReviewerAgent" +] \ No newline at end of file diff --git a/multi_agents/deep_research/agents/base.py b/multi_agents/deep_research/agents/base.py new file mode 100644 index 000000000..6f9100947 --- /dev/null +++ b/multi_agents/deep_research/agents/base.py @@ -0,0 +1,71 @@ +import logging +from typing import List, Dict, Any, Set, Optional +from gpt_researcher import GPTResearcher +from gpt_researcher.utils.enum import ReportType, ReportSource, Tone +from ...agents.utils.views import print_agent_output + +logger = logging.getLogger(__name__) + +# Maximum words allowed in context (25k words for safety margin) +MAX_CONTEXT_WORDS = 25000 + +def count_words(text: str) -> int: + """Count words in a text string""" + return len(text.split()) + +def trim_context_to_word_limit(context_list: List[str], max_words: int = MAX_CONTEXT_WORDS) -> List[str]: + """Trim context list to stay within word limit while preserving most recent/relevant items""" + total_words = 0 + trimmed_context = [] + + # Process in reverse to keep most recent items + for item in reversed(context_list): + words = count_words(item) + if total_words + words <= max_words: + trimmed_context.insert(0, item) # Insert at start to maintain original order + total_words += words + else: + break + + return trimmed_context + +class ResearchProgress: + """Track progress of deep research""" + def __init__(self, total_depth: int, total_breadth: int): + self.current_depth = 1 # Start from 1 and increment up to total_depth + self.total_depth = total_depth + self.current_breadth = 0 # Start from 0 and count up to total_breadth as queries complete + self.total_breadth = total_breadth + self.current_query: Optional[str] = None + self.total_queries = 0 + self.completed_queries = 0 + +class DeepResearchAgent: + """Base agent for deep research operations""" + def __init__(self, websocket=None, stream_output=None, tone=None, headers=None): + self.websocket = websocket + self.stream_output = stream_output + self.tone = tone or Tone.Objective + self.headers = headers or {} + + async def _stream_or_print(self, message: str, agent_type: str = "RESEARCHER"): + """Stream output to websocket or print to console""" + if self.websocket and self.stream_output: + await self.stream_output("logs", "deep_research", message, self.websocket) + else: + print_agent_output(message, agent=agent_type) + + async def basic_research(self, query: str, verbose: bool = True, source: str = "web") -> tuple: + """Conduct basic research using GPTResearcher""" + researcher = GPTResearcher( + query=query, + report_type=ReportType.ResearchReport.value, + report_source=source, + tone=self.tone, + websocket=self.websocket, + headers=self.headers + ) + + # Conduct research + context = await researcher.conduct_research() + return context, researcher.visited_urls, researcher.research_sources \ No newline at end of file diff --git a/multi_agents/deep_research/agents/explorer.py b/multi_agents/deep_research/agents/explorer.py new file mode 100644 index 000000000..b9dc481c0 --- /dev/null +++ b/multi_agents/deep_research/agents/explorer.py @@ -0,0 +1,74 @@ +from typing import Dict, List, Any +from datetime import datetime +from ...agents.utils.llms import call_model +from .base import DeepResearchAgent + +class DeepExplorerAgent(DeepResearchAgent): + """Agent responsible for exploring research queries and generating search queries""" + + async def generate_search_queries(self, query: str, num_queries: int = 3) -> List[Dict[str, str]]: + """Generate SERP queries for research""" + await self._stream_or_print(f"Generating {num_queries} search queries for: {query}", "EXPLORER") + + prompt = [ + {"role": "system", "content": "You are an expert researcher generating search queries."}, + {"role": "user", + "content": f"Given the following prompt, generate {num_queries} unique search queries to research the topic thoroughly. For each query, provide a research goal. Format as 'Query: ' followed by 'Goal: ' for each pair: {query}"} + ] + + response = await call_model( + prompt=prompt, + model="gpt-4o", + temperature=0.4 + ) + + lines = response.split('\n') + queries = [] + current_query = {} + + for line in lines: + line = line.strip() + if line.startswith('Query:'): + if current_query: + queries.append(current_query) + current_query = {'query': line.replace('Query:', '').strip()} + elif line.startswith('Goal:') and current_query: + current_query['researchGoal'] = line.replace('Goal:', '').strip() + + if current_query: + queries.append(current_query) + + await self._stream_or_print(f"Generated {len(queries)} search queries", "EXPLORER") + return queries[:num_queries] + + async def generate_research_plan(self, query: str, num_questions: int = 3) -> List[str]: + """Generate follow-up questions to clarify research direction""" + await self._stream_or_print(f"Generating research plan for: {query}", "EXPLORER") + + # Get current time for context + current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + prompt = [ + {"role": "system", "content": "You are an expert researcher. Your task is to analyze the original query and generate targeted questions that explore different aspects and time periods of the topic."}, + {"role": "user", + "content": f"""Original query: {query} + +Current time: {current_time} + +Based on the query and the current time, generate {num_questions} unique questions. Each question should explore a different aspect or time period of the topic, considering recent developments up to {current_time}. + +Format each question on a new line starting with 'Question: '"""} + ] + + response = await call_model( + prompt=prompt, + model="gpt-4o", + temperature=0.4 + ) + + questions = [q.replace('Question:', '').strip() + for q in response.split('\n') + if q.strip().startswith('Question:')] + + await self._stream_or_print(f"Generated {len(questions)} research questions", "EXPLORER") + return questions[:num_questions] \ No newline at end of file diff --git a/multi_agents/deep_research/agents/reviewer.py b/multi_agents/deep_research/agents/reviewer.py new file mode 100644 index 000000000..28a82e8cc --- /dev/null +++ b/multi_agents/deep_research/agents/reviewer.py @@ -0,0 +1,86 @@ +from typing import Dict, List, Any +from ...agents.utils.llms import call_model +from .base import DeepResearchAgent + +class DeepReviewerAgent(DeepResearchAgent): + """Agent responsible for reviewing and validating research results""" + + async def review_research(self, query: str, learnings: List[str], citations: Dict[str, str]) -> Dict[str, Any]: + """Review research results for quality and completeness""" + await self._stream_or_print(f"Reviewing research results for: {query}", "REVIEWER") + + # Format learnings with citations for review + formatted_learnings = [] + for learning in learnings: + citation = citations.get(learning, '') + if citation: + formatted_learnings.append(f"{learning} [Source: {citation}]") + else: + formatted_learnings.append(learning) + + learnings_text = "\n".join(formatted_learnings) + + prompt = [ + {"role": "system", "content": "You are an expert research reviewer. Your task is to evaluate research findings for quality, accuracy, and completeness."}, + {"role": "user", + "content": f"""Please review the following research findings for the query: '{query}' + +Research Findings: +{learnings_text} + +Evaluate these findings on: +1. Accuracy - Are the findings factually correct? +2. Completeness - Do they address the query comprehensively? +3. Quality of sources - Are the sources reliable and diverse? +4. Gaps - What important aspects are missing? + +Format your response as: +Quality Score (1-10): +Strengths: +Weaknesses: +Gaps: +Recommendations: """} + ] + + response = await call_model( + prompt=prompt, + model="gpt-4o", + temperature=0.3 + ) + + # Parse the review results + lines = response.split('\n') + review_results = { + 'score': 0, + 'strengths': [], + 'weaknesses': [], + 'gaps': [], + 'recommendations': [] + } + + current_section = None + + for line in lines: + line = line.strip() + if 'Quality Score' in line: + try: + score = int(line.split(':')[1].strip().split('/')[0]) + review_results['score'] = score + except: + pass + elif line.startswith('Strengths:'): + current_section = 'strengths' + elif line.startswith('Weaknesses:'): + current_section = 'weaknesses' + elif line.startswith('Gaps:'): + current_section = 'gaps' + elif line.startswith('Recommendations:'): + current_section = 'recommendations' + elif line and current_section: + if line.startswith('- '): + review_results[current_section].append(line[2:]) + else: + review_results[current_section].append(line) + + await self._stream_or_print(f"Review complete. Quality score: {review_results['score']}/10", "REVIEWER") + return review_results \ No newline at end of file diff --git a/multi_agents/deep_research/agents/synthesizer.py b/multi_agents/deep_research/agents/synthesizer.py new file mode 100644 index 000000000..4f252c9c9 --- /dev/null +++ b/multi_agents/deep_research/agents/synthesizer.py @@ -0,0 +1,58 @@ +import re +from typing import Dict, List, Any +from ...agents.utils.llms import call_model +from .base import DeepResearchAgent + +class DeepSynthesizerAgent(DeepResearchAgent): + """Agent responsible for synthesizing research results""" + + async def process_research_results(self, query: str, context: str, num_learnings: int = 3) -> Dict[str, List[str]]: + """Process research results to extract learnings and follow-up questions""" + await self._stream_or_print(f"Synthesizing research results for: {query}", "SYNTHESIZER") + + prompt = [ + {"role": "system", "content": "You are an expert researcher analyzing search results."}, + {"role": "user", + "content": f"Given the following research results for the query '{query}', extract key learnings and suggest follow-up questions. For each learning, include a citation to the source URL if available. Format each learning as 'Learning [source_url]: ' and each question as 'Question: ':\n\n{context}"} + ] + + response = await call_model( + prompt=prompt, + model="gpt-4o", + temperature=0.4 + ) + + lines = response.split('\n') + learnings = [] + questions = [] + citations = {} + + for line in lines: + line = line.strip() + if line.startswith('Learning'): + url_match = re.search(r'\[(.*?)\]:', line) + if url_match: + url = url_match.group(1) + learning = line.split(':', 1)[1].strip() + learnings.append(learning) + citations[learning] = url + else: + # Try to find URL in the line itself + url_match = re.search( + r'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\\(\\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+', line) + if url_match: + url = url_match.group(0) + learning = line.replace(url, '').replace('Learning:', '').strip() + learnings.append(learning) + citations[learning] = url + else: + learnings.append(line.replace('Learning:', '').strip()) + elif line.startswith('Question:'): + questions.append(line.replace('Question:', '').strip()) + + await self._stream_or_print(f"Extracted {len(learnings)} learnings and {len(questions)} follow-up questions", "SYNTHESIZER") + return { + 'learnings': learnings[:num_learnings], + 'followUpQuestions': questions[:num_learnings], + 'citations': citations + } \ No newline at end of file diff --git a/multi_agents/deep_research/deep_research_workflow.excalidraw b/multi_agents/deep_research/deep_research_workflow.excalidraw new file mode 100644 index 000000000..e69de29bb diff --git a/multi_agents/deep_research/main.py b/multi_agents/deep_research/main.py new file mode 100644 index 000000000..21d10f835 --- /dev/null +++ b/multi_agents/deep_research/main.py @@ -0,0 +1,175 @@ +import os +import json +import asyncio +import argparse +from typing import Dict, Any, Optional +from gpt_researcher.utils.enum import Tone + +from .orchestrator import DeepResearchOrchestrator +from ..agents.writer import WriterAgent +from ..agents.publisher import PublisherAgent + +async def run_deep_research( + query: str, + breadth: int = 4, + depth: int = 2, + concurrency: int = 2, + websocket=None, + stream_output=None, + tone=Tone.Objective, + headers=None, + source="web", + verbose=True, + publish_formats=None +) -> Dict[str, Any]: + """ + Run deep research on a given query. + + Args: + query: The research query + breadth: Number of parallel search queries at each level + depth: Maximum depth of recursive research + concurrency: Maximum number of concurrent research tasks + websocket: Optional websocket for streaming output + stream_output: Optional stream output function + tone: Research tone + headers: Optional headers for API requests + source: Research source ('web' or 'local') + verbose: Whether to print verbose output + publish_formats: Output formats to publish + + Returns: + Dictionary with research results + """ + # Create task configuration + task = { + "query": query, + "deep_research_breadth": breadth, + "deep_research_depth": depth, + "deep_research_concurrency": concurrency, + "source": source, + "verbose": verbose, + "publish_formats": publish_formats or {"markdown": True} + } + + # Run deep research + orchestrator = DeepResearchOrchestrator( + task=task, + websocket=websocket, + stream_output=stream_output, + tone=tone, + headers=headers + ) + + # Run the research + research_results = await orchestrator.run() + + # Generate report using the Writer agent + writer = WriterAgent(websocket, stream_output, headers) + + # Prepare research state for writer + research_state = { + "task": task, + "query": query, + "title": f"Deep Research: {query}", + "date": research_results.get("date", ""), + "context": research_results.get("context", ""), + "research_data": [{"topic": query, "content": research_results.get("context", "")}], + "sources": research_results.get("sources", []), + "citations": research_results.get("citations", {}) + } + + # Write the report + report_state = await writer.run(research_state) + + # Publish the report if formats are specified + if publish_formats: + publisher = PublisherAgent(orchestrator.output_dir, websocket, stream_output, headers) + publish_state = await publisher.run({ + "task": task, + "report": report_state.get("report", ""), + "title": research_state.get("title", "") + }) + + # Add published files to results + research_results["published_files"] = publish_state.get("published_files", []) + + # Add report to results + research_results["report"] = report_state.get("report", "") + + return research_results + +def open_task_file() -> Dict[str, Any]: + """Open and parse the task.json file""" + # Get the directory of the current script + script_dir = os.path.dirname(os.path.abspath(__file__)) + # Go up one level to the multi_agents directory + parent_dir = os.path.dirname(script_dir) + # Construct the path to task.json + task_path = os.path.join(parent_dir, "task.json") + + # Read and parse the task file + with open(task_path, "r") as f: + return json.load(f) + +async def main(): + """Main entry point for deep research""" + parser = argparse.ArgumentParser(description="Run deep research on a topic") + parser.add_argument("--query", type=str, help="Research query") + parser.add_argument("--breadth", type=int, default=4, help="Research breadth") + parser.add_argument("--depth", type=int, default=2, help="Research depth") + parser.add_argument("--concurrency", type=int, default=2, help="Concurrency limit") + parser.add_argument("--source", type=str, default="web", help="Research source (web or local)") + parser.add_argument("--verbose", action="store_true", help="Verbose output") + parser.add_argument("--task-file", action="store_true", help="Use task.json file") + + args = parser.parse_args() + + if args.task_file: + # Use task.json file + task = open_task_file() + query = task.get("query") + breadth = task.get("deep_research_breadth", 4) + depth = task.get("deep_research_depth", 2) + concurrency = task.get("deep_research_concurrency", 2) + source = task.get("source", "web") + verbose = task.get("verbose", True) + publish_formats = task.get("publish_formats", {"markdown": True}) + else: + # Use command line arguments + query = args.query + breadth = args.breadth + depth = args.depth + concurrency = args.concurrency + source = args.source + verbose = args.verbose + publish_formats = {"markdown": True} + + if not query: + print("Please provide a research query with --query or use --task-file") + return + + # Run deep research + results = await run_deep_research( + query=query, + breadth=breadth, + depth=depth, + concurrency=concurrency, + source=source, + verbose=verbose, + publish_formats=publish_formats + ) + + # Print summary + print(f"\nDeep Research completed for: {query}") + print(f"Execution time: {results.get('execution_time', 'N/A')}") + print(f"Learnings: {len(results.get('learnings', []))}") + print(f"Sources: {len(results.get('sources', []))}") + + if results.get("published_files"): + print("\nPublished files:") + for file in results["published_files"]: + print(f"- {file}") + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/multi_agents/deep_research/memory.py b/multi_agents/deep_research/memory.py new file mode 100644 index 000000000..50a1ba6c6 --- /dev/null +++ b/multi_agents/deep_research/memory.py @@ -0,0 +1,104 @@ +from typing import Dict, List, Any, Set, Optional +from pydantic import BaseModel, Field + +class DeepResearchState(BaseModel): + """State for deep research process""" + # Task configuration + task: Dict[str, Any] = Field(default_factory=dict) + + # Research parameters + query: str = "" + breadth: int = 4 + depth: int = 2 + + # Research progress + current_depth: int = 1 + total_depth: int = 2 + current_breadth: int = 0 + total_breadth: int = 4 + + # Research results + learnings: List[str] = Field(default_factory=list) + citations: Dict[str, str] = Field(default_factory=dict) + visited_urls: Set[str] = Field(default_factory=set) + context: List[str] = Field(default_factory=list) + sources: List[Dict[str, Any]] = Field(default_factory=list) + + # Intermediate results + search_queries: List[Dict[str, str]] = Field(default_factory=list) + research_plan: List[str] = Field(default_factory=list) + follow_up_questions: List[str] = Field(default_factory=list) + + # Review results + review: Optional[Dict[str, Any]] = None + + # Final output + final_context: str = "" + + def update_progress(self, depth: int, breadth: int): + """Update research progress""" + self.current_depth = depth + self.current_breadth = breadth + + def add_learning(self, learning: str, citation: Optional[str] = None): + """Add a learning with optional citation""" + if learning not in self.learnings: + self.learnings.append(learning) + if citation: + self.citations[learning] = citation + + def add_context(self, context: str): + """Add context to the research state""" + if context and context not in self.context: + self.context.append(context) + + def add_visited_urls(self, urls: List[str]): + """Add visited URLs to the research state""" + for url in urls: + self.visited_urls.add(url) + + def add_sources(self, sources: List[Dict[str, Any]]): + """Add sources to the research state""" + for source in sources: + if source not in self.sources: + self.sources.append(source) + + def set_search_queries(self, queries: List[Dict[str, str]]): + """Set search queries""" + self.search_queries = queries + self.total_breadth = len(queries) + + def set_research_plan(self, plan: List[str]): + """Set research plan""" + self.research_plan = plan + + def set_follow_up_questions(self, questions: List[str]): + """Set follow-up questions""" + self.follow_up_questions = questions + + def set_review(self, review: Dict[str, Any]): + """Set review results""" + self.review = review + + def finalize_context(self): + """Finalize context for report generation""" + from .agents.base import trim_context_to_word_limit + + # Prepare context with citations + context_with_citations = [] + for learning in self.learnings: + citation = self.citations.get(learning, '') + if citation: + context_with_citations.append(f"{learning} [Source: {citation}]") + else: + context_with_citations.append(learning) + + # Add all research context + if self.context: + context_with_citations.extend(self.context) + + # Trim final context to word limit + final_context_list = trim_context_to_word_limit(context_with_citations) + self.final_context = "\n".join(final_context_list) + + return self.final_context \ No newline at end of file diff --git a/multi_agents/deep_research/orchestrator.py b/multi_agents/deep_research/orchestrator.py new file mode 100644 index 000000000..094bdee89 --- /dev/null +++ b/multi_agents/deep_research/orchestrator.py @@ -0,0 +1,303 @@ +import os +import time +import asyncio +import logging +from typing import Dict, List, Any, Optional, Set +from datetime import datetime, timedelta + +from langgraph.graph import StateGraph, END +from ..agents.utils.views import print_agent_output +from ..agents.utils.utils import sanitize_filename + +from .memory import DeepResearchState +from .agents import ( + DeepResearchAgent, + DeepExplorerAgent, + DeepSynthesizerAgent, + DeepReviewerAgent +) + +logger = logging.getLogger(__name__) + +class DeepResearchOrchestrator: + """Orchestrator for deep research using LangGraph""" + + def __init__(self, task: dict, websocket=None, stream_output=None, tone=None, headers=None): + self.task = task + self.websocket = websocket + self.stream_output = stream_output + self.headers = headers or {} + self.tone = tone + self.task_id = self._generate_task_id() + self.output_dir = self._create_output_directory() + + # Research parameters + self.breadth = task.get('deep_research_breadth', 4) + self.depth = task.get('deep_research_depth', 2) + self.concurrency_limit = task.get('deep_research_concurrency', 2) + + # Initialize agents + self.agents = self._initialize_agents() + + def _generate_task_id(self): + """Generate a unique task ID""" + return int(time.time()) + + def _create_output_directory(self): + """Create output directory for research results""" + output_dir = "./outputs/" + sanitize_filename( + f"deep_research_{self.task_id}_{self.task.get('query')[0:40]}") + os.makedirs(output_dir, exist_ok=True) + return output_dir + + def _initialize_agents(self): + """Initialize all agents needed for deep research""" + return { + "explorer": DeepExplorerAgent(self.websocket, self.stream_output, self.tone, self.headers), + "researcher": DeepResearchAgent(self.websocket, self.stream_output, self.tone, self.headers), + "synthesizer": DeepSynthesizerAgent(self.websocket, self.stream_output, self.tone, self.headers), + "reviewer": DeepReviewerAgent(self.websocket, self.stream_output, self.tone, self.headers) + } + + async def _log_research_start(self): + """Log the start of the research process""" + message = f"Starting deep research process for query '{self.task.get('query')}'..." + if self.websocket and self.stream_output: + await self.stream_output("logs", "starting_deep_research", message, self.websocket) + else: + print_agent_output(message, "MASTER") + + async def _on_progress(self, state: DeepResearchState): + """Progress callback for deep research""" + message = (f"Deep Research Progress: Depth {state.current_depth}/{state.total_depth}, " + f"Breadth {state.current_breadth}/{state.total_breadth}") + + if self.websocket and self.stream_output: + await self.stream_output("logs", "deep_research_progress", message, self.websocket) + else: + print_agent_output(message, "MASTER") + + async def generate_search_queries(self, state: DeepResearchState) -> DeepResearchState: + """Generate search queries for research""" + explorer = self.agents["explorer"] + queries = await explorer.generate_search_queries(state.query, num_queries=state.breadth) + state.set_search_queries(queries) + return state + + async def generate_research_plan(self, state: DeepResearchState) -> DeepResearchState: + """Generate research plan with follow-up questions""" + explorer = self.agents["explorer"] + plan = await explorer.generate_research_plan(state.query, num_questions=3) + state.set_research_plan(plan) + return state + + async def process_query(self, state: DeepResearchState, query_index: int) -> DeepResearchState: + """Process a single search query""" + if query_index >= len(state.search_queries): + return state + + query_data = state.search_queries[query_index] + query = query_data['query'] + + # Update progress + state.current_breadth = query_index + 1 + await self._on_progress(state) + + # Conduct basic research + researcher = self.agents["researcher"] + context, visited_urls, sources = await researcher.basic_research( + query=query, + verbose=self.task.get('verbose', True), + source=self.task.get('source', 'web') + ) + + # Process research results + synthesizer = self.agents["synthesizer"] + results = await synthesizer.process_research_results(query, context) + + # Update state with results + for learning in results['learnings']: + citation = results['citations'].get(learning, '') + state.add_learning(learning, citation) + + state.add_context(context) + state.add_visited_urls(visited_urls) + state.add_sources(sources) + state.set_follow_up_questions(results['followUpQuestions']) + + return state + + async def process_all_queries(self, state: DeepResearchState) -> DeepResearchState: + """Process all search queries with concurrency limit""" + semaphore = asyncio.Semaphore(self.concurrency_limit) + + async def process_with_semaphore(index): + async with semaphore: + return await self.process_query(state, index) + + # Create tasks for all queries + tasks = [process_with_semaphore(i) for i in range(len(state.search_queries))] + + # Wait for all tasks to complete + results = await asyncio.gather(*tasks) + + # Merge results (the state is shared, so we just need the final state) + return results[-1] if results else state + + async def review_research(self, state: DeepResearchState) -> DeepResearchState: + """Review research results""" + reviewer = self.agents["reviewer"] + review = await reviewer.review_research(state.query, state.learnings, state.citations) + state.set_review(review) + return state + + async def recursive_research(self, state: DeepResearchState) -> DeepResearchState: + """Recursively conduct deeper research based on follow-up questions""" + # Base case: if we've reached the maximum depth, return the current state + if state.current_depth >= state.total_depth: + return state + + # Increment depth + state.current_depth += 1 + await self._on_progress(state) + + # Create a new query from follow-up questions + if not state.follow_up_questions: + return state + + new_query = f""" + Original query: {state.query} + Follow-up questions: {' '.join(state.follow_up_questions)} + """ + + # Create a new state for the next level + new_state = DeepResearchState( + task=state.task, + query=new_query, + breadth=max(2, state.breadth // 2), # Reduce breadth for deeper levels + depth=state.depth, + current_depth=state.current_depth, + total_depth=state.total_depth, + learnings=state.learnings.copy(), + citations=state.citations.copy(), + visited_urls=state.visited_urls.copy(), + context=state.context.copy(), + sources=state.sources.copy() + ) + + # Generate new search queries + new_state = await self.generate_search_queries(new_state) + + # Process all queries at this level + new_state = await self.process_all_queries(new_state) + + # Merge results back to original state + for learning in new_state.learnings: + if learning not in state.learnings: + state.add_learning(learning, new_state.citations.get(learning)) + + for context_item in new_state.context: + if context_item not in state.context: + state.add_context(context_item) + + state.add_visited_urls(list(new_state.visited_urls)) + state.add_sources(new_state.sources) + + # Continue recursion + return await self.recursive_research(state) + + async def finalize_research(self, state: DeepResearchState) -> DeepResearchState: + """Finalize research results""" + # Finalize context + state.finalize_context() + return state + + def should_continue_recursion(self, state: DeepResearchState) -> str: + """Determine if we should continue with recursive research""" + if state.current_depth < state.total_depth: + return "continue" + else: + return "finalize" + + def create_workflow(self) -> StateGraph: + """Create the LangGraph workflow for deep research""" + workflow = StateGraph(DeepResearchState) + + # Add nodes + workflow.add_node("generate_queries", self.generate_search_queries) + workflow.add_node("generate_plan", self.generate_research_plan) + workflow.add_node("process_queries", self.process_all_queries) + workflow.add_node("review_research", self.review_research) + workflow.add_node("recursive_research", self.recursive_research) + workflow.add_node("finalize_research", self.finalize_research) + + # Add edges + workflow.add_edge("generate_queries", "generate_plan") + workflow.add_edge("generate_plan", "process_queries") + workflow.add_edge("process_queries", "review_research") + + # Add conditional edge for recursion + workflow.add_conditional_edges( + "review_research", + self.should_continue_recursion, + { + "continue": "recursive_research", + "finalize": "finalize_research" + } + ) + + workflow.add_edge("recursive_research", "review_research") + workflow.add_edge("finalize_research", END) + + # Set entry point + workflow.set_entry_point("generate_queries") + + return workflow + + async def run(self) -> Dict[str, Any]: + """Run the deep research process""" + start_time = time.time() + + # Log start of research + await self._log_research_start() + + # Create workflow + workflow = self.create_workflow() + chain = workflow.compile() + + # Initialize state + initial_state = DeepResearchState( + task=self.task, + query=self.task.get('query'), + breadth=self.breadth, + depth=self.depth, + total_depth=self.depth + ) + + # Run the workflow + config = { + "configurable": { + "thread_id": self.task_id, + "thread_ts": datetime.utcnow() + } + } + + final_state = await chain.ainvoke(initial_state, config=config) + + # Log completion + end_time = time.time() + execution_time = timedelta(seconds=end_time - start_time) + logger.info(f"Total deep research execution time: {execution_time}") + + # Return results + return { + "task": self.task, + "query": self.task.get('query'), + "context": final_state.final_context, + "learnings": final_state.learnings, + "citations": final_state.citations, + "visited_urls": list(final_state.visited_urls), + "sources": final_state.sources, + "review": final_state.review, + "execution_time": str(execution_time) + } \ No newline at end of file From 13f76ff8f6f3f5bc82e2802feee3f9c34666955e Mon Sep 17 00:00:00 2001 From: Assaf Elovic Date: Sat, 8 Mar 2025 09:11:23 +0200 Subject: [PATCH 2/9] updated based logic but also fixed all errors with running langgraph --- gpt_researcher/actions/retriever.py | 109 ++++++----- gpt_researcher/config/config.py | 31 ++-- gpt_researcher/memory/embeddings.py | 173 +++++++++--------- multi_agents/README.md | 36 +++- multi_agents/agents/publisher.py | 66 +++++-- multi_agents/agents/utils/llms.py | 3 +- multi_agents/agents/utils/views.py | 2 + multi_agents/agents/writer.py | 55 +++++- .../deep_research_workflow.excalidraw | 0 multi_agents/deep_research/main.py | 10 +- multi_agents/deep_research/orchestrator.py | 27 ++- multi_agents/main.py | 61 +++++- 12 files changed, 377 insertions(+), 196 deletions(-) delete mode 100644 multi_agents/deep_research/deep_research_workflow.excalidraw diff --git a/gpt_researcher/actions/retriever.py b/gpt_researcher/actions/retriever.py index 6e1691c1b..f9bf7fa23 100644 --- a/gpt_researcher/actions/retriever.py +++ b/gpt_researcher/actions/retriever.py @@ -10,62 +10,59 @@ def get_retriever(retriever: str): retriever: Retriever class """ - match retriever: - case "google": - from gpt_researcher.retrievers import GoogleSearch - - return GoogleSearch - case "searx": - from gpt_researcher.retrievers import SearxSearch - - return SearxSearch - case "searchapi": - from gpt_researcher.retrievers import SearchApiSearch - - return SearchApiSearch - case "serpapi": - from gpt_researcher.retrievers import SerpApiSearch - - return SerpApiSearch - case "serper": - from gpt_researcher.retrievers import SerperSearch - - return SerperSearch - case "duckduckgo": - from gpt_researcher.retrievers import Duckduckgo - - return Duckduckgo - case "bing": - from gpt_researcher.retrievers import BingSearch - - return BingSearch - case "arxiv": - from gpt_researcher.retrievers import ArxivSearch - - return ArxivSearch - case "tavily": - from gpt_researcher.retrievers import TavilySearch - - return TavilySearch - case "exa": - from gpt_researcher.retrievers import ExaSearch - - return ExaSearch - case "semantic_scholar": - from gpt_researcher.retrievers import SemanticScholarSearch - - return SemanticScholarSearch - case "pubmed_central": - from gpt_researcher.retrievers import PubMedCentralSearch - - return PubMedCentralSearch - case "custom": - from gpt_researcher.retrievers import CustomRetriever - - return CustomRetriever - - case _: - return None + if retriever == "google": + from gpt_researcher.retrievers import GoogleSearch + return GoogleSearch + elif retriever == "searx": + from gpt_researcher.retrievers import SearxSearch + return SearxSearch + elif retriever == "searchapi": + from gpt_researcher.retrievers import SearchApiSearch + return SearchApiSearch + elif retriever == "serpapi": + from gpt_researcher.retrievers import SerpApiSearch + return SerpApiSearch + elif retriever == "serper": + from gpt_researcher.retrievers import SerperSearch + return SerperSearch + elif retriever == "duckduckgo": + from gpt_researcher.retrievers import Duckduckgo + return Duckduckgo + elif retriever == "bing": + from gpt_researcher.retrievers import BingSearch + return BingSearch + elif retriever == "arxiv": + from gpt_researcher.retrievers import ArxivSearch + return ArxivSearch + elif retriever == "tavily": + from gpt_researcher.retrievers import TavilySearch + return TavilySearch + elif retriever == "exa": + from gpt_researcher.retrievers import ExaSearch + return ExaSearch + elif retriever == "semantic_scholar": + from gpt_researcher.retrievers import SemanticScholarSearch + return SemanticScholarSearch + elif retriever == "pubmed_central": + from gpt_researcher.retrievers import PubMedCentralSearch + return PubMedCentralSearch + elif retriever == "custom": + from gpt_researcher.retrievers import CustomRetriever + return CustomRetriever + elif retriever == "brave": + from gpt_researcher.retrievers import BraveSearch + return BraveSearch + elif retriever == "you": + from gpt_researcher.retrievers import YouSearch + return YouSearch + elif retriever == "perplexity": + from gpt_researcher.retrievers import PerplexitySearch + return PerplexitySearch + elif retriever == "local_documents": + from gpt_researcher.retrievers import LocalDocumentRetriever + return LocalDocumentRetriever + else: + raise ValueError(f"Retriever {retriever} not found") def get_retrievers(headers: dict[str, str], cfg: Config): diff --git a/gpt_researcher/config/config.py b/gpt_researcher/config/config.py index 9762604aa..caddeedd5 100644 --- a/gpt_researcher/config/config.py +++ b/gpt_researcher/config/config.py @@ -61,21 +61,22 @@ def _handle_deprecated_attributes(self) -> None: os.environ["EMBEDDING_PROVIDER"] or self.embedding_provider ) - match os.environ["EMBEDDING_PROVIDER"]: - case "ollama": - self.embedding_model = os.environ["OLLAMA_EMBEDDING_MODEL"] - case "custom": - self.embedding_model = os.getenv("OPENAI_EMBEDDING_MODEL", "custom") - case "openai": - self.embedding_model = "text-embedding-3-large" - case "azure_openai": - self.embedding_model = "text-embedding-3-large" - case "huggingface": - self.embedding_model = "sentence-transformers/all-MiniLM-L6-v2" - case "google_genai": - self.embedding_model = "text-embedding-004" - case _: - raise Exception("Embedding provider not found.") + # Replace match statement with if-elif for Python 3.9 compatibility + embedding_provider = os.environ["EMBEDDING_PROVIDER"] + if embedding_provider == "ollama": + self.embedding_model = os.environ["OLLAMA_EMBEDDING_MODEL"] + elif embedding_provider == "custom": + self.embedding_model = os.getenv("OPENAI_EMBEDDING_MODEL", "custom") + elif embedding_provider == "openai": + self.embedding_model = "text-embedding-3-large" + elif embedding_provider == "azure_openai": + self.embedding_model = "text-embedding-3-large" + elif embedding_provider == "huggingface": + self.embedding_model = "sentence-transformers/all-MiniLM-L6-v2" + elif embedding_provider == "google_genai": + self.embedding_model = "text-embedding-004" + else: + raise Exception("Embedding provider not found.") _deprecation_warning = ( "LLM_PROVIDER, FAST_LLM_MODEL and SMART_LLM_MODEL are deprecated and " diff --git a/gpt_researcher/memory/embeddings.py b/gpt_researcher/memory/embeddings.py index f55c444a6..da48d4194 100644 --- a/gpt_researcher/memory/embeddings.py +++ b/gpt_researcher/memory/embeddings.py @@ -27,93 +27,92 @@ class Memory: def __init__(self, embedding_provider: str, model: str, **embdding_kwargs: Any): _embeddings = None - match embedding_provider: - case "custom": - from langchain_openai import OpenAIEmbeddings - - _embeddings = OpenAIEmbeddings( - model=model, - openai_api_key=os.getenv("OPENAI_API_KEY", "custom"), - openai_api_base=os.getenv( - "OPENAI_BASE_URL", "http://localhost:1234/v1" - ), # default for lmstudio - check_embedding_ctx_length=False, - **embdding_kwargs, - ) # quick fix for lmstudio - case "openai": - from langchain_openai import OpenAIEmbeddings - - _embeddings = OpenAIEmbeddings(model=model, **embdding_kwargs) - case "azure_openai": - from langchain_openai import AzureOpenAIEmbeddings - - _embeddings = AzureOpenAIEmbeddings( - model=model, - azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], - openai_api_key=os.environ["AZURE_OPENAI_API_KEY"], - openai_api_version=os.environ["AZURE_OPENAI_API_VERSION"], - **embdding_kwargs, - ) - case "cohere": - from langchain_cohere import CohereEmbeddings - - _embeddings = CohereEmbeddings(model=model, **embdding_kwargs) - case "google_vertexai": - from langchain_google_vertexai import VertexAIEmbeddings - - _embeddings = VertexAIEmbeddings(model=model, **embdding_kwargs) - case "google_genai": - from langchain_google_genai import GoogleGenerativeAIEmbeddings - - _embeddings = GoogleGenerativeAIEmbeddings( - model=model, **embdding_kwargs - ) - case "fireworks": - from langchain_fireworks import FireworksEmbeddings - - _embeddings = FireworksEmbeddings(model=model, **embdding_kwargs) - case "ollama": - from langchain_ollama import OllamaEmbeddings - - _embeddings = OllamaEmbeddings( - model=model, - base_url=os.environ["OLLAMA_BASE_URL"], - **embdding_kwargs, - ) - case "together": - from langchain_together import TogetherEmbeddings - - _embeddings = TogetherEmbeddings(model=model, **embdding_kwargs) - case "mistralai": - from langchain_mistralai import MistralAIEmbeddings - - _embeddings = MistralAIEmbeddings(model=model, **embdding_kwargs) - case "huggingface": - from langchain_huggingface import HuggingFaceEmbeddings - - _embeddings = HuggingFaceEmbeddings(model_name=model, **embdding_kwargs) - case "nomic": - from langchain_nomic import NomicEmbeddings - - _embeddings = NomicEmbeddings(model=model, **embdding_kwargs) - case "voyageai": - from langchain_voyageai import VoyageAIEmbeddings - - _embeddings = VoyageAIEmbeddings( - voyage_api_key=os.environ["VOYAGE_API_KEY"], - model=model, - **embdding_kwargs, - ) - case "dashscope": - from langchain_community.embeddings import DashScopeEmbeddings - - _embeddings = DashScopeEmbeddings(model=model, **embdding_kwargs) - case "bedrock": - from langchain_aws.embeddings import BedrockEmbeddings - - _embeddings = BedrockEmbeddings(model_id=model, **embdding_kwargs) - case _: - raise Exception("Embedding not found.") + if embedding_provider == "custom": + from langchain_openai import OpenAIEmbeddings + + _embeddings = OpenAIEmbeddings( + model=model, + openai_api_key=os.getenv("OPENAI_API_KEY", "custom"), + openai_api_base=os.getenv( + "OPENAI_BASE_URL", "http://localhost:1234/v1" + ), # default for lmstudio + check_embedding_ctx_length=False, + **embdding_kwargs, + ) # quick fix for lmstudio + elif embedding_provider == "openai": + from langchain_openai import OpenAIEmbeddings + + _embeddings = OpenAIEmbeddings(model=model, **embdding_kwargs) + elif embedding_provider == "azure_openai": + from langchain_openai import AzureOpenAIEmbeddings + + _embeddings = AzureOpenAIEmbeddings( + model=model, + azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], + openai_api_key=os.environ["AZURE_OPENAI_API_KEY"], + openai_api_version=os.environ["AZURE_OPENAI_API_VERSION"], + **embdding_kwargs, + ) + elif embedding_provider == "cohere": + from langchain_cohere import CohereEmbeddings + + _embeddings = CohereEmbeddings(model=model, **embdding_kwargs) + elif embedding_provider == "google_vertexai": + from langchain_google_vertexai import VertexAIEmbeddings + + _embeddings = VertexAIEmbeddings(model=model, **embdding_kwargs) + elif embedding_provider == "google_genai": + from langchain_google_genai import GoogleGenerativeAIEmbeddings + + _embeddings = GoogleGenerativeAIEmbeddings( + model=model, **embdding_kwargs + ) + elif embedding_provider == "fireworks": + from langchain_fireworks import FireworksEmbeddings + + _embeddings = FireworksEmbeddings(model=model, **embdding_kwargs) + elif embedding_provider == "ollama": + from langchain_ollama import OllamaEmbeddings + + _embeddings = OllamaEmbeddings( + model=model, + base_url=os.environ["OLLAMA_BASE_URL"], + **embdding_kwargs, + ) + elif embedding_provider == "together": + from langchain_together import TogetherEmbeddings + + _embeddings = TogetherEmbeddings(model=model, **embdding_kwargs) + elif embedding_provider == "mistralai": + from langchain_mistralai import MistralAIEmbeddings + + _embeddings = MistralAIEmbeddings(model=model, **embdding_kwargs) + elif embedding_provider == "huggingface": + from langchain_huggingface import HuggingFaceEmbeddings + + _embeddings = HuggingFaceEmbeddings(model_name=model, **embdding_kwargs) + elif embedding_provider == "nomic": + from langchain_nomic import NomicEmbeddings + + _embeddings = NomicEmbeddings(model=model, **embdding_kwargs) + elif embedding_provider == "voyageai": + from langchain_voyageai import VoyageAIEmbeddings + + _embeddings = VoyageAIEmbeddings( + voyage_api_key=os.environ["VOYAGE_API_KEY"], + model=model, + **embdding_kwargs, + ) + elif embedding_provider == "dashscope": + from langchain_community.embeddings import DashScopeEmbeddings + + _embeddings = DashScopeEmbeddings(model=model, **embdding_kwargs) + elif embedding_provider == "bedrock": + from langchain_aws.embeddings import BedrockEmbeddings + + _embeddings = BedrockEmbeddings(model_id=model, **embdding_kwargs) + else: + raise ValueError(f"Embedding provider {embedding_provider} not found. Please install the missing library with `pip install [pip-package-name]`") self._embeddings = _embeddings diff --git a/multi_agents/README.md b/multi_agents/README.md index 129d63f73..cc82281e3 100644 --- a/multi_agents/README.md +++ b/multi_agents/README.md @@ -10,6 +10,19 @@ An average run generates a 5-6 page research report in multiple formats such as Please note: Multi-agents are utilizing the same configuration of models like GPT-Researcher does. However, only the SMART_LLM is used for the time being. Please refer to the [LLM config pages](https://docs.gptr.dev/docs/gpt-researcher/llms/llms). +## Research Modes + +The system supports two research modes: + +### Standard Research +The standard research mode follows a sequential process with a team of agents working together to produce a comprehensive report. + +### Deep Research +The deep research mode performs more extensive research by exploring topics in breadth and depth. It allows for: +- Parallel exploration of multiple research queries +- Recursive research to specified depth levels +- Configurable concurrency for performance optimization + ## The Multi Agent Team The research team is made up of 8 agents: - **Human** - The human in the loop that oversees the process and provides feedback to the agents. @@ -55,7 +68,17 @@ More specifically (as seen in the architecture diagram) the process is as follow 2. Run the application: ```bash + # Run standard research mode python main.py + + # Run standard research with a specific query + python main.py --query "Your research question here" + + # Run deep research mode + python main.py --mode deep + + # Run deep research with custom parameters + python main.py --mode deep --query "Your research question" --breadth 5 --depth 3 --concurrency 3 ``` ## Usage @@ -71,7 +94,13 @@ To change the research query and customize the report, edit the `task.json` file - `guidelines` - A list of guidelines that the report must follow. - `verbose` - If true, the application will print detailed logs to the console. -#### For example: +#### Deep Research Configuration +For deep research mode, you can also configure: +- `deep_research_breadth` - Number of parallel search queries at each level (default: 4) +- `deep_research_depth` - Maximum depth of recursive research (default: 2) +- `deep_research_concurrency` - Maximum number of concurrent research tasks (default: 2) + +#### Example task.json: ```json { "query": "Is AI in a hype cycle?", @@ -90,7 +119,10 @@ To change the research query and customize the report, edit the `task.json` file "The report MUST be written in apa format", "The report MUST be written in english" ], - "verbose": true + "verbose": true, + "deep_research_breadth": 4, + "deep_research_depth": 2, + "deep_research_concurrency": 2 } ``` diff --git a/multi_agents/agents/publisher.py b/multi_agents/agents/publisher.py index 159cb0206..d46aad145 100644 --- a/multi_agents/agents/publisher.py +++ b/multi_agents/agents/publisher.py @@ -20,26 +20,66 @@ async def publish_research_report(self, research_state: dict, publish_formats: d return layout def generate_layout(self, research_state: dict): - sections = '\n\n'.join(f"{value}" - for subheader in research_state.get("research_data") + # Add safety checks for research_data + research_data = research_state.get("research_data", []) + if research_data is None: + research_data = [] + + # Create sections with proper error handling + sections = "" + try: + sections = '\n\n'.join(f"{value}" + for subheader in research_data for key, value in subheader.items()) - references = '\n'.join(f"{reference}" for reference in research_state.get("sources")) - headers = research_state.get("headers") - layout = f"""# {headers.get('title')} -#### {headers.get("date")}: {research_state.get('date')} + except Exception as e: + print(f"Error generating sections: {e}") + sections = "" + + # Add safety checks for sources + sources = research_state.get("sources", []) + if sources is None: + sources = [] + + # Create references with proper error handling + references = "" + try: + references = '\n'.join(f"{reference}" for reference in sources) + except Exception as e: + print(f"Error generating references: {e}") + references = "" + + # Get headers with default values + headers = research_state.get("headers", {}) + if headers is None: + headers = {} + + # Default values for all required fields + title = headers.get("title", research_state.get("title", "Research Report")) + date_header = headers.get("date", "Date") + date_value = research_state.get("date", "") + intro_header = headers.get("introduction", "Introduction") + intro_value = research_state.get("introduction", "") + toc_header = headers.get("table_of_contents", "Table of Contents") + toc_value = research_state.get("table_of_contents", "") + conclusion_header = headers.get("conclusion", "Conclusion") + conclusion_value = research_state.get("conclusion", "") + references_header = headers.get("references", "References") + + layout = f"""# {title} +#### {date_header}: {date_value} -## {headers.get("introduction")} -{research_state.get('introduction')} +## {intro_header} +{intro_value} -## {headers.get("table_of_contents")} -{research_state.get('table_of_contents')} +## {toc_header} +{toc_value} {sections} -## {headers.get("conclusion")} -{research_state.get('conclusion')} +## {conclusion_header} +{conclusion_value} -## {headers.get("references")} +## {references_header} {references} """ return layout diff --git a/multi_agents/agents/utils/llms.py b/multi_agents/agents/utils/llms.py index 133985c4d..b688a71cc 100644 --- a/multi_agents/agents/utils/llms.py +++ b/multi_agents/agents/utils/llms.py @@ -11,6 +11,7 @@ async def call_model( prompt: list, model: str, response_format: str | None = None, + temperature: float = 0.3, ): cfg = Config() @@ -20,7 +21,7 @@ async def call_model( response = await create_chat_completion( model=model, messages=lc_messages, - temperature=0, + temperature=temperature, llm_provider=cfg.smart_llm_provider, llm_kwargs=cfg.llm_kwargs, # cost_callback=cost_callback, diff --git a/multi_agents/agents/utils/views.py b/multi_agents/agents/utils/views.py index 3118603a6..e7197df87 100644 --- a/multi_agents/agents/utils/views.py +++ b/multi_agents/agents/utils/views.py @@ -10,6 +10,8 @@ class AgentColor(Enum): REVIEWER = Fore.CYAN REVISOR = Fore.LIGHTWHITE_EX MASTER = Fore.LIGHTYELLOW_EX + EXPLORER = Fore.LIGHTRED_EX + SYNTHESIZER = Fore.LIGHTMAGENTA_EX def print_agent_output(output:str, agent: str="RESEARCHER"): diff --git a/multi_agents/agents/writer.py b/multi_agents/agents/writer.py index 54017e4f4..99799d9d0 100644 --- a/multi_agents/agents/writer.py +++ b/multi_agents/agents/writer.py @@ -32,9 +32,15 @@ def get_headers(self, research_state: dict): async def write_sections(self, research_state: dict): query = research_state.get("title") data = research_state.get("research_data") - task = research_state.get("task") - follow_guidelines = task.get("follow_guidelines") - guidelines = task.get("guidelines") + task = research_state.get("task", {}) + follow_guidelines = task.get("follow_guidelines", False) + guidelines = task.get("guidelines", "") + # Get model from task or use a default model if None + model = task.get("model") + if model is None: + from gpt_researcher.config.config import Config + cfg = Config() + model = cfg.smart_llm_model # Use the default smart model from config prompt = [ { @@ -61,12 +67,19 @@ async def write_sections(self, research_state: dict): response = await call_model( prompt, - task.get("model"), + model, response_format="json", ) return response async def revise_headers(self, task: dict, headers: dict): + # Get model from task or use a default model if None + model = task.get("model") + if model is None: + from gpt_researcher.config.config import Config + cfg = Config() + model = cfg.smart_llm_model # Use the default smart model from config + prompt = [ { "role": "system", @@ -86,7 +99,7 @@ async def revise_headers(self, task: dict, headers: dict): response = await call_model( prompt, - task.get("model"), + model, response_format="json", ) return {"headers": response} @@ -106,8 +119,29 @@ async def run(self, research_state: dict): ) research_layout_content = await self.write_sections(research_state) + + # If research_layout_content is None, create a default empty structure + if research_layout_content is None: + if self.websocket and self.stream_output: + await self.stream_output( + "logs", + "error", + "Error generating research layout content. Using default empty structure.", + self.websocket, + ) + else: + print_agent_output( + "Error generating research layout content. Using default empty structure.", + agent="WRITER", + ) + research_layout_content = { + "table_of_contents": "", + "introduction": "", + "conclusion": "", + "sources": [] + } - if research_state.get("task").get("verbose"): + if research_state.get("task", {}).get("verbose"): if self.websocket and self.stream_output: research_layout_content_str = json.dumps( research_layout_content, indent=2 @@ -122,7 +156,7 @@ async def run(self, research_state: dict): print_agent_output(research_layout_content, agent="WRITER") headers = self.get_headers(research_state) - if research_state.get("task").get("follow_guidelines"): + if research_state.get("task", {}).get("follow_guidelines"): if self.websocket and self.stream_output: await self.stream_output( "logs", @@ -134,9 +168,10 @@ async def run(self, research_state: dict): print_agent_output( "Rewriting layout based on guidelines...", agent="WRITER" ) - headers = await self.revise_headers( - task=research_state.get("task"), headers=headers + headers_response = await self.revise_headers( + task=research_state.get("task", {}), headers=headers ) - headers = headers.get("headers") + if headers_response and "headers" in headers_response: + headers = headers_response.get("headers") return {**research_layout_content, "headers": headers} diff --git a/multi_agents/deep_research/deep_research_workflow.excalidraw b/multi_agents/deep_research/deep_research_workflow.excalidraw deleted file mode 100644 index e69de29bb..000000000 diff --git a/multi_agents/deep_research/main.py b/multi_agents/deep_research/main.py index 21d10f835..44b1af98f 100644 --- a/multi_agents/deep_research/main.py +++ b/multi_agents/deep_research/main.py @@ -87,8 +87,14 @@ async def run_deep_research( publisher = PublisherAgent(orchestrator.output_dir, websocket, stream_output, headers) publish_state = await publisher.run({ "task": task, - "report": report_state.get("report", ""), - "title": research_state.get("title", "") + "headers": report_state.get("headers", {}), + "research_data": research_state.get("research_data", []), + "sources": research_state.get("sources", []), + "introduction": report_state.get("introduction", ""), + "conclusion": report_state.get("conclusion", ""), + "table_of_contents": report_state.get("table_of_contents", ""), + "title": research_state.get("title", ""), + "date": research_state.get("date", "") }) # Add published files to results diff --git a/multi_agents/deep_research/orchestrator.py b/multi_agents/deep_research/orchestrator.py index 094bdee89..2ceb5263e 100644 --- a/multi_agents/deep_research/orchestrator.py +++ b/multi_agents/deep_research/orchestrator.py @@ -209,7 +209,20 @@ async def recursive_research(self, state: DeepResearchState) -> DeepResearchStat async def finalize_research(self, state: DeepResearchState) -> DeepResearchState: """Finalize research results""" # Finalize context - state.finalize_context() + try: + state.finalize_context() + except Exception as e: + logger.error(f"Error finalizing context: {e}") + # Fallback: join context items if finalize_context fails + if hasattr(state, 'context') and isinstance(state.context, list): + state.final_context = "\n".join(state.context) + else: + state.final_context = "" + + # Ensure final_context is set + if not hasattr(state, 'final_context') or not state.final_context: + state.final_context = "" + return state def should_continue_recursion(self, state: DeepResearchState) -> str: @@ -293,11 +306,11 @@ async def run(self) -> Dict[str, Any]: return { "task": self.task, "query": self.task.get('query'), - "context": final_state.final_context, - "learnings": final_state.learnings, - "citations": final_state.citations, - "visited_urls": list(final_state.visited_urls), - "sources": final_state.sources, - "review": final_state.review, + "context": getattr(final_state, 'final_context', '\n'.join(final_state.context) if hasattr(final_state, 'context') else ''), + "learnings": getattr(final_state, 'learnings', []), + "citations": getattr(final_state, 'citations', {}), + "visited_urls": list(getattr(final_state, 'visited_urls', set())), + "sources": getattr(final_state, 'sources', []), + "review": getattr(final_state, 'review', None), "execution_time": str(execution_time) } \ No newline at end of file diff --git a/multi_agents/main.py b/multi_agents/main.py index 8fda20b99..c9685ee1b 100644 --- a/multi_agents/main.py +++ b/multi_agents/main.py @@ -2,9 +2,11 @@ import sys import os import uuid +import argparse sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) from multi_agents.agents import ChiefEditorAgent +from multi_agents.deep_research import DeepResearchOrchestrator import asyncio import json from gpt_researcher.utils.enum import Tone @@ -40,11 +42,64 @@ async def run_research_task(query, websocket=None, stream_output=None, tone=Tone return research_report -async def main(): +async def run_deep_research_task(query, breadth=4, depth=2, concurrency=2, websocket=None, stream_output=None, tone=Tone.Objective, headers=None): + """Run deep research on a given query""" task = open_task() + task["query"] = query + task["deep_research_breadth"] = breadth + task["deep_research_depth"] = depth + task["deep_research_concurrency"] = concurrency + + # Import here to avoid circular imports + from multi_agents.deep_research.main import run_deep_research + + research_results = await run_deep_research( + query=query, + breadth=breadth, + depth=depth, + concurrency=concurrency, + websocket=websocket, + stream_output=stream_output, + tone=tone, + headers=headers, + source=task.get("source", "web"), + verbose=task.get("verbose", True), + publish_formats=task.get("publish_formats", {"markdown": True}) + ) + + if websocket and stream_output: + await stream_output("logs", "deep_research_report", research_results, websocket) + + return research_results - chief_editor = ChiefEditorAgent(task) - research_report = await chief_editor.run_research_task(task_id=uuid.uuid4()) +async def main(): + parser = argparse.ArgumentParser(description="Run research tasks") + parser.add_argument("--mode", type=str, choices=["standard", "deep"], default="standard", + help="Research mode: standard or deep") + parser.add_argument("--query", type=str, help="Research query (overrides task.json)") + parser.add_argument("--breadth", type=int, default=4, help="Deep research breadth") + parser.add_argument("--depth", type=int, default=2, help="Deep research depth") + parser.add_argument("--concurrency", type=int, default=2, help="Deep research concurrency") + + args = parser.parse_args() + + task = open_task() + query = args.query or task.get("query") + + if not query: + raise ValueError("No query provided. Please specify a query in task.json or with --query") + + if args.mode == "deep": + print(f"Running deep research on: {query}") + research_report = await run_deep_research_task( + query=query, + breadth=args.breadth, + depth=args.depth, + concurrency=args.concurrency + ) + else: + print(f"Running standard research on: {query}") + research_report = await run_research_task(query=query) return research_report From 8c3c3f2aca042b4e0f76b689882a2140432cba11 Mon Sep 17 00:00:00 2001 From: Assaf Elovic Date: Sat, 8 Mar 2025 10:11:53 +0200 Subject: [PATCH 3/9] added fixes to make research better --- multi_agents/README.md | 22 +++ multi_agents/agents/publisher.py | 32 +++- multi_agents/agents/utils/views.py | 3 + multi_agents/agents/writer.py | 18 +- multi_agents/deep_research/agents/README.md | 18 +- multi_agents/deep_research/agents/__init__.py | 6 +- multi_agents/deep_research/agents/explorer.py | 13 +- .../deep_research/agents/report_formatter.py | 120 ++++++++++++ .../deep_research/agents/section_writer.py | 176 ++++++++++++++++++ multi_agents/deep_research/main.py | 68 +++++-- 10 files changed, 445 insertions(+), 31 deletions(-) create mode 100644 multi_agents/deep_research/agents/report_formatter.py create mode 100644 multi_agents/deep_research/agents/section_writer.py diff --git a/multi_agents/README.md b/multi_agents/README.md index cc82281e3..40be4bdbc 100644 --- a/multi_agents/README.md +++ b/multi_agents/README.md @@ -22,6 +22,8 @@ The deep research mode performs more extensive research by exploring topics in b - Parallel exploration of multiple research queries - Recursive research to specified depth levels - Configurable concurrency for performance optimization +- Organized sections with logical headers +- Comprehensive report generation with proper formatting ## The Multi Agent Team The research team is made up of 8 agents: @@ -34,6 +36,14 @@ The research team is made up of 8 agents: - **Writer** - Responsible for compiling and writing the final report. - **Publisher** - Responsible for publishing the final report in various formats. +### Deep Research Agents +For deep research mode, additional specialized agents are used: +- **DeepExplorerAgent** - Generates search queries and research plans +- **DeepSynthesizerAgent** - Processes and synthesizes research results +- **DeepReviewerAgent** - Reviews research quality and completeness +- **SectionWriterAgent** - Organizes research data into logical sections with titles +- **ReportFormatterAgent** - Prepares the final state for the publisher + ## How it works Generally, the process is based on the following stages: 1. Planning stage @@ -59,6 +69,18 @@ More specifically (as seen in the architecture diagram) the process is as follow - Writer - Compiles and writes the final report including an introduction, conclusion and references section from the given research findings. - Publisher - Publishes the final report to multi formats such as PDF, Docx, Markdown, etc. +### Deep Research Workflow +The deep research mode follows a different workflow: +1. **DeepExplorerAgent** generates search queries and a research plan +2. Multiple search queries are processed in parallel with a concurrency limit +3. **DeepSynthesizerAgent** processes the research results +4. **DeepReviewerAgent** reviews the research quality +5. The process repeats recursively for deeper research levels +6. **SectionWriterAgent** organizes the research data into logical sections +7. Standard **WriterAgent** creates introduction, conclusion, and table of contents +8. **ReportFormatterAgent** prepares the final state for the publisher +9. Standard **PublisherAgent** creates the final report in the requested formats + ## How to run 1. Install required packages found in this root folder including `langgraph`: ```bash diff --git a/multi_agents/agents/publisher.py b/multi_agents/agents/publisher.py index d46aad145..eaaacca0f 100644 --- a/multi_agents/agents/publisher.py +++ b/multi_agents/agents/publisher.py @@ -25,15 +25,33 @@ def generate_layout(self, research_state: dict): if research_data is None: research_data = [] + # Check for sections from writer agent + sections_from_writer = research_state.get("sections", []) + # Create sections with proper error handling sections = "" - try: - sections = '\n\n'.join(f"{value}" - for subheader in research_data - for key, value in subheader.items()) - except Exception as e: - print(f"Error generating sections: {e}") - sections = "" + + # First try to use sections from writer agent + if sections_from_writer: + try: + sections_content = [] + for section in sections_from_writer: + if isinstance(section, dict) and "title" in section and "content" in section: + sections_content.append(f"## {section['title']}\n{section['content']}") + sections = '\n\n'.join(sections_content) + except Exception as e: + print(f"Error generating sections from writer: {e}") + sections = "" + + # Fallback to old method if no sections from writer + if not sections: + try: + sections = '\n\n'.join(f"{value}" + for subheader in research_data + for key, value in subheader.items()) + except Exception as e: + print(f"Error generating sections from research_data: {e}") + sections = "" # Add safety checks for sources sources = research_state.get("sources", []) diff --git a/multi_agents/agents/utils/views.py b/multi_agents/agents/utils/views.py index e7197df87..bb98a5e80 100644 --- a/multi_agents/agents/utils/views.py +++ b/multi_agents/agents/utils/views.py @@ -12,6 +12,9 @@ class AgentColor(Enum): MASTER = Fore.LIGHTYELLOW_EX EXPLORER = Fore.LIGHTRED_EX SYNTHESIZER = Fore.LIGHTMAGENTA_EX + SECTION_WRITER = Fore.LIGHTCYAN_EX + REPORT_FORMATTER = Fore.LIGHTWHITE_EX + DEEP_WRITER = Fore.LIGHTGREEN_EX def print_agent_output(output:str, agent: str="RESEARCHER"): diff --git a/multi_agents/agents/writer.py b/multi_agents/agents/writer.py index 99799d9d0..bc3e7a92c 100644 --- a/multi_agents/agents/writer.py +++ b/multi_agents/agents/writer.py @@ -7,6 +7,7 @@ { "table_of_contents": A table of contents in markdown syntax (using '-') based on the research headers and subheaders, "introduction": An indepth introduction to the topic in markdown syntax and hyperlink references to relevant sources, + "sections": An array of section objects, each with a "title" and "content" field. The content should be in markdown syntax with hyperlink references to relevant sources, "conclusion": A conclusion to the entire research based on all research data in markdown syntax and hyperlink references to relevant sources, "sources": A list with strings of all used source links in the entire research data in markdown syntax and apa citation format. For example: ['- Title, year, Author [source url](source)', ...] } @@ -42,11 +43,16 @@ async def write_sections(self, research_state: dict): cfg = Config() model = cfg.smart_llm_model # Use the default smart model from config + # Check if this is deep research data (has a single entry with 'topic' and 'content') + is_deep_research = False + if data and len(data) == 1 and 'topic' in data[0] and 'content' in data[0]: + is_deep_research = True + prompt = [ { "role": "system", "content": "You are a research writer. Your sole purpose is to write a well-written " - "research reports about a " + "research report about a " "topic based on research findings and information.\n ", }, { @@ -54,10 +60,11 @@ async def write_sections(self, research_state: dict): "content": f"Today's date is {datetime.now().strftime('%d/%m/%Y')}\n." f"Query or Topic: {query}\n" f"Research data: {str(data)}\n" - f"Your task is to write an in depth, well written and detailed " - f"introduction and conclusion to the research report based on the provided research data. " - f"Do not include headers in the results.\n" - f"You MUST include any relevant sources to the introduction and conclusion as markdown hyperlinks -" + f"Your task is to write an in-depth, well-written and detailed research report based on the provided research data. " + f"This should include an introduction, main content sections with appropriate headers, and a conclusion. " + f"Do not include headers in the introduction or conclusion results.\n" + f"{'For deep research data, analyze the content and organize it into logical sections with appropriate headers.' if is_deep_research else ''}\n" + f"You MUST include any relevant sources to all sections as markdown hyperlinks -" f"For example: 'This is a sample text. ([url website](url))'\n\n" f"{f'You must follow the guidelines provided: {guidelines}' if follow_guidelines else ''}\n" f"You MUST return nothing but a JSON in the following format (without json markdown):\n" @@ -137,6 +144,7 @@ async def run(self, research_state: dict): research_layout_content = { "table_of_contents": "", "introduction": "", + "sections": [], "conclusion": "", "sources": [] } diff --git a/multi_agents/deep_research/agents/README.md b/multi_agents/deep_research/agents/README.md index 9f165e4ac..b66aec739 100644 --- a/multi_agents/deep_research/agents/README.md +++ b/multi_agents/deep_research/agents/README.md @@ -26,6 +26,18 @@ This directory contains the specialized agents used in the deep research process - Methods: - `review_research()`: Evaluates research quality and completeness +- **section_writer.py**: Contains the `SectionWriterAgent` class + - Responsible for generating structured sections from deep research data + - Methods: + - `generate_sections()`: Organizes research data into logical sections with titles + - `transform_to_research_data()`: Transforms sections into the format expected by the writer agent + +- **report_formatter.py**: Contains the `ReportFormatterAgent` class + - Responsible for formatting the report for the publisher agent + - Methods: + - `extract_sections_from_research_data()`: Extracts sections from research data + - `prepare_publisher_state()`: Prepares the state for the publisher agent + ## Agent Workflow The agents work together in the following workflow: @@ -35,7 +47,11 @@ The agents work together in the following workflow: 3. `DeepSynthesizerAgent` processes the research results 4. `DeepReviewerAgent` reviews the research quality 5. The process repeats recursively for deeper research levels +6. `SectionWriterAgent` organizes the research data into logical sections +7. Standard `WriterAgent` creates introduction, conclusion, and table of contents +8. `ReportFormatterAgent` prepares the final state for the publisher +9. Standard `PublisherAgent` creates the final report in the requested formats ## Usage -The agents are orchestrated by the `DeepResearchOrchestrator` class, which creates a LangGraph workflow to coordinate their actions. You don't need to instantiate these agents directly in most cases. \ No newline at end of file +The agents are orchestrated by the `DeepResearchOrchestrator` class, which creates a LangGraph workflow to coordinate their actions. The research workflow is managed by the orchestrator, while the report generation is handled by the main.py file, which coordinates the SectionWriterAgent, WriterAgent, ReportFormatterAgent, and PublisherAgent. \ No newline at end of file diff --git a/multi_agents/deep_research/agents/__init__.py b/multi_agents/deep_research/agents/__init__.py index 54691be7c..803d3096f 100644 --- a/multi_agents/deep_research/agents/__init__.py +++ b/multi_agents/deep_research/agents/__init__.py @@ -2,6 +2,8 @@ from .explorer import DeepExplorerAgent from .synthesizer import DeepSynthesizerAgent from .reviewer import DeepReviewerAgent +from .section_writer import SectionWriterAgent +from .report_formatter import ReportFormatterAgent __all__ = [ "count_words", @@ -10,5 +12,7 @@ "DeepResearchAgent", "DeepExplorerAgent", "DeepSynthesizerAgent", - "DeepReviewerAgent" + "DeepReviewerAgent", + "SectionWriterAgent", + "ReportFormatterAgent" ] \ No newline at end of file diff --git a/multi_agents/deep_research/agents/explorer.py b/multi_agents/deep_research/agents/explorer.py index b9dc481c0..ba4237786 100644 --- a/multi_agents/deep_research/agents/explorer.py +++ b/multi_agents/deep_research/agents/explorer.py @@ -10,10 +10,21 @@ async def generate_search_queries(self, query: str, num_queries: int = 3) -> Lis """Generate SERP queries for research""" await self._stream_or_print(f"Generating {num_queries} search queries for: {query}", "EXPLORER") + # Get current time for context + current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + prompt = [ {"role": "system", "content": "You are an expert researcher generating search queries."}, {"role": "user", - "content": f"Given the following prompt, generate {num_queries} unique search queries to research the topic thoroughly. For each query, provide a research goal. Format as 'Query: ' followed by 'Goal: ' for each pair: {query}"} + "content": f"""Given the following prompt, generate {num_queries} unique search queries to research the topic thoroughly. For each query, provide a research goal. + +Current time: {current_time} + +Consider the current time when generating queries, especially for time-sensitive topics. Include recent developments up to {current_time} when relevant. + +Format as 'Query: ' followed by 'Goal: ' for each pair. + +Research topic: {query}"""} ] response = await call_model( diff --git a/multi_agents/deep_research/agents/report_formatter.py b/multi_agents/deep_research/agents/report_formatter.py new file mode 100644 index 000000000..2ed0776bf --- /dev/null +++ b/multi_agents/deep_research/agents/report_formatter.py @@ -0,0 +1,120 @@ +""" +Report Formatter Agent for Deep Research + +This agent is responsible for formatting the report for the publisher agent. +""" + +from typing import Dict, Any, List + +from ...agents.utils.views import print_agent_output +from .base import DeepResearchAgent + +class ReportFormatterAgent(DeepResearchAgent): + """ + Agent responsible for formatting the report for the publisher agent. + """ + + def __init__(self, websocket=None, stream_output=None, headers=None): + """ + Initialize the agent. + + Args: + websocket: Optional websocket for streaming output + stream_output: Optional stream output function + headers: Optional headers for API requests + """ + super().__init__(websocket, stream_output, headers) + + def extract_sections_from_research_data(self, research_data: List[Dict[str, str]]) -> List[Dict[str, str]]: + """ + Extract sections from research_data. + + Args: + research_data: List of research_data items + + Returns: + List of section objects with title and content + """ + sections = [] + + for item in research_data: + for title, content in item.items(): + sections.append({ + "title": title, + "content": content + }) + + return sections + + async def prepare_publisher_state(self, writer_output: Dict[str, Any], research_state: Dict[str, Any]) -> Dict[str, Any]: + """ + Prepare the state for the publisher agent. + + Args: + writer_output: Output from the writer agent + research_state: Original research state + + Returns: + State for the publisher agent + """ + # Log the action + if self.websocket and self.stream_output: + await self.stream_output( + "logs", + "formatting_report", + f"Formatting report for publisher...", + self.websocket, + ) + else: + print_agent_output( + f"Formatting report for publisher...", + agent="REPORT_FORMATTER", + ) + + # Use sections from research_state if available, otherwise extract from research_data + if "sections" in research_state and research_state["sections"]: + sections = research_state["sections"] + else: + sections = self.extract_sections_from_research_data(research_state.get("research_data", [])) + + # Format sources for references + sources = research_state.get("sources", []) + formatted_sources = [] + + # Ensure sources are properly formatted + for source in sources: + if isinstance(source, dict) and "url" in source: + # Format source as a reference + title = source.get("title", "Unknown Title") + url = source.get("url", "") + formatted_sources.append(f"- {title} [{url}]({url})") + elif isinstance(source, str): + # Source is already a string + formatted_sources.append(source) + + # Return the publisher state + return { + "task": research_state.get("task", {}), + "headers": writer_output.get("headers", {}), + "research_data": research_state.get("research_data", []), + "sources": formatted_sources, + "introduction": writer_output.get("introduction", ""), + "conclusion": writer_output.get("conclusion", ""), + "table_of_contents": writer_output.get("table_of_contents", ""), + "title": research_state.get("title", ""), + "date": research_state.get("date", ""), + "sections": sections # Explicitly include sections + } + + async def run(self, writer_output: Dict[str, Any], research_state: Dict[str, Any]) -> Dict[str, Any]: + """ + Run the report formatter agent. + + Args: + writer_output: Output from the writer agent + research_state: Original research state + + Returns: + State for the publisher agent + """ + return await self.prepare_publisher_state(writer_output, research_state) \ No newline at end of file diff --git a/multi_agents/deep_research/agents/section_writer.py b/multi_agents/deep_research/agents/section_writer.py new file mode 100644 index 000000000..9c15d9d58 --- /dev/null +++ b/multi_agents/deep_research/agents/section_writer.py @@ -0,0 +1,176 @@ +""" +Section Writer Agent for Deep Research + +This agent is responsible for generating sections from deep research data. +""" + +import asyncio +from datetime import datetime +from typing import Dict, Any, List + +from ...agents.utils.llms import call_model +from ...agents.utils.views import print_agent_output +from .base import DeepResearchAgent + +class SectionWriterAgent(DeepResearchAgent): + """ + Agent responsible for generating sections from deep research data. + """ + + def __init__(self, websocket=None, stream_output=None, headers=None): + """ + Initialize the agent. + + Args: + websocket: Optional websocket for streaming output + stream_output: Optional stream output function + headers: Optional headers for API requests + """ + super().__init__(websocket, stream_output, headers) + + async def generate_sections(self, research_state: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + Generate sections from deep research data. + + Args: + research_state: Research state containing deep research data + + Returns: + List of section objects with title and content + """ + query = research_state.get("query", "") + context = research_state.get("context", "") + task = research_state.get("task", {}) + sources = research_state.get("sources", []) + citations = research_state.get("citations", {}) + + # Get model from task or use a default model if None + model = task.get("model") + if model is None: + from gpt_researcher.config.config import Config + cfg = Config() + model = cfg.smart_llm_model # Use the default smart model from config + + # Log the action + if self.websocket and self.stream_output: + await self.stream_output( + "logs", + "generating_sections", + f"Generating sections from deep research data...", + self.websocket, + ) + else: + print_agent_output( + f"Generating sections from deep research data...", + agent="SECTION_WRITER", + ) + + # Create the prompt for generating sections + prompt = [ + { + "role": "system", + "content": "You are a research writer. Your sole purpose is to organize research data into logical sections with detailed content." + }, + { + "role": "user", + "content": f"""Today's date is {datetime.now().strftime('%d/%m/%Y')}. +Query or Topic: {query} +Research data: {context} +Sources: {sources} +Citations: {citations} + +Your task is to organize this research data into 3-5 logical sections with appropriate headers. +Each section should be comprehensive and detailed, with a minimum of 300 words per section. +Include any relevant sources in the content as markdown hyperlinks. +For example: 'This is a sample text. ([url website](url))' + +Make sure each section covers a distinct aspect of the research topic and provides in-depth analysis. +The sections should flow logically and cover the topic comprehensively. + +You MUST return nothing but a JSON array of section objects, each with a 'title' and 'content' field. +For example: +[ + {{ + "title": "Section Title 1", + "content": "Detailed section content with sources..." + }}, + {{ + "title": "Section Title 2", + "content": "More detailed section content with sources..." + }} +] +""" + } + ] + + # Call the model to generate sections + response = await call_model( + prompt, + model, + response_format="json", + ) + + # Handle potential errors + if not response or not isinstance(response, list): + if self.websocket and self.stream_output: + await self.stream_output( + "logs", + "error", + "Error generating sections. Using default empty structure.", + self.websocket, + ) + else: + print_agent_output( + "Error generating sections. Using default empty structure.", + agent="SECTION_WRITER", + ) + return [] + + return response + + async def transform_to_research_data(self, sections: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Transform sections to research_data format expected by the writer agent. + + Args: + sections: List of section objects with title and content + + Returns: + List of research_data items in the format expected by the writer agent + """ + research_data = [] + + for section in sections: + if isinstance(section, dict) and "title" in section and "content" in section: + research_data.append({ + section["title"]: section["content"] + }) + + return research_data + + async def run(self, research_state: Dict[str, Any]) -> Dict[str, Any]: + """ + Run the section writer agent. + + Args: + research_state: Research state containing deep research data + + Returns: + Transformed research state with sections as research_data + """ + # Generate sections from deep research data + sections = await self.generate_sections(research_state) + + # Transform sections to research_data format + research_data = await self.transform_to_research_data(sections) + + # If no sections were generated, use the original research data + if not research_data: + research_data = research_state.get("research_data", []) + + # Return the transformed research state + return { + **research_state, + "research_data": research_data, + "sections": sections # Store the original sections for later use + } \ No newline at end of file diff --git a/multi_agents/deep_research/main.py b/multi_agents/deep_research/main.py index 44b1af98f..b2868eafa 100644 --- a/multi_agents/deep_research/main.py +++ b/multi_agents/deep_research/main.py @@ -3,11 +3,13 @@ import asyncio import argparse from typing import Dict, Any, Optional +from datetime import datetime from gpt_researcher.utils.enum import Tone from .orchestrator import DeepResearchOrchestrator from ..agents.writer import WriterAgent from ..agents.publisher import PublisherAgent +from .agents import SectionWriterAgent, ReportFormatterAgent async def run_deep_research( query: str, @@ -64,38 +66,72 @@ async def run_deep_research( # Run the research research_results = await orchestrator.run() - # Generate report using the Writer agent - writer = WriterAgent(websocket, stream_output, headers) + # Create the section writer agent + section_writer = SectionWriterAgent(websocket, stream_output, headers) + + # Get current date + current_date = datetime.now().strftime("%d/%m/%Y") + + # Format sources for references if they're in dictionary format + sources = research_results.get("sources", []) + formatted_sources = [] + + for source in sources: + if isinstance(source, dict) and "url" in source: + # Format source as a reference + title = source.get("title", "Unknown Title") + url = source.get("url", "") + formatted_sources.append(f"- {title} [{url}]({url})") + elif isinstance(source, str): + # Source is already a string + formatted_sources.append(source) # Prepare research state for writer research_state = { "task": task, "query": query, "title": f"Deep Research: {query}", - "date": research_results.get("date", ""), + "date": current_date, "context": research_results.get("context", ""), "research_data": [{"topic": query, "content": research_results.get("context", "")}], - "sources": research_results.get("sources", []), + "sources": formatted_sources or research_results.get("sources", []), "citations": research_results.get("citations", {}) } - # Write the report - report_state = await writer.run(research_state) + # Generate sections and transform research data + transformed_research_state = await section_writer.run(research_state) + + # Generate report using the Writer agent + writer = WriterAgent(websocket, stream_output, headers) + report_state = await writer.run(transformed_research_state) + + # Create the report formatter agent + report_formatter = ReportFormatterAgent(websocket, stream_output, headers) + + # Format the report for the publisher + publisher_state = await report_formatter.run(report_state, transformed_research_state) # Publish the report if formats are specified if publish_formats: + # Create the publisher agent publisher = PublisherAgent(orchestrator.output_dir, websocket, stream_output, headers) - publish_state = await publisher.run({ + + # Ensure all necessary components are in the publisher state + complete_publisher_state = { "task": task, - "headers": report_state.get("headers", {}), - "research_data": research_state.get("research_data", []), - "sources": research_state.get("sources", []), - "introduction": report_state.get("introduction", ""), - "conclusion": report_state.get("conclusion", ""), - "table_of_contents": report_state.get("table_of_contents", ""), - "title": research_state.get("title", ""), - "date": research_state.get("date", "") - }) + "headers": publisher_state.get("headers", {}), + "research_data": publisher_state.get("research_data", []), + "sources": publisher_state.get("sources", []), + "introduction": publisher_state.get("introduction", ""), + "conclusion": publisher_state.get("conclusion", ""), + "table_of_contents": publisher_state.get("table_of_contents", ""), + "title": publisher_state.get("title", f"Deep Research: {query}"), + "date": publisher_state.get("date", current_date), + "sections": publisher_state.get("sections", []) + } + + # Run the publisher agent + publish_state = await publisher.run(complete_publisher_state) # Add published files to results research_results["published_files"] = publish_state.get("published_files", []) From 81060011f318985b485db0aa5d6535e5cc15ed2b Mon Sep 17 00:00:00 2001 From: Assaf Elovic Date: Sat, 8 Mar 2025 14:37:58 +0200 Subject: [PATCH 4/9] modified colors --- multi_agents/agents/utils/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multi_agents/agents/utils/views.py b/multi_agents/agents/utils/views.py index bb98a5e80..bb9929519 100644 --- a/multi_agents/agents/utils/views.py +++ b/multi_agents/agents/utils/views.py @@ -10,7 +10,7 @@ class AgentColor(Enum): REVIEWER = Fore.CYAN REVISOR = Fore.LIGHTWHITE_EX MASTER = Fore.LIGHTYELLOW_EX - EXPLORER = Fore.LIGHTRED_EX + EXPLORER = Fore.LIGHTBLUE_EX SYNTHESIZER = Fore.LIGHTMAGENTA_EX SECTION_WRITER = Fore.LIGHTCYAN_EX REPORT_FORMATTER = Fore.LIGHTWHITE_EX From 464b43a6d7ce32bf779dc3882c00d4ccb569defd Mon Sep 17 00:00:00 2001 From: Assaf Elovic Date: Sat, 8 Mar 2025 16:25:44 +0200 Subject: [PATCH 5/9] fixed wrong usage of langgraph states --- multi_agents/agents/utils/views.py | 1 + multi_agents/deep_research/agents/explorer.py | 59 ++- .../deep_research/agents/report_formatter.py | 56 ++- multi_agents/deep_research/agents/reviewer.py | 68 +++- .../deep_research/agents/section_writer.py | 55 ++- .../deep_research/agents/synthesizer.py | 13 +- multi_agents/deep_research/main.py | 24 +- multi_agents/deep_research/memory.py | 22 +- multi_agents/deep_research/orchestrator.py | 384 ++++++++++++++---- 9 files changed, 560 insertions(+), 122 deletions(-) diff --git a/multi_agents/agents/utils/views.py b/multi_agents/agents/utils/views.py index bb9929519..5748f5a94 100644 --- a/multi_agents/agents/utils/views.py +++ b/multi_agents/agents/utils/views.py @@ -15,6 +15,7 @@ class AgentColor(Enum): SECTION_WRITER = Fore.LIGHTCYAN_EX REPORT_FORMATTER = Fore.LIGHTWHITE_EX DEEP_WRITER = Fore.LIGHTGREEN_EX + ORCHESTRATOR = Fore.LIGHTRED_EX def print_agent_output(output:str, agent: str="RESEARCHER"): diff --git a/multi_agents/deep_research/agents/explorer.py b/multi_agents/deep_research/agents/explorer.py index ba4237786..4074ef456 100644 --- a/multi_agents/deep_research/agents/explorer.py +++ b/multi_agents/deep_research/agents/explorer.py @@ -52,21 +52,29 @@ async def generate_search_queries(self, query: str, num_queries: int = 3) -> Lis await self._stream_or_print(f"Generated {len(queries)} search queries", "EXPLORER") return queries[:num_queries] - async def generate_research_plan(self, query: str, num_questions: int = 3) -> List[str]: - """Generate follow-up questions to clarify research direction""" + async def generate_research_plan(self, query: str, query_titles: List[str] = None, num_questions: int = 3) -> List[str]: + """Generate a research plan based on query titles""" await self._stream_or_print(f"Generating research plan for: {query}", "EXPLORER") # Get current time for context current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - + + # Use query titles if provided, otherwise just use the main query + query_context = "" + if query_titles and isinstance(query_titles, list): + query_context = "\n".join([f"- {title}" for title in query_titles]) + prompt = [ - {"role": "system", "content": "You are an expert researcher. Your task is to analyze the original query and generate targeted questions that explore different aspects and time periods of the topic."}, + {"role": "system", "content": "You are an expert researcher. Your task is to analyze the original query and sub-queries to generate a comprehensive research plan."}, {"role": "user", "content": f"""Original query: {query} Current time: {current_time} -Based on the query and the current time, generate {num_questions} unique questions. Each question should explore a different aspect or time period of the topic, considering recent developments up to {current_time}. +Sub-queries: +{query_context} + +Based on the original query and sub-queries, generate {num_questions} research questions that will guide our investigation. Each question should explore a different aspect of the topic, considering recent developments up to {current_time}. Format each question on a new line starting with 'Question: '"""} ] @@ -82,4 +90,45 @@ async def generate_research_plan(self, query: str, num_questions: int = 3) -> Li if q.strip().startswith('Question:')] await self._stream_or_print(f"Generated {len(questions)} research questions", "EXPLORER") + return questions[:num_questions] + + async def generate_follow_up_questions(self, query: str, context: str, num_questions: int = 3) -> List[str]: + """Generate follow-up questions based on research context""" + await self._stream_or_print(f"Generating follow-up questions for: {query}", "EXPLORER") + + prompt = [ + {"role": "system", "content": "You are an expert researcher identifying follow-up questions."}, + {"role": "user", + "content": f"""Based on the following research context for the query '{query}', generate {num_questions} follow-up questions that would deepen the research. These questions should explore aspects not fully covered in the current research. + +Research context: +{context} + +IMPORTANT: Format each question on a new line starting with 'Question: ' - this exact format is required."""} + ] + + response = await call_model( + prompt=prompt, + model="gpt-4o", + temperature=0.4 + ) + + # First try to extract questions with the 'Question:' prefix + questions = [q.replace('Question:', '').strip() + for q in response.split('\n') + if q.strip().startswith('Question:')] + + # If no questions were found with the prefix, try to extract questions by line + if not questions: + # Split by newlines and filter out empty lines + lines = [line.strip() for line in response.split('\n') if line.strip()] + + # If we have at least num_questions lines, assume they are the questions + if len(lines) >= num_questions: + questions = lines[:num_questions] + # If we have fewer lines but at least one, use what we have + elif lines: + questions = lines + + await self._stream_or_print(f"Generated {len(questions)} follow-up questions", "EXPLORER") return questions[:num_questions] \ No newline at end of file diff --git a/multi_agents/deep_research/agents/report_formatter.py b/multi_agents/deep_research/agents/report_formatter.py index 2ed0776bf..9d566ae59 100644 --- a/multi_agents/deep_research/agents/report_formatter.py +++ b/multi_agents/deep_research/agents/report_formatter.py @@ -76,22 +76,74 @@ async def prepare_publisher_state(self, writer_output: Dict[str, Any], research_ sections = research_state["sections"] else: sections = self.extract_sections_from_research_data(research_state.get("research_data", [])) + + # Validate that sections exist + if not sections: + error_msg = "No sections found in research data. Cannot format report." + if self.websocket and self.stream_output: + await self.stream_output( + "logs", + "error", + error_msg, + self.websocket, + ) + else: + print_agent_output( + error_msg, + agent="REPORT_FORMATTER", + ) + raise ValueError(error_msg) # Format sources for references sources = research_state.get("sources", []) formatted_sources = [] - # Ensure sources are properly formatted + # Extract URLs from sections to ensure all cited sources are included + cited_urls = set() + for section in sections: + if isinstance(section, dict) and "content" in section: + content = section["content"] + # Find all URLs in markdown format + import re + urls = re.findall(r'\[.*?\]\((https?://[^\s\)]+)\)', content) + for url in urls: + cited_urls.add(url) + + # Ensure sources are properly formatted and include all cited URLs for source in sources: if isinstance(source, dict) and "url" in source: # Format source as a reference title = source.get("title", "Unknown Title") url = source.get("url", "") formatted_sources.append(f"- {title} [{url}]({url})") + # Remove from cited_urls if present + if url in cited_urls: + cited_urls.remove(url) elif isinstance(source, str): # Source is already a string formatted_sources.append(source) - + + # Add any remaining cited URLs that weren't in the sources + for url in cited_urls: + formatted_sources.append(f"- Additional Source [{url}]({url})") + + # Validate that we have sources + if not formatted_sources: + error_msg = "No sources found for research. Cannot format report without sources." + if self.websocket and self.stream_output: + await self.stream_output( + "logs", + "error", + error_msg, + self.websocket, + ) + else: + print_agent_output( + error_msg, + agent="REPORT_FORMATTER", + ) + raise ValueError(error_msg) + # Return the publisher state return { "task": research_state.get("task", {}), diff --git a/multi_agents/deep_research/agents/reviewer.py b/multi_agents/deep_research/agents/reviewer.py index 28a82e8cc..cb94b0898 100644 --- a/multi_agents/deep_research/agents/reviewer.py +++ b/multi_agents/deep_research/agents/reviewer.py @@ -20,19 +20,38 @@ async def review_research(self, query: str, learnings: List[str], citations: Dic learnings_text = "\n".join(formatted_learnings) + # Calculate metrics for informational purposes only + citation_ratio = sum(1 for l in learnings if l in citations) / max(1, len(learnings)) + learning_count = len(learnings) + prompt = [ - {"role": "system", "content": "You are an expert research reviewer. Your task is to evaluate research findings for quality, accuracy, and completeness."}, + {"role": "system", "content": """You are an expert research reviewer with high standards. Your task is to evaluate research findings for quality, accuracy, and completeness. + +IMPORTANT INSTRUCTION: Do NOT default to a score of 6/10. Be critical and honest in your assessment. Evaluate the actual quality of the research and provide a score that genuinely reflects it. + +Research quality varies widely, and your score should reflect this variation: +- Excellent research (8-10): Comprehensive, accurate, well-cited, addresses all aspects +- Good research (6-7): Solid but with some gaps or limitations +- Mediocre research (4-5): Significant gaps, limited citations, or superficial analysis +- Poor research (1-3): Major issues, inaccuracies, or very incomplete + +Your evaluation should be thorough and your score should be a true reflection of quality."""}, {"role": "user", "content": f"""Please review the following research findings for the query: '{query}' Research Findings: {learnings_text} +Research Statistics: +- Total findings: {learning_count} +- Percentage with citations: {int(citation_ratio * 100)}% + Evaluate these findings on: 1. Accuracy - Are the findings factually correct? 2. Completeness - Do they address the query comprehensively? 3. Quality of sources - Are the sources reliable and diverse? -4. Gaps - What important aspects are missing? +4. Depth - How in-depth is the analysis? +5. Gaps - What important aspects are missing? Format your response as: Quality Score (1-10): @@ -45,7 +64,7 @@ async def review_research(self, query: str, learnings: List[str], citations: Dic response = await call_model( prompt=prompt, model="gpt-4o", - temperature=0.3 + temperature=0.8 # Higher temperature for more variability ) # Parse the review results @@ -64,10 +83,30 @@ async def review_research(self, query: str, learnings: List[str], citations: Dic line = line.strip() if 'Quality Score' in line: try: - score = int(line.split(':')[1].strip().split('/')[0]) + # Extract score more robustly + score_text = line.split(':')[1].strip() + # Handle different formats like "7/10" or just "7" + if '/' in score_text: + score = int(score_text.split('/')[0]) + else: + score = int(score_text) review_results['score'] = score - except: - pass + except Exception as e: + # If we can't parse the score, make another call specifically for the score + score_prompt = [ + {"role": "system", "content": "You are a research quality evaluator. Your only task is to provide a numerical score from 1-10."}, + {"role": "user", "content": f"Based on the research for '{query}', with {learning_count} findings and {int(citation_ratio * 100)}% citation rate, provide a single number from 1-10 representing the quality. Only respond with the number, nothing else."} + ] + score_response = await call_model( + prompt=score_prompt, + model="gpt-4o", + temperature=0.5 + ) + try: + review_results['score'] = int(score_response.strip()) + except: + # Last resort fallback + review_results['score'] = 5 elif line.startswith('Strengths:'): current_section = 'strengths' elif line.startswith('Weaknesses:'): @@ -82,5 +121,22 @@ async def review_research(self, query: str, learnings: List[str], citations: Dic else: review_results[current_section].append(line) + # If we still don't have a score, make a dedicated call for just the score + if review_results['score'] == 0: + score_prompt = [ + {"role": "system", "content": "You are a research quality evaluator. Provide a numerical score from 1-10 based on the quality of research."}, + {"role": "user", "content": f"Based on the research for '{query}', with {learning_count} findings and {int(citation_ratio * 100)}% citation rate, provide a single number from 1-10 representing the quality. Only respond with the number, nothing else."} + ] + score_response = await call_model( + prompt=score_prompt, + model="gpt-4o", + temperature=0.5 + ) + try: + review_results['score'] = int(score_response.strip()) + except: + # Last resort fallback + review_results['score'] = 5 + await self._stream_or_print(f"Review complete. Quality score: {review_results['score']}/10", "REVIEWER") return review_results \ No newline at end of file diff --git a/multi_agents/deep_research/agents/section_writer.py b/multi_agents/deep_research/agents/section_writer.py index 9c15d9d58..539c0d38c 100644 --- a/multi_agents/deep_research/agents/section_writer.py +++ b/multi_agents/deep_research/agents/section_writer.py @@ -44,6 +44,24 @@ async def generate_sections(self, research_state: Dict[str, Any]) -> List[Dict[s sources = research_state.get("sources", []) citations = research_state.get("citations", {}) + # The context already contains the research results with sources + # We don't need to check for empty sources as the context itself is the source + # Just ensure the context is not empty + if not context: + if self.websocket and self.stream_output: + await self.stream_output( + "logs", + "error", + "No research context found. Cannot generate sections without research data.", + self.websocket, + ) + else: + print_agent_output( + "No research context found. Cannot generate sections without research data.", + agent="SECTION_WRITER", + ) + raise ValueError("No research context found. Cannot generate sections without research data.") + # Get model from task or use a default model if None model = task.get("model") if model is None: @@ -56,33 +74,48 @@ async def generate_sections(self, research_state: Dict[str, Any]) -> List[Dict[s await self.stream_output( "logs", "generating_sections", - f"Generating sections from deep research data...", + f"Generating sections from deep research data with {len(sources)} sources...", self.websocket, ) else: print_agent_output( - f"Generating sections from deep research data...", + f"Generating sections from deep research data with {len(sources)} sources...", agent="SECTION_WRITER", ) - + + # Format sources for better context + formatted_sources = [] + for source in sources: + if isinstance(source, dict) and "url" in source: + title = source.get("title", "Unknown Title") + url = source.get("url", "") + content = source.get("content", "") + formatted_sources.append(f"Source: {title}\nURL: {url}\nContent: {content}") + elif isinstance(source, str): + formatted_sources.append(source) + # Create the prompt for generating sections prompt = [ { "role": "system", - "content": "You are a research writer. Your sole purpose is to organize research data into logical sections with detailed content." + "content": "You are a research writer. Your sole purpose is to organize research data into logical sections with detailed content. You MUST include relevant sources as citations in each section." }, { "role": "user", "content": f"""Today's date is {datetime.now().strftime('%d/%m/%Y')}. Query or Topic: {query} Research data: {context} -Sources: {sources} +Sources: {formatted_sources} Citations: {citations} Your task is to organize this research data into 3-5 logical sections with appropriate headers. Each section should be comprehensive and detailed, with a minimum of 300 words per section. -Include any relevant sources in the content as markdown hyperlinks. -For example: 'This is a sample text. ([url website](url))' + +IMPORTANT: You MUST include relevant sources in the content as markdown hyperlinks. +For example: 'This is a sample text. ([Source Title](url))' + +For each major claim or piece of information, include a citation to the relevant source. +If a source doesn't have a URL, cite it by title. Make sure each section covers a distinct aspect of the research topic and provides in-depth analysis. The sections should flow logically and cover the topic comprehensively. @@ -167,10 +200,14 @@ async def run(self, research_state: Dict[str, Any]) -> Dict[str, Any]: # If no sections were generated, use the original research data if not research_data: research_data = research_state.get("research_data", []) - + + # Preserve the formatted_sources if they exist + formatted_sources = research_state.get("formatted_sources", []) + # Return the transformed research state return { **research_state, "research_data": research_data, - "sections": sections # Store the original sections for later use + "sections": sections, # Store the original sections for later use + "formatted_sources": formatted_sources # Preserve formatted sources } \ No newline at end of file diff --git a/multi_agents/deep_research/agents/synthesizer.py b/multi_agents/deep_research/agents/synthesizer.py index 4f252c9c9..9c609d841 100644 --- a/multi_agents/deep_research/agents/synthesizer.py +++ b/multi_agents/deep_research/agents/synthesizer.py @@ -6,7 +6,7 @@ class DeepSynthesizerAgent(DeepResearchAgent): """Agent responsible for synthesizing research results""" - async def process_research_results(self, query: str, context: str, num_learnings: int = 3) -> Dict[str, List[str]]: + async def process_research_results(self, query: str, context: str, num_learnings: int = 3) -> tuple: """Process research results to extract learnings and follow-up questions""" await self._stream_or_print(f"Synthesizing research results for: {query}", "SYNTHESIZER") @@ -51,8 +51,9 @@ async def process_research_results(self, query: str, context: str, num_learnings questions.append(line.replace('Question:', '').strip()) await self._stream_or_print(f"Extracted {len(learnings)} learnings and {len(questions)} follow-up questions", "SYNTHESIZER") - return { - 'learnings': learnings[:num_learnings], - 'followUpQuestions': questions[:num_learnings], - 'citations': citations - } \ No newline at end of file + + # Store follow-up questions in the state + follow_up_questions = questions[:num_learnings] + + # Return learnings and citations as a tuple + return learnings[:num_learnings], citations \ No newline at end of file diff --git a/multi_agents/deep_research/main.py b/multi_agents/deep_research/main.py index b2868eafa..8f6f61ce5 100644 --- a/multi_agents/deep_research/main.py +++ b/multi_agents/deep_research/main.py @@ -86,15 +86,33 @@ async def run_deep_research( # Source is already a string formatted_sources.append(source) + # The context already contains the research results with sources + # We don't need to check for empty sources as the context itself is the source + # Just ensure the context is not empty + context = research_results.get("context", "") + if not context: + error_msg = "No research context found in results. Cannot generate report without research data." + if websocket and stream_output: + await stream_output( + "logs", + "error", + error_msg, + websocket, + ) + else: + print(f"ERROR: {error_msg}") + raise ValueError(error_msg) + # Prepare research state for writer research_state = { "task": task, "query": query, "title": f"Deep Research: {query}", "date": current_date, - "context": research_results.get("context", ""), - "research_data": [{"topic": query, "content": research_results.get("context", "")}], - "sources": formatted_sources or research_results.get("sources", []), + "context": context, # Pass context as is, no need to convert + "research_data": [{"topic": query, "content": context}], # Pass context as is + "sources": sources, # Pass the original sources with full content + "formatted_sources": formatted_sources, # Also pass formatted sources for display "citations": research_results.get("citations", {}) } diff --git a/multi_agents/deep_research/memory.py b/multi_agents/deep_research/memory.py index 50a1ba6c6..f7d9b1ebf 100644 --- a/multi_agents/deep_research/memory.py +++ b/multi_agents/deep_research/memory.py @@ -49,7 +49,12 @@ def add_learning(self, learning: str, citation: Optional[str] = None): def add_context(self, context: str): """Add context to the research state""" - if context and context not in self.context: + # Skip empty context + if not context: + return + + # Add context if it's not already in the list + if context not in self.context: self.context.append(context) def add_visited_urls(self, urls: List[str]): @@ -86,17 +91,28 @@ def finalize_context(self): # Prepare context with citations context_with_citations = [] + + # Add learnings with citations for learning in self.learnings: citation = self.citations.get(learning, '') if citation: context_with_citations.append(f"{learning} [Source: {citation}]") else: context_with_citations.append(learning) - + # Add all research context if self.context: context_with_citations.extend(self.context) - + + # Add source content with proper attribution + for source in self.sources: + if isinstance(source, dict): + title = source.get("title", "Unknown Title") + url = source.get("url", "") + content = source.get("content", "") + if content: + context_with_citations.append(f"From {title} [{url}]: {content}") + # Trim final context to word limit final_context_list = trim_context_to_word_limit(context_with_citations) self.final_context = "\n".join(final_context_list) diff --git a/multi_agents/deep_research/orchestrator.py b/multi_agents/deep_research/orchestrator.py index 2ceb5263e..6a3883eba 100644 --- a/multi_agents/deep_research/orchestrator.py +++ b/multi_agents/deep_research/orchestrator.py @@ -6,7 +6,7 @@ from datetime import datetime, timedelta from langgraph.graph import StateGraph, END -from ..agents.utils.views import print_agent_output +from ..agents.utils.views import print_agent_output, AgentColor from ..agents.utils.utils import sanitize_filename from .memory import DeepResearchState @@ -67,116 +67,251 @@ async def _log_research_start(self): else: print_agent_output(message, "MASTER") - async def _on_progress(self, state: DeepResearchState): + async def _on_progress(self, state: DeepResearchState, current_depth=None, current_breadth=None, total_breadth=None): """Progress callback for deep research""" - message = (f"Deep Research Progress: Depth {state.current_depth}/{state.total_depth}, " - f"Breadth {state.current_breadth}/{state.total_breadth}") + # Use provided values or get from state + current_depth = current_depth if current_depth is not None else state.current_depth + current_breadth = current_breadth if current_breadth is not None else state.current_breadth + total_breadth = total_breadth if total_breadth is not None else state.total_breadth + + message = (f"Deep Research Progress: Depth {current_depth}/{state.total_depth}, " + f"Breadth {current_breadth}/{total_breadth}") if self.websocket and self.stream_output: await self.stream_output("logs", "deep_research_progress", message, self.websocket) else: print_agent_output(message, "MASTER") - async def generate_search_queries(self, state: DeepResearchState) -> DeepResearchState: + async def generate_search_queries(self, state: DeepResearchState) -> dict: """Generate search queries for research""" explorer = self.agents["explorer"] queries = await explorer.generate_search_queries(state.query, num_queries=state.breadth) - state.set_search_queries(queries) - return state - async def generate_research_plan(self, state: DeepResearchState) -> DeepResearchState: - """Generate research plan with follow-up questions""" + # Return a dictionary with updated search queries + return { + "search_queries": queries, + "total_breadth": len(queries) + } + + async def generate_research_plan(self, state: DeepResearchState) -> dict: + """Generate a research plan based on search queries""" explorer = self.agents["explorer"] - plan = await explorer.generate_research_plan(state.query, num_questions=3) - state.set_research_plan(plan) - return state - async def process_query(self, state: DeepResearchState, query_index: int) -> DeepResearchState: + # Extract query titles for planning + query_titles = [q.get("title", q.get("query", "")) for q in state.search_queries] + + # Generate research plan + plan = await explorer.generate_research_plan(state.query, query_titles) + + # Return a dictionary with updated research plan + return { + "research_plan": plan + } + + async def process_query(self, state: DeepResearchState, query_index: int) -> dict: """Process a single search query""" if query_index >= len(state.search_queries): - return state + return {} + # Get the query to process query_data = state.search_queries[query_index] - query = query_data['query'] + query_text = query_data.get("query", "") + query_title = query_data.get("title", query_text) - # Update progress - state.current_breadth = query_index + 1 - await self._on_progress(state) + # Calculate current progress + current_breadth = query_index + 1 + total_breadth = len(state.search_queries) - # Conduct basic research + # Log the action + message = f"Processing query {current_breadth}/{total_breadth}: {query_title}" + await self._on_progress(state, current_breadth=current_breadth, total_breadth=total_breadth) + + if self.websocket and self.stream_output: + await self.stream_output( + "logs", + "processing_query", + message, + self.websocket, + ) + else: + print_agent_output( + message, + agent="RESEARCHER", + ) + + # Conduct research researcher = self.agents["researcher"] context, visited_urls, sources = await researcher.basic_research( - query=query, - verbose=self.task.get('verbose', True), - source=self.task.get('source', 'web') + query=query_text, + source=self.task.get("source", "web") ) # Process research results synthesizer = self.agents["synthesizer"] - results = await synthesizer.process_research_results(query, context) + learnings, citations = await synthesizer.process_research_results( + query=query_text, + context=context + ) - # Update state with results - for learning in results['learnings']: - citation = results['citations'].get(learning, '') - state.add_learning(learning, citation) + # Generate follow-up questions if this is the first query + follow_up_questions = [] + if query_index == 0 and not state.follow_up_questions: + explorer = self.agents["explorer"] + follow_up_questions = await explorer.generate_follow_up_questions( + query=state.query, + context=context, + num_questions=3 + ) - state.add_context(context) - state.add_visited_urls(visited_urls) - state.add_sources(sources) - state.set_follow_up_questions(results['followUpQuestions']) + # Log the follow-up questions + if follow_up_questions: + questions_str = "\n".join([f"- {q}" for q in follow_up_questions]) + log_message = f"Generated follow-up questions:\n{questions_str}" + + if self.websocket and self.stream_output: + await self.stream_output( + "logs", + "follow_up_questions", + log_message, + self.websocket, + ) + else: + print_agent_output( + log_message, + agent="EXPLORER", + ) + + # Update state with new information + new_learnings = [] + new_citations = {} + + for learning in learnings: + if learning not in state.learnings: + new_learnings.append(learning) + if learning in citations: + new_citations[learning] = citations[learning] + + # Return a dictionary with updated values + result = { + "current_breadth": query_index + 1, + "learnings": state.learnings + new_learnings, + "citations": {**state.citations, **new_citations}, + "visited_urls": state.visited_urls.union(visited_urls), + "context": state.context + [context] if context else state.context, + "sources": state.sources + sources if sources else state.sources + } - return state + # Add follow-up questions if generated + if follow_up_questions: + result["follow_up_questions"] = follow_up_questions + + return result - async def process_all_queries(self, state: DeepResearchState) -> DeepResearchState: - """Process all search queries with concurrency limit""" + async def process_all_queries(self, state: DeepResearchState) -> dict: + """Process all search queries with concurrency control""" + # Create a semaphore to limit concurrency semaphore = asyncio.Semaphore(self.concurrency_limit) + # Define a function to process a query with the semaphore async def process_with_semaphore(index): async with semaphore: return await self.process_query(state, index) - - # Create tasks for all queries - tasks = [process_with_semaphore(i) for i in range(len(state.search_queries))] - # Wait for all tasks to complete + # Process queries concurrently with semaphore control + tasks = [process_with_semaphore(i) for i in range(len(state.search_queries))] results = await asyncio.gather(*tasks) - # Merge results (the state is shared, so we just need the final state) - return results[-1] if results else state + # Combine all results + combined_learnings = state.learnings.copy() + combined_citations = state.citations.copy() + combined_visited_urls = state.visited_urls.copy() + combined_context = state.context.copy() + combined_sources = state.sources.copy() + combined_follow_up_questions = state.follow_up_questions.copy() + + for result in results: + if result: # Skip empty results + # Add learnings and citations + new_learnings = result.get("learnings", []) + for i, learning in enumerate(new_learnings): + if learning not in combined_learnings: + combined_learnings.append(learning) + + # Update citations + combined_citations.update(result.get("citations", {})) + + # Update visited URLs + combined_visited_urls = combined_visited_urls.union(result.get("visited_urls", set())) + + # Update context + new_context = result.get("context", []) + for ctx in new_context: + if ctx and ctx not in combined_context: + combined_context.append(ctx) + + # Update sources + new_sources = result.get("sources", []) + for source in new_sources: + if source and source not in combined_sources: + combined_sources.append(source) + + # Update follow-up questions + new_follow_up_questions = result.get("follow_up_questions", []) + for question in new_follow_up_questions: + if question and question not in combined_follow_up_questions: + combined_follow_up_questions.append(question) + + # Log completion of all queries + total_breadth = len(state.search_queries) + await self._on_progress(state, current_breadth=total_breadth, total_breadth=total_breadth) + + # Return a dictionary with all updated values + return { + "current_breadth": total_breadth, + "learnings": combined_learnings, + "citations": combined_citations, + "visited_urls": combined_visited_urls, + "context": combined_context, + "sources": combined_sources, + "follow_up_questions": combined_follow_up_questions + } - async def review_research(self, state: DeepResearchState) -> DeepResearchState: + async def review_research(self, state: DeepResearchState) -> dict: """Review research results""" reviewer = self.agents["reviewer"] review = await reviewer.review_research(state.query, state.learnings, state.citations) - state.set_review(review) - return state - async def recursive_research(self, state: DeepResearchState) -> DeepResearchState: + # Return a dictionary with the review + return { + "review": review + } + + async def recursive_research(self, state: DeepResearchState) -> dict: """Recursively conduct deeper research based on follow-up questions""" # Base case: if we've reached the maximum depth, return the current state if state.current_depth >= state.total_depth: - return state + return {} # Increment depth - state.current_depth += 1 - await self._on_progress(state) + current_depth = state.current_depth + 1 + await self._on_progress(state, current_depth=current_depth) # Create a new query from follow-up questions if not state.follow_up_questions: - return state + return {} new_query = f""" Original query: {state.query} Follow-up questions: {' '.join(state.follow_up_questions)} """ - # Create a new state for the next level - new_state = DeepResearchState( + # Create a temporary state for the next level + temp_state = DeepResearchState( task=state.task, query=new_query, breadth=max(2, state.breadth // 2), # Reduce breadth for deeper levels depth=state.depth, - current_depth=state.current_depth, + current_depth=current_depth, total_depth=state.total_depth, learnings=state.learnings.copy(), citations=state.citations.copy(), @@ -186,48 +321,93 @@ async def recursive_research(self, state: DeepResearchState) -> DeepResearchStat ) # Generate new search queries - new_state = await self.generate_search_queries(new_state) + queries_result = await self.generate_search_queries(temp_state) + temp_state.search_queries = queries_result.get("search_queries", []) + temp_state.total_breadth = queries_result.get("total_breadth", 0) # Process all queries at this level - new_state = await self.process_all_queries(new_state) + process_result = await self.process_all_queries(temp_state) + + # Merge results + new_learnings = [] + new_citations = {} + new_context = [] + new_sources = [] - # Merge results back to original state - for learning in new_state.learnings: + # Get new learnings and citations + for learning in process_result.get("learnings", []): if learning not in state.learnings: - state.add_learning(learning, new_state.citations.get(learning)) + new_learnings.append(learning) + citation = process_result.get("citations", {}).get(learning) + if citation: + new_citations[learning] = citation + + # Get new context + for ctx in process_result.get("context", []): + if ctx not in state.context: + new_context.append(ctx) - for context_item in new_state.context: - if context_item not in state.context: - state.add_context(context_item) + # Get new sources + for source in process_result.get("sources", []): + if source not in state.sources: + new_sources.append(source) - state.add_visited_urls(list(new_state.visited_urls)) - state.add_sources(new_state.sources) - - # Continue recursion - return await self.recursive_research(state) - - async def finalize_research(self, state: DeepResearchState) -> DeepResearchState: - """Finalize research results""" - # Finalize context - try: - state.finalize_context() - except Exception as e: - logger.error(f"Error finalizing context: {e}") - # Fallback: join context items if finalize_context fails - if hasattr(state, 'context') and isinstance(state.context, list): - state.final_context = "\n".join(state.context) - else: - state.final_context = "" + # Return a dictionary with all updated values + return { + "current_depth": current_depth, + "learnings": state.learnings + new_learnings, + "citations": {**state.citations, **new_citations}, + "visited_urls": state.visited_urls.union(process_result.get("visited_urls", set())), + "context": state.context + new_context, + "sources": state.sources + new_sources + } - # Ensure final_context is set - if not hasattr(state, 'final_context') or not state.final_context: - state.final_context = "" + async def finalize_research(self, state: DeepResearchState) -> dict: + """Finalize research and prepare for report generation""" + # Log the action + if self.websocket and self.stream_output: + await self.stream_output( + "logs", + "finalizing_research", + f"Finalizing research...", + self.websocket, + ) + else: + print_agent_output( + f"Finalizing research...", + agent="ORCHESTRATOR", + ) - return state + # The context already contains the research results with sources + # We don't need to check for empty sources as the context itself is the source + # Just ensure the context is not empty + if not state.context: + error_msg = "No research context collected. Cannot generate report without research data." + if self.websocket and self.stream_output: + await self.stream_output( + "logs", + "error", + error_msg, + self.websocket, + ) + else: + print_agent_output( + error_msg, + agent="ORCHESTRATOR", + ) + raise ValueError(error_msg) + + # Finalize context by joining all context items + final_context = state.finalize_context() + + # Return a dictionary with the final context + return { + "final_context": final_context + } def should_continue_recursion(self, state: DeepResearchState) -> str: """Determine if we should continue with recursive research""" - if state.current_depth < state.total_depth: + if state.current_depth < state.total_depth and state.follow_up_questions: return "continue" else: return "finalize" @@ -302,15 +482,43 @@ async def run(self) -> Dict[str, Any]: execution_time = timedelta(seconds=end_time - start_time) logger.info(f"Total deep research execution time: {execution_time}") + # Extract values from the final state + # The final state might be an AddableValuesDict or a DeepResearchState + if hasattr(final_state, 'get'): + # It's a dictionary-like object + context = final_state.get('final_context', '') + if not context and 'context' in final_state: + # Try to join context if it's a list + context_list = final_state.get('context', []) + if isinstance(context_list, list): + context = '\n'.join(context_list) + else: + context = str(context_list) + + # Extract other values + learnings = final_state.get('learnings', []) + citations = final_state.get('citations', {}) + visited_urls = final_state.get('visited_urls', set()) + sources = final_state.get('sources', []) + review = final_state.get('review', None) + else: + # It's a DeepResearchState object + context = final_state.final_context if hasattr(final_state, 'final_context') else '\n'.join(final_state.context) + learnings = final_state.learnings + citations = final_state.citations + visited_urls = final_state.visited_urls + sources = final_state.sources + review = final_state.review + # Return results return { "task": self.task, "query": self.task.get('query'), - "context": getattr(final_state, 'final_context', '\n'.join(final_state.context) if hasattr(final_state, 'context') else ''), - "learnings": getattr(final_state, 'learnings', []), - "citations": getattr(final_state, 'citations', {}), - "visited_urls": list(getattr(final_state, 'visited_urls', set())), - "sources": getattr(final_state, 'sources', []), - "review": getattr(final_state, 'review', None), + "context": context, + "learnings": learnings, + "citations": citations, + "visited_urls": list(visited_urls) if isinstance(visited_urls, set) else [], + "sources": sources, + "review": review, "execution_time": str(execution_time) } \ No newline at end of file From 6ccbc62f702046e2473ea3ba73b1c25357f08f57 Mon Sep 17 00:00:00 2001 From: Assaf Elovic Date: Sat, 8 Mar 2025 17:45:37 +0200 Subject: [PATCH 6/9] added newer more optimized version of gptr langgraph. revert if causes issue to latest stable commit --- multi_agents/agents/utils/views.py | 5 +- multi_agents/deep_research/agents/__init__.py | 12 +- multi_agents/deep_research/agents/base.py | 116 ++- .../deep_research/agents/finalizer.py | 176 ++++ multi_agents/deep_research/agents/planner.py | 239 ++++++ .../{report_formatter.py => reporter.py} | 12 +- multi_agents/deep_research/agents/reviewer.py | 80 +- .../deep_research/agents/synthesizer.py | 289 ++++++- .../agents/{section_writer.py => writer.py} | 111 ++- multi_agents/deep_research/main.py | 48 +- multi_agents/deep_research/memory.py | 74 +- multi_agents/deep_research/orchestrator.py | 793 +++++++++++------- 12 files changed, 1535 insertions(+), 420 deletions(-) create mode 100644 multi_agents/deep_research/agents/finalizer.py create mode 100644 multi_agents/deep_research/agents/planner.py rename multi_agents/deep_research/agents/{report_formatter.py => reporter.py} (95%) rename multi_agents/deep_research/agents/{section_writer.py => writer.py} (67%) diff --git a/multi_agents/agents/utils/views.py b/multi_agents/agents/utils/views.py index 5748f5a94..907a68b34 100644 --- a/multi_agents/agents/utils/views.py +++ b/multi_agents/agents/utils/views.py @@ -12,8 +12,9 @@ class AgentColor(Enum): MASTER = Fore.LIGHTYELLOW_EX EXPLORER = Fore.LIGHTBLUE_EX SYNTHESIZER = Fore.LIGHTMAGENTA_EX - SECTION_WRITER = Fore.LIGHTCYAN_EX - REPORT_FORMATTER = Fore.LIGHTWHITE_EX + PLANNER = Fore.LIGHTCYAN_EX + FINALIZER = Fore.LIGHTWHITE_EX + REPORTER = Fore.LIGHTWHITE_EX DEEP_WRITER = Fore.LIGHTGREEN_EX ORCHESTRATOR = Fore.LIGHTRED_EX diff --git a/multi_agents/deep_research/agents/__init__.py b/multi_agents/deep_research/agents/__init__.py index 803d3096f..4399eb06c 100644 --- a/multi_agents/deep_research/agents/__init__.py +++ b/multi_agents/deep_research/agents/__init__.py @@ -2,8 +2,10 @@ from .explorer import DeepExplorerAgent from .synthesizer import DeepSynthesizerAgent from .reviewer import DeepReviewerAgent -from .section_writer import SectionWriterAgent -from .report_formatter import ReportFormatterAgent +from .writer import WriterAgent +from .reporter import ReporterAgent +from .planner import PlannerAgent +from .finalizer import FinalizerAgent __all__ = [ "count_words", @@ -13,6 +15,8 @@ "DeepExplorerAgent", "DeepSynthesizerAgent", "DeepReviewerAgent", - "SectionWriterAgent", - "ReportFormatterAgent" + "WriterAgent", + "ReporterAgent", + "PlannerAgent", + "FinalizerAgent" ] \ No newline at end of file diff --git a/multi_agents/deep_research/agents/base.py b/multi_agents/deep_research/agents/base.py index 6f9100947..c63206421 100644 --- a/multi_agents/deep_research/agents/base.py +++ b/multi_agents/deep_research/agents/base.py @@ -6,15 +6,35 @@ logger = logging.getLogger(__name__) -# Maximum words allowed in context (25k words for safety margin) -MAX_CONTEXT_WORDS = 25000 +# Maximum words allowed in context (100k words for larger research contexts) +MAX_CONTEXT_WORDS = 100000 def count_words(text: str) -> int: """Count words in a text string""" return len(text.split()) -def trim_context_to_word_limit(context_list: List[str], max_words: int = MAX_CONTEXT_WORDS) -> List[str]: - """Trim context list to stay within word limit while preserving most recent/relevant items""" +def trim_context_to_word_limit(context, max_words: int = MAX_CONTEXT_WORDS) -> str: + """ + Trim context to stay within word limit while preserving most recent/relevant items. + + Args: + context: Either a string or a list of strings to trim + max_words: Maximum number of words to include + + Returns: + A trimmed string + """ + # Handle different input types + if isinstance(context, str): + # If input is a string, convert to list of paragraphs + context_list = context.split("\n\n") + elif isinstance(context, list): + # If input is already a list, use as is + context_list = context + else: + # For any other type, convert to string and then split + context_list = str(context).split("\n\n") + total_words = 0 trimmed_context = [] @@ -26,8 +46,9 @@ def trim_context_to_word_limit(context_list: List[str], max_words: int = MAX_CON total_words += words else: break - - return trimmed_context + + # Join the trimmed context items into a single string + return "\n\n".join(trimmed_context) class ResearchProgress: """Track progress of deep research""" @@ -68,4 +89,85 @@ async def basic_research(self, query: str, verbose: bool = True, source: str = " # Conduct research context = await researcher.conduct_research() - return context, researcher.visited_urls, researcher.research_sources \ No newline at end of file + return context, researcher.visited_urls, researcher.research_sources + + async def search(self, query: str, task: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + Search for information on a query. + + Args: + query: The search query + task: Task configuration + + Returns: + List of search results + """ + # Log the action + await self._stream_or_print(f"Searching for information on: {query}", "RESEARCHER") + + # Get the source from task or use default + source = task.get("source", "web") + + # Conduct basic research + context, visited_urls, sources = await self.basic_research( + query=query, + source=source + ) + + # Debug log the research results + print_agent_output(f"Basic research returned: {len(sources)} sources and {len(context) if context else 0} chars of context", "RESEARCHER") + + # Format search results + search_results = [] + + # Add sources with content + for i, source in enumerate(sources): + if isinstance(source, dict): + # Debug log each source + source_keys = ', '.join(source.keys()) + print_agent_output(f"Source {i+1} has keys: {source_keys}", "RESEARCHER") + + # Ensure the source has content + if "content" not in source or not source["content"]: + # Try to extract content from other fields + for field in ["text", "snippet", "body", "description"]: + if field in source and source[field]: + source["content"] = source[field] + print_agent_output(f"Using {field} as content for source {i+1}", "RESEARCHER") + break + + # If still no content, create content from available fields + if "content" not in source or not source["content"]: + content_parts = [] + for key, value in source.items(): + if key not in ["title", "url"] and isinstance(value, str) and len(value) > 10: + content_parts.append(f"{key}: {value}") + + if content_parts: + source["content"] = "\n".join(content_parts) + print_agent_output(f"Created content from other fields for source {i+1}", "RESEARCHER") + + search_results.append(source) + + # If no sources with content, create a result from context + if not search_results and context: + print_agent_output(f"No sources with content found. Creating a result from context ({len(context)} chars)", "RESEARCHER") + search_results.append({ + "title": f"Research on {query}", + "url": "", + "content": context + }) + + # If still no search results, create a minimal result + if not search_results: + print_agent_output("No search results or context found. Creating a minimal result.", "RESEARCHER") + search_results.append({ + "title": f"Research on {query}", + "url": "", + "content": f"No specific information found for the query: {query}. This could be due to API limitations, network issues, or lack of relevant information." + }) + + # Debug log the final search results + print_agent_output(f"Returning {len(search_results)} search results", "RESEARCHER") + + return search_results \ No newline at end of file diff --git a/multi_agents/deep_research/agents/finalizer.py b/multi_agents/deep_research/agents/finalizer.py new file mode 100644 index 000000000..a93cef914 --- /dev/null +++ b/multi_agents/deep_research/agents/finalizer.py @@ -0,0 +1,176 @@ +""" +Finalizer Agent for Deep Research + +This agent is responsible for finalizing the research process and preparing the final context. +""" + +from typing import Dict, Any, List +import asyncio + +from ...agents.utils.views import print_agent_output +from .base import DeepResearchAgent, trim_context_to_word_limit + +class FinalizerAgent(DeepResearchAgent): + """ + Agent responsible for finalizing the research process and preparing the final context. + """ + + def __init__(self, websocket=None, stream_output=None, tone=None, headers=None): + """ + Initialize the agent. + + Args: + websocket: Optional websocket for streaming output + stream_output: Optional stream output function + tone: Optional tone for the research + headers: Optional headers for API requests + """ + super().__init__(websocket, stream_output, tone, headers) + + async def finalize_research(self, research_state: Dict[str, Any]) -> Dict[str, Any]: + """ + Finalize the research process and prepare the final context. + + Args: + research_state: Current research state + + Returns: + Updated research state with final context + """ + # Log the action + if self.websocket and self.stream_output: + await self.stream_output( + "logs", + "finalizing_research", + "Finalizing research and preparing final context...", + self.websocket, + ) + else: + print_agent_output( + "Finalizing research and preparing final context...", + agent="FINALIZER", + ) + + # Get the context items + context_items = research_state.get("context_items", []) + + # Get the sources + sources = research_state.get("sources", []) + + # Get the citations + citations = research_state.get("citations", {}) + + # Get the query + query = research_state.get("query", "") + + # Log the current state + print_agent_output( + f"Finalizing research with {len(context_items)} context items and {len(sources)} sources.", + agent="FINALIZER", + ) + + # If context_items is empty but we have sources, create context from sources + new_context_items = list(context_items) # Create a copy to avoid modifying the original + + if not new_context_items and sources: + print_agent_output( + f"Creating context items from {len(sources)} sources.", + agent="FINALIZER", + ) + for source in sources: + if isinstance(source, dict) and "content" in source: + title = source.get("title", "Unknown Title") + url = source.get("url", "") + content = source.get("content", "") + if content: + new_context_items.append(f"From {title} [{url}]:\n{content}") + + # If still no context items, create a default context + if not new_context_items: + print_agent_output( + "Creating default context item since no context items or sources were found.", + agent="FINALIZER", + ) + new_context_items = [f"Research on: {query}\n\nNo specific research data was collected."] + + # Combine all context items into a single context + combined_context = "\n\n".join(new_context_items) + + # Trim the context to a reasonable size if needed + max_words = 100000 # Increased from 10000 to 100000 + + # Import the fixed trim_context_to_word_limit function + from .base import trim_context_to_word_limit + + try: + # Use the fixed function which now returns a string + trimmed_context = trim_context_to_word_limit(combined_context, max_words) + except Exception as e: + print_agent_output( + f"Error trimming context: {str(e)}. Using original context.", + agent="FINALIZER", + ) + # Fallback to using the combined context directly + trimmed_context = combined_context + + # Generate a summary of the research if the context is large + summary = "" + if len(combined_context.split()) > max_words: + # Get model from config + from gpt_researcher.config.config import Config + cfg = Config() + model = cfg.smart_llm_model + + # Create the prompt for generating the summary + from ...agents.utils.llms import call_model + prompt = [ + { + "role": "system", + "content": "You are a research summarizer. Your task is to create a concise summary of research findings." + }, + { + "role": "user", + "content": f"""I have conducted research on the following topic: + +Topic: {query} + +Here are the key findings: +{trimmed_context[:5000]}... + +Please create a concise summary (around 500 words) of these research findings. +Focus on the most important insights, patterns, and conclusions. +""" + } + ] + + # Call the model to generate the summary + summary = await call_model( + prompt, + model, + ) + + # Handle potential errors + if not summary or not isinstance(summary, str): + summary = f"Research summary for '{query}'." + + # Return updated research state following LangGraph pattern + return { + "context_items": new_context_items, # Return updated context items + "summary": summary, + "final_context": trimmed_context, # Set the final context + "sources": sources, # Explicitly include sources + "citations": citations # Explicitly include citations + } + + async def run(self, research_state: Dict[str, Any]) -> Dict[str, Any]: + """ + Run the finalizer agent. + + Args: + research_state: Current research state + + Returns: + Updated research state with final context + """ + # Call finalize_research to generate the final context + return await self.finalize_research(research_state) \ No newline at end of file diff --git a/multi_agents/deep_research/agents/planner.py b/multi_agents/deep_research/agents/planner.py new file mode 100644 index 000000000..0c945fb53 --- /dev/null +++ b/multi_agents/deep_research/agents/planner.py @@ -0,0 +1,239 @@ +""" +Planner Agent for Deep Research + +This agent is responsible for generating research plans and search queries. +""" + +from typing import Dict, Any, List +import asyncio + +from ...agents.utils.views import print_agent_output +from .base import DeepResearchAgent + +class PlannerAgent(DeepResearchAgent): + """ + Agent responsible for planning the research process, including generating search queries + and creating a research plan. + """ + + def __init__(self, websocket=None, stream_output=None, tone=None, headers=None): + """ + Initialize the agent. + + Args: + websocket: Optional websocket for streaming output + stream_output: Optional stream output function + tone: Optional tone for the research + headers: Optional headers for API requests + """ + super().__init__(websocket, stream_output, tone, headers) + + async def generate_search_queries(self, query: str, num_queries: int = 4) -> List[Dict[str, str]]: + """ + Generate search queries for research. + + Args: + query: The main research query + num_queries: Number of search queries to generate + + Returns: + List of search query objects with title and query + """ + # Log the action + if self.websocket and self.stream_output: + await self.stream_output( + "logs", + "generating_search_queries", + f"Generating {num_queries} search queries for '{query}'...", + self.websocket, + ) + else: + print_agent_output( + f"Generating {num_queries} search queries for '{query}'...", + agent="PLANNER", + ) + + # Use the explorer agent to generate search queries + # This is a simplified version that would be implemented with LLM calls + # For now, we'll use a basic approach to generate queries + + # Get model from config + from gpt_researcher.config.config import Config + cfg = Config() + model = cfg.smart_llm_model + + # Create the prompt for generating search queries + from ...agents.utils.llms import call_model + prompt = [ + { + "role": "system", + "content": f"You are a research planner. Your task is to generate {num_queries} specific search queries to thoroughly research a topic." + }, + { + "role": "user", + "content": f"""I need to research the following topic: + +Topic: {query} + +Please generate {num_queries} specific search queries that would help me thoroughly research this topic. +Each query should focus on a different aspect of the topic to ensure comprehensive coverage. + +Return your response as a JSON array of objects, each with 'title' and 'query' fields. +For example: +[ + {{ + "title": "Brief descriptive title for query 1", + "query": "Specific search query 1" + }}, + {{ + "title": "Brief descriptive title for query 2", + "query": "Specific search query 2" + }} +] + +Make sure each query is specific enough to return relevant results but broad enough to capture important information. +""" + } + ] + + # Call the model to generate search queries + response = await call_model( + prompt, + model, + response_format="json", + ) + + # Handle potential errors + if not response or not isinstance(response, list): + if self.websocket and self.stream_output: + await self.stream_output( + "logs", + "error", + "Error generating search queries. Using default queries.", + self.websocket, + ) + else: + print_agent_output( + "Error generating search queries. Using default queries.", + agent="PLANNER", + ) + # Create default queries + return [{"title": f"Query {i+1}", "query": f"{query} aspect {i+1}"} for i in range(num_queries)] + + return response + + async def generate_research_plan(self, query: str, search_queries: List[Dict[str, str]]) -> str: + """ + Generate a research plan based on search queries. + + Args: + query: The main research query + search_queries: List of search query objects + + Returns: + Research plan as a string + """ + # Log the action + if self.websocket and self.stream_output: + await self.stream_output( + "logs", + "generating_research_plan", + f"Generating research plan for '{query}' with {len(search_queries)} queries...", + self.websocket, + ) + else: + print_agent_output( + f"Generating research plan for '{query}' with {len(search_queries)} queries...", + agent="PLANNER", + ) + + # Extract query titles for planning + query_titles = [q.get("title", f"Query {i+1}") for i, q in enumerate(search_queries)] + + # Get model from config + from gpt_researcher.config.config import Config + cfg = Config() + model = cfg.smart_llm_model + + # Create the prompt for generating the research plan + from ...agents.utils.llms import call_model + prompt = [ + { + "role": "system", + "content": "You are a research planner. Your task is to create a structured research plan based on a set of search queries." + }, + { + "role": "user", + "content": f"""I need to research the following topic: + +Topic: {query} + +I have the following search queries to explore: +{', '.join(query_titles)} + +Please create a structured research plan that outlines how to approach this research topic using these queries. +The plan should include: +1. The overall research goal +2. How each query contributes to understanding the topic +3. What specific information to look for in each query +4. How to synthesize the findings into a comprehensive understanding + +Your plan should be detailed but concise, focusing on the strategy rather than the actual research content. +""" + } + ] + + # Call the model to generate the research plan + plan = await call_model( + prompt, + model, + ) + + # Handle potential errors + if not plan or not isinstance(plan, str): + if self.websocket and self.stream_output: + await self.stream_output( + "logs", + "error", + "Error generating research plan. Using default plan.", + self.websocket, + ) + else: + print_agent_output( + "Error generating research plan. Using default plan.", + agent="PLANNER", + ) + # Create default plan + return f"Research Plan for '{query}':\n\n" + "\n".join([f"- Explore {title}" for title in query_titles]) + + return plan + + async def run(self, research_state: Dict[str, Any]) -> Dict[str, Any]: + """ + Run the planner agent. + + Args: + research_state: Current research state + + Returns: + Updated research state with search queries and research plan + """ + query = research_state.get("query", "") + breadth = research_state.get("breadth", 4) + + # Generate search queries + search_queries = await self.generate_search_queries(query, num_queries=breadth) + + # Generate research plan + research_plan_str = await self.generate_research_plan(query, search_queries) + + # Convert research plan string to list (split by newlines) + research_plan = research_plan_str.split('\n') + + # Return updated research state + return { + **research_state, + "search_queries": search_queries, + "total_breadth": len(search_queries), + "research_plan": research_plan + } \ No newline at end of file diff --git a/multi_agents/deep_research/agents/report_formatter.py b/multi_agents/deep_research/agents/reporter.py similarity index 95% rename from multi_agents/deep_research/agents/report_formatter.py rename to multi_agents/deep_research/agents/reporter.py index 9d566ae59..02341a2c3 100644 --- a/multi_agents/deep_research/agents/report_formatter.py +++ b/multi_agents/deep_research/agents/reporter.py @@ -1,5 +1,5 @@ """ -Report Formatter Agent for Deep Research +Reporter Agent for Deep Research This agent is responsible for formatting the report for the publisher agent. """ @@ -9,7 +9,7 @@ from ...agents.utils.views import print_agent_output from .base import DeepResearchAgent -class ReportFormatterAgent(DeepResearchAgent): +class ReporterAgent(DeepResearchAgent): """ Agent responsible for formatting the report for the publisher agent. """ @@ -68,7 +68,7 @@ async def prepare_publisher_state(self, writer_output: Dict[str, Any], research_ else: print_agent_output( f"Formatting report for publisher...", - agent="REPORT_FORMATTER", + agent="REPORTER", ) # Use sections from research_state if available, otherwise extract from research_data @@ -90,7 +90,7 @@ async def prepare_publisher_state(self, writer_output: Dict[str, Any], research_ else: print_agent_output( error_msg, - agent="REPORT_FORMATTER", + agent="REPORTER", ) raise ValueError(error_msg) @@ -140,7 +140,7 @@ async def prepare_publisher_state(self, writer_output: Dict[str, Any], research_ else: print_agent_output( error_msg, - agent="REPORT_FORMATTER", + agent="REPORTER", ) raise ValueError(error_msg) @@ -160,7 +160,7 @@ async def prepare_publisher_state(self, writer_output: Dict[str, Any], research_ async def run(self, writer_output: Dict[str, Any], research_state: Dict[str, Any]) -> Dict[str, Any]: """ - Run the report formatter agent. + Run the reporter agent. Args: writer_output: Output from the writer agent diff --git a/multi_agents/deep_research/agents/reviewer.py b/multi_agents/deep_research/agents/reviewer.py index cb94b0898..42b852e7c 100644 --- a/multi_agents/deep_research/agents/reviewer.py +++ b/multi_agents/deep_research/agents/reviewer.py @@ -139,4 +139,82 @@ async def review_research(self, query: str, learnings: List[str], citations: Dic review_results['score'] = 5 await self._stream_or_print(f"Review complete. Quality score: {review_results['score']}/10", "REVIEWER") - return review_results \ No newline at end of file + return review_results + + async def review(self, query: str, context_items: List[str], current_depth: int, total_depth: int) -> Dict[str, Any]: + """ + Review research results and identify follow-up questions. + + Args: + query: The original research query + context_items: List of context items from research + current_depth: Current research depth + total_depth: Maximum research depth + + Returns: + Dictionary with review results and follow-up questions + """ + # Log the action + await self._stream_or_print(f"Reviewing research at depth {current_depth}/{total_depth}", "REVIEWER") + + # If no context items, return empty result + if not context_items: + return { + "follow_up_questions": [] + } + + # Combine context items + combined_context = "\n\n".join(context_items) + + # Create prompt for generating follow-up questions + prompt = [ + {"role": "system", "content": "You are an expert research reviewer. Your task is to identify gaps in research and suggest follow-up questions to explore."}, + {"role": "user", + "content": f"""Review the following research on the topic: '{query}' + +Research Content: +{combined_context} + +Current Research Depth: {current_depth} of {total_depth} + +Based on this research, identify 3-5 specific follow-up questions that would help deepen the research. +These questions should: +1. Address gaps or unexplored areas in the current research +2. Be specific and focused enough to yield useful results +3. Help build a more comprehensive understanding of the topic + +Format your response as a JSON array of strings, each containing a follow-up question. +For example: +["What are the environmental impacts of X?", "How does Y compare to Z in terms of efficiency?"] + +ONLY return the JSON array, nothing else."""} + ] + + # Call the model to generate follow-up questions + response = await call_model( + prompt=prompt, + model="gpt-4o", + temperature=0.7, + response_format="json" + ) + + # Handle potential errors + follow_up_questions = [] + if isinstance(response, list): + follow_up_questions = response + + # Limit the number of questions + max_questions = 5 + follow_up_questions = follow_up_questions[:max_questions] + + # Log the follow-up questions + if follow_up_questions: + questions_str = "\n".join([f"- {q}" for q in follow_up_questions]) + await self._stream_or_print(f"Generated follow-up questions:\n{questions_str}", "REVIEWER") + else: + await self._stream_or_print("No follow-up questions generated.", "REVIEWER") + + # Return the review results + return { + "follow_up_questions": follow_up_questions + } \ No newline at end of file diff --git a/multi_agents/deep_research/agents/synthesizer.py b/multi_agents/deep_research/agents/synthesizer.py index 9c609d841..0cd8a515f 100644 --- a/multi_agents/deep_research/agents/synthesizer.py +++ b/multi_agents/deep_research/agents/synthesizer.py @@ -1,6 +1,7 @@ import re from typing import Dict, List, Any from ...agents.utils.llms import call_model +from ...agents.utils.views import print_agent_output from .base import DeepResearchAgent class DeepSynthesizerAgent(DeepResearchAgent): @@ -10,50 +11,264 @@ async def process_research_results(self, query: str, context: str, num_learnings """Process research results to extract learnings and follow-up questions""" await self._stream_or_print(f"Synthesizing research results for: {query}", "SYNTHESIZER") - prompt = [ - {"role": "system", "content": "You are an expert researcher analyzing search results."}, - {"role": "user", - "content": f"Given the following research results for the query '{query}', extract key learnings and suggest follow-up questions. For each learning, include a citation to the source URL if available. Format each learning as 'Learning [source_url]: ' and each question as 'Question: ':\n\n{context}"} - ] + # If context is too long, truncate it + max_context_length = 100000 # Increased from 15000 to 100000 + if len(context) > max_context_length: + print_agent_output( + f"Context too long ({len(context)} chars). Truncating to {max_context_length} chars.", + "SYNTHESIZER" + ) + context = context[:max_context_length] + "..." + + try: + prompt = [ + {"role": "system", "content": "You are an expert researcher analyzing search results."}, + {"role": "user", + "content": f"Given the following research results for the query '{query}', extract key learnings and suggest follow-up questions. For each learning, include a citation to the source URL if available. Format each learning as 'Learning [source_url]: ' and each question as 'Question: ':\n\n{context}"} + ] - response = await call_model( - prompt=prompt, - model="gpt-4o", - temperature=0.4 - ) + response = await call_model( + prompt=prompt, + model="gpt-4o", + temperature=0.4 + ) - lines = response.split('\n') - learnings = [] - questions = [] - citations = {} + lines = response.split('\n') + learnings = [] + questions = [] + citations = {} - for line in lines: - line = line.strip() - if line.startswith('Learning'): - url_match = re.search(r'\[(.*?)\]:', line) - if url_match: - url = url_match.group(1) - learning = line.split(':', 1)[1].strip() - learnings.append(learning) - citations[learning] = url - else: - # Try to find URL in the line itself - url_match = re.search( - r'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\\(\\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+', line) + for line in lines: + line = line.strip() + if line.startswith('Learning'): + url_match = re.search(r'\[(.*?)\]:', line) if url_match: - url = url_match.group(0) - learning = line.replace(url, '').replace('Learning:', '').strip() + url = url_match.group(1) + learning = line.split(':', 1)[1].strip() learnings.append(learning) citations[learning] = url else: - learnings.append(line.replace('Learning:', '').strip()) - elif line.startswith('Question:'): - questions.append(line.replace('Question:', '').strip()) + # Try to find URL in the line itself + url_match = re.search( + r'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\\(\\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+', line) + if url_match: + url = url_match.group(0) + learning = line.replace(url, '').replace('Learning:', '').strip() + learnings.append(learning) + citations[learning] = url + else: + learnings.append(line.replace('Learning:', '').strip()) + elif line.startswith('Question:'): + questions.append(line.replace('Question:', '').strip()) - await self._stream_or_print(f"Extracted {len(learnings)} learnings and {len(questions)} follow-up questions", "SYNTHESIZER") + await self._stream_or_print(f"Extracted {len(learnings)} learnings and {len(questions)} follow-up questions", "SYNTHESIZER") + + # If no learnings were extracted, create some default ones + if not learnings: + print_agent_output("WARNING: No learnings extracted. Creating default learnings.", "SYNTHESIZER") + + # Create default learnings based on the query + learnings = [ + f"Research on '{query}' found limited specific information.", + "The search results may require further analysis or additional sources.", + "Consider refining the search query for more targeted results." + ] + + # Store follow-up questions in the state + follow_up_questions = questions[:num_learnings] + + # Return learnings and citations as a tuple + return learnings[:num_learnings], citations + + except Exception as e: + print_agent_output(f"Error processing research results: {str(e)}. Using default learnings.", "SYNTHESIZER") + + # Create default learnings + default_learnings = [ + f"Research on '{query}' encountered processing issues.", + "The available information could not be fully synthesized.", + "Consider trying a different approach or search query." + ] + + # Return default learnings with empty citations + return default_learnings, {} + + async def synthesize(self, query: str, search_results: List[Dict[str, Any]], task: Dict[str, Any]) -> Dict[str, Any]: + """ + Synthesize search results into a coherent context. + + Args: + query: The search query + search_results: List of search results + task: Task configuration + + Returns: + Dictionary with synthesized context, sources, and citations + """ + # Log the action + await self._stream_or_print(f"Synthesizing search results for: {query}", "SYNTHESIZER") + + # Debug log the search results structure + print_agent_output(f"Received {len(search_results)} search results to synthesize", "SYNTHESIZER") + + # Extract content from search results + content_list = [] + sources = [] - # Store follow-up questions in the state - follow_up_questions = questions[:num_learnings] + # Debug log the structure of the first search result if available + if search_results and len(search_results) > 0: + first_result = search_results[0] + if isinstance(first_result, dict): + print_agent_output(f"First search result keys: {', '.join(first_result.keys())}", "SYNTHESIZER") + else: + print_agent_output(f"First search result is not a dictionary but a {type(first_result)}", "SYNTHESIZER") - # Return learnings and citations as a tuple - return learnings[:num_learnings], citations \ No newline at end of file + for i, result in enumerate(search_results): + if isinstance(result, dict): + title = result.get("title", "Unknown Title") + url = result.get("url", "") + content = result.get("content", "") + + # Debug log each result + print_agent_output(f"Result {i+1}: Title: {title}, URL: {url}, Content length: {len(content)}", "SYNTHESIZER") + + # More robust content extraction - try different keys that might contain content + if not content and "text" in result: + content = result.get("text", "") + print_agent_output(f"Using 'text' field instead of 'content' for result {i+1}", "SYNTHESIZER") + + if not content and "snippet" in result: + content = result.get("snippet", "") + print_agent_output(f"Using 'snippet' field instead of 'content' for result {i+1}", "SYNTHESIZER") + + if not content and "body" in result: + content = result.get("body", "") + print_agent_output(f"Using 'body' field instead of 'content' for result {i+1}", "SYNTHESIZER") + + # If we still don't have content but have other fields, create a summary from available fields + if not content: + summary_parts = [] + for key, value in result.items(): + if key not in ["title", "url"] and isinstance(value, str) and len(value) > 0: + summary_parts.append(f"{key}: {value}") + + if summary_parts: + content = "\n".join(summary_parts) + print_agent_output(f"Created content from other fields for result {i+1}", "SYNTHESIZER") + + if content: + content_list.append(f"Source: {title}\nURL: {url}\nContent: {content}") + sources.append(result) + else: + print_agent_output(f"No content found for result {i+1}", "SYNTHESIZER") + else: + print_agent_output(f"Result {i+1} is not a dictionary but a {type(result)}", "SYNTHESIZER") + + # Combine content + combined_content = "\n\n".join(content_list) + + # Log the combined content length + print_agent_output(f"Combined content length: {len(combined_content)}", "SYNTHESIZER") + + # If no content, create a fallback + if not combined_content: + print_agent_output("WARNING: No content extracted from search results. Creating fallback content.", "SYNTHESIZER") + + # Create a fallback context directly from the search results + fallback_context = f"# Research on: {query}\n\n" + + # Try to extract any useful information from the search results + for i, result in enumerate(search_results): + if isinstance(result, dict): + title = result.get("title", "Unknown Title") + url = result.get("url", "") + + # Try to extract any text content from any field + content_fields = [] + for key, value in result.items(): + if isinstance(value, str) and len(value) > 10 and key not in ["title", "url"]: + content_fields.append(f"{key}: {value}") + + snippet = "\n".join(content_fields) if content_fields else "" + + if not snippet: + snippet = "No detailed content available for this source." + + if title or url or snippet: + fallback_context += f"## Source {i+1}: {title}\n" + if url: + fallback_context += f"URL: {url}\n" + fallback_context += f"Summary: {snippet}\n\n" + + # If we still don't have any content, create a generic message + if fallback_context == f"# Research on: {query}\n\n": + fallback_context += "No specific content could be extracted from the search results. This could be due to:\n\n" + fallback_context += "1. API limitations or rate limiting\n" + fallback_context += "2. Search results not containing detailed content\n" + fallback_context += "3. Technical issues with content extraction\n\n" + fallback_context += "Consider refining your search query or trying again later." + + # Use the fallback context + return { + "context": fallback_context, + "sources": search_results, # Use the original search results as sources + "citations": {} + } + + try: + # Process research results + learnings, citations = await self.process_research_results(query, combined_content) + + # Format learnings into a coherent context + context_parts = [] + + # Add query as header + context_parts.append(f"# Research on: {query}") + context_parts.append("") # Empty line + + # Add learnings + for learning in learnings: + citation = citations.get(learning, "") + if citation: + context_parts.append(f"- {learning} [Source: {citation}]") + else: + context_parts.append(f"- {learning}") + + # Combine into final context + context = "\n".join(context_parts) + + # Return synthesized result + return { + "context": context, + "sources": sources, + "citations": citations + } + except Exception as e: + # Log the error + print_agent_output(f"Error processing research results: {str(e)}. Using fallback approach.", "SYNTHESIZER") + + # Create a simple context from the combined content + simple_context = f"# Research on: {query}\n\n" + simple_context += "## Key Information\n\n" + + # Add a summary of each source + for i, source in enumerate(sources): + title = source.get("title", "Unknown Title") + url = source.get("url", "") + content = source.get("content", "") + + simple_context += f"### Source {i+1}: {title}\n" + if url: + simple_context += f"URL: {url}\n" + + # Add a snippet of the content + if content: + snippet = content[:300] + "..." if len(content) > 300 else content + simple_context += f"Summary: {snippet}\n\n" + + # Return the simple context + return { + "context": simple_context, + "sources": sources, + "citations": {} + } \ No newline at end of file diff --git a/multi_agents/deep_research/agents/section_writer.py b/multi_agents/deep_research/agents/writer.py similarity index 67% rename from multi_agents/deep_research/agents/section_writer.py rename to multi_agents/deep_research/agents/writer.py index 539c0d38c..a2b540fc6 100644 --- a/multi_agents/deep_research/agents/section_writer.py +++ b/multi_agents/deep_research/agents/writer.py @@ -1,7 +1,7 @@ """ -Section Writer Agent for Deep Research +Writer Agent for Deep Research -This agent is responsible for generating sections from deep research data. +This agent is responsible for generating sections and title from deep research data. """ import asyncio @@ -12,9 +12,9 @@ from ...agents.utils.views import print_agent_output from .base import DeepResearchAgent -class SectionWriterAgent(DeepResearchAgent): +class WriterAgent(DeepResearchAgent): """ - Agent responsible for generating sections from deep research data. + Agent responsible for generating sections and title from deep research data. """ def __init__(self, websocket=None, stream_output=None, headers=None): @@ -27,6 +27,95 @@ def __init__(self, websocket=None, stream_output=None, headers=None): headers: Optional headers for API requests """ super().__init__(websocket, stream_output, headers) + + async def generate_title(self, research_state: Dict[str, Any]) -> str: + """ + Generate an engaging title for the research report. + + Args: + research_state: Research state containing deep research data + + Returns: + A generated title for the research report + """ + query = research_state.get("query", "") + context = research_state.get("context", "") + task = research_state.get("task", {}) + + # Get model from task or use a default model if None + model = task.get("model") + if model is None: + from gpt_researcher.config.config import Config + cfg = Config() + model = cfg.smart_llm_model # Use the default smart model from config + + # Log the action + if self.websocket and self.stream_output: + await self.stream_output( + "logs", + "generating_title", + "Generating an engaging title for the research report...", + self.websocket, + ) + else: + print_agent_output( + "Generating an engaging title for the research report...", + agent="WRITER", + ) + + # Create the prompt for generating title + prompt = [ + { + "role": "system", + "content": "You are a research writer. Your task is to create an engaging, professional title for a research report." + }, + { + "role": "user", + "content": f"""Today's date is {datetime.now().strftime('%d/%m/%Y')}. +Query or Topic: {query} +Research context summary: {context[:1000]}... + +Create an engaging, professional title for this research report. The title should be: +1. Clear and descriptive of the content +2. Engaging but not clickbait +3. Professional in tone +4. Between 5-12 words in length + +Return only the title text with no additional formatting or explanation. +""" + } + ] + + # Call the model to generate title + title = await call_model( + prompt, + model, + ) + + # Handle potential errors + if not title or not isinstance(title, str): + if self.websocket and self.stream_output: + await self.stream_output( + "logs", + "error", + "Error generating title. Using default title.", + self.websocket, + ) + else: + print_agent_output( + "Error generating title. Using default title.", + agent="WRITER", + ) + return f"Deep Research: {query}" + + # Clean up the title - remove quotes and extra whitespace + title = title.strip() + if title.startswith('"') and title.endswith('"'): + title = title[1:-1].strip() + elif title.startswith("'") and title.endswith("'"): + title = title[1:-1].strip() + + return title async def generate_sections(self, research_state: Dict[str, Any]) -> List[Dict[str, Any]]: """ @@ -58,7 +147,7 @@ async def generate_sections(self, research_state: Dict[str, Any]) -> List[Dict[s else: print_agent_output( "No research context found. Cannot generate sections without research data.", - agent="SECTION_WRITER", + agent="WRITER", ) raise ValueError("No research context found. Cannot generate sections without research data.") @@ -80,7 +169,7 @@ async def generate_sections(self, research_state: Dict[str, Any]) -> List[Dict[s else: print_agent_output( f"Generating sections from deep research data with {len(sources)} sources...", - agent="SECTION_WRITER", + agent="WRITER", ) # Format sources for better context @@ -155,7 +244,7 @@ async def generate_sections(self, research_state: Dict[str, Any]) -> List[Dict[s else: print_agent_output( "Error generating sections. Using default empty structure.", - agent="SECTION_WRITER", + agent="WRITER", ) return [] @@ -183,14 +272,17 @@ async def transform_to_research_data(self, sections: List[Dict[str, Any]]) -> Li async def run(self, research_state: Dict[str, Any]) -> Dict[str, Any]: """ - Run the section writer agent. + Run the writer agent. Args: research_state: Research state containing deep research data Returns: - Transformed research state with sections as research_data + Transformed research state with sections as research_data and AI-generated title """ + # Generate an engaging title + title = await self.generate_title(research_state) + # Generate sections from deep research data sections = await self.generate_sections(research_state) @@ -207,6 +299,7 @@ async def run(self, research_state: Dict[str, Any]) -> Dict[str, Any]: # Return the transformed research state return { **research_state, + "title": title, # Use the AI-generated title "research_data": research_data, "sections": sections, # Store the original sections for later use "formatted_sources": formatted_sources # Preserve formatted sources diff --git a/multi_agents/deep_research/main.py b/multi_agents/deep_research/main.py index 8f6f61ce5..9d64df31a 100644 --- a/multi_agents/deep_research/main.py +++ b/multi_agents/deep_research/main.py @@ -7,9 +7,9 @@ from gpt_researcher.utils.enum import Tone from .orchestrator import DeepResearchOrchestrator -from ..agents.writer import WriterAgent +from ..agents.writer import WriterAgent as MainWriterAgent from ..agents.publisher import PublisherAgent -from .agents import SectionWriterAgent, ReportFormatterAgent +from .agents import WriterAgent, ReporterAgent async def run_deep_research( query: str, @@ -67,7 +67,7 @@ async def run_deep_research( research_results = await orchestrator.run() # Create the section writer agent - section_writer = SectionWriterAgent(websocket, stream_output, headers) + writer = WriterAgent(websocket, stream_output, headers) # Get current date current_date = datetime.now().strftime("%d/%m/%Y") @@ -101,7 +101,19 @@ async def run_deep_research( ) else: print(f"ERROR: {error_msg}") - raise ValueError(error_msg) + + # Create a fallback context + context = f"Research on: {query}\n\nNo specific research data was collected. This could be due to API limitations, network issues, or lack of relevant information." + print(f"Created fallback context: {len(context)} chars") + + # If we have sources but no formatted sources, create them + if sources and not formatted_sources: + print("Creating formatted sources from sources") + for i, source in enumerate(sources): + if isinstance(source, dict): + title = source.get("title", f"Source {i+1}") + url = source.get("url", "") + formatted_sources.append(f"- {title} [{url}]({url})") # Prepare research state for writer research_state = { @@ -116,18 +128,36 @@ async def run_deep_research( "citations": research_results.get("citations", {}) } + # If context is empty but we have sources, create context from sources + if not context and sources: + print("WARNING: Context is empty but sources exist. Creating context from sources.") + context_parts = [] + for source in sources: + if isinstance(source, dict) and "content" in source: + title = source.get("title", "Unknown Title") + url = source.get("url", "") + content = source.get("content", "") + if content: + context_parts.append(f"From {title} [{url}]:\n{content}") + + if context_parts: + context = "\n\n".join(context_parts) + research_state["context"] = context + research_state["research_data"] = [{"topic": query, "content": context}] + print(f"Created context from sources: {len(context)} chars") + # Generate sections and transform research data - transformed_research_state = await section_writer.run(research_state) + transformed_research_state = await writer.run(research_state) # Generate report using the Writer agent - writer = WriterAgent(websocket, stream_output, headers) - report_state = await writer.run(transformed_research_state) + main_writer = MainWriterAgent(websocket, stream_output, headers) + report_state = await main_writer.run(transformed_research_state) # Create the report formatter agent - report_formatter = ReportFormatterAgent(websocket, stream_output, headers) + reporter = ReporterAgent(websocket, stream_output, headers) # Format the report for the publisher - publisher_state = await report_formatter.run(report_state, transformed_research_state) + publisher_state = await reporter.run(report_state, transformed_research_state) # Publish the report if formats are specified if publish_formats: diff --git a/multi_agents/deep_research/memory.py b/multi_agents/deep_research/memory.py index f7d9b1ebf..f007d1f6b 100644 --- a/multi_agents/deep_research/memory.py +++ b/multi_agents/deep_research/memory.py @@ -2,7 +2,13 @@ from pydantic import BaseModel, Field class DeepResearchState(BaseModel): - """State for deep research process""" + """ + State for deep research process. + + Key context-related attributes: + - context_items: List of individual research findings/context pieces collected during research + - final_context: The final processed context string (only set at the end by the finalizer) + """ # Task configuration task: Dict[str, Any] = Field(default_factory=dict) @@ -21,7 +27,14 @@ class DeepResearchState(BaseModel): learnings: List[str] = Field(default_factory=list) citations: Dict[str, str] = Field(default_factory=dict) visited_urls: Set[str] = Field(default_factory=set) + + # Primary context storage - individual pieces of research + context_items: List[str] = Field(default_factory=list) + + # Legacy context field - kept for backward compatibility context: List[str] = Field(default_factory=list) + + # Sources from research sources: List[Dict[str, Any]] = Field(default_factory=list) # Intermediate results @@ -32,7 +45,7 @@ class DeepResearchState(BaseModel): # Review results review: Optional[Dict[str, Any]] = None - # Final output + # Final output - set by the finalizer final_context: str = "" def update_progress(self, depth: int, breadth: int): @@ -48,7 +61,7 @@ def add_learning(self, learning: str, citation: Optional[str] = None): self.citations[learning] = citation def add_context(self, context: str): - """Add context to the research state""" + """Add context to the research state (legacy method)""" # Skip empty context if not context: return @@ -57,6 +70,16 @@ def add_context(self, context: str): if context not in self.context: self.context.append(context) + def add_context_item(self, context_item: str): + """Add a context item to the research state""" + # Skip empty context items + if not context_item: + return + + # Add context item if it's not already in the list + if context_item not in self.context_items: + self.context_items.append(context_item) + def add_visited_urls(self, urls: List[str]): """Add visited URLs to the research state""" for url in urls: @@ -86,35 +109,28 @@ def set_review(self, review: Dict[str, Any]): self.review = review def finalize_context(self): - """Finalize context for report generation""" - from .agents.base import trim_context_to_word_limit - - # Prepare context with citations - context_with_citations = [] + """ + Finalize context for report generation. - # Add learnings with citations - for learning in self.learnings: - citation = self.citations.get(learning, '') - if citation: - context_with_citations.append(f"{learning} [Source: {citation}]") - else: - context_with_citations.append(learning) + This method combines all context_items into a single string, + trims it to a reasonable size, and returns it. + """ + from .agents.base import trim_context_to_word_limit - # Add all research context - if self.context: - context_with_citations.extend(self.context) + # Combine all context items + combined_context = "\n\n".join(self.context_items) - # Add source content with proper attribution - for source in self.sources: - if isinstance(source, dict): - title = source.get("title", "Unknown Title") - url = source.get("url", "") - content = source.get("content", "") - if content: - context_with_citations.append(f"From {title} [{url}]: {content}") + # If no context items, try using legacy context + if not combined_context and self.context: + combined_context = "\n\n".join(self.context) + + # If still no context, create a default message + if not combined_context: + combined_context = f"Research on: {self.query}\n\nNo specific research data was collected." - # Trim final context to word limit - final_context_list = trim_context_to_word_limit(context_with_citations) - self.final_context = "\n".join(final_context_list) + # Trim the combined context to a reasonable size + trimmed_context = trim_context_to_word_limit(combined_context) + # Set and return the final context + self.final_context = trimmed_context return self.final_context \ No newline at end of file diff --git a/multi_agents/deep_research/orchestrator.py b/multi_agents/deep_research/orchestrator.py index 6a3883eba..8b4d525b8 100644 --- a/multi_agents/deep_research/orchestrator.py +++ b/multi_agents/deep_research/orchestrator.py @@ -14,7 +14,9 @@ DeepResearchAgent, DeepExplorerAgent, DeepSynthesizerAgent, - DeepReviewerAgent + DeepReviewerAgent, + PlannerAgent, + FinalizerAgent ) logger = logging.getLogger(__name__) @@ -56,7 +58,9 @@ def _initialize_agents(self): "explorer": DeepExplorerAgent(self.websocket, self.stream_output, self.tone, self.headers), "researcher": DeepResearchAgent(self.websocket, self.stream_output, self.tone, self.headers), "synthesizer": DeepSynthesizerAgent(self.websocket, self.stream_output, self.tone, self.headers), - "reviewer": DeepReviewerAgent(self.websocket, self.stream_output, self.tone, self.headers) + "reviewer": DeepReviewerAgent(self.websocket, self.stream_output, self.tone, self.headers), + "planner": PlannerAgent(self.websocket, self.stream_output, self.tone, self.headers), + "finalizer": FinalizerAgent(self.websocket, self.stream_output, self.tone, self.headers) } async def _log_research_start(self): @@ -83,326 +87,421 @@ async def _on_progress(self, state: DeepResearchState, current_depth=None, curre print_agent_output(message, "MASTER") async def generate_search_queries(self, state: DeepResearchState) -> dict: - """Generate search queries for research""" - explorer = self.agents["explorer"] - queries = await explorer.generate_search_queries(state.query, num_queries=state.breadth) + """Generate search queries for research using the planner agent""" + planner = self.agents["planner"] - # Return a dictionary with updated search queries - return { - "search_queries": queries, - "total_breadth": len(queries) + # Create a temporary state for the planner + planner_state = { + "query": state.query, + "breadth": state.breadth } - async def generate_research_plan(self, state: DeepResearchState) -> dict: - """Generate a research plan based on search queries""" - explorer = self.agents["explorer"] + # Run the planner to generate search queries + planner_result = await planner.run(planner_state) - # Extract query titles for planning - query_titles = [q.get("title", q.get("query", "")) for q in state.search_queries] + # Extract search queries and total breadth + queries = planner_result.get("search_queries", []) + total_breadth = planner_result.get("total_breadth", len(queries)) - # Generate research plan - plan = await explorer.generate_research_plan(state.query, query_titles) - - # Return a dictionary with updated research plan + # Return a dictionary with updated search queries return { - "research_plan": plan + "search_queries": queries, + "total_breadth": total_breadth, + "research_plan": planner_result.get("research_plan", "") } async def process_query(self, state: DeepResearchState, query_index: int) -> dict: """Process a single search query""" + # Get the query if query_index >= len(state.search_queries): - return {} + return {"error": f"Query index {query_index} out of range"} - # Get the query to process - query_data = state.search_queries[query_index] - query_text = query_data.get("query", "") - query_title = query_data.get("title", query_text) + query_obj = state.search_queries[query_index] + query = query_obj.get("query", "") + title = query_obj.get("title", f"Query {query_index + 1}") - # Calculate current progress - current_breadth = query_index + 1 - total_breadth = len(state.search_queries) + # Log progress + await self._on_progress(state, current_breadth=query_index+1) # Log the action - message = f"Processing query {current_breadth}/{total_breadth}: {query_title}" - await self._on_progress(state, current_breadth=current_breadth, total_breadth=total_breadth) - + message = f"Processing query {query_index + 1}/{state.total_breadth}: '{title}'" if self.websocket and self.stream_output: - await self.stream_output( - "logs", - "processing_query", - message, - self.websocket, - ) + await self.stream_output("logs", "processing_query", message, self.websocket) else: - print_agent_output( - message, - agent="RESEARCHER", - ) + print_agent_output(message, "MASTER") - # Conduct research + # Get the researcher agent researcher = self.agents["researcher"] - context, visited_urls, sources = await researcher.basic_research( - query=query_text, - source=self.task.get("source", "web") - ) - # Process research results - synthesizer = self.agents["synthesizer"] - learnings, citations = await synthesizer.process_research_results( - query=query_text, - context=context - ) + # Run the researcher agent to get search results + print_agent_output(f"Searching for: '{query}'", "MASTER") - # Generate follow-up questions if this is the first query - follow_up_questions = [] - if query_index == 0 and not state.follow_up_questions: - explorer = self.agents["explorer"] - follow_up_questions = await explorer.generate_follow_up_questions( - query=state.query, - context=context, - num_questions=3 - ) + try: + search_results = await researcher.search(query, state.task) - # Log the follow-up questions - if follow_up_questions: - questions_str = "\n".join([f"- {q}" for q in follow_up_questions]) - log_message = f"Generated follow-up questions:\n{questions_str}" - + # Check if we have search results + if not search_results: + message = f"No search results found for query: '{title}'" if self.websocket and self.stream_output: - await self.stream_output( - "logs", - "follow_up_questions", - log_message, - self.websocket, - ) + await self.stream_output("logs", "no_search_results", message, self.websocket) else: - print_agent_output( - log_message, - agent="EXPLORER", - ) - - # Update state with new information - new_learnings = [] - new_citations = {} - - for learning in learnings: - if learning not in state.learnings: - new_learnings.append(learning) - if learning in citations: - new_citations[learning] = citations[learning] + print_agent_output(message, "MASTER") + return {"context_item": "", "sources": [], "citations": {}} - # Return a dictionary with updated values - result = { - "current_breadth": query_index + 1, - "learnings": state.learnings + new_learnings, - "citations": {**state.citations, **new_citations}, - "visited_urls": state.visited_urls.union(visited_urls), - "context": state.context + [context] if context else state.context, - "sources": state.sources + sources if sources else state.sources - } - - # Add follow-up questions if generated - if follow_up_questions: - result["follow_up_questions"] = follow_up_questions + # Log search results + print_agent_output(f"Found {len(search_results)} search results for query: '{title}'", "MASTER") + + # Get the synthesizer agent + synthesizer = self.agents["synthesizer"] + + # Run the synthesizer agent to synthesize the search results + print_agent_output(f"Synthesizing search results for query: '{title}'", "MASTER") + + synthesis_result = await synthesizer.synthesize( + query=query, + search_results=search_results, + task=state.task + ) + + # Extract the context, sources, and citations + context_item = synthesis_result.get("context", "") + sources = synthesis_result.get("sources", []) + citations = synthesis_result.get("citations", {}) + + # Log synthesis results + print_agent_output( + f"Synthesis complete: {len(context_item)} chars context, {len(sources)} sources, {len(citations)} citations", + "MASTER" + ) + + # If synthesis didn't return any context but we have search results, create a fallback + if not context_item and search_results: + print_agent_output( + "WARNING: Synthesis returned empty context despite having search results. Creating fallback context.", + "MASTER" + ) + + # Create a fallback context directly from the search results + fallback_context = f"# Research on: {query}\n\n" + + # Add content from each search result + for i, result in enumerate(search_results): + if isinstance(result, dict): + result_title = result.get("title", "Unknown Title") + url = result.get("url", "") + content = result.get("content", "") + + if result_title or url or content: + fallback_context += f"## Source {i+1}: {result_title}\n" + if url: + fallback_context += f"URL: {url}\n\n" + if content: + # Add a snippet of the content + snippet = content[:500] + "..." if len(content) > 500 else content + fallback_context += f"{snippet}\n\n" + + # Use the fallback context + context_item = fallback_context + sources = search_results + + print_agent_output( + f"Created fallback context: {len(context_item)} chars", + "MASTER" + ) - return result + # Return the results + return { + "context_item": context_item, + "sources": sources, + "citations": citations + } + + except Exception as e: + print_agent_output(f"Error processing query '{title}': {str(e)}", "MASTER") + return {"error": f"Exception: {str(e)}"} async def process_all_queries(self, state: DeepResearchState) -> dict: - """Process all search queries with concurrency control""" + """Process all search queries""" # Create a semaphore to limit concurrency semaphore = asyncio.Semaphore(self.concurrency_limit) - # Define a function to process a query with the semaphore + # Debug log the initial state + print_agent_output( + f"Starting process_all_queries with {len(state.search_queries)} queries, {len(state.context_items)} context items, and {len(state.sources)} sources.", + "MASTER" + ) + + # Check if we have any search queries + if not state.search_queries: + print_agent_output( + "WARNING: No search queries found. Cannot process queries without search queries.", + "MASTER" + ) + return { + "context_items": state.context_items, + "sources": state.sources, + "citations": state.citations, + "current_depth": state.current_depth + } + async def process_with_semaphore(index): + """Process a query with semaphore for concurrency control""" async with semaphore: - return await self.process_query(state, index) - - # Process queries concurrently with semaphore control + try: + result = await self.process_query(state, index) + # Debug log each query result + if "error" in result: + print_agent_output(f"Query {index} error: {result['error']}", "MASTER") + else: + context_item = result.get("context_item", "") + sources = result.get("sources", []) + print_agent_output( + f"Query {index} result: {len(context_item)} chars context, {len(sources)} sources", + "MASTER" + ) + return result + except Exception as e: + print_agent_output(f"Error processing query {index}: {str(e)}", "MASTER") + return {"error": f"Exception: {str(e)}"} + + # Create tasks for all queries tasks = [process_with_semaphore(i) for i in range(len(state.search_queries))] + + # Wait for all tasks to complete results = await asyncio.gather(*tasks) - # Combine all results - combined_learnings = state.learnings.copy() - combined_citations = state.citations.copy() - combined_visited_urls = state.visited_urls.copy() - combined_context = state.context.copy() - combined_sources = state.sources.copy() - combined_follow_up_questions = state.follow_up_questions.copy() + # Combine results + combined_context_items = list(state.context_items) # Create a copy to avoid modifying the original + combined_sources = list(state.sources) # Create a copy + combined_citations = dict(state.citations) # Create a copy - for result in results: - if result: # Skip empty results - # Add learnings and citations - new_learnings = result.get("learnings", []) - for i, learning in enumerate(new_learnings): - if learning not in combined_learnings: - combined_learnings.append(learning) - - # Update citations - combined_citations.update(result.get("citations", {})) - - # Update visited URLs - combined_visited_urls = combined_visited_urls.union(result.get("visited_urls", set())) + # Debug log the results before combining + print_agent_output( + f"Processing {len(results)} query results to combine", + "MASTER" + ) + + # Track how many results actually contributed data + results_with_context = 0 + results_with_sources = 0 + + for i, result in enumerate(results): + if "error" in result: + # Skip errors + print_agent_output(f"Skipping result {i} due to error", "MASTER") + continue - # Update context - new_context = result.get("context", []) - for ctx in new_context: - if ctx and ctx not in combined_context: - combined_context.append(ctx) + # Add context item + context_item = result.get("context_item", "") + if context_item: + combined_context_items.append(context_item) + results_with_context += 1 + print_agent_output(f"Added context item from result {i}: {len(context_item)} chars", "MASTER") - # Update sources - new_sources = result.get("sources", []) - for source in new_sources: - if source and source not in combined_sources: - combined_sources.append(source) + # Add sources + sources = result.get("sources", []) + if sources: + combined_sources.extend(sources) + results_with_sources += 1 + print_agent_output(f"Added {len(sources)} sources from result {i}", "MASTER") - # Update follow-up questions - new_follow_up_questions = result.get("follow_up_questions", []) - for question in new_follow_up_questions: - if question and question not in combined_follow_up_questions: - combined_follow_up_questions.append(question) + # Add citations + citations = result.get("citations", {}) + if citations: + combined_citations.update(citations) + print_agent_output(f"Added {len(citations)} citations from result {i}", "MASTER") + + # Log the results + message = f"Collected {len(combined_context_items)} context items and {len(combined_sources)} sources." + if self.websocket and self.stream_output: + await self.stream_output("logs", "collected_results", message, self.websocket) + else: + print_agent_output(message, "MASTER") + + # Debug log the final state + print_agent_output( + f"Ending process_all_queries with {len(combined_context_items)} context items and {len(combined_sources)} sources.", + "MASTER" + ) + + # Debug log how many results contributed data + print_agent_output( + f"{results_with_context}/{len(results)} results contributed context items, {results_with_sources}/{len(results)} contributed sources", + "MASTER" + ) - # Log completion of all queries - total_breadth = len(state.search_queries) - await self._on_progress(state, current_breadth=total_breadth, total_breadth=total_breadth) + # If we didn't collect any context items or sources, create a fallback + if not combined_context_items and not combined_sources: + print_agent_output( + "WARNING: No context items or sources collected. Creating fallback context.", + "MASTER" + ) + + # Create a fallback context + fallback_context = f"Research on: {state.query}\n\n" + fallback_context += "No specific research data was collected from the search queries. " + fallback_context += "This could be due to API limitations, network issues, or lack of relevant information. " + fallback_context += "Consider refining your search query or trying again later." + + # Add the fallback context + combined_context_items.append(fallback_context) - # Return a dictionary with all updated values + # Return combined results following LangGraph pattern return { - "current_breadth": total_breadth, - "learnings": combined_learnings, - "citations": combined_citations, - "visited_urls": combined_visited_urls, - "context": combined_context, + "context_items": combined_context_items, "sources": combined_sources, - "follow_up_questions": combined_follow_up_questions + "citations": combined_citations, + "current_depth": state.current_depth # Explicitly include current_depth } async def review_research(self, state: DeepResearchState) -> dict: - """Review research results""" + """Review the research results and identify follow-up questions""" + # Get the reviewer agent reviewer = self.agents["reviewer"] - review = await reviewer.review_research(state.query, state.learnings, state.citations) - # Return a dictionary with the review + # Log the action + message = f"Reviewing research results and identifying follow-up questions..." + if self.websocket and self.stream_output: + await self.stream_output("logs", "reviewing_research", message, self.websocket) + else: + print_agent_output(message, "MASTER") + + # Run the reviewer agent + review_result = await reviewer.review( + query=state.query, + context_items=state.context_items, + current_depth=state.current_depth, + total_depth=state.total_depth + ) + + # Extract follow-up questions + follow_up_questions = review_result.get("follow_up_questions", []) + + # Return the results, including the current depth to ensure it's preserved return { - "review": review + "follow_up_questions": follow_up_questions, + "current_depth": state.current_depth # Explicitly include current_depth } async def recursive_research(self, state: DeepResearchState) -> dict: - """Recursively conduct deeper research based on follow-up questions""" - # Base case: if we've reached the maximum depth, return the current state - if state.current_depth >= state.total_depth: - return {} + """Perform recursive research on follow-up questions""" + # Log the action + message = f"Starting recursive research at depth {state.current_depth + 1}..." + if self.websocket and self.stream_output: + await self.stream_output("logs", "recursive_research", message, self.websocket) + else: + print_agent_output(message, "MASTER") - # Increment depth - current_depth = state.current_depth + 1 - await self._on_progress(state, current_depth=current_depth) + # Get the follow-up questions + follow_up_questions = state.follow_up_questions - # Create a new query from follow-up questions - if not state.follow_up_questions: - return {} - - new_query = f""" - Original query: {state.query} - Follow-up questions: {' '.join(state.follow_up_questions)} - """ - - # Create a temporary state for the next level - temp_state = DeepResearchState( - task=state.task, - query=new_query, - breadth=max(2, state.breadth // 2), # Reduce breadth for deeper levels - depth=state.depth, - current_depth=current_depth, - total_depth=state.total_depth, - learnings=state.learnings.copy(), - citations=state.citations.copy(), - visited_urls=state.visited_urls.copy(), - context=state.context.copy(), - sources=state.sources.copy() - ) + # Create a semaphore to limit concurrency + semaphore = asyncio.Semaphore(self.concurrency_limit) + + async def process_follow_up(question): + """Process a follow-up question""" + async with semaphore: + # Log the action + q_message = f"Researching follow-up question: '{question}'" + if self.websocket and self.stream_output: + await self.stream_output("logs", "follow_up_question", q_message, self.websocket) + else: + print_agent_output(q_message, "MASTER") + + # Get the researcher agent + researcher = self.agents["researcher"] + + # Run the researcher agent to get search results + search_results = await researcher.search(question, state.task) + + # Check if we have search results + if not search_results: + q_message = f"No search results found for follow-up question: '{question}'" + if self.websocket and self.stream_output: + await self.stream_output("logs", "no_search_results", q_message, self.websocket) + else: + print_agent_output(q_message, "MASTER") + return {"context_item": "", "sources": [], "citations": {}} + + # Get the synthesizer agent + synthesizer = self.agents["synthesizer"] + + # Run the synthesizer agent to synthesize the search results + synthesis_result = await synthesizer.synthesize( + query=question, + search_results=search_results, + task=state.task + ) + + # Extract the context, sources, and citations + context_item = synthesis_result.get("context", "") + sources = synthesis_result.get("sources", []) + citations = synthesis_result.get("citations", {}) + + # Return the results + return { + "context_item": context_item, + "sources": sources, + "citations": citations + } + + # Create tasks for all follow-up questions + tasks = [process_follow_up(q) for q in follow_up_questions] - # Generate new search queries - queries_result = await self.generate_search_queries(temp_state) - temp_state.search_queries = queries_result.get("search_queries", []) - temp_state.total_breadth = queries_result.get("total_breadth", 0) - - # Process all queries at this level - process_result = await self.process_all_queries(temp_state) - - # Merge results - new_learnings = [] - new_citations = {} - new_context = [] - new_sources = [] - - # Get new learnings and citations - for learning in process_result.get("learnings", []): - if learning not in state.learnings: - new_learnings.append(learning) - citation = process_result.get("citations", {}).get(learning) - if citation: - new_citations[learning] = citation - - # Get new context - for ctx in process_result.get("context", []): - if ctx not in state.context: - new_context.append(ctx) + # Wait for all tasks to complete + results = await asyncio.gather(*tasks) + + # Combine results + combined_context_items = list(state.context_items) # Create a copy to avoid modifying the original + combined_sources = list(state.sources) # Create a copy + combined_citations = dict(state.citations) # Create a copy + + for result in results: + # Add context item + context_item = result.get("context_item", "") + if context_item: + combined_context_items.append(context_item) - # Get new sources - for source in process_result.get("sources", []): - if source not in state.sources: - new_sources.append(source) + # Add sources + sources = result.get("sources", []) + if sources: + combined_sources.extend(sources) - # Return a dictionary with all updated values + # Add citations + citations = result.get("citations", {}) + if citations: + combined_citations.update(citations) + + # Return combined results with incremented depth following LangGraph pattern return { - "current_depth": current_depth, - "learnings": state.learnings + new_learnings, - "citations": {**state.citations, **new_citations}, - "visited_urls": state.visited_urls.union(process_result.get("visited_urls", set())), - "context": state.context + new_context, - "sources": state.sources + new_sources + "context_items": combined_context_items, + "sources": combined_sources, + "citations": combined_citations, + "current_depth": state.current_depth + 1 } async def finalize_research(self, state: DeepResearchState) -> dict: - """Finalize research and prepare for report generation""" - # Log the action - if self.websocket and self.stream_output: - await self.stream_output( - "logs", - "finalizing_research", - f"Finalizing research...", - self.websocket, - ) - else: - print_agent_output( - f"Finalizing research...", - agent="ORCHESTRATOR", - ) - - # The context already contains the research results with sources - # We don't need to check for empty sources as the context itself is the source - # Just ensure the context is not empty - if not state.context: - error_msg = "No research context collected. Cannot generate report without research data." - if self.websocket and self.stream_output: - await self.stream_output( - "logs", - "error", - error_msg, - self.websocket, - ) - else: - print_agent_output( - error_msg, - agent="ORCHESTRATOR", - ) - raise ValueError(error_msg) + """Finalize the research process using the finalizer agent""" + finalizer = self.agents["finalizer"] + + # Create a temporary state for the finalizer + finalizer_state = { + "query": state.query, + "context_items": state.context_items, + "sources": state.sources, + "citations": state.citations + } - # Finalize context by joining all context items - final_context = state.finalize_context() + # Run the finalizer to generate the final context + finalizer_result = await finalizer.run(finalizer_state) - # Return a dictionary with the final context + # Return the finalized research state return { - "final_context": final_context + "context": finalizer_result.get("context", ""), + "summary": finalizer_result.get("summary", ""), + "sources": finalizer_result.get("sources", []), + "citations": finalizer_result.get("citations", {}), + "final_context": finalizer_result.get("final_context", "") } def should_continue_recursion(self, state: DeepResearchState) -> str: @@ -418,15 +517,13 @@ def create_workflow(self) -> StateGraph: # Add nodes workflow.add_node("generate_queries", self.generate_search_queries) - workflow.add_node("generate_plan", self.generate_research_plan) workflow.add_node("process_queries", self.process_all_queries) workflow.add_node("review_research", self.review_research) workflow.add_node("recursive_research", self.recursive_research) workflow.add_node("finalize_research", self.finalize_research) # Add edges - workflow.add_edge("generate_queries", "generate_plan") - workflow.add_edge("generate_plan", "process_queries") + workflow.add_edge("generate_queries", "process_queries") workflow.add_edge("process_queries", "review_research") # Add conditional edge for recursion @@ -449,25 +546,31 @@ def create_workflow(self) -> StateGraph: async def run(self) -> Dict[str, Any]: """Run the deep research process""" - start_time = time.time() - - # Log start of research + # Log the start of the research process await self._log_research_start() - # Create workflow + # Create the workflow workflow = self.create_workflow() + + # Compile the workflow to get an executable chain chain = workflow.compile() - # Initialize state + # Create the initial state initial_state = DeepResearchState( + query=self.task.get("query", ""), task=self.task, - query=self.task.get('query'), breadth=self.breadth, - depth=self.depth, - total_depth=self.depth + total_depth=self.depth, + current_depth=1, # Start at depth 1 to match DeepResearchState default + total_breadth=0, + search_queries=[], + context_items=[], + sources=[], + citations={}, + follow_up_questions=[] ) - # Run the workflow + # Run the workflow with async invoke config = { "configurable": { "thread_id": self.task_id, @@ -475,50 +578,108 @@ async def run(self) -> Dict[str, Any]: } } - final_state = await chain.ainvoke(initial_state, config=config) - - # Log completion - end_time = time.time() - execution_time = timedelta(seconds=end_time - start_time) - logger.info(f"Total deep research execution time: {execution_time}") - - # Extract values from the final state - # The final state might be an AddableValuesDict or a DeepResearchState - if hasattr(final_state, 'get'): - # It's a dictionary-like object - context = final_state.get('final_context', '') - if not context and 'context' in final_state: - # Try to join context if it's a list - context_list = final_state.get('context', []) - if isinstance(context_list, list): - context = '\n'.join(context_list) - else: - context = str(context_list) - - # Extract other values - learnings = final_state.get('learnings', []) - citations = final_state.get('citations', {}) - visited_urls = final_state.get('visited_urls', set()) - sources = final_state.get('sources', []) - review = final_state.get('review', None) - else: - # It's a DeepResearchState object - context = final_state.final_context if hasattr(final_state, 'final_context') else '\n'.join(final_state.context) - learnings = final_state.learnings - citations = final_state.citations - visited_urls = final_state.visited_urls - sources = final_state.sources - review = final_state.review - - # Return results - return { - "task": self.task, - "query": self.task.get('query'), - "context": context, - "learnings": learnings, - "citations": citations, - "visited_urls": list(visited_urls) if isinstance(visited_urls, set) else [], - "sources": sources, - "review": review, - "execution_time": str(execution_time) - } \ No newline at end of file + try: + result = await chain.ainvoke(initial_state, config=config) + + # Convert the result to a dictionary to ensure we can access all attributes + result_dict = {} + + # Try to convert the result to a dictionary + try: + # First try to convert directly + result_dict = dict(result) + except (TypeError, ValueError): + # If that fails, try to extract attributes from the object + try: + result_dict = result.__dict__ + except AttributeError: + # If that fails too, try to extract items one by one + for key in dir(result): + if not key.startswith('_') and not callable(getattr(result, key, None)): + try: + result_dict[key] = getattr(result, key) + except (AttributeError, TypeError): + pass + + # Extract data from the result dictionary + context_items = result_dict.get('context_items', []) + sources = result_dict.get('sources', []) + citations = result_dict.get('citations', {}) + final_context = result_dict.get('final_context', '') + + # If we couldn't extract the data from the result dictionary, try to access it directly + if not context_items and hasattr(result, 'context_items'): + context_items = result.context_items + + if not sources and hasattr(result, 'sources'): + sources = result.sources + + if not citations and hasattr(result, 'citations'): + citations = result.citations + + if not final_context and hasattr(result, 'final_context'): + final_context = result.final_context + + # Extract the final context, ensuring it's not empty + if not final_context and context_items: + # If no final_context but we have context_items, join them + final_context = "\n\n".join(context_items) + print_agent_output("Using joined context_items as final context", "MASTER") + elif not final_context: + # Create a default context if all else fails + final_context = f"Research on: {self.task.get('query', '')}\n\nNo specific research data was collected." + print_agent_output("Using default context as final context", "MASTER") + + # If we have sources but they're not in the right format, try to fix them + processed_sources = [] + for source in sources: + if isinstance(source, dict): + processed_sources.append(source) + elif isinstance(source, str): + # Try to parse the source string + if source.startswith('http'): + processed_sources.append({ + 'title': 'Source from URL', + 'url': source, + 'content': '' + }) + else: + processed_sources.append({ + 'title': 'Source', + 'url': '', + 'content': source + }) + + # If we still don't have any sources but we have context, create a source from the context + if not processed_sources and final_context: + processed_sources.append({ + 'title': f"Research on {self.task.get('query', '')}", + 'url': '', + 'content': final_context + }) + + # Log the final result + print_agent_output( + f"Final result: {len(final_context)} chars context, {len(processed_sources)} sources, {len(citations)} citations", + "MASTER" + ) + + # Return the result + return { + "query": self.task.get("query", ""), + "context": final_context, + "sources": processed_sources, + "citations": citations + } + + except Exception as e: + # Log the error + print_agent_output(f"Error running research workflow: {str(e)}", "MASTER") + + # Return a default result + return { + "query": self.task.get("query", ""), + "context": f"Research on: {self.task.get('query', '')}\n\nAn error occurred during research: {str(e)}", + "sources": [], + "citations": {} + } \ No newline at end of file From caa372e89eebc116e06f1eb95c0707d8bc93d12a Mon Sep 17 00:00:00 2001 From: Assaf Elovic Date: Sun, 9 Mar 2025 12:14:20 +0200 Subject: [PATCH 7/9] refactored and cleaned --- multi_agents/README.md | 83 +- .../deep_research/LangGraph-instructions.md | 1225 +++++++++++++++++ multi_agents/deep_research/README.md | 41 +- .../deep_research/agents/synthesizer.py | 98 +- multi_agents/deep_research/main.py | 67 +- multi_agents/deep_research/orchestrator.py | 111 +- multi_agents/main.py | 43 +- 7 files changed, 1339 insertions(+), 329 deletions(-) create mode 100644 multi_agents/deep_research/LangGraph-instructions.md diff --git a/multi_agents/README.md b/multi_agents/README.md index 40be4bdbc..a877ca814 100644 --- a/multi_agents/README.md +++ b/multi_agents/README.md @@ -91,63 +91,50 @@ The deep research mode follows a different workflow: 2. Run the application: ```bash # Run standard research mode - python main.py - - # Run standard research with a specific query python main.py --query "Your research question here" # Run deep research mode - python main.py --mode deep - - # Run deep research with custom parameters - python main.py --mode deep --query "Your research question" --breadth 5 --depth 3 --concurrency 3 + python main.py --mode deep --query "Your research question" --breadth 4 --depth 2 --concurrency 2 ``` ## Usage -To change the research query and customize the report, edit the `task.json` file in the main directory. -#### Task.json contains the following fields: -- `query` - The research query or task. -- `model` - The OpenAI LLM to use for the agents. -- `max_sections` - The maximum number of sections in the report. Each section is a subtopic of the research query. -- `include_human_feedback` - If true, the user can provide feedback to the agents. If false, the agents will work autonomously. -- `publish_formats` - The formats to publish the report in. The reports will be written in the `output` directory. -- `source` - The location from which to conduct the research. Options: `web` or `local`. For local, please add `DOC_PATH` env var. -- `follow_guidelines` - If true, the research report will follow the guidelines below. It will take longer to complete. If false, the report will be generated faster but may not follow the guidelines. -- `guidelines` - A list of guidelines that the report must follow. -- `verbose` - If true, the application will print detailed logs to the console. - -#### Deep Research Configuration +To run research with custom parameters, use the command line arguments: + +```bash +# Run standard research +python main.py --mode standard --query "Your research question" + +# Run deep research +python main.py --mode deep --query "Your research question" --breadth 4 --depth 2 --concurrency 2 --model "gpt-4o" --verbose +``` + +### Available Command Line Arguments +- `--mode` - Research mode: "standard" or "deep" (default: "standard") +- `--query` - The research query or task (required) +- `--model` - The OpenAI LLM to use for the agents (default: "gpt-4o") +- `--verbose` - Enable verbose output (default: True) + +### Deep Research Arguments For deep research mode, you can also configure: -- `deep_research_breadth` - Number of parallel search queries at each level (default: 4) -- `deep_research_depth` - Maximum depth of recursive research (default: 2) -- `deep_research_concurrency` - Maximum number of concurrent research tasks (default: 2) - -#### Example task.json: -```json -{ - "query": "Is AI in a hype cycle?", - "model": "gpt-4o", - "max_sections": 3, - "publish_formats": { - "markdown": true, - "pdf": true, - "docx": true - }, - "include_human_feedback": false, - "source": "web", - "follow_guidelines": true, - "guidelines": [ - "The report MUST fully answer the original question", - "The report MUST be written in apa format", - "The report MUST be written in english" - ], - "verbose": true, - "deep_research_breadth": 4, - "deep_research_depth": 2, - "deep_research_concurrency": 2 -} +- `--breadth` - Number of parallel search queries at each level (default: 4) +- `--depth` - Maximum depth of recursive research (default: 2) +- `--concurrency` - Maximum number of concurrent research tasks (default: 2) +- `--markdown` - Generate markdown output (default: True) +- `--pdf` - Generate PDF output (default: False) +- `--docx` - Generate DOCX output (default: False) + +### Example Commands + +```bash +# Run standard research on AI +python main.py --mode standard --query "Is AI in a hype cycle?" + +# Run deep research with custom parameters +python main.py --mode deep --query "Impact of climate change on agriculture" --breadth 5 --depth 3 --concurrency 3 --pdf --docx ``` +The reports will be written in the `outputs` directory. + ## To Deploy ```shell diff --git a/multi_agents/deep_research/LangGraph-instructions.md b/multi_agents/deep_research/LangGraph-instructions.md new file mode 100644 index 000000000..40824636c --- /dev/null +++ b/multi_agents/deep_research/LangGraph-instructions.md @@ -0,0 +1,1225 @@ +LangGraph Quickstart¶ +In this tutorial, we will build a support chatbot in LangGraph that can: + +✅ Answer common questions by searching the web +✅ Maintain conversation state across calls +✅ Route complex queries to a human for review +✅ Use custom state to control its behavior +✅ Rewind and explore alternative conversation paths + +We'll start with a basic chatbot and progressively add more sophisticated capabilities, introducing key LangGraph concepts along the way. Let’s dive in! 🌟 + +Setup¶ +First, install the required packages and configure your environment: + + +%%capture --no-stderr +%pip install -U langgraph langsmith langchain_anthropic + +import getpass +import os + + +def _set_env(var: str): + if not os.environ.get(var): + os.environ[var] = getpass.getpass(f"{var}: ") + + +_set_env("ANTHROPIC_API_KEY") +Set up LangSmith for LangGraph development + +Sign up for LangSmith to quickly spot issues and improve the performance of your LangGraph projects. LangSmith lets you use trace data to debug, test, and monitor your LLM apps built with LangGraph — read more about how to get started here. + +Part 1: Build a Basic Chatbot¶ +We'll first create a simple chatbot using LangGraph. This chatbot will respond directly to user messages. Though simple, it will illustrate the core concepts of building with LangGraph. By the end of this section, you will have a built rudimentary chatbot. + +Start by creating a StateGraph. A StateGraph object defines the structure of our chatbot as a "state machine". We'll add nodes to represent the llm and functions our chatbot can call and edges to specify how the bot should transition between these functions. + + +from typing import Annotated + +from typing_extensions import TypedDict + +from langgraph.graph import StateGraph, START, END +from langgraph.graph.message import add_messages + + +class State(TypedDict): + # Messages have the type "list". The `add_messages` function + # in the annotation defines how this state key should be updated + # (in this case, it appends messages to the list, rather than overwriting them) + messages: Annotated[list, add_messages] + + +graph_builder = StateGraph(State) +API Reference: StateGraph | START | END | add_messages + +Our graph can now handle two key tasks: + +Each node can receive the current State as input and output an update to the state. +Updates to messages will be appended to the existing list rather than overwriting it, thanks to the prebuilt add_messages function used with the Annotated syntax. +Concept + +When defining a graph, the first step is to define its State. The State includes the graph's schema and reducer functions that handle state updates. In our example, State is a TypedDict with one key: messages. The add_messages reducer function is used to append new messages to the list instead of overwriting it. Keys without a reducer annotation will overwrite previous values. Learn more about state, reducers, and related concepts in this guide. + +Next, add a "chatbot" node. Nodes represent units of work. They are typically regular python functions. + + +from langchain_anthropic import ChatAnthropic + +llm = ChatAnthropic(model="claude-3-5-sonnet-20240620") + + +def chatbot(state: State): + return {"messages": [llm.invoke(state["messages"])]} + + +# The first argument is the unique node name +# The second argument is the function or object that will be called whenever +# the node is used. +graph_builder.add_node("chatbot", chatbot) +API Reference: ChatAnthropic + +Notice how the chatbot node function takes the current State as input and returns a dictionary containing an updated messages list under the key "messages". This is the basic pattern for all LangGraph node functions. + +The add_messages function in our State will append the llm's response messages to whatever messages are already in the state. + +Next, add an entry point. This tells our graph where to start its work each time we run it. + + +graph_builder.add_edge(START, "chatbot") +Similarly, set a finish point. This instructs the graph "any time this node is run, you can exit." + + +graph_builder.add_edge("chatbot", END) +Finally, we'll want to be able to run our graph. To do so, call "compile()" on the graph builder. This creates a "CompiledGraph" we can use invoke on our state. + + +graph = graph_builder.compile() +You can visualize the graph using the get_graph method and one of the "draw" methods, like draw_ascii or draw_png. The draw methods each require additional dependencies. + + +from IPython.display import Image, display + +try: + display(Image(graph.get_graph().draw_mermaid_png())) +except Exception: + # This requires some extra dependencies and is optional + pass + + +Now let's run the chatbot! + +Tip: You can exit the chat loop at any time by typing "quit", "exit", or "q". + + +def stream_graph_updates(user_input: str): + for event in graph.stream({"messages": [{"role": "user", "content": user_input}]}): + for value in event.values(): + print("Assistant:", value["messages"][-1].content) + + +while True: + try: + user_input = input("User: ") + if user_input.lower() in ["quit", "exit", "q"]: + print("Goodbye!") + break + + stream_graph_updates(user_input) + except: + # fallback if input() is not available + user_input = "What do you know about LangGraph?" + print("User: " + user_input) + stream_graph_updates(user_input) + break + +Assistant: LangGraph is a library designed to help build stateful multi-agent applications using language models. It provides tools for creating workflows and state machines to coordinate multiple AI agents or language model interactions. LangGraph is built on top of LangChain, leveraging its components while adding graph-based coordination capabilities. It's particularly useful for developing more complex, stateful AI applications that go beyond simple query-response interactions. +Goodbye! +Congratulations! You've built your first chatbot using LangGraph. This bot can engage in basic conversation by taking user input and generating responses using an LLM. You can inspect a LangSmith Trace for the call above at the provided link. +However, you may have noticed that the bot's knowledge is limited to what's in its training data. In the next part, we'll add a web search tool to expand the bot's knowledge and make it more capable. + +Below is the full code for this section for your reference: + +Full Code +Part 2: 🛠️ Enhancing the Chatbot with Tools¶ +To handle queries our chatbot can't answer "from memory", we'll integrate a web search tool. Our bot can use this tool to find relevant information and provide better responses. + +Requirements¶ +Before we start, make sure you have the necessary packages installed and API keys set up: + +First, install the requirements to use the Tavily Search Engine, and set your TAVILY_API_KEY. + + +%%capture --no-stderr +%pip install -U tavily-python langchain_community + +_set_env("TAVILY_API_KEY") + +TAVILY_API_KEY: ········ +Next, define the tool: + +from langchain_community.tools.tavily_search import TavilySearchResults + +tool = TavilySearchResults(max_results=2) +tools = [tool] +tool.invoke("What's a 'node' in LangGraph?") +API Reference: TavilySearchResults + + +[{'url': 'https://medium.com/@cplog/introduction-to-langgraph-a-beginners-guide-14f9be027141', + 'content': 'Nodes: Nodes are the building blocks of your LangGraph. Each node represents a function or a computation step. You define nodes to perform specific tasks, such as processing input, making ...'}, + {'url': 'https://saksheepatil05.medium.com/demystifying-langgraph-a-beginner-friendly-dive-into-langgraph-concepts-5ffe890ddac0', + 'content': 'Nodes (Tasks): Nodes are like the workstations on the assembly line. Each node performs a specific task on the product. In LangGraph, nodes are Python functions that take the current state, do some work, and return an updated state. Next, we define the nodes, each representing a task in our sandwich-making process.'}] +The results are page summaries our chat bot can use to answer questions. + +Next, we'll start defining our graph. The following is all the same as in Part 1, except we have added bind_tools on our LLM. This lets the LLM know the correct JSON format to use if it wants to use our search engine. + + +from typing import Annotated + +from langchain_anthropic import ChatAnthropic +from typing_extensions import TypedDict + +from langgraph.graph import StateGraph, START, END +from langgraph.graph.message import add_messages + + +class State(TypedDict): + messages: Annotated[list, add_messages] + + +graph_builder = StateGraph(State) + + +llm = ChatAnthropic(model="claude-3-5-sonnet-20240620") +# Modification: tell the LLM which tools it can call +llm_with_tools = llm.bind_tools(tools) + + +def chatbot(state: State): + return {"messages": [llm_with_tools.invoke(state["messages"])]} + + +graph_builder.add_node("chatbot", chatbot) +API Reference: ChatAnthropic | StateGraph | START | END | add_messages + +Next we need to create a function to actually run the tools if they are called. We'll do this by adding the tools to a new node. + +Below, we implement a BasicToolNode that checks the most recent message in the state and calls tools if the message contains tool_calls. It relies on the LLM's tool_calling support, which is available in Anthropic, OpenAI, Google Gemini, and a number of other LLM providers. + +We will later replace this with LangGraph's prebuilt ToolNode to speed things up, but building it ourselves first is instructive. + + +import json + +from langchain_core.messages import ToolMessage + + +class BasicToolNode: + """A node that runs the tools requested in the last AIMessage.""" + + def __init__(self, tools: list) -> None: + self.tools_by_name = {tool.name: tool for tool in tools} + + def __call__(self, inputs: dict): + if messages := inputs.get("messages", []): + message = messages[-1] + else: + raise ValueError("No message found in input") + outputs = [] + for tool_call in message.tool_calls: + tool_result = self.tools_by_name[tool_call["name"]].invoke( + tool_call["args"] + ) + outputs.append( + ToolMessage( + content=json.dumps(tool_result), + name=tool_call["name"], + tool_call_id=tool_call["id"], + ) + ) + return {"messages": outputs} + + +tool_node = BasicToolNode(tools=[tool]) +graph_builder.add_node("tools", tool_node) +API Reference: ToolMessage + +With the tool node added, we can define the conditional_edges. + +Recall that edges route the control flow from one node to the next. Conditional edges usually contain "if" statements to route to different nodes depending on the current graph state. These functions receive the current graph state and return a string or list of strings indicating which node(s) to call next. + +Below, call define a router function called route_tools, that checks for tool_calls in the chatbot's output. Provide this function to the graph by calling add_conditional_edges, which tells the graph that whenever the chatbot node completes to check this function to see where to go next. + +The condition will route to tools if tool calls are present and END if not. + +Later, we will replace this with the prebuilt tools_condition to be more concise, but implementing it ourselves first makes things more clear. + + +def route_tools( + state: State, +): + """ + Use in the conditional_edge to route to the ToolNode if the last message + has tool calls. Otherwise, route to the end. + """ + if isinstance(state, list): + ai_message = state[-1] + elif messages := state.get("messages", []): + ai_message = messages[-1] + else: + raise ValueError(f"No messages found in input state to tool_edge: {state}") + if hasattr(ai_message, "tool_calls") and len(ai_message.tool_calls) > 0: + return "tools" + return END + + +# The `tools_condition` function returns "tools" if the chatbot asks to use a tool, and "END" if +# it is fine directly responding. This conditional routing defines the main agent loop. +graph_builder.add_conditional_edges( + "chatbot", + route_tools, + # The following dictionary lets you tell the graph to interpret the condition's outputs as a specific node + # It defaults to the identity function, but if you + # want to use a node named something else apart from "tools", + # You can update the value of the dictionary to something else + # e.g., "tools": "my_tools" + {"tools": "tools", END: END}, +) +# Any time a tool is called, we return to the chatbot to decide the next step +graph_builder.add_edge("tools", "chatbot") +graph_builder.add_edge(START, "chatbot") +graph = graph_builder.compile() +Notice that conditional edges start from a single node. This tells the graph "any time the 'chatbot' node runs, either go to 'tools' if it calls a tool, or end the loop if it responds directly. + +Like the prebuilt tools_condition, our function returns the END string if no tool calls are made. When the graph transitions to END, it has no more tasks to complete and ceases execution. Because the condition can return END, we don't need to explicitly set a finish_point this time. Our graph already has a way to finish! + +Let's visualize the graph we've built. The following function has some additional dependencies to run that are unimportant for this tutorial. + + +from IPython.display import Image, display + +try: + display(Image(graph.get_graph().draw_mermaid_png())) +except Exception: + # This requires some extra dependencies and is optional + pass + + +Now we can ask the bot questions outside its training data. + + +while True: + try: + user_input = input("User: ") + if user_input.lower() in ["quit", "exit", "q"]: + print("Goodbye!") + break + + stream_graph_updates(user_input) + except: + # fallback if input() is not available + user_input = "What do you know about LangGraph?" + print("User: " + user_input) + stream_graph_updates(user_input) + break + +Assistant: [{'text': "To provide you with accurate and up-to-date information about LangGraph, I'll need to search for the latest details. Let me do that for you.", 'type': 'text'}, {'id': 'toolu_01Q588CszHaSvvP2MxRq9zRD', 'input': {'query': 'LangGraph AI tool information'}, 'name': 'tavily_search_results_json', 'type': 'tool_use'}] +Assistant: [{"url": "https://www.langchain.com/langgraph", "content": "LangGraph sets the foundation for how we can build and scale AI workloads \u2014 from conversational agents, complex task automation, to custom LLM-backed experiences that 'just work'. The next chapter in building complex production-ready features with LLMs is agentic, and with LangGraph and LangSmith, LangChain delivers an out-of-the-box solution ..."}, {"url": "https://github.com/langchain-ai/langgraph", "content": "Overview. LangGraph is a library for building stateful, multi-actor applications with LLMs, used to create agent and multi-agent workflows. Compared to other LLM frameworks, it offers these core benefits: cycles, controllability, and persistence. LangGraph allows you to define flows that involve cycles, essential for most agentic architectures ..."}] +Assistant: Based on the search results, I can provide you with information about LangGraph: + +1. Purpose: + LangGraph is a library designed for building stateful, multi-actor applications with Large Language Models (LLMs). It's particularly useful for creating agent and multi-agent workflows. + +2. Developer: + LangGraph is developed by LangChain, a company known for its tools and frameworks in the AI and LLM space. + +3. Key Features: + - Cycles: LangGraph allows the definition of flows that involve cycles, which is essential for most agentic architectures. + - Controllability: It offers enhanced control over the application flow. + - Persistence: The library provides ways to maintain state and persistence in LLM-based applications. + +4. Use Cases: + LangGraph can be used for various applications, including: + - Conversational agents + - Complex task automation + - Custom LLM-backed experiences + +5. Integration: + LangGraph works in conjunction with LangSmith, another tool by LangChain, to provide an out-of-the-box solution for building complex, production-ready features with LLMs. + +6. Significance: + LangGraph is described as setting the foundation for building and scaling AI workloads. It's positioned as a key tool in the next chapter of LLM-based application development, particularly in the realm of agentic AI. + +7. Availability: + LangGraph is open-source and available on GitHub, which suggests that developers can access and contribute to its codebase. + +8. Comparison to Other Frameworks: + LangGraph is noted to offer unique benefits compared to other LLM frameworks, particularly in its ability to handle cycles, provide controllability, and maintain persistence. + +LangGraph appears to be a significant tool in the evolving landscape of LLM-based application development, offering developers new ways to create more complex, stateful, and interactive AI systems. +Goodbye! +Congrats! You've created a conversational agent in langgraph that can use a search engine to retrieve updated information when needed. Now it can handle a wider range of user queries. To inspect all the steps your agent just took, check out this LangSmith trace. +Our chatbot still can't remember past interactions on its own, limiting its ability to have coherent, multi-turn conversations. In the next part, we'll add memory to address this. + +The full code for the graph we've created in this section is reproduced below, replacing our BasicToolNode for the prebuilt ToolNode, and our route_tools condition with the prebuilt tools_condition + +Full Code +Part 3: Adding Memory to the Chatbot¶ +Our chatbot can now use tools to answer user questions, but it doesn't remember the context of previous interactions. This limits its ability to have coherent, multi-turn conversations. + +LangGraph solves this problem through persistent checkpointing. If you provide a checkpointer when compiling the graph and a thread_id when calling your graph, LangGraph automatically saves the state after each step. When you invoke the graph again using the same thread_id, the graph loads its saved state, allowing the chatbot to pick up where it left off. + +We will see later that checkpointing is much more powerful than simple chat memory - it lets you save and resume complex state at any time for error recovery, human-in-the-loop workflows, time travel interactions, and more. But before we get too ahead of ourselves, let's add checkpointing to enable multi-turn conversations. + +To get started, create a MemorySaver checkpointer. + + +from langgraph.checkpoint.memory import MemorySaver + +memory = MemorySaver() +API Reference: MemorySaver + +Notice we're using an in-memory checkpointer. This is convenient for our tutorial (it saves it all in-memory). In a production application, you would likely change this to use SqliteSaver or PostgresSaver and connect to your own DB. + +Next define the graph. Now that you've already built your own BasicToolNode, we'll replace it with LangGraph's prebuilt ToolNode and tools_condition, since these do some nice things like parallel API execution. Apart from that, the following is all copied from Part 2. + + +from typing import Annotated + +from langchain_anthropic import ChatAnthropic +from langchain_community.tools.tavily_search import TavilySearchResults +from langchain_core.messages import BaseMessage +from typing_extensions import TypedDict + +from langgraph.graph import StateGraph, START, END +from langgraph.graph.message import add_messages +from langgraph.prebuilt import ToolNode, tools_condition + + +class State(TypedDict): + messages: Annotated[list, add_messages] + + +graph_builder = StateGraph(State) + + +tool = TavilySearchResults(max_results=2) +tools = [tool] +llm = ChatAnthropic(model="claude-3-5-sonnet-20240620") +llm_with_tools = llm.bind_tools(tools) + + +def chatbot(state: State): + return {"messages": [llm_with_tools.invoke(state["messages"])]} + + +graph_builder.add_node("chatbot", chatbot) + +tool_node = ToolNode(tools=[tool]) +graph_builder.add_node("tools", tool_node) + +graph_builder.add_conditional_edges( + "chatbot", + tools_condition, +) +# Any time a tool is called, we return to the chatbot to decide the next step +graph_builder.add_edge("tools", "chatbot") +graph_builder.add_edge(START, "chatbot") +API Reference: ChatAnthropic | TavilySearchResults | BaseMessage | StateGraph | START | END | add_messages | ToolNode | tools_condition + +Finally, compile the graph with the provided checkpointer. + + +graph = graph_builder.compile(checkpointer=memory) +Notice the connectivity of the graph hasn't changed since Part 2. All we are doing is checkpointing the State as the graph works through each node. + + +from IPython.display import Image, display + +try: + display(Image(graph.get_graph().draw_mermaid_png())) +except Exception: + # This requires some extra dependencies and is optional + pass + + +Now you can interact with your bot! First, pick a thread to use as the key for this conversation. + + +config = {"configurable": {"thread_id": "1"}} +Next, call your chat bot. + + +user_input = "Hi there! My name is Will." + +# The config is the **second positional argument** to stream() or invoke()! +events = graph.stream( + {"messages": [{"role": "user", "content": user_input}]}, + config, + stream_mode="values", +) +for event in events: + event["messages"][-1].pretty_print() + +================================ Human Message ================================= + +Hi there! My name is Will. +================================== Ai Message ================================== + +Hello Will! It's nice to meet you. How can I assist you today? Is there anything specific you'd like to know or discuss? +Note: The config was provided as the second positional argument when calling our graph. It importantly is not nested within the graph inputs ({'messages': []}). +Let's ask a followup: see if it remembers your name. + + +user_input = "Remember my name?" + +# The config is the **second positional argument** to stream() or invoke()! +events = graph.stream( + {"messages": [{"role": "user", "content": user_input}]}, + config, + stream_mode="values", +) +for event in events: + event["messages"][-1].pretty_print() + +================================ Human Message ================================= + +Remember my name? +================================== Ai Message ================================== + +Of course, I remember your name, Will. I always try to pay attention to important details that users share with me. Is there anything else you'd like to talk about or any questions you have? I'm here to help with a wide range of topics or tasks. +Notice that we aren't using an external list for memory: it's all handled by the checkpointer! You can inspect the full execution in this LangSmith trace to see what's going on. +Don't believe me? Try this using a different config. + + +# The only difference is we change the `thread_id` here to "2" instead of "1" +events = graph.stream( + {"messages": [{"role": "user", "content": user_input}]}, + {"configurable": {"thread_id": "2"}}, + stream_mode="values", +) +for event in events: + event["messages"][-1].pretty_print() + +================================ Human Message ================================= + +Remember my name? +================================== Ai Message ================================== + +I apologize, but I don't have any previous context or memory of your name. As an AI assistant, I don't retain information from past conversations. Each interaction starts fresh. Could you please tell me your name so I can address you properly in this conversation? +Notice that the only change we've made is to modify the thread_id in the config. See this call's LangSmith trace for comparison. +By now, we have made a few checkpoints across two different threads. But what goes into a checkpoint? To inspect a graph's state for a given config at any time, call get_state(config). + + +snapshot = graph.get_state(config) +snapshot + +StateSnapshot(values={'messages': [HumanMessage(content='Hi there! My name is Will.', additional_kwargs={}, response_metadata={}, id='8c1ca919-c553-4ebf-95d4-b59a2d61e078'), AIMessage(content="Hello Will! It's nice to meet you. How can I assist you today? Is there anything specific you'd like to know or discuss?", additional_kwargs={}, response_metadata={'id': 'msg_01WTQebPhNwmMrmmWojJ9KXJ', 'model': 'claude-3-5-sonnet-20240620', 'stop_reason': 'end_turn', 'stop_sequence': None, 'usage': {'input_tokens': 405, 'output_tokens': 32}}, id='run-58587b77-8c82-41e6-8a90-d62c444a261d-0', usage_metadata={'input_tokens': 405, 'output_tokens': 32, 'total_tokens': 437}), HumanMessage(content='Remember my name?', additional_kwargs={}, response_metadata={}, id='daba7df6-ad75-4d6b-8057-745881cea1ca'), AIMessage(content="Of course, I remember your name, Will. I always try to pay attention to important details that users share with me. Is there anything else you'd like to talk about or any questions you have? I'm here to help with a wide range of topics or tasks.", additional_kwargs={}, response_metadata={'id': 'msg_01E41KitY74HpENRgXx94vag', 'model': 'claude-3-5-sonnet-20240620', 'stop_reason': 'end_turn', 'stop_sequence': None, 'usage': {'input_tokens': 444, 'output_tokens': 58}}, id='run-ffeaae5c-4d2d-4ddb-bd59-5d5cbf2a5af8-0', usage_metadata={'input_tokens': 444, 'output_tokens': 58, 'total_tokens': 502})]}, next=(), config={'configurable': {'thread_id': '1', 'checkpoint_ns': '', 'checkpoint_id': '1ef7d06e-93e0-6acc-8004-f2ac846575d2'}}, metadata={'source': 'loop', 'writes': {'chatbot': {'messages': [AIMessage(content="Of course, I remember your name, Will. I always try to pay attention to important details that users share with me. Is there anything else you'd like to talk about or any questions you have? I'm here to help with a wide range of topics or tasks.", additional_kwargs={}, response_metadata={'id': 'msg_01E41KitY74HpENRgXx94vag', 'model': 'claude-3-5-sonnet-20240620', 'stop_reason': 'end_turn', 'stop_sequence': None, 'usage': {'input_tokens': 444, 'output_tokens': 58}}, id='run-ffeaae5c-4d2d-4ddb-bd59-5d5cbf2a5af8-0', usage_metadata={'input_tokens': 444, 'output_tokens': 58, 'total_tokens': 502})]}}, 'step': 4, 'parents': {}}, created_at='2024-09-27T19:30:10.820758+00:00', parent_config={'configurable': {'thread_id': '1', 'checkpoint_ns': '', 'checkpoint_id': '1ef7d06e-859f-6206-8003-e1bd3c264b8f'}}, tasks=()) + +snapshot.next # (since the graph ended this turn, `next` is empty. If you fetch a state from within a graph invocation, next tells which node will execute next) + +() +The snapshot above contains the current state values, corresponding config, and the next node to process. In our case, the graph has reached an END state, so next is empty. + +Congratulations! Your chatbot can now maintain conversation state across sessions thanks to LangGraph's checkpointing system. This opens up exciting possibilities for more natural, contextual interactions. LangGraph's checkpointing even handles arbitrarily complex graph states, which is much more expressive and powerful than simple chat memory. + +In the next part, we'll introduce human oversight to our bot to handle situations where it may need guidance or verification before proceeding. + +Check out the code snippet below to review our graph from this section. + +Full Code +Part 4: Human-in-the-loop¶ +Agents can be unreliable and may need human input to successfully accomplish tasks. Similarly, for some actions, you may want to require human approval before running to ensure that everything is running as intended. + +LangGraph's persistence layer supports human-in-the-loop workflows, allowing execution to pause and resume based on user feedback. The primary interface to this functionality is the interrupt function. Calling interrupt inside a node will pause execution. Execution can be resumed, together with new input from a human, by passing in a Command. interrupt is ergonomically similar to Python's built-in input(), with some caveats. We demonstrate an example below. + +First, start with our existing code from Part 3. We will make one change, which is to add a simple human_assistance tool accessible to the chatbot. This tool uses interrupt to receive information from a human. + + +from typing import Annotated + +from langchain_anthropic import ChatAnthropic +from langchain_community.tools.tavily_search import TavilySearchResults +from langchain_core.tools import tool +from typing_extensions import TypedDict + +from langgraph.checkpoint.memory import MemorySaver +from langgraph.graph import StateGraph, START, END +from langgraph.graph.message import add_messages +from langgraph.prebuilt import ToolNode, tools_condition + +from langgraph.types import Command, interrupt + + +class State(TypedDict): + messages: Annotated[list, add_messages] + + +graph_builder = StateGraph(State) + + +@tool +def human_assistance(query: str) -> str: + """Request assistance from a human.""" + human_response = interrupt({"query": query}) + return human_response["data"] + + +tool = TavilySearchResults(max_results=2) +tools = [tool, human_assistance] +llm = ChatAnthropic(model="claude-3-5-sonnet-20240620") +llm_with_tools = llm.bind_tools(tools) + + +def chatbot(state: State): + message = llm_with_tools.invoke(state["messages"]) + # Because we will be interrupting during tool execution, + # we disable parallel tool calling to avoid repeating any + # tool invocations when we resume. + assert len(message.tool_calls) <= 1 + return {"messages": [message]} + + +graph_builder.add_node("chatbot", chatbot) + +tool_node = ToolNode(tools=tools) +graph_builder.add_node("tools", tool_node) + +graph_builder.add_conditional_edges( + "chatbot", + tools_condition, +) +graph_builder.add_edge("tools", "chatbot") +graph_builder.add_edge(START, "chatbot") +API Reference: ChatAnthropic | TavilySearchResults | tool | MemorySaver | StateGraph | START | END | add_messages | ToolNode | tools_condition | Command | interrupt + +Tip + +Check out the Human-in-the-loop section of the How-to Guides for more examples of Human-in-the-loop workflows, including how to review and edit tool calls before they are executed. + +We compile the graph with a checkpointer, as before: + + +memory = MemorySaver() + +graph = graph_builder.compile(checkpointer=memory) +Visualizing the graph, we recover the same layout as before. We have just added a tool! + + +from IPython.display import Image, display + +try: + display(Image(graph.get_graph().draw_mermaid_png())) +except Exception: + # This requires some extra dependencies and is optional + pass + + +Let's now prompt the chatbot with a question that will engage the new human_assistance tool: + + +user_input = "I need some expert guidance for building an AI agent. Could you request assistance for me?" +config = {"configurable": {"thread_id": "1"}} + +events = graph.stream( + {"messages": [{"role": "user", "content": user_input}]}, + config, + stream_mode="values", +) +for event in events: + if "messages" in event: + event["messages"][-1].pretty_print() + +================================ Human Message ================================= + +I need some expert guidance for building an AI agent. Could you request assistance for me? +================================== Ai Message ================================== + +[{'text': "Certainly! I'd be happy to request expert assistance for you regarding building an AI agent. To do this, I'll use the human_assistance function to relay your request. Let me do that for you now.", 'type': 'text'}, {'id': 'toolu_01ABUqneqnuHNuo1vhfDFQCW', 'input': {'query': 'A user is requesting expert guidance for building an AI agent. Could you please provide some expert advice or resources on this topic?'}, 'name': 'human_assistance', 'type': 'tool_use'}] +Tool Calls: + human_assistance (toolu_01ABUqneqnuHNuo1vhfDFQCW) + Call ID: toolu_01ABUqneqnuHNuo1vhfDFQCW + Args: + query: A user is requesting expert guidance for building an AI agent. Could you please provide some expert advice or resources on this topic? +The chatbot generated a tool call, but then execution has been interrupted! Note that if we inspect the graph state, we see that it stopped at the tools node: + +snapshot = graph.get_state(config) +snapshot.next + +('tools',) +Let's take a closer look at the human_assistance tool: + + +@tool +def human_assistance(query: str) -> str: + """Request assistance from a human.""" + human_response = interrupt({"query": query}) + return human_response["data"] +Similar to Python's built-in input() function, calling interrupt inside the tool will pause execution. Progress is persisted based on our choice of checkpointer-- so if we are persisting with Postgres, we can resume at any time as long as the database is alive. Here we are persisting with the in-memory checkpointer, so we can resume any time as long as our Python kernel is running. + +To resume execution, we pass a Command object containing data expected by the tool. The format of this data can be customized based on our needs. Here, we just need a dict with a key "data": + + +human_response = ( + "We, the experts are here to help! We'd recommend you check out LangGraph to build your agent." + " It's much more reliable and extensible than simple autonomous agents." +) + +human_command = Command(resume={"data": human_response}) + +events = graph.stream(human_command, config, stream_mode="values") +for event in events: + if "messages" in event: + event["messages"][-1].pretty_print() + +================================== Ai Message ================================== + +[{'text': "Certainly! I'd be happy to request expert assistance for you regarding building an AI agent. To do this, I'll use the human_assistance function to relay your request. Let me do that for you now.", 'type': 'text'}, {'id': 'toolu_01ABUqneqnuHNuo1vhfDFQCW', 'input': {'query': 'A user is requesting expert guidance for building an AI agent. Could you please provide some expert advice or resources on this topic?'}, 'name': 'human_assistance', 'type': 'tool_use'}] +Tool Calls: + human_assistance (toolu_01ABUqneqnuHNuo1vhfDFQCW) + Call ID: toolu_01ABUqneqnuHNuo1vhfDFQCW + Args: + query: A user is requesting expert guidance for building an AI agent. Could you please provide some expert advice or resources on this topic? +================================= Tool Message ================================= +Name: human_assistance + +We, the experts are here to help! We'd recommend you check out LangGraph to build your agent. It's much more reliable and extensible than simple autonomous agents. +================================== Ai Message ================================== + +Thank you for your patience. I've received some expert advice regarding your request for guidance on building an AI agent. Here's what the experts have suggested: + +The experts recommend that you look into LangGraph for building your AI agent. They mention that LangGraph is a more reliable and extensible option compared to simple autonomous agents. + +LangGraph is likely a framework or library designed specifically for creating AI agents with advanced capabilities. Here are a few points to consider based on this recommendation: + +1. Reliability: The experts emphasize that LangGraph is more reliable than simpler autonomous agent approaches. This could mean it has better stability, error handling, or consistent performance. + +2. Extensibility: LangGraph is described as more extensible, which suggests that it probably offers a flexible architecture that allows you to easily add new features or modify existing ones as your agent's requirements evolve. + +3. Advanced capabilities: Given that it's recommended over "simple autonomous agents," LangGraph likely provides more sophisticated tools and techniques for building complex AI agents. + +To get started with LangGraph, you might want to: + +1. Search for the official LangGraph documentation or website to learn more about its features and how to use it. +2. Look for tutorials or guides specifically focused on building AI agents with LangGraph. +3. Check if there are any community forums or discussion groups where you can ask questions and get support from other developers using LangGraph. + +If you'd like more specific information about LangGraph or have any questions about this recommendation, please feel free to ask, and I can request further assistance from the experts. +Our input has been received and processed as a tool message. Review this call's LangSmith trace to see the exact work that was done in the above call. Notice that the state is loaded in the first step so that our chatbot can continue where it left off. +Congrats! You've used an interrupt to add human-in-the-loop execution to your chatbot, allowing for human oversight and intervention when needed. This opens up the potential UIs you can create with your AI systems. Since we have already added a checkpointer, as long as the underlying persistence layer is running, the graph can be paused indefinitely and resumed at any time as if nothing had happened. + +Human-in-the-loop workflows enable a variety of new workflows and user experiences. Check out this section of the How-to Guides for more examples of Human-in-the-loop workflows, including how to review and edit tool calls before they are executed. + +Full Code +Part 5: Customizing State¶ +So far, we've relied on a simple state with one entry-- a list of messages. You can go far with this simple state, but if you want to define complex behavior without relying on the message list, you can add additional fields to the state. Here we will demonstrate a new scenario, in which the chatbot is using its search tool to find specific information, and forwarding them to a human for review. Let's have the chatbot research the birthday of an entity. We will add name and birthday keys to the state: + + +from typing import Annotated + +from typing_extensions import TypedDict + +from langgraph.graph.message import add_messages + + +class State(TypedDict): + messages: Annotated[list, add_messages] + name: str + birthday: str +API Reference: add_messages + +Adding this information to the state makes it easily accessible by other graph nodes (e.g., a downstream node that stores or processes the information), as well as the graph's persistence layer. + +Here, we will populate the state keys inside of our human_assistance tool. This allows a human to review the information before it is stored in the state. We will again use Command, this time to issue a state update from inside our tool. Read more about use cases for Command here. + + +from langchain_core.messages import ToolMessage +from langchain_core.tools import InjectedToolCallId, tool + +from langgraph.types import Command, interrupt + + +@tool +# Note that because we are generating a ToolMessage for a state update, we +# generally require the ID of the corresponding tool call. We can use +# LangChain's InjectedToolCallId to signal that this argument should not +# be revealed to the model in the tool's schema. +def human_assistance( + name: str, birthday: str, tool_call_id: Annotated[str, InjectedToolCallId] +) -> str: + """Request assistance from a human.""" + human_response = interrupt( + { + "question": "Is this correct?", + "name": name, + "birthday": birthday, + }, + ) + # If the information is correct, update the state as-is. + if human_response.get("correct", "").lower().startswith("y"): + verified_name = name + verified_birthday = birthday + response = "Correct" + # Otherwise, receive information from the human reviewer. + else: + verified_name = human_response.get("name", name) + verified_birthday = human_response.get("birthday", birthday) + response = f"Made a correction: {human_response}" + + # This time we explicitly update the state with a ToolMessage inside + # the tool. + state_update = { + "name": verified_name, + "birthday": verified_birthday, + "messages": [ToolMessage(response, tool_call_id=tool_call_id)], + } + # We return a Command object in the tool to update our state. + return Command(update=state_update) +API Reference: ToolMessage | InjectedToolCallId | tool | Command | interrupt + +Otherwise, the rest of our graph is the same: + + +from langchain_anthropic import ChatAnthropic +from langchain_community.tools.tavily_search import TavilySearchResults + +from langgraph.checkpoint.memory import MemorySaver +from langgraph.graph import StateGraph, START, END +from langgraph.prebuilt import ToolNode, tools_condition + + +tool = TavilySearchResults(max_results=2) +tools = [tool, human_assistance] +llm = ChatAnthropic(model="claude-3-5-sonnet-20240620") +llm_with_tools = llm.bind_tools(tools) + + +def chatbot(state: State): + message = llm_with_tools.invoke(state["messages"]) + assert len(message.tool_calls) <= 1 + return {"messages": [message]} + + +graph_builder = StateGraph(State) +graph_builder.add_node("chatbot", chatbot) + +tool_node = ToolNode(tools=tools) +graph_builder.add_node("tools", tool_node) + +graph_builder.add_conditional_edges( + "chatbot", + tools_condition, +) +graph_builder.add_edge("tools", "chatbot") +graph_builder.add_edge(START, "chatbot") + +memory = MemorySaver() +graph = graph_builder.compile(checkpointer=memory) +API Reference: ChatAnthropic | TavilySearchResults | MemorySaver | StateGraph | START | END | ToolNode | tools_condition + +Let's prompt our application to look up the "birthday" of the LangGraph library. We will direct the chatbot to reach out to the human_assistance tool once it has the required information. Note that setting name and birthday in the arguments for the tool, we force the chatbot to generate proposals for these fields. + + +user_input = ( + "Can you look up when LangGraph was released? " + "When you have the answer, use the human_assistance tool for review." +) +config = {"configurable": {"thread_id": "1"}} + +events = graph.stream( + {"messages": [{"role": "user", "content": user_input}]}, + config, + stream_mode="values", +) +for event in events: + if "messages" in event: + event["messages"][-1].pretty_print() + +================================ Human Message ================================= + +Can you look up when LangGraph was released? When you have the answer, use the human_assistance tool for review. +================================== Ai Message ================================== + +[{'text': "Certainly! I'll start by searching for information about LangGraph's release date using the Tavily search function. Then, I'll use the human_assistance tool for review.", 'type': 'text'}, {'id': 'toolu_01JoXQPgTVJXiuma8xMVwqAi', 'input': {'query': 'LangGraph release date'}, 'name': 'tavily_search_results_json', 'type': 'tool_use'}] +Tool Calls: + tavily_search_results_json (toolu_01JoXQPgTVJXiuma8xMVwqAi) + Call ID: toolu_01JoXQPgTVJXiuma8xMVwqAi + Args: + query: LangGraph release date +================================= Tool Message ================================= +Name: tavily_search_results_json + +[{"url": "https://blog.langchain.dev/langgraph-cloud/", "content": "We also have a new stable release of LangGraph. By LangChain 6 min read Jun 27, 2024 (Oct '24) Edit: Since the launch of LangGraph Cloud, we now have multiple deployment options alongside LangGraph Studio - which now fall under LangGraph Platform. LangGraph Cloud is synonymous with our Cloud SaaS deployment option."}, {"url": "https://changelog.langchain.com/announcements/langgraph-cloud-deploy-at-scale-monitor-carefully-iterate-boldly", "content": "LangChain - Changelog | ☁ 🚀 LangGraph Cloud: Deploy at scale, monitor LangChain LangSmith LangGraph LangChain LangSmith LangGraph LangChain LangSmith LangGraph LangChain Changelog Sign up for our newsletter to stay up to date DATE: The LangChain Team LangGraph LangGraph Cloud ☁ 🚀 LangGraph Cloud: Deploy at scale, monitor carefully, iterate boldly DATE: June 27, 2024 AUTHOR: The LangChain Team LangGraph Cloud is now in closed beta, offering scalable, fault-tolerant deployment for LangGraph agents. LangGraph Cloud also includes a new playground-like studio for debugging agent failure modes and quick iteration: Join the waitlist today for LangGraph Cloud. And to learn more, read our blog post announcement or check out our docs. Subscribe By clicking subscribe, you accept our privacy policy and terms and conditions."}] +================================== Ai Message ================================== + +[{'text': "Based on the search results, it appears that LangGraph was already in existence before June 27, 2024, when LangGraph Cloud was announced. However, the search results don't provide a specific release date for the original LangGraph. \n\nGiven this information, I'll use the human_assistance tool to review and potentially provide more accurate information about LangGraph's initial release date.", 'type': 'text'}, {'id': 'toolu_01JDQAV7nPqMkHHhNs3j3XoN', 'input': {'name': 'Assistant', 'birthday': '2023-01-01'}, 'name': 'human_assistance', 'type': 'tool_use'}] +Tool Calls: + human_assistance (toolu_01JDQAV7nPqMkHHhNs3j3XoN) + Call ID: toolu_01JDQAV7nPqMkHHhNs3j3XoN + Args: + name: Assistant + birthday: 2023-01-01 +We've hit the interrupt in the human_assistance tool again. In this case, the chatbot failed to identify the correct date, so we can supply it: + +human_command = Command( + resume={ + "name": "LangGraph", + "birthday": "Jan 17, 2024", + }, +) + +events = graph.stream(human_command, config, stream_mode="values") +for event in events: + if "messages" in event: + event["messages"][-1].pretty_print() + +================================== Ai Message ================================== + +[{'text': "Based on the search results, it appears that LangGraph was already in existence before June 27, 2024, when LangGraph Cloud was announced. However, the search results don't provide a specific release date for the original LangGraph. \n\nGiven this information, I'll use the human_assistance tool to review and potentially provide more accurate information about LangGraph's initial release date.", 'type': 'text'}, {'id': 'toolu_01JDQAV7nPqMkHHhNs3j3XoN', 'input': {'name': 'Assistant', 'birthday': '2023-01-01'}, 'name': 'human_assistance', 'type': 'tool_use'}] +Tool Calls: + human_assistance (toolu_01JDQAV7nPqMkHHhNs3j3XoN) + Call ID: toolu_01JDQAV7nPqMkHHhNs3j3XoN + Args: + name: Assistant + birthday: 2023-01-01 +================================= Tool Message ================================= +Name: human_assistance + +Made a correction: {'name': 'LangGraph', 'birthday': 'Jan 17, 2024'} +================================== Ai Message ================================== + +Thank you for the human assistance. I can now provide you with the correct information about LangGraph's release date. + +LangGraph was initially released on January 17, 2024. This information comes from the human assistance correction, which is more accurate than the search results I initially found. + +To summarize: +1. LangGraph's original release date: January 17, 2024 +2. LangGraph Cloud announcement: June 27, 2024 + +It's worth noting that LangGraph had been in development and use for some time before the LangGraph Cloud announcement, but the official initial release of LangGraph itself was on January 17, 2024. +Note that these fields are now reflected in the state: + +snapshot = graph.get_state(config) + +{k: v for k, v in snapshot.values.items() if k in ("name", "birthday")} + +{'name': 'LangGraph', 'birthday': 'Jan 17, 2024'} +This makes them easily accessible to downstream nodes (e.g., a node that further processes or stores the information). + +Manually updating state¶ +LangGraph gives a high degree of control over the application state. For instance, at any point (including when interrupted), we can manually override a key using graph.update_state: + + +graph.update_state(config, {"name": "LangGraph (library)"}) + +{'configurable': {'thread_id': '1', + 'checkpoint_ns': '', + 'checkpoint_id': '1efd4ec5-cf69-6352-8006-9278f1730162'}} +If we call graph.get_state, we can see the new value is reflected: + + +snapshot = graph.get_state(config) + +{k: v for k, v in snapshot.values.items() if k in ("name", "birthday")} + +{'name': 'LangGraph (library)', 'birthday': 'Jan 17, 2024'} +Manual state updates will even generate a trace in LangSmith. If desired, they can also be used to control human-in-the-loop workflows, as described in this guide. Use of the interrupt function is generally recommended instead, as it allows data to be transmitted in a human-in-the-loop interaction independently of state updates. + +Congratulations! You've added custom keys to the state to facilitate a more complex workflow, and learned how to generate state updates from inside tools. + +We're almost done with the tutorial, but there is one more concept we'd like to review before finishing that connects checkpointing and state updates. + +This section's code is reproduced below for your reference. + +Full Code +Part 6: Time Travel¶ +In a typical chat bot workflow, the user interacts with the bot 1 or more times to accomplish a task. In the previous sections, we saw how to add memory and a human-in-the-loop to be able to checkpoint our graph state and control future responses. + +But what if you want to let your user start from a previous response and "branch off" to explore a separate outcome? Or what if you want users to be able to "rewind" your assistant's work to fix some mistakes or try a different strategy (common in applications like autonomous software engineers)? + +You can create both of these experiences and more using LangGraph's built-in "time travel" functionality. + +In this section, you will "rewind" your graph by fetching a checkpoint using the graph's get_state_history method. You can then resume execution at this previous point in time. + +For this, let's use the simple chatbot with tools from Part 3: + + +from typing import Annotated + +from langchain_anthropic import ChatAnthropic +from langchain_community.tools.tavily_search import TavilySearchResults +from langchain_core.messages import BaseMessage +from typing_extensions import TypedDict + +from langgraph.checkpoint.memory import MemorySaver +from langgraph.graph import StateGraph, START, END +from langgraph.graph.message import add_messages +from langgraph.prebuilt import ToolNode, tools_condition + + +class State(TypedDict): + messages: Annotated[list, add_messages] + + +graph_builder = StateGraph(State) + + +tool = TavilySearchResults(max_results=2) +tools = [tool] +llm = ChatAnthropic(model="claude-3-5-sonnet-20240620") +llm_with_tools = llm.bind_tools(tools) + + +def chatbot(state: State): + return {"messages": [llm_with_tools.invoke(state["messages"])]} + + +graph_builder.add_node("chatbot", chatbot) + +tool_node = ToolNode(tools=[tool]) +graph_builder.add_node("tools", tool_node) + +graph_builder.add_conditional_edges( + "chatbot", + tools_condition, +) +graph_builder.add_edge("tools", "chatbot") +graph_builder.add_edge(START, "chatbot") + +memory = MemorySaver() +graph = graph_builder.compile(checkpointer=memory) +API Reference: ChatAnthropic | TavilySearchResults | BaseMessage | MemorySaver | StateGraph | START | END | add_messages | ToolNode | tools_condition + +Let's have our graph take a couple steps. Every step will be checkpointed in its state history: + + +config = {"configurable": {"thread_id": "1"}} +events = graph.stream( + { + "messages": [ + { + "role": "user", + "content": ( + "I'm learning LangGraph. " + "Could you do some research on it for me?" + ), + }, + ], + }, + config, + stream_mode="values", +) +for event in events: + if "messages" in event: + event["messages"][-1].pretty_print() + +================================ Human Message ================================= + +I'm learning LangGraph. Could you do some research on it for me? +================================== Ai Message ================================== + +[{'text': "Certainly! I'd be happy to research LangGraph for you. To get the most up-to-date and accurate information, I'll use the Tavily search engine to look this up. Let me do that for you now.", 'type': 'text'}, {'id': 'toolu_01BscbfJJB9EWJFqGrN6E54e', 'input': {'query': 'LangGraph latest information and features'}, 'name': 'tavily_search_results_json', 'type': 'tool_use'}] +Tool Calls: + tavily_search_results_json (toolu_01BscbfJJB9EWJFqGrN6E54e) + Call ID: toolu_01BscbfJJB9EWJFqGrN6E54e + Args: + query: LangGraph latest information and features +================================= Tool Message ================================= +Name: tavily_search_results_json + +[{"url": "https://blockchain.news/news/langchain-new-features-upcoming-events-update", "content": "LangChain, a leading platform in the AI development space, has released its latest updates, showcasing new use cases and enhancements across its ecosystem. According to the LangChain Blog, the updates cover advancements in LangGraph Cloud, LangSmith's self-improving evaluators, and revamped documentation for LangGraph."}, {"url": "https://blog.langchain.dev/langgraph-platform-announce/", "content": "With these learnings under our belt, we decided to couple some of our latest offerings under LangGraph Platform. LangGraph Platform today includes LangGraph Server, LangGraph Studio, plus the CLI and SDK. ... we added features in LangGraph Server to deliver on a few key value areas. Below, we'll focus on these aspects of LangGraph Platform."}] +================================== Ai Message ================================== + +Thank you for your patience. I've found some recent information about LangGraph for you. Let me summarize the key points: + +1. LangGraph is part of the LangChain ecosystem, which is a leading platform in AI development. + +2. Recent updates and features of LangGraph include: + + a. LangGraph Cloud: This seems to be a cloud-based version of LangGraph, though specific details weren't provided in the search results. + + b. LangGraph Platform: This is a newly introduced concept that combines several offerings: + - LangGraph Server + - LangGraph Studio + - CLI (Command Line Interface) + - SDK (Software Development Kit) + +3. LangGraph Server: This component has received new features to enhance its value proposition, though the specific features weren't detailed in the search results. + +4. LangGraph Studio: This appears to be a new tool in the LangGraph ecosystem, likely providing a graphical interface for working with LangGraph. + +5. Documentation: The LangGraph documentation has been revamped, which should make it easier for learners like yourself to understand and use the tool. + +6. Integration with LangSmith: While not directly part of LangGraph, LangSmith (another tool in the LangChain ecosystem) now features self-improving evaluators, which might be relevant if you're using LangGraph as part of a larger LangChain project. + +As you're learning LangGraph, it would be beneficial to: + +1. Check out the official LangChain documentation, especially the newly revamped LangGraph sections. +2. Explore the different components of the LangGraph Platform (Server, Studio, CLI, and SDK) to see which best fits your learning needs. +3. Keep an eye on LangGraph Cloud developments, as cloud-based solutions often provide an easier starting point for learners. +4. Consider how LangGraph fits into the broader LangChain ecosystem, especially its interaction with tools like LangSmith. + +Is there any specific aspect of LangGraph you'd like to know more about? I'd be happy to do a more focused search on particular features or use cases. + +events = graph.stream( + { + "messages": [ + { + "role": "user", + "content": ( + "Ya that's helpful. Maybe I'll " + "build an autonomous agent with it!" + ), + }, + ], + }, + config, + stream_mode="values", +) +for event in events: + if "messages" in event: + event["messages"][-1].pretty_print() + +================================ Human Message ================================= + +Ya that's helpful. Maybe I'll build an autonomous agent with it! +================================== Ai Message ================================== + +[{'text': "That's an exciting idea! Building an autonomous agent with LangGraph is indeed a great application of this technology. LangGraph is particularly well-suited for creating complex, multi-step AI workflows, which is perfect for autonomous agents. Let me gather some more specific information about using LangGraph for building autonomous agents.", 'type': 'text'}, {'id': 'toolu_01QWNHhUaeeWcGXvA4eHT7Zo', 'input': {'query': 'Building autonomous agents with LangGraph examples and tutorials'}, 'name': 'tavily_search_results_json', 'type': 'tool_use'}] +Tool Calls: + tavily_search_results_json (toolu_01QWNHhUaeeWcGXvA4eHT7Zo) + Call ID: toolu_01QWNHhUaeeWcGXvA4eHT7Zo + Args: + query: Building autonomous agents with LangGraph examples and tutorials +================================= Tool Message ================================= +Name: tavily_search_results_json + +[{"url": "https://towardsdatascience.com/building-autonomous-multi-tool-agents-with-gemini-2-0-and-langgraph-ad3d7bd5e79d", "content": "Building Autonomous Multi-Tool Agents with Gemini 2.0 and LangGraph | by Youness Mansar | Jan, 2025 | Towards Data Science Building Autonomous Multi-Tool Agents with Gemini 2.0 and LangGraph A practical tutorial with full code examples for building and running multi-tool agents Towards Data Science LLMs are remarkable — they can memorize vast amounts of information, answer general knowledge questions, write code, generate stories, and even fix your grammar. In this tutorial, we are going to build a simple LLM agent that is equipped with four tools that it can use to answer a user’s question. This Agent will have the following specifications: Follow Published in Towards Data Science --------------------------------- Your home for data science and AI. Follow Follow Follow"}, {"url": "https://github.com/anmolaman20/Tools_and_Agents", "content": "GitHub - anmolaman20/Tools_and_Agents: This repository provides resources for building AI agents using Langchain and Langgraph. This repository provides resources for building AI agents using Langchain and Langgraph. This repository provides resources for building AI agents using Langchain and Langgraph. This repository serves as a comprehensive guide for building AI-powered agents using Langchain and Langgraph. It provides hands-on examples, practical tutorials, and resources for developers and AI enthusiasts to master building intelligent systems and workflows. AI Agent Development: Gain insights into creating intelligent systems that think, reason, and adapt in real time. This repository is ideal for AI practitioners, developers exploring language models, or anyone interested in building intelligent systems. This repository provides resources for building AI agents using Langchain and Langgraph."}] +================================== Ai Message ================================== + +Great idea! Building an autonomous agent with LangGraph is definitely an exciting project. Based on the latest information I've found, here are some insights and tips for building autonomous agents with LangGraph: + +1. Multi-Tool Agents: LangGraph is particularly well-suited for creating autonomous agents that can use multiple tools. This allows your agent to have a diverse set of capabilities and choose the right tool for each task. + +2. Integration with Large Language Models (LLMs): You can combine LangGraph with powerful LLMs like Gemini 2.0 to create more intelligent and capable agents. The LLM can serve as the "brain" of your agent, making decisions and generating responses. + +3. Workflow Management: LangGraph excels at managing complex, multi-step AI workflows. This is crucial for autonomous agents that need to break down tasks into smaller steps and execute them in the right order. + +4. Practical Tutorials Available: There are tutorials available that provide full code examples for building and running multi-tool agents. These can be incredibly helpful as you start your project. + +5. Langchain Integration: LangGraph is often used in conjunction with Langchain. This combination provides a powerful framework for building AI agents, offering features like memory management, tool integration, and prompt management. + +6. GitHub Resources: There are repositories available (like the one by anmolaman20) that provide comprehensive resources for building AI agents using Langchain and LangGraph. These can be valuable references as you develop your agent. + +7. Real-time Adaptation: LangGraph allows you to create agents that can think, reason, and adapt in real-time, which is crucial for truly autonomous behavior. + +8. Customization: You can equip your agent with specific tools tailored to your use case. For example, you might include tools for web searching, data analysis, or interacting with specific APIs. + +To get started with your autonomous agent project: + +1. Familiarize yourself with LangGraph's documentation and basic concepts. +2. Look into tutorials that specifically deal with building autonomous agents, like the one mentioned from Towards Data Science. +3. Decide on the specific capabilities you want your agent to have and identify the tools it will need. +4. Start with a simple agent and gradually add complexity as you become more comfortable with the framework. +5. Experiment with different LLMs to find the one that works best for your use case. +6. Pay attention to how you structure the agent's decision-making process and workflow. +7. Don't forget to implement proper error handling and safety measures, especially if your agent will be interacting with external systems or making important decisions. + +Building an autonomous agent is an iterative process, so be prepared to refine and improve your agent over time. Good luck with your project! If you need any more specific information as you progress, feel free to ask. +Now that we've had the agent take a couple steps, we can replay the full state history to see everything that occurred. + +to_replay = None +for state in graph.get_state_history(config): + print("Num Messages: ", len(state.values["messages"]), "Next: ", state.next) + print("-" * 80) + if len(state.values["messages"]) == 6: + # We are somewhat arbitrarily selecting a specific state based on the number of chat messages in the state. + to_replay = state + +Num Messages: 8 Next: () +-------------------------------------------------------------------------------- +Num Messages: 7 Next: ('chatbot',) +-------------------------------------------------------------------------------- +Num Messages: 6 Next: ('tools',) +-------------------------------------------------------------------------------- +Num Messages: 5 Next: ('chatbot',) +-------------------------------------------------------------------------------- +Num Messages: 4 Next: ('__start__',) +-------------------------------------------------------------------------------- +Num Messages: 4 Next: () +-------------------------------------------------------------------------------- +Num Messages: 3 Next: ('chatbot',) +-------------------------------------------------------------------------------- +Num Messages: 2 Next: ('tools',) +-------------------------------------------------------------------------------- +Num Messages: 1 Next: ('chatbot',) +-------------------------------------------------------------------------------- +Num Messages: 0 Next: ('__start__',) +-------------------------------------------------------------------------------- +Notice that checkpoints are saved for every step of the graph. This spans invocations so you can rewind across a full thread's history. We've picked out to_replay as a state to resume from. This is the state after the chatbot node in the second graph invocation above. +Resuming from this point should call the action node next. + + +print(to_replay.next) +print(to_replay.config) + +('tools',) +{'configurable': {'thread_id': '1', 'checkpoint_ns': '', 'checkpoint_id': '1efd43e3-0c1f-6c4e-8006-891877d65740'}} +Notice that the checkpoint's config (to_replay.config) contains a checkpoint_id timestamp. Providing this checkpoint_id value tells LangGraph's checkpointer to load the state from that moment in time. Let's try it below: + +# The `checkpoint_id` in the `to_replay.config` corresponds to a state we've persisted to our checkpointer. +for event in graph.stream(None, to_replay.config, stream_mode="values"): + if "messages" in event: + event["messages"][-1].pretty_print() + +================================== Ai Message ================================== + +[{'text': "That's an exciting idea! Building an autonomous agent with LangGraph is indeed a great application of this technology. LangGraph is particularly well-suited for creating complex, multi-step AI workflows, which is perfect for autonomous agents. Let me gather some more specific information about using LangGraph for building autonomous agents.", 'type': 'text'}, {'id': 'toolu_01QWNHhUaeeWcGXvA4eHT7Zo', 'input': {'query': 'Building autonomous agents with LangGraph examples and tutorials'}, 'name': 'tavily_search_results_json', 'type': 'tool_use'}] +Tool Calls: + tavily_search_results_json (toolu_01QWNHhUaeeWcGXvA4eHT7Zo) + Call ID: toolu_01QWNHhUaeeWcGXvA4eHT7Zo + Args: + query: Building autonomous agents with LangGraph examples and tutorials +================================= Tool Message ================================= +Name: tavily_search_results_json + +[{"url": "https://towardsdatascience.com/building-autonomous-multi-tool-agents-with-gemini-2-0-and-langgraph-ad3d7bd5e79d", "content": "Building Autonomous Multi-Tool Agents with Gemini 2.0 and LangGraph | by Youness Mansar | Jan, 2025 | Towards Data Science Building Autonomous Multi-Tool Agents with Gemini 2.0 and LangGraph A practical tutorial with full code examples for building and running multi-tool agents Towards Data Science LLMs are remarkable — they can memorize vast amounts of information, answer general knowledge questions, write code, generate stories, and even fix your grammar. In this tutorial, we are going to build a simple LLM agent that is equipped with four tools that it can use to answer a user’s question. This Agent will have the following specifications: Follow Published in Towards Data Science --------------------------------- Your home for data science and AI. Follow Follow Follow"}, {"url": "https://github.com/anmolaman20/Tools_and_Agents", "content": "GitHub - anmolaman20/Tools_and_Agents: This repository provides resources for building AI agents using Langchain and Langgraph. This repository provides resources for building AI agents using Langchain and Langgraph. This repository provides resources for building AI agents using Langchain and Langgraph. This repository serves as a comprehensive guide for building AI-powered agents using Langchain and Langgraph. It provides hands-on examples, practical tutorials, and resources for developers and AI enthusiasts to master building intelligent systems and workflows. AI Agent Development: Gain insights into creating intelligent systems that think, reason, and adapt in real time. This repository is ideal for AI practitioners, developers exploring language models, or anyone interested in building intelligent systems. This repository provides resources for building AI agents using Langchain and Langgraph."}] +================================== Ai Message ================================== + +Great idea! Building an autonomous agent with LangGraph is indeed an excellent way to apply and deepen your understanding of the technology. Based on the search results, I can provide you with some insights and resources to help you get started: + +1. Multi-Tool Agents: + LangGraph is well-suited for building autonomous agents that can use multiple tools. This allows your agent to have a variety of capabilities and choose the appropriate tool based on the task at hand. + +2. Integration with Large Language Models (LLMs): + There's a tutorial that specifically mentions using Gemini 2.0 (Google's LLM) with LangGraph to build autonomous agents. This suggests that LangGraph can be integrated with various LLMs, giving you flexibility in choosing the language model that best fits your needs. + +3. Practical Tutorials: + There are tutorials available that provide full code examples for building and running multi-tool agents. These can be invaluable as you start your project, giving you a concrete starting point and demonstrating best practices. + +4. GitHub Resources: + There's a GitHub repository (github.com/anmolaman20/Tools_and_Agents) that provides resources for building AI agents using both Langchain and Langgraph. This could be a great resource for code examples, tutorials, and understanding how LangGraph fits into the broader LangChain ecosystem. + +5. Real-Time Adaptation: + The resources mention creating intelligent systems that can think, reason, and adapt in real-time. This is a key feature of advanced autonomous agents and something you can aim for in your project. + +6. Diverse Applications: + The materials suggest that these techniques can be applied to various tasks, from answering questions to potentially more complex decision-making processes. + +To get started with your autonomous agent project using LangGraph, you might want to: + +1. Review the tutorials mentioned, especially those with full code examples. +2. Explore the GitHub repository for hands-on examples and resources. +3. Decide on the specific tasks or capabilities you want your agent to have. +4. Choose an LLM to integrate with LangGraph (like GPT, Gemini, or others). +5. Start with a simple agent that uses one or two tools, then gradually expand its capabilities. +6. Implement decision-making logic to help your agent choose between different tools or actions. +7. Test your agent thoroughly with various inputs and scenarios to ensure robust performance. + +Remember, building an autonomous agent is an iterative process. Start simple and gradually increase complexity as you become more comfortable with LangGraph and its capabilities. + +Would you like more information on any specific aspect of building your autonomous agent with LangGraph? +Notice that the graph resumed execution from the **action** node. You can tell this is the case since the first value printed above is the response from our search engine tool. +Congratulations! You've now used time-travel checkpoint traversal in LangGraph. Being able to rewind and explore alternative paths opens up a world of possibilities for debugging, experimentation, and interactive applications. \ No newline at end of file diff --git a/multi_agents/deep_research/README.md b/multi_agents/deep_research/README.md index 99f414b88..15a9e9b2a 100644 --- a/multi_agents/deep_research/README.md +++ b/multi_agents/deep_research/README.md @@ -46,10 +46,13 @@ The recursive research step is conditionally executed based on the current depth You can use the deep research implementation in two ways: +> **Note:** The command line examples below should be run from the parent `multi_agents` directory, not from within the `deep_research` directory. + ### 1. From the command line: ```bash -python -m multi_agents.main --mode deep --query "Your research query" --depth 2 --breadth 4 +# Run deep research from the multi_agents directory +python main.py --mode deep --query "Your research query" --depth 2 --breadth 4 ``` ### 2. Programmatically: @@ -67,21 +70,27 @@ results = await run_deep_research( ## Configuration -You can configure the deep research process through the `task.json` file by adding these parameters: - -```json -{ - "query": "Your research query", - "deep_research_depth": 2, - "deep_research_breadth": 4, - "deep_research_concurrency": 2, - "source": "web", - "verbose": true, - "publish_formats": { - "markdown": true, - "pdf": true - } -} +You can configure the deep research process through command line arguments: + +```bash +# Run deep research with all options +python main.py --mode deep --query "Your research query" --depth 2 --breadth 4 --concurrency 2 --model "gpt-4o" --verbose --pdf --docx +``` + +### Available Arguments +- `--query` - The research query (required) +- `--depth` - Maximum depth of recursive research (default: 2) +- `--breadth` - Number of parallel search queries at each level (default: 4) +- `--concurrency` - Maximum number of concurrent research tasks (default: 2) +- `--model` - The model to use for research (default: "gpt-4o") +- `--verbose` - Enable verbose output (default: True) +- `--pdf` - Generate PDF output (default: False) +- `--docx` - Generate DOCX output (default: False) + +### Example +```bash +# Run deep research with custom parameters +python main.py --mode deep --query "Impact of climate change on agriculture" --depth 3 --breadth 5 --concurrency 3 --pdf --docx ``` ## Output diff --git a/multi_agents/deep_research/agents/synthesizer.py b/multi_agents/deep_research/agents/synthesizer.py index 0cd8a515f..d5686afcd 100644 --- a/multi_agents/deep_research/agents/synthesizer.py +++ b/multi_agents/deep_research/agents/synthesizer.py @@ -115,35 +115,21 @@ async def synthesize(self, query: str, search_results: List[Dict[str, Any]], tas content_list = [] sources = [] - # Debug log the structure of the first search result if available - if search_results and len(search_results) > 0: - first_result = search_results[0] - if isinstance(first_result, dict): - print_agent_output(f"First search result keys: {', '.join(first_result.keys())}", "SYNTHESIZER") - else: - print_agent_output(f"First search result is not a dictionary but a {type(first_result)}", "SYNTHESIZER") - for i, result in enumerate(search_results): if isinstance(result, dict): title = result.get("title", "Unknown Title") url = result.get("url", "") content = result.get("content", "") - # Debug log each result - print_agent_output(f"Result {i+1}: Title: {title}, URL: {url}, Content length: {len(content)}", "SYNTHESIZER") - # More robust content extraction - try different keys that might contain content if not content and "text" in result: content = result.get("text", "") - print_agent_output(f"Using 'text' field instead of 'content' for result {i+1}", "SYNTHESIZER") if not content and "snippet" in result: content = result.get("snippet", "") - print_agent_output(f"Using 'snippet' field instead of 'content' for result {i+1}", "SYNTHESIZER") if not content and "body" in result: content = result.get("body", "") - print_agent_output(f"Using 'body' field instead of 'content' for result {i+1}", "SYNTHESIZER") # If we still don't have content but have other fields, create a summary from available fields if not content: @@ -154,67 +140,14 @@ async def synthesize(self, query: str, search_results: List[Dict[str, Any]], tas if summary_parts: content = "\n".join(summary_parts) - print_agent_output(f"Created content from other fields for result {i+1}", "SYNTHESIZER") if content: content_list.append(f"Source: {title}\nURL: {url}\nContent: {content}") sources.append(result) - else: - print_agent_output(f"No content found for result {i+1}", "SYNTHESIZER") - else: - print_agent_output(f"Result {i+1} is not a dictionary but a {type(result)}", "SYNTHESIZER") - + # Combine content combined_content = "\n\n".join(content_list) - # Log the combined content length - print_agent_output(f"Combined content length: {len(combined_content)}", "SYNTHESIZER") - - # If no content, create a fallback - if not combined_content: - print_agent_output("WARNING: No content extracted from search results. Creating fallback content.", "SYNTHESIZER") - - # Create a fallback context directly from the search results - fallback_context = f"# Research on: {query}\n\n" - - # Try to extract any useful information from the search results - for i, result in enumerate(search_results): - if isinstance(result, dict): - title = result.get("title", "Unknown Title") - url = result.get("url", "") - - # Try to extract any text content from any field - content_fields = [] - for key, value in result.items(): - if isinstance(value, str) and len(value) > 10 and key not in ["title", "url"]: - content_fields.append(f"{key}: {value}") - - snippet = "\n".join(content_fields) if content_fields else "" - - if not snippet: - snippet = "No detailed content available for this source." - - if title or url or snippet: - fallback_context += f"## Source {i+1}: {title}\n" - if url: - fallback_context += f"URL: {url}\n" - fallback_context += f"Summary: {snippet}\n\n" - - # If we still don't have any content, create a generic message - if fallback_context == f"# Research on: {query}\n\n": - fallback_context += "No specific content could be extracted from the search results. This could be due to:\n\n" - fallback_context += "1. API limitations or rate limiting\n" - fallback_context += "2. Search results not containing detailed content\n" - fallback_context += "3. Technical issues with content extraction\n\n" - fallback_context += "Consider refining your search query or trying again later." - - # Use the fallback context - return { - "context": fallback_context, - "sources": search_results, # Use the original search results as sources - "citations": {} - } - try: # Process research results learnings, citations = await self.process_research_results(query, combined_content) @@ -243,32 +176,7 @@ async def synthesize(self, query: str, search_results: List[Dict[str, Any]], tas "sources": sources, "citations": citations } + except Exception as e: # Log the error - print_agent_output(f"Error processing research results: {str(e)}. Using fallback approach.", "SYNTHESIZER") - - # Create a simple context from the combined content - simple_context = f"# Research on: {query}\n\n" - simple_context += "## Key Information\n\n" - - # Add a summary of each source - for i, source in enumerate(sources): - title = source.get("title", "Unknown Title") - url = source.get("url", "") - content = source.get("content", "") - - simple_context += f"### Source {i+1}: {title}\n" - if url: - simple_context += f"URL: {url}\n" - - # Add a snippet of the content - if content: - snippet = content[:300] + "..." if len(content) > 300 else content - simple_context += f"Summary: {snippet}\n\n" - - # Return the simple context - return { - "context": simple_context, - "sources": sources, - "citations": {} - } \ No newline at end of file + print_agent_output(f"Error processing research results: {str(e)}. Using fallback approach.", "SYNTHESIZER") \ No newline at end of file diff --git a/multi_agents/deep_research/main.py b/multi_agents/deep_research/main.py index 9d64df31a..6c3224a28 100644 --- a/multi_agents/deep_research/main.py +++ b/multi_agents/deep_research/main.py @@ -189,55 +189,33 @@ async def run_deep_research( return research_results -def open_task_file() -> Dict[str, Any]: - """Open and parse the task.json file""" - # Get the directory of the current script - script_dir = os.path.dirname(os.path.abspath(__file__)) - # Go up one level to the multi_agents directory - parent_dir = os.path.dirname(script_dir) - # Construct the path to task.json - task_path = os.path.join(parent_dir, "task.json") - - # Read and parse the task file - with open(task_path, "r") as f: - return json.load(f) - async def main(): """Main entry point for deep research""" parser = argparse.ArgumentParser(description="Run deep research on a topic") - parser.add_argument("--query", type=str, help="Research query") + parser.add_argument("--query", type=str, required=True, help="Research query") parser.add_argument("--breadth", type=int, default=4, help="Research breadth") parser.add_argument("--depth", type=int, default=2, help="Research depth") parser.add_argument("--concurrency", type=int, default=2, help="Concurrency limit") parser.add_argument("--source", type=str, default="web", help="Research source (web or local)") - parser.add_argument("--verbose", action="store_true", help="Verbose output") - parser.add_argument("--task-file", action="store_true", help="Use task.json file") + parser.add_argument("--verbose", action="store_true", default=True, help="Verbose output") + parser.add_argument("--markdown", action="store_true", default=True, help="Generate markdown output") + parser.add_argument("--pdf", action="store_true", default=False, help="Generate PDF output") + parser.add_argument("--docx", action="store_true", default=False, help="Generate DOCX output") args = parser.parse_args() - if args.task_file: - # Use task.json file - task = open_task_file() - query = task.get("query") - breadth = task.get("deep_research_breadth", 4) - depth = task.get("deep_research_depth", 2) - concurrency = task.get("deep_research_concurrency", 2) - source = task.get("source", "web") - verbose = task.get("verbose", True) - publish_formats = task.get("publish_formats", {"markdown": True}) - else: - # Use command line arguments - query = args.query - breadth = args.breadth - depth = args.depth - concurrency = args.concurrency - source = args.source - verbose = args.verbose - publish_formats = {"markdown": True} - - if not query: - print("Please provide a research query with --query or use --task-file") - return + # Use command line arguments + query = args.query + breadth = args.breadth + depth = args.depth + concurrency = args.concurrency + source = args.source + verbose = args.verbose + publish_formats = { + "markdown": args.markdown, + "pdf": args.pdf, + "docx": args.docx + } # Run deep research results = await run_deep_research( @@ -250,16 +228,7 @@ async def main(): publish_formats=publish_formats ) - # Print summary - print(f"\nDeep Research completed for: {query}") - print(f"Execution time: {results.get('execution_time', 'N/A')}") - print(f"Learnings: {len(results.get('learnings', []))}") - print(f"Sources: {len(results.get('sources', []))}") - - if results.get("published_files"): - print("\nPublished files:") - for file in results["published_files"]: - print(f"- {file}") + return results if __name__ == "__main__": asyncio.run(main()) \ No newline at end of file diff --git a/multi_agents/deep_research/orchestrator.py b/multi_agents/deep_research/orchestrator.py index 8b4d525b8..785d315aa 100644 --- a/multi_agents/deep_research/orchestrator.py +++ b/multi_agents/deep_research/orchestrator.py @@ -173,43 +173,7 @@ async def process_query(self, state: DeepResearchState, query_index: int) -> dic f"Synthesis complete: {len(context_item)} chars context, {len(sources)} sources, {len(citations)} citations", "MASTER" ) - - # If synthesis didn't return any context but we have search results, create a fallback - if not context_item and search_results: - print_agent_output( - "WARNING: Synthesis returned empty context despite having search results. Creating fallback context.", - "MASTER" - ) - - # Create a fallback context directly from the search results - fallback_context = f"# Research on: {query}\n\n" - - # Add content from each search result - for i, result in enumerate(search_results): - if isinstance(result, dict): - result_title = result.get("title", "Unknown Title") - url = result.get("url", "") - content = result.get("content", "") - - if result_title or url or content: - fallback_context += f"## Source {i+1}: {result_title}\n" - if url: - fallback_context += f"URL: {url}\n\n" - if content: - # Add a snippet of the content - snippet = content[:500] + "..." if len(content) > 500 else content - fallback_context += f"{snippet}\n\n" - - # Use the fallback context - context_item = fallback_context - sources = search_results - - print_agent_output( - f"Created fallback context: {len(context_item)} chars", - "MASTER" - ) - - # Return the results + return { "context_item": context_item, "sources": sources, @@ -249,16 +213,12 @@ async def process_with_semaphore(index): async with semaphore: try: result = await self.process_query(state, index) - # Debug log each query result - if "error" in result: - print_agent_output(f"Query {index} error: {result['error']}", "MASTER") - else: - context_item = result.get("context_item", "") - sources = result.get("sources", []) - print_agent_output( - f"Query {index} result: {len(context_item)} chars context, {len(sources)} sources", - "MASTER" - ) + context_item = result.get("context_item", "") + sources = result.get("sources", []) + print_agent_output( + f"Query {index} result: {len(context_item)} chars context, {len(sources)} sources", + "MASTER" + ) return result except Exception as e: print_agent_output(f"Error processing query {index}: {str(e)}", "MASTER") @@ -286,43 +246,22 @@ async def process_with_semaphore(index): results_with_sources = 0 for i, result in enumerate(results): - if "error" in result: - # Skip errors - print_agent_output(f"Skipping result {i} due to error", "MASTER") - continue - # Add context item context_item = result.get("context_item", "") if context_item: combined_context_items.append(context_item) results_with_context += 1 - print_agent_output(f"Added context item from result {i}: {len(context_item)} chars", "MASTER") # Add sources sources = result.get("sources", []) if sources: combined_sources.extend(sources) results_with_sources += 1 - print_agent_output(f"Added {len(sources)} sources from result {i}", "MASTER") # Add citations citations = result.get("citations", {}) if citations: combined_citations.update(citations) - print_agent_output(f"Added {len(citations)} citations from result {i}", "MASTER") - - # Log the results - message = f"Collected {len(combined_context_items)} context items and {len(combined_sources)} sources." - if self.websocket and self.stream_output: - await self.stream_output("logs", "collected_results", message, self.websocket) - else: - print_agent_output(message, "MASTER") - - # Debug log the final state - print_agent_output( - f"Ending process_all_queries with {len(combined_context_items)} context items and {len(combined_sources)} sources.", - "MASTER" - ) # Debug log how many results contributed data print_agent_output( @@ -330,22 +269,6 @@ async def process_with_semaphore(index): "MASTER" ) - # If we didn't collect any context items or sources, create a fallback - if not combined_context_items and not combined_sources: - print_agent_output( - "WARNING: No context items or sources collected. Creating fallback context.", - "MASTER" - ) - - # Create a fallback context - fallback_context = f"Research on: {state.query}\n\n" - fallback_context += "No specific research data was collected from the search queries. " - fallback_context += "This could be due to API limitations, network issues, or lack of relevant information. " - fallback_context += "Consider refining your search query or trying again later." - - # Add the fallback context - combined_context_items.append(fallback_context) - # Return combined results following LangGraph pattern return { "context_items": combined_context_items, @@ -363,8 +286,6 @@ async def review_research(self, state: DeepResearchState) -> dict: message = f"Reviewing research results and identifying follow-up questions..." if self.websocket and self.stream_output: await self.stream_output("logs", "reviewing_research", message, self.websocket) - else: - print_agent_output(message, "MASTER") # Run the reviewer agent review_result = await reviewer.review( @@ -592,14 +513,9 @@ async def run(self) -> Dict[str, Any]: # If that fails, try to extract attributes from the object try: result_dict = result.__dict__ - except AttributeError: - # If that fails too, try to extract items one by one - for key in dir(result): - if not key.startswith('_') and not callable(getattr(result, key, None)): - try: - result_dict[key] = getattr(result, key) - except (AttributeError, TypeError): - pass + except Exception as e: + self.stream_output("logs", "error", f"Error converting result to dictionary. Error: {e}", self.websocket) + pass # Extract data from the result dictionary context_items = result_dict.get('context_items', []) @@ -624,11 +540,6 @@ async def run(self) -> Dict[str, Any]: if not final_context and context_items: # If no final_context but we have context_items, join them final_context = "\n\n".join(context_items) - print_agent_output("Using joined context_items as final context", "MASTER") - elif not final_context: - # Create a default context if all else fails - final_context = f"Research on: {self.task.get('query', '')}\n\nNo specific research data was collected." - print_agent_output("Using default context as final context", "MASTER") # If we have sources but they're not in the right format, try to fix them processed_sources = [] @@ -682,4 +593,4 @@ async def run(self) -> Dict[str, Any]: "context": f"Research on: {self.task.get('query', '')}\n\nAn error occurred during research: {str(e)}", "sources": [], "citations": {} - } \ No newline at end of file + } \ No newline at end of file diff --git a/multi_agents/main.py b/multi_agents/main.py index c9685ee1b..dd8a1da51 100644 --- a/multi_agents/main.py +++ b/multi_agents/main.py @@ -16,22 +16,25 @@ os.environ["LANGCHAIN_TRACING_V2"] = "true" load_dotenv() -def open_task(): - # Get the directory of the current script - current_dir = os.path.dirname(os.path.abspath(__file__)) - # Construct the absolute path to task.json - task_json_path = os.path.join(current_dir, 'task.json') - - with open(task_json_path, 'r') as f: - task = json.load(f) - - if not task: - raise Exception("No task found. Please ensure a valid task.json file is present in the multi_agents directory and contains the necessary task information.") - - return task +def create_default_task(): + """Create a default task configuration with sensible defaults.""" + return { + "max_sections": 3, + "publish_formats": { + "markdown": True, + "pdf": True, + "docx": True + }, + "include_human_feedback": False, + "follow_guidelines": False, + "model": "gpt-4o", + "guidelines": [], + "verbose": True, + "source": "web" + } async def run_research_task(query, websocket=None, stream_output=None, tone=Tone.Objective, headers=None): - task = open_task() + task = create_default_task() task["query"] = query chief_editor = ChiefEditorAgent(task, websocket, stream_output, tone, headers) @@ -44,7 +47,7 @@ async def run_research_task(query, websocket=None, stream_output=None, tone=Tone async def run_deep_research_task(query, breadth=4, depth=2, concurrency=2, websocket=None, stream_output=None, tone=Tone.Objective, headers=None): """Run deep research on a given query""" - task = open_task() + task = create_default_task() task["query"] = query task["deep_research_breadth"] = breadth task["deep_research_depth"] = depth @@ -76,18 +79,16 @@ async def main(): parser = argparse.ArgumentParser(description="Run research tasks") parser.add_argument("--mode", type=str, choices=["standard", "deep"], default="standard", help="Research mode: standard or deep") - parser.add_argument("--query", type=str, help="Research query (overrides task.json)") + parser.add_argument("--query", type=str, required=True, help="Research query") parser.add_argument("--breadth", type=int, default=4, help="Deep research breadth") parser.add_argument("--depth", type=int, default=2, help="Deep research depth") parser.add_argument("--concurrency", type=int, default=2, help="Deep research concurrency") + parser.add_argument("--model", type=str, default="gpt-4o", help="Model to use for research") + parser.add_argument("--verbose", action="store_true", default=True, help="Enable verbose output") args = parser.parse_args() - task = open_task() - query = args.query or task.get("query") - - if not query: - raise ValueError("No query provided. Please specify a query in task.json or with --query") + query = args.query if args.mode == "deep": print(f"Running deep research on: {query}") From 11f1fc0cf4fb048cbc5dac92de31fc5898fe7cdb Mon Sep 17 00:00:00 2001 From: Assaf Elovic Date: Sun, 9 Mar 2025 12:22:02 +0200 Subject: [PATCH 8/9] removed need for task.json --- multi_agents/README.md | 83 ++++++++++++---------------- multi_agents/deep_research/README.md | 41 ++++++++------ multi_agents/deep_research/main.py | 58 +++++++------------ multi_agents/main.py | 45 ++++++++------- 4 files changed, 103 insertions(+), 124 deletions(-) diff --git a/multi_agents/README.md b/multi_agents/README.md index 40be4bdbc..a877ca814 100644 --- a/multi_agents/README.md +++ b/multi_agents/README.md @@ -91,63 +91,50 @@ The deep research mode follows a different workflow: 2. Run the application: ```bash # Run standard research mode - python main.py - - # Run standard research with a specific query python main.py --query "Your research question here" # Run deep research mode - python main.py --mode deep - - # Run deep research with custom parameters - python main.py --mode deep --query "Your research question" --breadth 5 --depth 3 --concurrency 3 + python main.py --mode deep --query "Your research question" --breadth 4 --depth 2 --concurrency 2 ``` ## Usage -To change the research query and customize the report, edit the `task.json` file in the main directory. -#### Task.json contains the following fields: -- `query` - The research query or task. -- `model` - The OpenAI LLM to use for the agents. -- `max_sections` - The maximum number of sections in the report. Each section is a subtopic of the research query. -- `include_human_feedback` - If true, the user can provide feedback to the agents. If false, the agents will work autonomously. -- `publish_formats` - The formats to publish the report in. The reports will be written in the `output` directory. -- `source` - The location from which to conduct the research. Options: `web` or `local`. For local, please add `DOC_PATH` env var. -- `follow_guidelines` - If true, the research report will follow the guidelines below. It will take longer to complete. If false, the report will be generated faster but may not follow the guidelines. -- `guidelines` - A list of guidelines that the report must follow. -- `verbose` - If true, the application will print detailed logs to the console. - -#### Deep Research Configuration +To run research with custom parameters, use the command line arguments: + +```bash +# Run standard research +python main.py --mode standard --query "Your research question" + +# Run deep research +python main.py --mode deep --query "Your research question" --breadth 4 --depth 2 --concurrency 2 --model "gpt-4o" --verbose +``` + +### Available Command Line Arguments +- `--mode` - Research mode: "standard" or "deep" (default: "standard") +- `--query` - The research query or task (required) +- `--model` - The OpenAI LLM to use for the agents (default: "gpt-4o") +- `--verbose` - Enable verbose output (default: True) + +### Deep Research Arguments For deep research mode, you can also configure: -- `deep_research_breadth` - Number of parallel search queries at each level (default: 4) -- `deep_research_depth` - Maximum depth of recursive research (default: 2) -- `deep_research_concurrency` - Maximum number of concurrent research tasks (default: 2) - -#### Example task.json: -```json -{ - "query": "Is AI in a hype cycle?", - "model": "gpt-4o", - "max_sections": 3, - "publish_formats": { - "markdown": true, - "pdf": true, - "docx": true - }, - "include_human_feedback": false, - "source": "web", - "follow_guidelines": true, - "guidelines": [ - "The report MUST fully answer the original question", - "The report MUST be written in apa format", - "The report MUST be written in english" - ], - "verbose": true, - "deep_research_breadth": 4, - "deep_research_depth": 2, - "deep_research_concurrency": 2 -} +- `--breadth` - Number of parallel search queries at each level (default: 4) +- `--depth` - Maximum depth of recursive research (default: 2) +- `--concurrency` - Maximum number of concurrent research tasks (default: 2) +- `--markdown` - Generate markdown output (default: True) +- `--pdf` - Generate PDF output (default: False) +- `--docx` - Generate DOCX output (default: False) + +### Example Commands + +```bash +# Run standard research on AI +python main.py --mode standard --query "Is AI in a hype cycle?" + +# Run deep research with custom parameters +python main.py --mode deep --query "Impact of climate change on agriculture" --breadth 5 --depth 3 --concurrency 3 --pdf --docx ``` +The reports will be written in the `outputs` directory. + ## To Deploy ```shell diff --git a/multi_agents/deep_research/README.md b/multi_agents/deep_research/README.md index 99f414b88..15a9e9b2a 100644 --- a/multi_agents/deep_research/README.md +++ b/multi_agents/deep_research/README.md @@ -46,10 +46,13 @@ The recursive research step is conditionally executed based on the current depth You can use the deep research implementation in two ways: +> **Note:** The command line examples below should be run from the parent `multi_agents` directory, not from within the `deep_research` directory. + ### 1. From the command line: ```bash -python -m multi_agents.main --mode deep --query "Your research query" --depth 2 --breadth 4 +# Run deep research from the multi_agents directory +python main.py --mode deep --query "Your research query" --depth 2 --breadth 4 ``` ### 2. Programmatically: @@ -67,21 +70,27 @@ results = await run_deep_research( ## Configuration -You can configure the deep research process through the `task.json` file by adding these parameters: - -```json -{ - "query": "Your research query", - "deep_research_depth": 2, - "deep_research_breadth": 4, - "deep_research_concurrency": 2, - "source": "web", - "verbose": true, - "publish_formats": { - "markdown": true, - "pdf": true - } -} +You can configure the deep research process through command line arguments: + +```bash +# Run deep research with all options +python main.py --mode deep --query "Your research query" --depth 2 --breadth 4 --concurrency 2 --model "gpt-4o" --verbose --pdf --docx +``` + +### Available Arguments +- `--query` - The research query (required) +- `--depth` - Maximum depth of recursive research (default: 2) +- `--breadth` - Number of parallel search queries at each level (default: 4) +- `--concurrency` - Maximum number of concurrent research tasks (default: 2) +- `--model` - The model to use for research (default: "gpt-4o") +- `--verbose` - Enable verbose output (default: True) +- `--pdf` - Generate PDF output (default: False) +- `--docx` - Generate DOCX output (default: False) + +### Example +```bash +# Run deep research with custom parameters +python main.py --mode deep --query "Impact of climate change on agriculture" --depth 3 --breadth 5 --concurrency 3 --pdf --docx ``` ## Output diff --git a/multi_agents/deep_research/main.py b/multi_agents/deep_research/main.py index 9d64df31a..b8f3eec97 100644 --- a/multi_agents/deep_research/main.py +++ b/multi_agents/deep_research/main.py @@ -189,55 +189,33 @@ async def run_deep_research( return research_results -def open_task_file() -> Dict[str, Any]: - """Open and parse the task.json file""" - # Get the directory of the current script - script_dir = os.path.dirname(os.path.abspath(__file__)) - # Go up one level to the multi_agents directory - parent_dir = os.path.dirname(script_dir) - # Construct the path to task.json - task_path = os.path.join(parent_dir, "task.json") - - # Read and parse the task file - with open(task_path, "r") as f: - return json.load(f) - async def main(): """Main entry point for deep research""" parser = argparse.ArgumentParser(description="Run deep research on a topic") - parser.add_argument("--query", type=str, help="Research query") + parser.add_argument("--query", type=str, required=True, help="Research query") parser.add_argument("--breadth", type=int, default=4, help="Research breadth") parser.add_argument("--depth", type=int, default=2, help="Research depth") parser.add_argument("--concurrency", type=int, default=2, help="Concurrency limit") parser.add_argument("--source", type=str, default="web", help="Research source (web or local)") - parser.add_argument("--verbose", action="store_true", help="Verbose output") - parser.add_argument("--task-file", action="store_true", help="Use task.json file") + parser.add_argument("--verbose", action="store_true", default=True, help="Verbose output") + parser.add_argument("--markdown", action="store_true", default=True, help="Generate markdown output") + parser.add_argument("--pdf", action="store_true", default=False, help="Generate PDF output") + parser.add_argument("--docx", action="store_true", default=False, help="Generate DOCX output") args = parser.parse_args() - if args.task_file: - # Use task.json file - task = open_task_file() - query = task.get("query") - breadth = task.get("deep_research_breadth", 4) - depth = task.get("deep_research_depth", 2) - concurrency = task.get("deep_research_concurrency", 2) - source = task.get("source", "web") - verbose = task.get("verbose", True) - publish_formats = task.get("publish_formats", {"markdown": True}) - else: - # Use command line arguments - query = args.query - breadth = args.breadth - depth = args.depth - concurrency = args.concurrency - source = args.source - verbose = args.verbose - publish_formats = {"markdown": True} - - if not query: - print("Please provide a research query with --query or use --task-file") - return + # Use command line arguments + query = args.query + breadth = args.breadth + depth = args.depth + concurrency = args.concurrency + source = args.source + verbose = args.verbose + publish_formats = { + "markdown": args.markdown, + "pdf": args.pdf, + "docx": args.docx + } # Run deep research results = await run_deep_research( @@ -260,6 +238,8 @@ async def main(): print("\nPublished files:") for file in results["published_files"]: print(f"- {file}") + + return results if __name__ == "__main__": asyncio.run(main()) \ No newline at end of file diff --git a/multi_agents/main.py b/multi_agents/main.py index c9685ee1b..fa32f9d87 100644 --- a/multi_agents/main.py +++ b/multi_agents/main.py @@ -16,22 +16,25 @@ os.environ["LANGCHAIN_TRACING_V2"] = "true" load_dotenv() -def open_task(): - # Get the directory of the current script - current_dir = os.path.dirname(os.path.abspath(__file__)) - # Construct the absolute path to task.json - task_json_path = os.path.join(current_dir, 'task.json') - - with open(task_json_path, 'r') as f: - task = json.load(f) - - if not task: - raise Exception("No task found. Please ensure a valid task.json file is present in the multi_agents directory and contains the necessary task information.") - - return task +def create_default_task(): + """Create a default task configuration with sensible defaults.""" + return { + "max_sections": 3, + "publish_formats": { + "markdown": True, + "pdf": False, + "docx": False + }, + "include_human_feedback": False, + "follow_guidelines": False, + "model": "gpt-4o", + "guidelines": [], + "verbose": True, + "source": "web" + } async def run_research_task(query, websocket=None, stream_output=None, tone=Tone.Objective, headers=None): - task = open_task() + task = create_default_task() task["query"] = query chief_editor = ChiefEditorAgent(task, websocket, stream_output, tone, headers) @@ -44,7 +47,7 @@ async def run_research_task(query, websocket=None, stream_output=None, tone=Tone async def run_deep_research_task(query, breadth=4, depth=2, concurrency=2, websocket=None, stream_output=None, tone=Tone.Objective, headers=None): """Run deep research on a given query""" - task = open_task() + task = create_default_task() task["query"] = query task["deep_research_breadth"] = breadth task["deep_research_depth"] = depth @@ -76,18 +79,18 @@ async def main(): parser = argparse.ArgumentParser(description="Run research tasks") parser.add_argument("--mode", type=str, choices=["standard", "deep"], default="standard", help="Research mode: standard or deep") - parser.add_argument("--query", type=str, help="Research query (overrides task.json)") + parser.add_argument("--query", type=str, required=True, help="Research query") parser.add_argument("--breadth", type=int, default=4, help="Deep research breadth") parser.add_argument("--depth", type=int, default=2, help="Deep research depth") parser.add_argument("--concurrency", type=int, default=2, help="Deep research concurrency") + parser.add_argument("--model", type=str, default="gpt-4o", help="Model to use for research") + parser.add_argument("--verbose", action="store_true", default=True, help="Enable verbose output") + parser.add_argument("--pdf", action="store_true", default=False, help="Generate PDF output") + parser.add_argument("--docx", action="store_true", default=False, help="Generate DOCX output") args = parser.parse_args() - task = open_task() - query = args.query or task.get("query") - - if not query: - raise ValueError("No query provided. Please specify a query in task.json or with --query") + query = args.query if args.mode == "deep": print(f"Running deep research on: {query}") From 803764f4628a49c9be6d32241f5b60ff9071be19 Mon Sep 17 00:00:00 2001 From: Assaf Elovic Date: Mon, 10 Mar 2025 09:59:11 +0200 Subject: [PATCH 9/9] updated to be more suited for langgraph --- multi_agents/deep_research/README.md | 181 +++++--- multi_agents/deep_research/__init__.py | 2 + multi_agents/deep_research/agents/base.py | 9 - multi_agents/deep_research/agents/writer.py | 39 +- multi_agents/deep_research/main.py | 393 +++++++++++------- multi_agents/deep_research/memory.py | 32 +- multi_agents/deep_research/orchestrator.py | 359 +++++++++++----- .../deep_research/run_deep_research.py | 19 + multi_agents/deep_research/setup.py | 31 ++ requirements.txt | 2 +- 10 files changed, 687 insertions(+), 380 deletions(-) create mode 100644 multi_agents/deep_research/run_deep_research.py create mode 100644 multi_agents/deep_research/setup.py diff --git a/multi_agents/deep_research/README.md b/multi_agents/deep_research/README.md index 15a9e9b2a..e2307d3dd 100644 --- a/multi_agents/deep_research/README.md +++ b/multi_agents/deep_research/README.md @@ -1,104 +1,161 @@ # Deep Research with LangGraph -This module implements a deep research capability using LangGraph, inspired by the recursive deep research approach in GPT Researcher's `deep_research.py`. +This project implements a deep research system using LangGraph, a library for building stateful, multi-actor applications with LLMs. The system can perform in-depth research on a given topic, synthesize the information, and generate comprehensive reports. -## Overview +## Features -The Deep Research implementation uses LangGraph to create a recursive research workflow that: - -1. Generates search queries based on the initial query -2. Creates a research plan with follow-up questions -3. Conducts parallel research on each search query -4. Synthesizes and reviews the research results -5. Recursively explores deeper based on follow-up questions -6. Finalizes the research with citations and context +- **Deep Research**: Recursively explore a topic with configurable breadth and depth +- **Stateful Workflow**: Maintain state across multiple research steps using LangGraph's checkpointing +- **Human-in-the-Loop**: Optional human review and feedback during the research process +- **Multi-format Output**: Generate reports in Markdown, PDF, and DOCX formats +- **Customizable Tone**: Adjust the tone of the research output ## Architecture -The deep research system is built with the following components: +The system is built using LangGraph's StateGraph to create a workflow with the following components: -- **DeepResearchOrchestrator**: Coordinates the entire research process using LangGraph -- **DeepExplorerAgent**: Generates search queries and research plans -- **DeepResearchAgent**: Conducts basic research using GPTResearcher -- **DeepSynthesizerAgent**: Processes and synthesizes research results -- **DeepReviewerAgent**: Reviews research quality and completeness -- **DeepResearchState**: Manages the state of the research process +1. **Planner**: Generates a research plan with search queries +2. **Explorer**: Executes search queries and collects information +3. **Synthesizer**: Processes and synthesizes the collected information +4. **Reviewer**: Reviews the research and identifies follow-up questions +5. **Finalizer**: Finalizes the research and prepares it for report generation -The LangGraph workflow is structured as follows: +The workflow is designed to support recursive research, allowing it to explore topics in depth based on the findings at each level. -``` -generate_queries → generate_plan → process_queries → review_research → [recursive_research] → finalize_research -``` +## State Management + +The system uses LangGraph's state management capabilities to maintain and update the research state throughout the workflow. The state includes: + +- Research parameters (query, breadth, depth) +- Research progress (current depth, breadth) +- Research results (context items, sources, citations) +- Intermediate results (search queries, follow-up questions) + +## Human-in-the-Loop + +The system supports human-in-the-loop research through LangGraph's interrupt mechanism. When enabled, the system will pause at review points to allow human feedback and guidance. Humans can: + +- Review the current research progress +- Add feedback or additional context +- Suggest follow-up questions +- Continue or stop the research process + +## Installation -The recursive research step is conditionally executed based on the current depth and creates a recursive loop back to the review step. +1. Clone the repository: +```bash +git clone https://github.com/yourusername/your-repo.git +cd your-repo +``` -## How It Works +2. Install the required dependencies: +```bash +pip install -r requirements.txt +``` -1. **Query Generation**: The system generates multiple search queries based on the initial query -2. **Research Planning**: Follow-up questions are generated to guide the research -3. **Parallel Research**: Each search query is processed in parallel with a concurrency limit -4. **Research Synthesis**: Results are synthesized to extract key learnings with citations -5. **Research Review**: The quality and completeness of the research is evaluated -6. **Recursive Exploration**: If the maximum depth hasn't been reached, the system generates new queries based on follow-up questions and continues the research -7. **Finalization**: The research is finalized with a comprehensive context that includes all learnings with citations +3. Set up your API keys: +Create a `.env` file in the root directory with your API keys: +``` +OPENAI_API_KEY=your_openai_api_key +ANTHROPIC_API_KEY=your_anthropic_api_key +TAVILY_API_KEY=your_tavily_api_key +``` ## Usage -You can use the deep research implementation in two ways: +### Command Line + +You can run the deep research module directly: -> **Note:** The command line examples below should be run from the parent `multi_agents` directory, not from within the `deep_research` directory. +```bash +python -m multi_agents.deep_research.main --query "Your research query" --depth 2 --breadth 4 --human-review +``` -### 1. From the command line: +Or through the main interface: ```bash -# Run deep research from the multi_agents directory -python main.py --mode deep --query "Your research query" --depth 2 --breadth 4 +python -m multi_agents.main --mode deep --query "Your research query" --depth 2 --breadth 4 --human-review ``` -### 2. Programmatically: +If you don't provide a query, you'll be prompted to enter one. + +### Parameters + +- `--mode`: Research mode (default: "deep") +- `--query`: The research query +- `--depth`: Maximum depth of recursive research (default: 2) +- `--breadth`: Number of parallel search queries at each level (default: 4) +- `--concurrency`: Maximum number of concurrent research tasks (default: 2) +- `--source`: Research source ('web' or 'local') (default: 'web') +- `--verbose`: Enable verbose output (default: True) +- `--markdown`: Generate markdown output (default: True) +- `--pdf`: Generate PDF output (default: False) +- `--docx`: Generate DOCX output (default: False) +- `--human-review`: Enable human review during research (default: False) + +### Programmatic Usage ```python -from multi_agents.deep_research.main import run_deep_research +from multi_agents.deep_research import run_deep_research results = await run_deep_research( query="Your research query", depth=2, breadth=4, - concurrency=2 + human_review=True ) ``` -## Configuration +## Output + +The system generates a comprehensive research report that includes: + +- Introduction and context +- Detailed findings organized by sections +- Sources and citations +- Conclusion and summary + +## Requirements + +- Python 3.8+ +- LangGraph +- LangChain +- An LLM provider (e.g., OpenAI, Anthropic) + +## Troubleshooting -You can configure the deep research process through command line arguments: +### LangGraph Compatibility Issues +This project is designed to work with LangGraph version 0.0.19. If you encounter import errors, try the following: + +1. Install the exact version specified in requirements.txt: ```bash -# Run deep research with all options -python main.py --mode deep --query "Your research query" --depth 2 --breadth 4 --concurrency 2 --model "gpt-4o" --verbose --pdf --docx +pip install -r requirements.txt ``` -### Available Arguments -- `--query` - The research query (required) -- `--depth` - Maximum depth of recursive research (default: 2) -- `--breadth` - Number of parallel search queries at each level (default: 4) -- `--concurrency` - Maximum number of concurrent research tasks (default: 2) -- `--model` - The model to use for research (default: "gpt-4o") -- `--verbose` - Enable verbose output (default: True) -- `--pdf` - Generate PDF output (default: False) -- `--docx` - Generate DOCX output (default: False) - -### Example +2. If you're still having issues, you can try installing a specific version of LangGraph: ```bash -# Run deep research with custom parameters -python main.py --mode deep --query "Impact of climate change on agriculture" --depth 3 --breadth 5 --concurrency 3 --pdf --docx +pip install langgraph==0.0.19 ``` -## Output +3. For newer versions of LangGraph, you might need to update import paths. Common issues include: + + - `append_list` and `merge_dicts` functions: The project includes custom implementations if these are not available in your LangGraph version. + + - `MemorySaver` location: This might be in `langgraph.checkpoint.memory` or directly in `langgraph.checkpoint` depending on your version. + + - `Command` and `interrupt`: These might be in different modules in different versions. + +4. If you're using a very new version of LangGraph, you might need to update the code to use the latest API. Check the [LangGraph documentation](https://python.langchain.com/docs/langgraph) for the latest information. + +### Python Version Compatibility + +This project is tested with Python 3.8+. If you're using Python 3.12, you might encounter compatibility issues with some dependencies. Try using Python 3.10 or 3.11 if possible. + +### API Key Issues + +If you're encountering errors related to API calls, make sure your API keys are correctly set in the `.env` file and that you have sufficient credits/quota for the services you're using. -The deep research process produces: +## License -1. A comprehensive research context with citations -2. A list of key learnings from the research -3. A quality review of the research -4. A final report generated by the Writer agent -5. Published files in the specified formats \ No newline at end of file +[MIT License](LICENSE) \ No newline at end of file diff --git a/multi_agents/deep_research/__init__.py b/multi_agents/deep_research/__init__.py index d22e0bfbc..161fe5546 100644 --- a/multi_agents/deep_research/__init__.py +++ b/multi_agents/deep_research/__init__.py @@ -1,4 +1,5 @@ from .orchestrator import DeepResearchOrchestrator +from .main import run_deep_research from .agents import ( DeepResearchAgent, DeepExplorerAgent, @@ -8,6 +9,7 @@ __all__ = [ "DeepResearchOrchestrator", + "run_deep_research", "DeepResearchAgent", "DeepExplorerAgent", "DeepSynthesizerAgent", diff --git a/multi_agents/deep_research/agents/base.py b/multi_agents/deep_research/agents/base.py index 87493e474..1b8e6ec1a 100644 --- a/multi_agents/deep_research/agents/base.py +++ b/multi_agents/deep_research/agents/base.py @@ -154,15 +154,6 @@ async def search(self, query: str, task: Dict[str, Any]) -> List[Dict[str, Any]] "url": "", "content": context }) - - # If still no search results, create a minimal result - if not search_results: - print_agent_output("No search results or context found. Creating a minimal result.", "RESEARCHER") - search_results.append({ - "title": f"Research on {query}", - "url": "", - "content": f"No specific information found for the query: {query}. This could be due to API limitations, network issues, or lack of relevant information." - }) # Debug log the final search results print_agent_output(f"Returning {len(search_results)} search results", "RESEARCHER") diff --git a/multi_agents/deep_research/agents/writer.py b/multi_agents/deep_research/agents/writer.py index a2b540fc6..eb307d64d 100644 --- a/multi_agents/deep_research/agents/writer.py +++ b/multi_agents/deep_research/agents/writer.py @@ -131,24 +131,15 @@ async def generate_sections(self, research_state: Dict[str, Any]) -> List[Dict[s context = research_state.get("context", "") task = research_state.get("task", {}) sources = research_state.get("sources", []) - citations = research_state.get("citations", {}) # The context already contains the research results with sources # We don't need to check for empty sources as the context itself is the source # Just ensure the context is not empty if not context: - if self.websocket and self.stream_output: - await self.stream_output( - "logs", - "error", - "No research context found. Cannot generate sections without research data.", - self.websocket, - ) - else: - print_agent_output( - "No research context found. Cannot generate sections without research data.", - agent="WRITER", - ) + print_agent_output( + "No research context found. Cannot generate sections without research data.", + agent="WRITER", + ) raise ValueError("No research context found. Cannot generate sections without research data.") # Get model from task or use a default model if None @@ -159,18 +150,10 @@ async def generate_sections(self, research_state: Dict[str, Any]) -> List[Dict[s model = cfg.smart_llm_model # Use the default smart model from config # Log the action - if self.websocket and self.stream_output: - await self.stream_output( - "logs", - "generating_sections", - f"Generating sections from deep research data with {len(sources)} sources...", - self.websocket, - ) - else: - print_agent_output( - f"Generating sections from deep research data with {len(sources)} sources...", - agent="WRITER", - ) + print_agent_output( + f"Generating sections from deep research data with {len(sources)} sources...", + agent="WRITER", + ) # Format sources for better context formatted_sources = [] @@ -182,7 +165,7 @@ async def generate_sections(self, research_state: Dict[str, Any]) -> List[Dict[s formatted_sources.append(f"Source: {title}\nURL: {url}\nContent: {content}") elif isinstance(source, str): formatted_sources.append(source) - + # Create the prompt for generating sections prompt = [ { @@ -194,11 +177,9 @@ async def generate_sections(self, research_state: Dict[str, Any]) -> List[Dict[s "content": f"""Today's date is {datetime.now().strftime('%d/%m/%Y')}. Query or Topic: {query} Research data: {context} -Sources: {formatted_sources} -Citations: {citations} Your task is to organize this research data into 3-5 logical sections with appropriate headers. -Each section should be comprehensive and detailed, with a minimum of 300 words per section. +Each section should be comprehensive and detailed, with a minimum of 400 words per section. IMPORTANT: You MUST include relevant sources in the content as markdown hyperlinks. For example: 'This is a sample text. ([Source Title](url))' diff --git a/multi_agents/deep_research/main.py b/multi_agents/deep_research/main.py index b8f3eec97..a0c0824ee 100644 --- a/multi_agents/deep_research/main.py +++ b/multi_agents/deep_research/main.py @@ -2,14 +2,38 @@ import json import asyncio import argparse +import logging from typing import Dict, Any, Optional from datetime import datetime -from gpt_researcher.utils.enum import Tone -from .orchestrator import DeepResearchOrchestrator -from ..agents.writer import WriterAgent as MainWriterAgent -from ..agents.publisher import PublisherAgent -from .agents import WriterAgent, ReporterAgent +# Configure logging - only show warnings and errors by default +logging.basicConfig( + level=logging.WARNING, # Changed from INFO to WARNING + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# Try to import required modules with fallbacks +try: + from gpt_researcher.utils.enum import Tone +except ImportError: + # Create a simple Tone enum if not available + from enum import Enum + class Tone(Enum): + Objective = "objective" + Balanced = "balanced" + Critical = "critical" + Optimistic = "optimistic" + +try: + from .orchestrator import DeepResearchOrchestrator + from ..agents.writer import WriterAgent as MainWriterAgent + from ..agents.publisher import PublisherAgent + from .agents import WriterAgent, ReporterAgent +except ImportError as e: + logger.error(f"Error importing required modules: {str(e)}") + logger.error("Please make sure all dependencies are installed correctly.") + raise async def run_deep_research( query: str, @@ -18,11 +42,12 @@ async def run_deep_research( concurrency: int = 2, websocket=None, stream_output=None, - tone=Tone.Objective, + tone=None, # Make tone optional with default set below headers=None, source="web", verbose=True, - publish_formats=None + publish_formats=None, + human_review=False ) -> Dict[str, Any]: """ Run deep research on a given query. @@ -39,10 +64,25 @@ async def run_deep_research( source: Research source ('web' or 'local') verbose: Whether to print verbose output publish_formats: Output formats to publish + human_review: Whether to enable human review during research Returns: Dictionary with research results """ + # Set default tone if not provided + if tone is None: + try: + tone = Tone.Objective + except: + # If Tone enum is not available, use a string + tone = "objective" + + # Set logging level based on verbose flag + if verbose: + logging.getLogger('multi_agents.deep_research').setLevel(logging.INFO) + else: + logging.getLogger('multi_agents.deep_research').setLevel(logging.WARNING) + # Create task configuration task = { "query": query, @@ -54,158 +94,185 @@ async def run_deep_research( "publish_formats": publish_formats or {"markdown": True} } - # Run deep research - orchestrator = DeepResearchOrchestrator( - task=task, - websocket=websocket, - stream_output=stream_output, - tone=tone, - headers=headers - ) - - # Run the research - research_results = await orchestrator.run() - - # Create the section writer agent - writer = WriterAgent(websocket, stream_output, headers) - - # Get current date - current_date = datetime.now().strftime("%d/%m/%Y") - - # Format sources for references if they're in dictionary format - sources = research_results.get("sources", []) - formatted_sources = [] - - for source in sources: - if isinstance(source, dict) and "url" in source: - # Format source as a reference - title = source.get("title", "Unknown Title") - url = source.get("url", "") - formatted_sources.append(f"- {title} [{url}]({url})") - elif isinstance(source, str): - # Source is already a string - formatted_sources.append(source) - - # The context already contains the research results with sources - # We don't need to check for empty sources as the context itself is the source - # Just ensure the context is not empty - context = research_results.get("context", "") - if not context: - error_msg = "No research context found in results. Cannot generate report without research data." - if websocket and stream_output: - await stream_output( - "logs", - "error", - error_msg, - websocket, - ) - else: - print(f"ERROR: {error_msg}") - - # Create a fallback context - context = f"Research on: {query}\n\nNo specific research data was collected. This could be due to API limitations, network issues, or lack of relevant information." - print(f"Created fallback context: {len(context)} chars") - - # If we have sources but no formatted sources, create them - if sources and not formatted_sources: - print("Creating formatted sources from sources") - for i, source in enumerate(sources): - if isinstance(source, dict): - title = source.get("title", f"Source {i+1}") - url = source.get("url", "") - formatted_sources.append(f"- {title} [{url}]({url})") - - # Prepare research state for writer - research_state = { - "task": task, - "query": query, - "title": f"Deep Research: {query}", - "date": current_date, - "context": context, # Pass context as is, no need to convert - "research_data": [{"topic": query, "content": context}], # Pass context as is - "sources": sources, # Pass the original sources with full content - "formatted_sources": formatted_sources, # Also pass formatted sources for display - "citations": research_results.get("citations", {}) - } + try: + # Run deep research + orchestrator = DeepResearchOrchestrator( + task=task, + websocket=websocket, + stream_output=stream_output, + tone=tone, + headers=headers, + human_review_enabled=human_review + ) + + # Run the research + research_results = await orchestrator.run() + except Exception as e: + logger.error(f"Error during research: {str(e)}", exc_info=True) + return { + "query": query, + "context": f"Error during research: {str(e)}", + "sources": [], + "citations": {}, + "error": str(e) + } - # If context is empty but we have sources, create context from sources - if not context and sources: - print("WARNING: Context is empty but sources exist. Creating context from sources.") - context_parts = [] + try: + # Create the section writer agent + writer = WriterAgent(websocket, stream_output, headers) + + # Get current date + current_date = datetime.now().strftime("%d/%m/%Y") + + # Format sources for references if they're in dictionary format + sources = research_results.get("sources", []) + formatted_sources = [] + for source in sources: - if isinstance(source, dict) and "content" in source: + if isinstance(source, dict) and "url" in source: + # Format source as a reference title = source.get("title", "Unknown Title") url = source.get("url", "") - content = source.get("content", "") - if content: - context_parts.append(f"From {title} [{url}]:\n{content}") - - if context_parts: - context = "\n\n".join(context_parts) - research_state["context"] = context - research_state["research_data"] = [{"topic": query, "content": context}] - print(f"Created context from sources: {len(context)} chars") - - # Generate sections and transform research data - transformed_research_state = await writer.run(research_state) - - # Generate report using the Writer agent - main_writer = MainWriterAgent(websocket, stream_output, headers) - report_state = await main_writer.run(transformed_research_state) - - # Create the report formatter agent - reporter = ReporterAgent(websocket, stream_output, headers) - - # Format the report for the publisher - publisher_state = await reporter.run(report_state, transformed_research_state) - - # Publish the report if formats are specified - if publish_formats: - # Create the publisher agent - publisher = PublisherAgent(orchestrator.output_dir, websocket, stream_output, headers) + formatted_sources.append(f"- {title} [{url}]({url})") + elif isinstance(source, str): + # Source is already a string + formatted_sources.append(source) + + # The context already contains the research results with sources + # We don't need to check for empty sources as the context itself is the source + # Just ensure the context is not empty + context = research_results.get("context", "") + if not context: + error_msg = "No research context found in results. Cannot generate report without research data." + if websocket and stream_output: + await stream_output( + "logs", + "error", + error_msg, + websocket, + ) + else: + logger.error(error_msg) + + # Create a fallback context + context = f"Research on: {query}\n\nNo specific research data was collected. This could be due to API limitations, network issues, or lack of relevant information." + logger.info(f"Created fallback context: {len(context)} chars") - # Ensure all necessary components are in the publisher state - complete_publisher_state = { + # If we have sources but no formatted sources, create them + if sources and not formatted_sources: + logger.info("Creating formatted sources from sources") + for i, source in enumerate(sources): + if isinstance(source, dict): + title = source.get("title", f"Source {i+1}") + url = source.get("url", "") + formatted_sources.append(f"- {title} [{url}]({url})") + + # Prepare research state for writer + research_state = { "task": task, - "headers": publisher_state.get("headers", {}), - "research_data": publisher_state.get("research_data", []), - "sources": publisher_state.get("sources", []), - "introduction": publisher_state.get("introduction", ""), - "conclusion": publisher_state.get("conclusion", ""), - "table_of_contents": publisher_state.get("table_of_contents", ""), - "title": publisher_state.get("title", f"Deep Research: {query}"), - "date": publisher_state.get("date", current_date), - "sections": publisher_state.get("sections", []) + "query": query, + "title": f"Deep Research: {query}", + "date": current_date, + "context": context, # Pass context as is, no need to convert + "research_data": [{"topic": query, "content": context}], # Pass context as is + "sources": sources, # Pass the original sources with full content + "formatted_sources": formatted_sources, # Also pass formatted sources for display + "citations": research_results.get("citations", {}) } - # Run the publisher agent - publish_state = await publisher.run(complete_publisher_state) + # If context is empty but we have sources, create context from sources + if not context and sources: + logger.warning("Context is empty but sources exist. Creating context from sources.") + context_parts = [] + for source in sources: + if isinstance(source, dict) and "content" in source: + title = source.get("title", "Unknown Title") + url = source.get("url", "") + content = source.get("content", "") + if content: + context_parts.append(f"From {title} [{url}]:\n{content}") + + if context_parts: + context = "\n\n".join(context_parts) + research_state["context"] = context + research_state["research_data"] = [{"topic": query, "content": context}] + logger.info(f"Created context from sources: {len(context)} chars") - # Add published files to results - research_results["published_files"] = publish_state.get("published_files", []) - - # Add report to results - research_results["report"] = report_state.get("report", "") - - return research_results + # Generate sections and transform research data + transformed_research_state = await writer.run(research_state) + + # Generate report using the Writer agent + main_writer = MainWriterAgent(websocket, stream_output, headers) + report_state = await main_writer.run(transformed_research_state) + + # Create the report formatter agent + reporter = ReporterAgent(websocket, stream_output, headers) + + # Format the report for the publisher + publisher_state = await reporter.run(report_state, transformed_research_state) + + # Publish the report if formats are specified + if publish_formats: + # Create the publisher agent + publisher = PublisherAgent(orchestrator.output_dir, websocket, stream_output, headers) + + # Ensure all necessary components are in the publisher state + complete_publisher_state = { + "task": task, + "headers": publisher_state.get("headers", {}), + "research_data": publisher_state.get("research_data", []), + "sources": publisher_state.get("sources", []), + "introduction": publisher_state.get("introduction", ""), + "conclusion": publisher_state.get("conclusion", ""), + "table_of_contents": publisher_state.get("table_of_contents", ""), + "title": publisher_state.get("title", f"Deep Research: {query}"), + "date": publisher_state.get("date", current_date), + "sections": publisher_state.get("sections", []) + } + + # Run the publisher agent + publish_state = await publisher.run(complete_publisher_state) + + # Add published files to results + research_results["published_files"] = publish_state.get("published_files", []) + + # Add report to results + research_results["report"] = report_state.get("report", "") + + return research_results + except Exception as e: + logger.error(f"Error during report generation: {str(e)}") + # Return the research results even if report generation failed + research_results["error"] = f"Error during report generation: {str(e)}" + return research_results async def main(): """Main entry point for deep research""" parser = argparse.ArgumentParser(description="Run deep research on a topic") - parser.add_argument("--query", type=str, required=True, help="Research query") + parser.add_argument("--query", type=str, help="Research query") parser.add_argument("--breadth", type=int, default=4, help="Research breadth") parser.add_argument("--depth", type=int, default=2, help="Research depth") parser.add_argument("--concurrency", type=int, default=2, help="Concurrency limit") parser.add_argument("--source", type=str, default="web", help="Research source (web or local)") - parser.add_argument("--verbose", action="store_true", default=True, help="Verbose output") + parser.add_argument("--verbose", action="store_true", default=False, help="Verbose output") parser.add_argument("--markdown", action="store_true", default=True, help="Generate markdown output") parser.add_argument("--pdf", action="store_true", default=False, help="Generate PDF output") parser.add_argument("--docx", action="store_true", default=False, help="Generate DOCX output") + parser.add_argument("--human-review", action="store_true", default=False, help="Enable human review during research") + parser.add_argument("--mode", type=str, default="deep", help="Research mode (deep or standard)") args = parser.parse_args() - # Use command line arguments + # Handle the mode argument + if args.mode != "deep": + logger.warning(f"Mode '{args.mode}' is not supported. Using 'deep' mode.") + + # Get the query from arguments or prompt the user query = args.query + if not query: + query = input("Enter your research query: ") + + # Use command line arguments breadth = args.breadth depth = args.depth concurrency = args.concurrency @@ -216,30 +283,40 @@ async def main(): "pdf": args.pdf, "docx": args.docx } + human_review = args.human_review - # Run deep research - results = await run_deep_research( - query=query, - breadth=breadth, - depth=depth, - concurrency=concurrency, - source=source, - verbose=verbose, - publish_formats=publish_formats - ) - - # Print summary - print(f"\nDeep Research completed for: {query}") - print(f"Execution time: {results.get('execution_time', 'N/A')}") - print(f"Learnings: {len(results.get('learnings', []))}") - print(f"Sources: {len(results.get('sources', []))}") - - if results.get("published_files"): - print("\nPublished files:") - for file in results["published_files"]: - print(f"- {file}") - - return results + try: + # Run deep research + results = await run_deep_research( + query=query, + breadth=breadth, + depth=depth, + concurrency=concurrency, + source=source, + verbose=verbose, + publish_formats=publish_formats, + human_review=human_review + ) + + # Print summary + print(f"\nDeep Research completed for: {query}") + print(f"Execution time: {results.get('execution_time', 'N/A')}") + print(f"Learnings: {len(results.get('learnings', []))}") + print(f"Sources: {len(results.get('sources', []))}") + + if results.get("published_files"): + print("\nPublished files:") + for file in results["published_files"]: + print(f"- {file}") + + if results.get("error"): + print(f"\nWarning: {results['error']}") + + return results + except Exception as e: + logger.error(f"Error in main function: {str(e)}") + print(f"\nError: {str(e)}") + return {"error": str(e)} if __name__ == "__main__": asyncio.run(main()) \ No newline at end of file diff --git a/multi_agents/deep_research/memory.py b/multi_agents/deep_research/memory.py index f007d1f6b..a9de90d7b 100644 --- a/multi_agents/deep_research/memory.py +++ b/multi_agents/deep_research/memory.py @@ -1,5 +1,22 @@ -from typing import Dict, List, Any, Set, Optional +from typing import Dict, List, Any, Set, Optional, Annotated from pydantic import BaseModel, Field +from .agents.base import trim_context_to_word_limit + +# Define our own merge_dicts function since it's not available in the current langgraph version +def merge_dicts(old_dict: Dict, new_dict: Dict) -> Dict: + """Merge two dictionaries, updating the old_dict with values from new_dict.""" + result = old_dict.copy() + result.update(new_dict) + return result + +# Define our own append_list function since it's not available in the current langgraph version +def append_list(old_list: List, new_list: List) -> List: + """Append items from new_list to old_list, avoiding duplicates.""" + result = old_list.copy() + for item in new_list: + if item not in result: + result.append(item) + return result class DeepResearchState(BaseModel): """ @@ -24,18 +41,18 @@ class DeepResearchState(BaseModel): total_breadth: int = 4 # Research results - learnings: List[str] = Field(default_factory=list) - citations: Dict[str, str] = Field(default_factory=dict) - visited_urls: Set[str] = Field(default_factory=set) + learnings: Annotated[List[str], append_list] = Field(default_factory=list) + citations: Annotated[Dict[str, str], merge_dicts] = Field(default_factory=dict) + visited_urls: Annotated[Set[str], lambda x, y: x.union(y)] = Field(default_factory=set) # Primary context storage - individual pieces of research - context_items: List[str] = Field(default_factory=list) + context_items: Annotated[List[str], append_list] = Field(default_factory=list) # Legacy context field - kept for backward compatibility - context: List[str] = Field(default_factory=list) + context: Annotated[List[str], append_list] = Field(default_factory=list) # Sources from research - sources: List[Dict[str, Any]] = Field(default_factory=list) + sources: Annotated[List[Dict[str, Any]], append_list] = Field(default_factory=list) # Intermediate results search_queries: List[Dict[str, str]] = Field(default_factory=list) @@ -115,7 +132,6 @@ def finalize_context(self): This method combines all context_items into a single string, trims it to a reasonable size, and returns it. """ - from .agents.base import trim_context_to_word_limit # Combine all context items combined_context = "\n\n".join(self.context_items) diff --git a/multi_agents/deep_research/orchestrator.py b/multi_agents/deep_research/orchestrator.py index 785d315aa..2aff33ab2 100644 --- a/multi_agents/deep_research/orchestrator.py +++ b/multi_agents/deep_research/orchestrator.py @@ -5,7 +5,52 @@ from typing import Dict, List, Any, Optional, Set from datetime import datetime, timedelta -from langgraph.graph import StateGraph, END +# Basic LangGraph imports that should be available in all versions +try: + from langgraph.graph import StateGraph, START, END +except ImportError: + logging.error("Could not import StateGraph from langgraph.graph. Please check your LangGraph installation.") + raise + +# Try to import MemorySaver from different possible locations +try: + from langgraph.checkpoint.memory import MemorySaver +except ImportError: + try: + from langgraph.checkpoint import MemorySaver + except ImportError: + # Create a simple in-memory checkpointer if not available + class MemorySaver: + def __init__(self): + self.memory = {} + + def get(self, key, default=None): + return self.memory.get(key, default) + + def put(self, key, value): + self.memory[key] = value + return key + logging.warning("Using a simplified MemorySaver implementation as the original could not be imported.") + +# Try to import Command and interrupt, create placeholders if not available +try: + from langgraph.types import Command, interrupt +except ImportError: + try: + from langgraph.graph.types import Command, interrupt + except ImportError: + # Create placeholder implementations + class Command: + def __init__(self, resume=None, update=None): + self.resume = resume + self.update = update + + def interrupt(data): + """Placeholder for interrupt function if not available.""" + logging.warning("Interrupt functionality not available in this version of LangGraph.") + return {"action": "Continue"} + logging.warning("Using placeholder implementations for Command and interrupt.") + from ..agents.utils.views import print_agent_output, AgentColor from ..agents.utils.utils import sanitize_filename @@ -24,7 +69,7 @@ class DeepResearchOrchestrator: """Orchestrator for deep research using LangGraph""" - def __init__(self, task: dict, websocket=None, stream_output=None, tone=None, headers=None): + def __init__(self, task: dict, websocket=None, stream_output=None, tone=None, headers=None, human_review_enabled=False): self.task = task self.websocket = websocket self.stream_output = stream_output @@ -32,6 +77,7 @@ def __init__(self, task: dict, websocket=None, stream_output=None, tone=None, he self.tone = tone self.task_id = self._generate_task_id() self.output_dir = self._create_output_directory() + self.human_review_enabled = human_review_enabled # Research parameters self.breadth = task.get('deep_research_breadth', 4) @@ -41,6 +87,29 @@ def __init__(self, task: dict, websocket=None, stream_output=None, tone=None, he # Initialize agents self.agents = self._initialize_agents() + # Initialize checkpointer + self.checkpointer = MemorySaver() + + async def _stream_or_print(self, message: str, agent_type: str = "MASTER"): + """ + Stream a message to the websocket or print it to the console. + + Args: + message: The message to stream or print + agent_type: The type of agent sending the message (for coloring) + """ + if self.websocket and self.stream_output: + # Stream to websocket if available + await self.stream_output( + "logs", + agent_type.lower(), + message, + self.websocket + ) + else: + # Otherwise print to console + print_agent_output(message, agent_type) + def _generate_task_id(self): """Generate a unique task ID""" return int(time.time()) @@ -103,6 +172,9 @@ async def generate_search_queries(self, state: DeepResearchState) -> dict: queries = planner_result.get("search_queries", []) total_breadth = planner_result.get("total_breadth", len(queries)) + # Update the state's total_breadth directly + state.total_breadth = total_breadth + # Return a dictionary with updated search queries return { "search_queries": queries, @@ -120,6 +192,9 @@ async def process_query(self, state: DeepResearchState, query_index: int) -> dic query = query_obj.get("query", "") title = query_obj.get("title", f"Query {query_index + 1}") + # Update the state's current_breadth + state.current_breadth = query_index + 1 + # Log progress await self._on_progress(state, current_breadth=query_index+1) @@ -167,12 +242,6 @@ async def process_query(self, state: DeepResearchState, query_index: int) -> dic context_item = synthesis_result.get("context", "") sources = synthesis_result.get("sources", []) citations = synthesis_result.get("citations", {}) - - # Log synthesis results - print_agent_output( - f"Synthesis complete: {len(context_item)} chars context, {len(sources)} sources, {len(citations)} citations", - "MASTER" - ) return { "context_item": context_item, @@ -207,6 +276,9 @@ async def process_all_queries(self, state: DeepResearchState) -> dict: "citations": state.citations, "current_depth": state.current_depth } + + # Reset current_breadth to 0 at the start + state.current_breadth = 0 async def process_with_semaphore(index): """Process a query with semaphore for concurrency control""" @@ -230,17 +302,14 @@ async def process_with_semaphore(index): # Wait for all tasks to complete results = await asyncio.gather(*tasks) + # Update the state's current_breadth to the total after all queries are processed + state.current_breadth = len(state.search_queries) + # Combine results combined_context_items = list(state.context_items) # Create a copy to avoid modifying the original combined_sources = list(state.sources) # Create a copy combined_citations = dict(state.citations) # Create a copy - # Debug log the results before combining - print_agent_output( - f"Processing {len(results)} query results to combine", - "MASTER" - ) - # Track how many results actually contributed data results_with_context = 0 results_with_sources = 0 @@ -278,31 +347,118 @@ async def process_with_semaphore(index): } async def review_research(self, state: DeepResearchState) -> dict: - """Review the research results and identify follow-up questions""" + """Review the research results""" # Get the reviewer agent - reviewer = self.agents["reviewer"] + reviewer = self.agents.get("reviewer") - # Log the action - message = f"Reviewing research results and identifying follow-up questions..." - if self.websocket and self.stream_output: - await self.stream_output("logs", "reviewing_research", message, self.websocket) - - # Run the reviewer agent + # Update progress + await self._on_progress(state, current_depth=state.current_depth) + + # Log the review process + await self._stream_or_print(f"Reviewing research results for depth {state.current_depth}...", "REVIEWER") + + # If human review is enabled, request human input + if self.human_review_enabled: + return await self.human_review_research(state) + + # Otherwise, use the AI reviewer review_result = await reviewer.review( - query=state.query, - context_items=state.context_items, - current_depth=state.current_depth, - total_depth=state.total_depth + state.query, + state.context_items, + state.current_depth, + state.total_depth ) - # Extract follow-up questions - follow_up_questions = review_result.get("follow_up_questions", []) + # Set the review in the state + state.set_review(review_result) - # Return the results, including the current depth to ensure it's preserved - return { - "follow_up_questions": follow_up_questions, - "current_depth": state.current_depth # Explicitly include current_depth - } + # Set follow-up questions if available + if "follow_up_questions" in review_result: + state.set_follow_up_questions(review_result["follow_up_questions"]) + + return state + + async def human_review_research(self, state: DeepResearchState) -> dict: + """Request human review of research results""" + # Prepare a summary of the research for human review + context_summary = "\n\n".join(state.context_items[:3]) # Show first 3 items + if len(state.context_items) > 3: + context_summary += f"\n\n... and {len(state.context_items) - 3} more items" + + sources_summary = "\n".join([ + f"- {s.get('title', 'Source')} ({s.get('url', 'No URL')})" + for s in state.sources[:5] # Show first 5 sources + ]) + if len(state.sources) > 5: + sources_summary += f"\n... and {len(state.sources) - 5} more sources" + + # Create a summary message + summary = f""" +Research Progress: Depth {state.current_depth} of {state.total_depth} + +Query: {state.query} + +Context Items: {len(state.context_items)} +{context_summary} + +Sources: {len(state.sources)} +{sources_summary} + +Learnings: {len(state.learnings)} +""" + + # Log that we're waiting for human review + await self._stream_or_print("Waiting for human review...", "HUMAN") + + # Use interrupt to pause execution and wait for human input + try: + human_response = interrupt({ + "summary": summary, + "question": "Would you like to continue with this research or provide feedback?", + "options": ["Continue", "Add feedback", "Stop research"] + }) + + # Process the human response + if human_response.get("action") == "Stop research": + # If the human wants to stop, set current_depth to total_depth to end recursion + state.current_depth = state.total_depth + state.set_review({"follow_up_questions": []}) + await self._stream_or_print("Research stopped by human reviewer", "HUMAN") + + elif human_response.get("action") == "Add feedback": + # Add the human feedback as a context item + feedback = human_response.get("feedback", "") + if feedback: + state.add_context_item(f"Human feedback: {feedback}") + await self._stream_or_print(f"Added human feedback: {feedback}", "HUMAN") + + # Set any follow-up questions provided by the human + follow_ups = human_response.get("follow_up_questions", []) + if follow_ups: + state.set_follow_up_questions(follow_ups) + await self._stream_or_print(f"Added {len(follow_ups)} follow-up questions from human", "HUMAN") + + state.set_review({"follow_up_questions": follow_ups}) + + else: + # Continue with default AI-generated follow-up questions + reviewer = self.agents.get("reviewer") + review_result = await reviewer.review( + state.query, + state.context_items, + state.current_depth, + state.total_depth + ) + state.set_review(review_result) + + # Set follow-up questions if available + if "follow_up_questions" in review_result: + state.set_follow_up_questions(review_result["follow_up_questions"]) + + except Exception as e: + logger.warning(f"Human review interrupted with error: {str(e)}. Falling back to AI reviewer.") + + return state async def recursive_research(self, state: DeepResearchState) -> dict: """Perform recursive research on follow-up questions""" @@ -405,24 +561,33 @@ async def finalize_research(self, state: DeepResearchState) -> dict: """Finalize the research process using the finalizer agent""" finalizer = self.agents["finalizer"] + # First, call the state's finalize_context method to ensure it's set + final_context = state.finalize_context() + # Create a temporary state for the finalizer finalizer_state = { "query": state.query, "context_items": state.context_items, "sources": state.sources, - "citations": state.citations + "citations": state.citations, + "final_context": final_context # Include the finalized context } # Run the finalizer to generate the final context finalizer_result = await finalizer.run(finalizer_state) + # Ensure final_context is set + if not finalizer_result.get("final_context") and final_context: + finalizer_result["final_context"] = final_context + # Return the finalized research state return { "context": finalizer_result.get("context", ""), "summary": finalizer_result.get("summary", ""), "sources": finalizer_result.get("sources", []), "citations": finalizer_result.get("citations", {}), - "final_context": finalizer_result.get("final_context", "") + "final_context": finalizer_result.get("final_context", final_context), # Use the state's final_context as fallback + "learnings": finalizer_result.get("learnings", []) } def should_continue_recursion(self, state: DeepResearchState) -> str: @@ -443,7 +608,8 @@ def create_workflow(self) -> StateGraph: workflow.add_node("recursive_research", self.recursive_research) workflow.add_node("finalize_research", self.finalize_research) - # Add edges + # Add edges with START and END nodes + workflow.add_edge(START, "generate_queries") workflow.add_edge("generate_queries", "process_queries") workflow.add_edge("process_queries", "review_research") @@ -460,9 +626,6 @@ def create_workflow(self) -> StateGraph: workflow.add_edge("recursive_research", "review_research") workflow.add_edge("finalize_research", END) - # Set entry point - workflow.set_entry_point("generate_queries") - return workflow async def run(self) -> Dict[str, Any]: @@ -473,8 +636,8 @@ async def run(self) -> Dict[str, Any]: # Create the workflow workflow = self.create_workflow() - # Compile the workflow to get an executable chain - chain = workflow.compile() + # Compile the workflow with checkpointer + chain = workflow.compile(checkpointer=self.checkpointer) # Create the initial state initial_state = DeepResearchState( @@ -494,84 +657,49 @@ async def run(self) -> Dict[str, Any]: # Run the workflow with async invoke config = { "configurable": { - "thread_id": self.task_id, - "thread_ts": datetime.utcnow() + "thread_id": str(self.task_id), # Convert to string for compatibility + "checkpoint_ns": "", # Use default namespace } } try: + # Use ainvoke to run the workflow asynchronously + print_agent_output("Starting LangGraph workflow...", "MASTER") result = await chain.ainvoke(initial_state, config=config) + print_agent_output("LangGraph workflow completed", "MASTER") + + final_context = result.get('final_context', '') + sources = result.get('sources', []) + citations = result.get('citations', {}) + learnings = result.get('learnings', []) - # Convert the result to a dictionary to ensure we can access all attributes - result_dict = {} - - # Try to convert the result to a dictionary - try: - # First try to convert directly - result_dict = dict(result) - except (TypeError, ValueError): - # If that fails, try to extract attributes from the object - try: - result_dict = result.__dict__ - except Exception as e: - self.stream_output("logs", "error", f"Error converting result to dictionary. Error: {e}", self.websocket) - pass - - # Extract data from the result dictionary - context_items = result_dict.get('context_items', []) - sources = result_dict.get('sources', []) - citations = result_dict.get('citations', {}) - final_context = result_dict.get('final_context', '') - - # If we couldn't extract the data from the result dictionary, try to access it directly - if not context_items and hasattr(result, 'context_items'): - context_items = result.context_items - - if not sources and hasattr(result, 'sources'): - sources = result.sources - - if not citations and hasattr(result, 'citations'): - citations = result.citations - - if not final_context and hasattr(result, 'final_context'): - final_context = result.final_context - - # Extract the final context, ensuring it's not empty - if not final_context and context_items: - # If no final_context but we have context_items, join them - final_context = "\n\n".join(context_items) - - # If we have sources but they're not in the right format, try to fix them - processed_sources = [] - for source in sources: - if isinstance(source, dict): - processed_sources.append(source) - elif isinstance(source, str): - # Try to parse the source string - if source.startswith('http'): - processed_sources.append({ - 'title': 'Source from URL', - 'url': source, - 'content': '' - }) - else: - processed_sources.append({ - 'title': 'Source', - 'url': '', - 'content': source - }) - - # If we still don't have any sources but we have context, create a source from the context - if not processed_sources and final_context: - processed_sources.append({ - 'title': f"Research on {self.task.get('query', '')}", - 'url': '', - 'content': final_context - }) + # If final_context is empty but we have context_items, join them + if not final_context: + context_items = result.get('context_items', []) if hasattr(result, 'get') else getattr(result, 'context_items', []) + if context_items: + print_agent_output(f"No final_context found, creating from {len(context_items)} context items", "MASTER") + final_context = "\n\n".join(context_items) + else: + print_agent_output("No final_context or context_items found", "MASTER") + # Try to get context from the state directly + try: + state = chain.get_state(config) + if state and hasattr(state, 'values'): + state_obj = state.values + if hasattr(state_obj, 'finalize_context'): + print_agent_output("Calling finalize_context on state", "MASTER") + final_context = state_obj.finalize_context() + elif hasattr(state_obj, 'context_items'): + context_items = getattr(state_obj, 'context_items', []) + if context_items: + print_agent_output(f"Creating final_context from state's {len(context_items)} context items", "MASTER") + final_context = "\n\n".join(context_items) + except Exception as e: + print_agent_output(f"Error getting state: {str(e)}", "MASTER") # Log the final result print_agent_output( - f"Final result: {len(final_context)} chars context, {len(processed_sources)} sources, {len(citations)} citations", + f"Final result: {len(final_context)} chars context, {len(sources)} sources, {len(citations)} citations", "MASTER" ) @@ -579,18 +707,23 @@ async def run(self) -> Dict[str, Any]: return { "query": self.task.get("query", ""), "context": final_context, - "sources": processed_sources, - "citations": citations + "sources": sources, + "citations": citations, + "execution_time": str(datetime.now() - datetime.fromtimestamp(self.task_id)), + "learnings": learnings } except Exception as e: # Log the error print_agent_output(f"Error running research workflow: {str(e)}", "MASTER") + logger.exception("Error in research workflow") # Return a default result return { "query": self.task.get("query", ""), "context": f"Research on: {self.task.get('query', '')}\n\nAn error occurred during research: {str(e)}", "sources": [], - "citations": {} + "citations": {}, + "execution_time": str(datetime.now() - datetime.fromtimestamp(self.task_id)), + "learnings": [] } \ No newline at end of file diff --git a/multi_agents/deep_research/run_deep_research.py b/multi_agents/deep_research/run_deep_research.py new file mode 100644 index 000000000..095ad358d --- /dev/null +++ b/multi_agents/deep_research/run_deep_research.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python +""" +Deep Research Runner Script + +This script provides a simple way to run the deep research module directly. +""" + +import asyncio +import sys +import os + +# Add the parent directory to the path to allow imports +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from multi_agents.deep_research.main import main + +if __name__ == "__main__": + # Run the main function + asyncio.run(main()) \ No newline at end of file diff --git a/multi_agents/deep_research/setup.py b/multi_agents/deep_research/setup.py new file mode 100644 index 000000000..0c15707c5 --- /dev/null +++ b/multi_agents/deep_research/setup.py @@ -0,0 +1,31 @@ +from setuptools import setup, find_packages + +with open("README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +with open("requirements.txt", "r", encoding="utf-8") as f: + requirements = f.read().splitlines() + +setup( + name="deep-research", + version="0.1.0", + author="Your Name", + author_email="your.email@example.com", + description="A deep research system using LangGraph", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/yourusername/your-repo", + packages=find_packages(), + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + ], + python_requires=">=3.8", + install_requires=requirements, + entry_points={ + "console_scripts": [ + "deep-research=multi_agents.deep_research.main:main", + ], + }, +) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 3c7268dce..93ead6d6f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,7 +29,7 @@ unstructured json_repair json5 loguru -langgraph==0.2.76 +langgraph # uncomment for testing # pytest