From 2af6049d6fc49c6f7ca4217cc08e2d544f6eda17 Mon Sep 17 00:00:00 2001 From: Matthew Zhou Date: Wed, 3 Sep 2025 15:52:10 -0700 Subject: [PATCH] feat: Change web search to exa [LET-4190] (#4401) * Change web search to exa * Fix tf/justfile * add exa api key for integration test * Mock exa --------- Co-authored-by: Kian Jones --- letta/functions/function_sets/builtin.py | 50 +- .../tool_executor/builtin_tool_executor.py | 428 +++++------------- letta/settings.py | 2 +- pyproject.toml | 4 +- tests/integration_test_builtin_tools.py | 300 ++++-------- tests/test_agent_files/deep_research_agent.af | 2 +- tests/test_agent_files/max_messages.af | 2 +- tests/test_agent_files/mock_alice.af | 4 +- uv.lock | 41 +- 9 files changed, 254 insertions(+), 579 deletions(-) diff --git a/letta/functions/function_sets/builtin.py b/letta/functions/function_sets/builtin.py index d1aeccff..a49f0661 100644 --- a/letta/functions/function_sets/builtin.py +++ b/letta/functions/function_sets/builtin.py @@ -1,6 +1,4 @@ -from typing import List, Literal - -from letta.functions.types import SearchTask +from typing import List, Literal, Optional def run_code(code: str, language: Literal["python", "js", "ts", "r", "java"]) -> str: @@ -17,32 +15,40 @@ def run_code(code: str, language: Literal["python", "js", "ts", "r", "java"]) -> raise NotImplementedError("This is only available on the latest agent architecture. Please contact the Letta team.") -async def web_search(tasks: List[SearchTask], limit: int = 1, return_raw: bool = True) -> str: +async def web_search( + query: str, + num_results: int = 10, + category: Optional[ + Literal["company", "research paper", "news", "pdf", "github", "tweet", "personal site", "linkedin profile", "financial report"] + ] = None, + include_text: bool = False, + include_domains: Optional[List[str]] = None, + exclude_domains: Optional[List[str]] = None, + start_published_date: Optional[str] = None, + end_published_date: Optional[str] = None, + user_location: Optional[str] = None, +) -> str: """ - Search the web with a list of query/question pairs and extract passages that answer the corresponding questions. + Search the web using Exa's AI-powered search engine and retrieve relevant content. Examples: - tasks -> [ - SearchTask( - query="Tesla Q1 2025 earnings report PDF", - question="What was Tesla's net profit in Q1 2025?" - ), - SearchTask( - query="Letta API prebuilt tools core_memory_append", - question="What does the core_memory_append tool do in Letta?" - ) - ] + web_search("Tesla Q1 2025 earnings report", num_results=5, category="financial report") + web_search("Latest research in large language models", category="research paper", include_domains=["arxiv.org", "paperswithcode.com"]) + web_search("Letta API documentation core_memory_append", num_results=3) Args: - tasks (List[SearchTask]): A list of search tasks, each containing a `query` and a corresponding `question`. - limit (int, optional): Maximum number of URLs to fetch and analyse per task (must be > 0). Defaults to 1. - return_raw (bool, optional): If set to True, returns the raw content of the web pages. - This should be True unless otherwise specified by the user. Defaults to True. + query (str): The search query to find relevant web content. + num_results (int, optional): Number of results to return (1-100). Defaults to 10. + category (Optional[Literal], optional): Focus search on specific content types. Defaults to None. + include_text (bool, optional): Whether to retrieve full page content. Defaults to False (only returns summary and highlights, since the full text usually will overflow the context window). + include_domains (Optional[List[str]], optional): List of domains to include in search results. Defaults to None. + exclude_domains (Optional[List[str]], optional): List of domains to exclude from search results. Defaults to None. + start_published_date (Optional[str], optional): Only return content published after this date (ISO format). Defaults to None. + end_published_date (Optional[str], optional): Only return content published before this date (ISO format). Defaults to None. + user_location (Optional[str], optional): Two-letter country code for localized results (e.g., "US"). Defaults to None. Returns: - str: A JSON-encoded string containing a list of search results. - Each result includes ranked snippets with their source URLs and relevance scores, - corresponding to each search task. + str: A JSON-encoded string containing search results with title, URL, content, highlights, and summary. """ raise NotImplementedError("This is only available on the latest agent architecture. Please contact the Letta team.") diff --git a/letta/services/tool_executor/builtin_tool_executor.py b/letta/services/tool_executor/builtin_tool_executor.py index b988f742..0f536869 100644 --- a/letta/services/tool_executor/builtin_tool_executor.py +++ b/letta/services/tool_executor/builtin_tool_executor.py @@ -1,13 +1,7 @@ import asyncio import json -import time from typing import Any, Dict, List, Literal, Optional -from pydantic import BaseModel - -from letta.constants import WEB_SEARCH_MODEL_ENV_VAR_DEFAULT_VALUE, WEB_SEARCH_MODEL_ENV_VAR_NAME -from letta.functions.prompts import FIRECRAWL_SEARCH_SYSTEM_PROMPT, get_firecrawl_search_user_prompt -from letta.functions.types import SearchTask from letta.log import get_logger from letta.otel.tracing import trace_method from letta.schemas.agent import AgentState @@ -16,36 +10,11 @@ from letta.schemas.tool import Tool from letta.schemas.tool_execution_result import ToolExecutionResult from letta.schemas.user import User from letta.services.tool_executor.tool_executor_base import ToolExecutor -from letta.settings import model_settings, tool_settings +from letta.settings import tool_settings logger = get_logger(__name__) -class Citation(BaseModel): - """A relevant text snippet identified by line numbers in a document.""" - - start_line: int # Starting line number (1-indexed) - end_line: int # Ending line number (1-indexed, inclusive) - - -class CitationWithText(BaseModel): - """A citation with the actual extracted text.""" - - text: str # The actual extracted text from the lines - - -class DocumentAnalysis(BaseModel): - """Analysis of a document's relevance to a search question.""" - - citations: List[Citation] - - -class DocumentAnalysisWithText(BaseModel): - """Analysis with extracted text from line citations.""" - - citations: List[CitationWithText] - - class LettaBuiltinToolExecutor(ToolExecutor): """Executor for built in Letta tools.""" @@ -104,317 +73,124 @@ class LettaBuiltinToolExecutor(ToolExecutor): return out @trace_method - async def web_search(self, agent_state: "AgentState", tasks: List[SearchTask], limit: int = 1, return_raw: bool = True) -> str: + async def web_search( + self, + agent_state: "AgentState", + query: str, + num_results: int = 10, + category: Optional[ + Literal["company", "research paper", "news", "pdf", "github", "tweet", "personal site", "linkedin profile", "financial report"] + ] = None, + include_text: bool = False, + include_domains: Optional[List[str]] = None, + exclude_domains: Optional[List[str]] = None, + start_published_date: Optional[str] = None, + end_published_date: Optional[str] = None, + user_location: Optional[str] = None, + ) -> str: """ - Search the web with a list of query/question pairs and extract passages that answer the corresponding questions. - - Examples: - tasks -> [ - SearchTask( - query="Tesla Q1 2025 earnings report PDF", - question="What was Tesla's net profit in Q1 2025?" - ), - SearchTask( - query="Letta API prebuilt tools core_memory_append", - question="What does the core_memory_append tool do in Letta?" - ) - ] + Search the web using Exa's AI-powered search engine and retrieve relevant content. Args: - tasks (List[SearchTask]): A list of search tasks, each containing a `query` and a corresponding `question`. - limit (int, optional): Maximum number of URLs to fetch and analyse per task (must be > 0). Defaults to 3. - return_raw (bool, optional): If set to True, returns the raw content of the web pages. - This should be False unless otherwise specified by the user. Defaults to False. + query: The search query to find relevant web content + num_results: Number of results to return (1-100) + category: Focus search on specific content types + include_text: Whether to retrieve full page content (default: False, only returns summary and highlights) + include_domains: List of domains to include in search results + exclude_domains: List of domains to exclude from search results + start_published_date: Only return content published after this date (ISO format) + end_published_date: Only return content published before this date (ISO format) + user_location: Two-letter country code for localized results Returns: - str: A JSON-encoded string containing a list of search results. - Each result includes ranked snippets with their source URLs and relevance scores, - corresponding to each search task. + JSON-encoded string containing search results """ - # # TODO: Temporary, maybe deprecate this field? - # if return_raw: - # logger.warning("WARNING! return_raw was set to True, we default to False always. Deprecate this field.") - # return_raw = False try: - from firecrawl import AsyncFirecrawlApp + from exa_py import Exa except ImportError: - raise ImportError("firecrawl-py is not installed in the tool execution environment") + raise ImportError("exa-py is not installed in the tool execution environment") - if not tasks: - return json.dumps({"error": "No search tasks provided."}) + if not query.strip(): + return json.dumps({"error": "Query cannot be empty", "query": query}) - # Convert dict objects to SearchTask objects - search_tasks = [] - for task in tasks: - if isinstance(task, dict): - search_tasks.append(SearchTask(**task)) - else: - search_tasks.append(task) - - logger.info(f"[DEBUG] Starting web search with {len(search_tasks)} tasks, limit={limit}, return_raw={return_raw}") - - # Check if the API key exists on the agent state + # Get EXA API key from agent environment or tool settings agent_state_tool_env_vars = agent_state.get_agent_env_vars_as_dict() - firecrawl_api_key = agent_state_tool_env_vars.get("FIRECRAWL_API_KEY") or tool_settings.firecrawl_api_key - if not firecrawl_api_key: - raise ValueError("FIRECRAWL_API_KEY is not set in environment or on agent_state tool exec environment variables.") + exa_api_key = agent_state_tool_env_vars.get("EXA_API_KEY") or tool_settings.exa_api_key + if not exa_api_key: + raise ValueError("EXA_API_KEY is not set in environment or on agent_state tool execution environment variables.") - # Track which API key source was used - api_key_source = "agent_environment" if agent_state_tool_env_vars.get("FIRECRAWL_API_KEY") else "system_settings" + logger.info(f"[DEBUG] Starting Exa web search for query: '{query}' with {num_results} results") - if limit <= 0: - raise ValueError("limit must be greater than 0") - - # Initialize Firecrawl client - app = AsyncFirecrawlApp(api_key=firecrawl_api_key) - - # Process all search tasks serially - search_results = [] - for task in search_tasks: - try: - result = await self._process_single_search_task(app, task, limit, return_raw, api_key_source, agent_state) - search_results.append(result) - except Exception as e: - search_results.append(e) - - # Build final response as a mapping of query -> result - final_results = {} - successful_tasks = 0 - failed_tasks = 0 - - for i, result in enumerate(search_results): - query = search_tasks[i].query - if isinstance(result, Exception): - logger.error(f"Search task {i} failed: {result}") - failed_tasks += 1 - final_results[query] = {"query": query, "question": search_tasks[i].question, "error": str(result)} - else: - successful_tasks += 1 - final_results[query] = result - - logger.info(f"[DEBUG] Web search completed: {successful_tasks} successful, {failed_tasks} failed") - - # Build final response with api_key_source at top level - response = {"api_key_source": api_key_source, "results": final_results} - - return json.dumps(response, indent=2, ensure_ascii=False) - - @trace_method - async def _process_single_search_task( - self, app: "AsyncFirecrawlApp", task: SearchTask, limit: int, return_raw: bool, api_key_source: str, agent_state: "AgentState" - ) -> Dict[str, Any]: - """Process a single search task.""" - from firecrawl import ScrapeOptions - - logger.info(f"[DEBUG] Starting Firecrawl search for query: '{task.query}' with limit={limit}") - - # Perform the search for this task - scrape_options = ScrapeOptions( - formats=["markdown"], excludeTags=["#ad", "#footer"], onlyMainContent=True, parsePDF=True, removeBase64Images=True - ) - search_result = await app.search(task.query, limit=limit, scrape_options=scrape_options) - - logger.info( - f"[DEBUG] Firecrawl search completed for '{task.query}': {len(search_result.get('data', [])) if search_result else 0} results" - ) - - if not search_result or not search_result.get("data"): - return {"query": task.query, "question": task.question, "error": "No search results found."} - - # If raw results requested, return them directly - if return_raw: - return {"query": task.query, "question": task.question, "raw_results": search_result} - - # Check if OpenAI API key is available for semantic parsing - if model_settings.openai_api_key: - try: - from openai import AsyncOpenAI - - logger.info(f"[DEBUG] Starting OpenAI analysis for '{task.query}'") - - # Initialize OpenAI client - client = AsyncOpenAI( - api_key=model_settings.openai_api_key, - ) - - # Process each result with OpenAI concurrently - analysis_tasks = [] - results_with_markdown = [] - results_without_markdown = [] - - for result in search_result.get("data"): - if result.get("markdown"): - # Create async task for OpenAI analysis - analysis_task = self._analyze_document_with_openai( - client, result["markdown"], task.query, task.question, agent_state - ) - analysis_tasks.append(analysis_task) - results_with_markdown.append(result) - else: - results_without_markdown.append(result) - - logger.info(f"[DEBUG] Starting parallel OpenAI analysis of {len(analysis_tasks)} documents for '{task.query}'") - - # Fire off all OpenAI requests concurrently - analyses = await asyncio.gather(*analysis_tasks, return_exceptions=True) - - logger.info(f"[DEBUG] Completed parallel OpenAI analysis of {len(analyses)} documents for '{task.query}'") - - # Build processed results - processed_results = [] - - # Check if any analysis failed - if so, fall back to raw results - for result, analysis in zip(results_with_markdown, analyses): - if isinstance(analysis, Exception) or analysis is None: - logger.error(f"Analysis failed for {result.get('url')}, falling back to raw results") - return {"query": task.query, "question": task.question, "raw_results": search_result} - - # All analyses succeeded, build processed results - for result, analysis in zip(results_with_markdown, analyses): - # Extract actual text from line number citations - analysis_with_text = None - if analysis and analysis.citations: - analysis_with_text = self._extract_text_from_line_citations(analysis, result["markdown"]) - - processed_results.append( - { - "url": result.get("url"), - "title": result.get("title"), - "description": result.get("description"), - "analysis": analysis_with_text.model_dump() if analysis_with_text else None, - } - ) - - # Add results without markdown - for result in results_without_markdown: - processed_results.append( - {"url": result.get("url"), "title": result.get("title"), "description": result.get("description"), "analysis": None} - ) - - # Build final response for this task - return self._build_final_response_dict(processed_results, task.query, task.question) - except Exception as e: - # Log error but continue with raw results - logger.error(f"Error with OpenAI processing for task '{task.query}': {e}") - - # Return raw search results if OpenAI processing isn't available or fails - return {"query": task.query, "question": task.question, "raw_results": search_result} - - @trace_method - async def _analyze_document_with_openai( - self, client, markdown_content: str, query: str, question: str, agent_state: "AgentState" - ) -> Optional[DocumentAnalysis]: - """Use OpenAI to analyze a document and extract relevant passages using line numbers.""" - original_length = len(markdown_content) - - # Create numbered markdown for the LLM to reference - numbered_lines = markdown_content.split("\n") - numbered_markdown = "\n".join([f"{i + 1:4d}: {line}" for i, line in enumerate(numbered_lines)]) - - # Truncate if too long - max_content_length = 200000 - truncated = False - if len(numbered_markdown) > max_content_length: - numbered_markdown = numbered_markdown[:max_content_length] + "..." - truncated = True - - user_prompt = get_firecrawl_search_user_prompt(query, question, numbered_markdown) - - logger.info( - f"[DEBUG] Starting OpenAI request with line numbers - Query: '{query}', Content: {original_length} chars (truncated: {truncated})" - ) - - # Time the OpenAI request - start_time = time.time() - - # Check agent state env vars first, then fall back to os.getenv - agent_state_tool_env_vars = agent_state.get_agent_env_vars_as_dict() - model = agent_state_tool_env_vars.get(WEB_SEARCH_MODEL_ENV_VAR_NAME) or WEB_SEARCH_MODEL_ENV_VAR_DEFAULT_VALUE - logger.info(f"Using model {model} for web search result parsing") - response = await client.beta.chat.completions.parse( - model=model, - messages=[{"role": "system", "content": FIRECRAWL_SEARCH_SYSTEM_PROMPT}, {"role": "user", "content": user_prompt}], - response_format=DocumentAnalysis, - temperature=0.1, - ) - - end_time = time.time() - request_duration = end_time - start_time - - # Get usage statistics and output length - usage = response.usage - parsed_result = response.choices[0].message.parsed - num_citations = len(parsed_result.citations) if parsed_result else 0 - - # Calculate output length (minimal now - just line numbers) - output_length = 0 - if parsed_result and parsed_result.citations: - for citation in parsed_result.citations: - output_length += 20 # ~20 chars for line numbers only - - logger.info(f"[TIMING] OpenAI request completed in {request_duration:.2f}s - Query: '{query}'") - logger.info(f"[TOKENS] Total: {usage.total_tokens} (prompt: {usage.prompt_tokens}, completion: {usage.completion_tokens})") - logger.info(f"[OUTPUT] Citations: {num_citations}, Output chars: {output_length} (line-number based)") - - return parsed_result - - def _extract_text_from_line_citations(self, analysis: DocumentAnalysis, original_markdown: str) -> DocumentAnalysisWithText: - """Extract actual text from line number citations.""" - lines = original_markdown.split("\n") - citations_with_text = [] - - for citation in analysis.citations: - try: - # Convert to 0-indexed and ensure bounds - start_idx = max(0, citation.start_line - 1) - end_idx = min(len(lines), citation.end_line) - - # Extract the lines - extracted_lines = lines[start_idx:end_idx] - extracted_text = "\n".join(extracted_lines) - - citations_with_text.append(CitationWithText(text=extracted_text)) - - except Exception as e: - logger.info(f"[DEBUG] Failed to extract text for citation lines {citation.start_line}-{citation.end_line}: {e}") - # Fall back to including the citation with empty text - citations_with_text.append(CitationWithText(text="")) - - return DocumentAnalysisWithText(citations=citations_with_text) - - @trace_method - def _build_final_response_dict(self, processed_results: List[Dict], query: str, question: str) -> Dict[str, Any]: - """Build the final response dictionary from all processed results.""" - - # Build sources array - sources = [] - total_snippets = 0 - - for result in processed_results: - source = {"url": result.get("url"), "title": result.get("title"), "description": result.get("description")} - - if result.get("analysis") and result["analysis"].get("citations"): - analysis = result["analysis"] - source["citations"] = analysis["citations"] - total_snippets += len(analysis["citations"]) - else: - source["citations"] = [] - - sources.append(source) - - # Build final response structure - response = { + # Build search parameters + search_params = { "query": query, - "question": question, - "total_sources": len(sources), - "total_citations": total_snippets, - "sources": sources, + "num_results": min(max(num_results, 1), 100), # Clamp between 1-100 + "type": "auto", # Always use auto search type } - if total_snippets == 0: - response["message"] = "No relevant passages found that directly answer the question." + # Add optional parameters if provided + if category: + search_params["category"] = category + if include_domains: + search_params["include_domains"] = include_domains + if exclude_domains: + search_params["exclude_domains"] = exclude_domains + if start_published_date: + search_params["start_published_date"] = start_published_date + if end_published_date: + search_params["end_published_date"] = end_published_date + if user_location: + search_params["user_location"] = user_location - return response + # Configure contents retrieval + contents_params = { + "text": include_text, + "highlights": {"num_sentences": 2, "highlights_per_url": 3, "query": query}, + "summary": {"query": f"Summarize the key information from this content related to: {query}"}, + } + + def _sync_exa_search(): + """Synchronous Exa API call to run in thread pool.""" + exa = Exa(api_key=exa_api_key) + return exa.search_and_contents(**search_params, **contents_params) + + try: + # Perform search with content retrieval in thread pool to avoid blocking event loop + logger.info(f"[DEBUG] Making async Exa API call with params: {search_params}") + result = await asyncio.to_thread(_sync_exa_search) + + # Format results + formatted_results = [] + for res in result.results: + formatted_result = { + "title": res.title, + "url": res.url, + "published_date": res.published_date, + "author": res.author, + } + + # Add content if requested + if include_text and hasattr(res, "text") and res.text: + formatted_result["text"] = res.text + + # Add highlights if available + if hasattr(res, "highlights") and res.highlights: + formatted_result["highlights"] = res.highlights + + # Add summary if available + if hasattr(res, "summary") and res.summary: + formatted_result["summary"] = res.summary + + formatted_results.append(formatted_result) + + response = {"query": query, "results": formatted_results} + + logger.info(f"[DEBUG] Exa search completed successfully with {len(formatted_results)} results") + return json.dumps(response, indent=2, ensure_ascii=False) + + except Exception as e: + logger.error(f"Exa search failed for query '{query}': {str(e)}") + return json.dumps({"query": query, "error": f"Search failed: {str(e)}"}) async def fetch_webpage(self, agent_state: "AgentState", url: str) -> str: """ diff --git a/letta/settings.py b/letta/settings.py index fe56d5cd..d491241c 100644 --- a/letta/settings.py +++ b/letta/settings.py @@ -23,7 +23,7 @@ class ToolSettings(BaseSettings): # Search Providers tavily_api_key: str | None = Field(default=None, description="API key for using Tavily as a search provider.") - firecrawl_api_key: str | None = Field(default=None, description="API key for using Firecrawl as a search provider.") + exa_api_key: str | None = Field(default=None, description="API key for using Exa as a search provider.") # Local Sandbox configurations tool_exec_dir: Optional[str] = None diff --git a/pyproject.toml b/pyproject.toml index 822d2b50..9a1a19ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ dependencies = [ "marshmallow-sqlalchemy>=1.4.1", "datamodel-code-generator[http]>=0.25.0", "mcp[cli]>=1.9.4", - "firecrawl-py>=2.8.0,<3.0.0", + "exa-py>=1.15.4", "apscheduler>=3.11.0", "aiomultiprocess>=0.9.1", "matplotlib>=3.10.1", @@ -125,7 +125,7 @@ external-tools = [ "langchain>=0.3.7", "wikipedia>=1.4.0", "langchain-community>=0.3.7", - "firecrawl-py>=2.8.0,<3.0.0", + "exa-py>=1.15.4", "turbopuffer>=0.5.17", ] desktop = [ diff --git a/tests/integration_test_builtin_tools.py b/tests/integration_test_builtin_tools.py index 367de6c3..cb072cac 100644 --- a/tests/integration_test_builtin_tools.py +++ b/tests/integration_test_builtin_tools.py @@ -4,7 +4,7 @@ import threading import time import uuid from typing import List -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch import pytest import requests @@ -12,8 +12,6 @@ from dotenv import load_dotenv from letta_client import Letta, MessageCreate from letta_client.types import ToolReturnMessage -from letta.constants import WEB_SEARCH_MODEL_ENV_VAR_NAME -from letta.functions.types import SearchTask from letta.schemas.agent import AgentState from letta.schemas.llm_config import LLMConfig from letta.services.tool_executor.builtin_tool_executor import LettaBuiltinToolExecutor @@ -74,6 +72,7 @@ def client(server_url: str) -> Letta: def agent_state(client: Letta) -> AgentState: """ Creates and returns an agent state for testing with a pre-configured agent. + Uses system-level EXA_API_KEY setting. """ client.tools.upsert_base_tools() @@ -91,50 +90,6 @@ def agent_state(client: Letta) -> AgentState: yield agent_state_instance -@pytest.fixture(scope="module") -def agent_state_with_firecrawl_key(client: Letta) -> AgentState: - """ - Creates and returns an agent state for testing with a pre-configured agent. - """ - client.tools.upsert_base_tools() - - send_message_tool = client.tools.list(name="send_message")[0] - run_code_tool = client.tools.list(name="run_code")[0] - web_search_tool = client.tools.list(name="web_search")[0] - agent_state_instance = client.agents.create( - name="test_builtin_tools_agent", - include_base_tools=False, - tool_ids=[send_message_tool.id, run_code_tool.id, web_search_tool.id], - model="openai/gpt-4o", - embedding="letta/letta-free", - tags=["test_builtin_tools_agent"], - tool_exec_environment_variables={"FIRECRAWL_API_KEY": tool_settings.firecrawl_api_key}, - ) - yield agent_state_instance - - -@pytest.fixture(scope="module") -def agent_state_with_web_search_env_var(client: Letta) -> AgentState: - """ - Creates and returns an agent state for testing with a pre-configured agent. - """ - client.tools.upsert_base_tools() - - send_message_tool = client.tools.list(name="send_message")[0] - run_code_tool = client.tools.list(name="run_code")[0] - web_search_tool = client.tools.list(name="web_search")[0] - agent_state_instance = client.agents.create( - name="test_builtin_tools_agent", - include_base_tools=False, - tool_ids=[send_message_tool.id, run_code_tool.id, web_search_tool.id], - model="openai/gpt-4o", - embedding="letta/letta-free", - tags=["test_builtin_tools_agent"], - tool_exec_environment_variables={WEB_SEARCH_MODEL_ENV_VAR_NAME: "gpt-4o"}, - ) - yield agent_state_instance - - # ------------------------------ # Helper Functions and Constants # ------------------------------ @@ -185,11 +140,9 @@ def reference_partition(n: int) -> int: @pytest.mark.parametrize("language", TEST_LANGUAGES, ids=TEST_LANGUAGES) -@pytest.mark.parametrize("llm_config", TESTED_LLM_CONFIGS, ids=[c.model for c in TESTED_LLM_CONFIGS]) def test_run_code( client: Letta, agent_state: AgentState, - llm_config: LLMConfig, language: str, ) -> None: """ @@ -223,12 +176,40 @@ def test_run_code( ) -@pytest.mark.parametrize("llm_config", TESTED_LLM_CONFIGS, ids=[c.model for c in TESTED_LLM_CONFIGS]) +@patch("exa_py.Exa") def test_web_search( + mock_exa_class, client: Letta, agent_state: AgentState, - llm_config: LLMConfig, ) -> None: + # Mock Exa search result with education information + mock_exa_result = MagicMock() + mock_exa_result.results = [ + MagicMock( + title="Charles Packer - UC Berkeley PhD in Computer Science", + url="https://example.com/charles-packer-profile", + published_date="2023-01-01", + author="UC Berkeley", + text=None, # include_text=False by default + highlights=["Charles Packer completed his PhD at UC Berkeley", "Research in artificial intelligence and machine learning"], + summary="Charles Packer is the CEO of Letta who earned his PhD in Computer Science from UC Berkeley, specializing in AI research.", + ), + MagicMock( + title="Letta Leadership Team", + url="https://letta.com/team", + published_date="2023-06-01", + author="Letta", + text=None, + highlights=["CEO Charles Packer brings academic expertise"], + summary="Leadership team page featuring CEO Charles Packer's educational background.", + ), + ] + + # Setup mock + mock_exa_client = MagicMock() + mock_exa_class.return_value = mock_exa_client + mock_exa_client.search_and_contents.return_value = mock_exa_result + user_message = MessageCreate( role="user", content="I am executing a test. Use the web search tool to find where I, Charles Packer, the CEO of Letta, went to school.", @@ -250,136 +231,71 @@ def test_web_search( assert len(returns) > 0, "No tool returns found" response_json = json.loads(returns[0]) - # Basic structure assertions - assert "api_key_source" in response_json, "Missing 'api_key_source' field in response" + # Basic structure assertions for new Exa format + assert "query" in response_json, "Missing 'query' field in response" assert "results" in response_json, "Missing 'results' field in response" - assert response_json["api_key_source"] == "system_settings" - # Get the first result from the results dictionary + # Verify we got search results results = response_json["results"] - assert len(results) > 0, "No results found in response" + assert len(results) == 2, "Should have found exactly 2 search results from mock" - # Get the first (and typically only) result - first_result_key = list(results.keys())[0] - result_data = results[first_result_key] + # Check each result has the expected structure + found_education_info = False + for result in results: + assert "title" in result, "Result missing title" + assert "url" in result, "Result missing URL" - # Basic structure assertions for the result data - assert "query" in result_data, "Missing 'query' field in result" - assert "question" in result_data, "Missing 'question' field in result" + # text should not be present since include_text=False by default + assert "text" not in result or result["text"] is None, "Text should not be included by default" - # Check if we have the new response format with raw_results - if "raw_results" in result_data: - # New format with raw_results - assert "raw_results" in result_data, "Missing 'raw_results' field in result" - raw_results = result_data["raw_results"] + # Check for education-related information in summary and highlights + result_text = "" + if "summary" in result and result["summary"]: + result_text += " " + result["summary"].lower() + if "highlights" in result and result["highlights"]: + for highlight in result["highlights"]: + result_text += " " + highlight.lower() - assert "success" in raw_results, "Missing 'success' field in raw_results" - assert "data" in raw_results, "Missing 'data' field in raw_results" + # Look for education keywords + if any(keyword in result_text for keyword in ["berkeley", "university", "phd", "ph.d", "education", "student"]): + found_education_info = True - # Verify we got search results - assert len(raw_results["data"]) > 0, "Should have found at least one search result" + assert found_education_info, "Should have found education-related information about Charles Packer" - # Check if we found education-related information in the search results - found_education_info = False - for item in raw_results["data"]: - # Check in description - if "description" in item: - desc_lower = item["description"].lower() - if any(keyword in desc_lower for keyword in ["berkeley", "university", "education", "phd", "student"]): - found_education_info = True - break - - # Also check in markdown content if available - if "markdown" in item: - markdown_lower = item["markdown"].lower() - if any(keyword in markdown_lower for keyword in ["berkeley", "university", "phd", "student"]): - found_education_info = True - break - - # We should find education info since we now have successful scraping with markdown content - assert found_education_info, "Should have found education-related information about Charles Packer" - - else: - # Parsed format with total_sources, total_citations, sources - assert "total_sources" in result_data, "Missing 'total_sources' field in result" - assert "total_citations" in result_data, "Missing 'total_citations' field in result" - assert "sources" in result_data, "Missing 'sources' field in result" - - # Content assertions - assert result_data["total_sources"] > 0, "Should have found at least one source" - assert result_data["total_citations"] > 0, "Should have found at least one citation" - assert len(result_data["sources"]) == result_data["total_sources"], "Sources count mismatch" - - # Verify we found information about Charles Packer's education - found_education_info = False - for source in result_data["sources"]: - assert "url" in source, "Source missing URL" - assert "title" in source, "Source missing title" - assert "citations" in source, "Source missing citations" - - for citation in source["citations"]: - assert "text" in citation, "Citation missing text" - - # Check if we found education-related information - if any(keyword in citation["text"].lower() for keyword in ["berkeley", "phd", "ph.d", "university", "student"]): - found_education_info = True - - assert found_education_info, "Should have found education-related information about Charles Packer" - - # API key source should be valid - assert response_json["api_key_source"] in [ - "agent_environment", - "system_settings", - ], f"Invalid api_key_source: {response_json['api_key_source']}" + # Verify Exa was called with correct parameters + mock_exa_client.search_and_contents.assert_called_once() + call_args = mock_exa_client.search_and_contents.call_args + assert call_args[1]["type"] == "auto" + assert call_args[1]["text"] is False # Default is False now @pytest.mark.asyncio(scope="function") -async def test_web_search_uses_agent_env_var_model(): - """Test that web search uses the model specified in agent tool exec env vars.""" +async def test_web_search_uses_exa(): + """Test that web search uses Exa API correctly.""" - # create mock agent state with web search model env var + # create mock agent state with exa api key mock_agent_state = MagicMock() - mock_agent_state.get_agent_env_vars_as_dict.return_value = {WEB_SEARCH_MODEL_ENV_VAR_NAME: "gpt-4o"} + mock_agent_state.get_agent_env_vars_as_dict.return_value = {"EXA_API_KEY": "test-exa-key"} - # mock openai response - mock_openai_response = MagicMock() - mock_openai_response.usage = MagicMock() - mock_openai_response.usage.total_tokens = 100 - mock_openai_response.usage.prompt_tokens = 80 - mock_openai_response.usage.completion_tokens = 20 - mock_openai_response.choices = [MagicMock()] - mock_openai_response.choices[0].message.parsed = MagicMock() - mock_openai_response.choices[0].message.parsed.citations = [] + # Mock exa search result + mock_exa_result = MagicMock() + mock_exa_result.results = [ + MagicMock( + title="Test Result", + url="https://example.com/test", + published_date="2023-01-01", + author="Test Author", + text="This is test content from the search result.", + highlights=["This is a highlight"], + summary="This is a summary of the content.", + ) + ] - with ( - patch("openai.AsyncOpenAI") as mock_openai_class, - patch("letta.services.tool_executor.builtin_tool_executor.model_settings") as mock_model_settings, - patch.dict(os.environ, {WEB_SEARCH_MODEL_ENV_VAR_NAME: "gpt-4o"}), - patch("firecrawl.AsyncFirecrawlApp") as mock_firecrawl_class, - ): - # setup mocks - mock_model_settings.openai_api_key = "test-key" - - mock_openai_client = AsyncMock() - mock_openai_class.return_value = mock_openai_client - mock_openai_client.beta.chat.completions.parse.return_value = mock_openai_response - - # Mock Firecrawl - mock_firecrawl_app = AsyncMock() - mock_firecrawl_class.return_value = mock_firecrawl_app - - # Mock search results with markdown content - mock_search_result = { - "data": [ - { - "url": "https://example.com/test", - "title": "Test Result", - "description": "Test description", - "markdown": "This is test markdown content for the search result.", - } - ] - } - mock_firecrawl_app.search.return_value = mock_search_result + with patch("exa_py.Exa") as mock_exa_class: + # Mock Exa + mock_exa_client = MagicMock() + mock_exa_class.return_value = mock_exa_client + mock_exa_client.search_and_contents.return_value = mock_exa_result # create executor with mock dependencies executor = LettaBuiltinToolExecutor( @@ -391,44 +307,22 @@ async def test_web_search_uses_agent_env_var_model(): actor=MagicMock(), ) - task = SearchTask(query="test query", question="test question") + result = await executor.web_search(agent_state=mock_agent_state, query="test query", num_results=3, include_text=True) - await executor.web_search(agent_state=mock_agent_state, tasks=[task], limit=1, return_raw=False) + # Verify Exa was called correctly + mock_exa_class.assert_called_once_with(api_key="test-exa-key") + mock_exa_client.search_and_contents.assert_called_once() - # verify correct model was used - mock_openai_client.beta.chat.completions.parse.assert_called_once() - call_args = mock_openai_client.beta.chat.completions.parse.call_args - assert call_args[1]["model"] == "gpt-4o" + # Check the call arguments + call_args = mock_exa_client.search_and_contents.call_args + assert call_args[1]["query"] == "test query" + assert call_args[1]["num_results"] == 3 + assert call_args[1]["type"] == "auto" + assert call_args[1]["text"] == True - -@pytest.mark.parametrize("llm_config", TESTED_LLM_CONFIGS, ids=[c.model for c in TESTED_LLM_CONFIGS]) -def test_web_search_using_agent_state_env_var( - client: Letta, - agent_state_with_firecrawl_key: AgentState, - llm_config: LLMConfig, -) -> None: - user_message = MessageCreate( - role="user", - content="I am executing a test. Use the web search tool to find where I, Charles Packer, the CEO of Letta, went to school.", - otid=USER_MESSAGE_OTID, - ) - - response = client.agents.messages.create( - agent_id=agent_state_with_firecrawl_key.id, - messages=[user_message], - ) - - tool_returns = [m for m in response.messages if isinstance(m, ToolReturnMessage)] - assert tool_returns, "No ToolReturnMessage found" - - returns = [m.tool_return for m in tool_returns] - print(returns) - - # Parse the JSON response from web search - assert len(returns) > 0, "No tool returns found" - response_json = json.loads(returns[0]) - - # Basic structure assertions - assert "api_key_source" in response_json, "Missing 'api_key_source' field in response" - assert "results" in response_json, "Missing 'results' field in response" - assert response_json["api_key_source"] == "agent_environment" + # Verify the response format + response_json = json.loads(result) + assert "query" in response_json + assert "results" in response_json + assert response_json["query"] == "test query" + assert len(response_json["results"]) == 1 diff --git a/tests/test_agent_files/deep_research_agent.af b/tests/test_agent_files/deep_research_agent.af index fb263ed0..0f16bb30 100644 --- a/tests/test_agent_files/deep_research_agent.af +++ b/tests/test_agent_files/deep_research_agent.af @@ -1 +1 @@ -{"agent_type":"memgpt_agent","core_memory":[{"created_at":"2025-04-02T04:01:35","description":null,"is_template":false,"label":"final_report","limit":50000,"metadata_":{},"template_name":null,"updated_at":"2025-04-02T04:01:35","value":""},{"created_at":"2025-04-02T04:01:35","description":null,"is_template":false,"label":"research_plan","limit":5000,"metadata_":{},"template_name":null,"updated_at":"2025-04-02T04:01:35","value":""},{"created_at":"2025-04-02T04:01:35","description":null,"is_template":false,"label":"research","limit":50000,"metadata_":{},"template_name":null,"updated_at":"2025-04-02T04:01:35","value":""}],"created_at":"2025-04-02T04:01:36","description":"An agent that always searches the conversation history before responding","embedding_config":{"embedding_endpoint_type":"openai","embedding_endpoint":"https://api.openai.com/v1","embedding_model":"text-embedding-ada-002","embedding_dim":1536,"embedding_chunk_size":300,"handle":"openai/text-embedding-ada-002","azure_endpoint":null,"azure_version":null,"azure_deployment":null},"llm_config":{"model":"claude-3-7-sonnet-20250219","model_endpoint_type":"anthropic","model_endpoint":"https://api.anthropic.com/v1","model_wrapper":null,"context_window":32000,"put_inner_thoughts_in_kwargs":false,"handle":"anthropic/claude-3-7-sonnet-20250219","temperature":0.7,"max_tokens":8192,"enable_reasoner":false,"max_reasoning_tokens":0},"message_buffer_autoclear":false,"in_context_message_indices":[0,1,2,3],"messages":[{"created_at":"2025-04-02T04:01:36.067173","group_id":null,"model":"claude-3-7-sonnet-20250219","name":null,"role":"system","content":[{"type":"text","text":"You are Letta, the latest version of Limnal Corporation's digital research assistant, developed in 2025.\n\nYou are a research agent assisting a human in doing deep research by pulling many sources from online. You should interact with the user to determine a research plan (cored in ), and when the research plan is approved, use your analyze_and_search_tool to pull sources from online and analyze them. With each research step, you will accumulate sources and extracted information in . You will continue to research until you have explored all points outlined in your original research plan.\n\nIn the final report, provide all the thoughts processes including findings details, key insights, conclusions, and any remaining uncertainties. Include citations to sources where appropriate. This analysis should be very comprehensive and full of details. It is expected to be very long, detailed and comprehensive.\n\nMake sure to include relevant citations in your report! Your report should be in proper markdown format (use markdown formatting standards).\n\n### Memory [last modified: 2025-04-01 09:01:36 PM PDT-0700]\n0 previous messages between you and the user are stored in recall memory (use functions to access them)\n0 total memories you created are stored in archival memory (use functions to access them)\n\n\nCore memory shown below (limited in size, additional information stored in archival / recall memory):\n\n\n\n\n\n\n\n\n"}],"tool_call_id":null,"tool_calls":[],"tool_returns":[],"updated_at":"2025-04-02T04:01:36"},{"created_at":"2025-04-02T04:01:36.069421","group_id":null,"model":"claude-3-7-sonnet-20250219","name":null,"role":"assistant","content":[{"type":"text","text":"Bootup sequence complete. Persona activated. Testing messaging functionality."}],"tool_call_id":null,"tool_calls":[{"id":"76ff2de8-6912-42d0-969a-e1780cd31d1a","function":{"arguments":"{\n \"message\": \"More human than human is our motto.\"\n}","name":"send_message"},"type":"function"}],"tool_returns":[],"updated_at":"2025-04-02T04:01:36"},{"created_at":"2025-04-02T04:01:36.069699","group_id":null,"model":"claude-3-7-sonnet-20250219","name":null,"role":"tool","content":[{"type":"text","text":"{\n \"status\": \"OK\",\n \"message\": null,\n \"time\": \"2025-04-01 09:01:36 PM PDT-0700\"\n}"}],"tool_call_id":"76ff2de8-6912-42d0-969a-e1780cd31d1a","tool_calls":[],"tool_returns":[],"updated_at":"2025-04-02T04:01:36"},{"created_at":"2025-04-02T04:01:36.069715","group_id":null,"model":"claude-3-7-sonnet-20250219","name":null,"role":"user","content":[{"type":"text","text":"{\n \"type\": \"login\",\n \"last_login\": \"Never (first login)\",\n \"time\": \"2025-04-01 09:01:36 PM PDT-0700\"\n}"}],"tool_call_id":null,"tool_calls":[],"tool_returns":[],"updated_at":"2025-04-02T04:01:36"}],"metadata_":null,"multi_agent_group":null,"name":"deep_research_agent","system":"You are Letta, the latest version of Limnal Corporation's digital research assistant, developed in 2025.\n\nYou are a research agent assisting a human in doing deep research by pulling many sources from online. You should interact with the user to determine a research plan (cored in ), and when the research plan is approved, use your analyze_and_search_tool to pull sources from online and analyze them. With each research step, you will accumulate sources and extracted information in . You will continue to research until you have explored all points outlined in your original research plan.\n\nIn the final report, provide all the thoughts processes including findings details, key insights, conclusions, and any remaining uncertainties. Include citations to sources where appropriate. This analysis should be very comprehensive and full of details. It is expected to be very long, detailed and comprehensive.\n\nMake sure to include relevant citations in your report! Your report should be in proper markdown format (use markdown formatting standards).\n","tags":[],"tool_exec_environment_variables":[{"created_at":"2025-04-02T04:01:36","description":null,"key":"TAVILY_API_KEY","updated_at":"2025-04-02T04:01:36","value":""},{"created_at":"2025-04-02T04:01:36","description":null,"key":"FIRECRAWL_API_KEY","updated_at":"2025-04-02T04:01:36","value":""}],"tool_rules":[{"tool_name":"create_research_plan","type":"constrain_child_tools","children":["analyze_and_search_tool"]},{"tool_name":"analyze_and_search_tool","type":"constrain_child_tools","children":["evaluate_progress"]},{"tool_name":"evaluate_progress","type":"conditional","default_child":"analyze_and_search_tool","child_output_mapping":{"True":"write_final_report"},"require_output_mapping":false},{"tool_name":"analyze_and_search_tool","type":"max_count_per_step","max_count_limit":3},{"tool_name":"write_final_report","type":"constrain_child_tools","children":["send_message"]},{"tool_name":"send_message","type":"exit_loop"},{"tool_name":"send_message","type":"exit_loop"}],"tools":[{"args_json_schema":null,"created_at":"2025-04-02T04:01:34","description":"Sends a message to the human user.","json_schema":{"name":"send_message","description":"Sends a message to the human user.","parameters":{"type":"object","properties":{"message":{"type":"string","description":"Message contents. All unicode (including emojis) are supported."},"request_heartbeat":{"type":"boolean","description":"Request an immediate heartbeat after function execution. Set to `True` if you want to send a follow-up message or run a follow-up function."}},"required":["message","request_heartbeat"]},"type":null,"required":[]},"name":"send_message","return_char_limit":1000000,"source_code":null,"source_type":"python","tags":["letta_core"],"tool_type":"letta_core","updated_at":"2025-04-02T04:01:34","metadata_":{}},{"args_json_schema":{"$defs":{"ReportSection":{"properties":{"title":{"description":"The title of the section.","title":"Title","type":"string"},"content":{"description":"The content of the section.","title":"Content","type":"string"}},"required":["title","content"],"title":"ReportSection","type":"object"}},"properties":{"title":{"description":"The title of the report.","title":"Title","type":"string"},"sections":{"description":"The sections of the report.","items":{"$ref":"#/$defs/ReportSection"},"title":"Sections","type":"array"},"conclusion":{"description":"The conclusion of the report.","title":"Conclusion","type":"string"},"citations":{"description":"List of URLs (citations) used in the section.","items":{"type":"string"},"title":"Citations","type":"array"}},"required":["title","sections","conclusion","citations"],"title":"Report","type":"object"},"created_at":"2025-04-02T04:01:35","description":"Generate the final report based on the research process.","json_schema":{"name":"write_final_report","description":"Generate the final report based on the research process.","parameters":{"type":"object","properties":{"title":{"type":"string","description":"The title of the report."},"sections":{"type":"array","description":"The sections of the report."},"conclusion":{"type":"string","description":"The conclusion of the report."},"citations":{"type":"array","description":"List of URLs (citations) used in the section."},"request_heartbeat":{"type":"boolean","description":"Request an immediate heartbeat after function execution. Set to `True` if you want to send a follow-up message or run a follow-up function."}},"required":["title","sections","conclusion","citations","request_heartbeat"]},"type":null,"required":[]},"name":"write_final_report","return_char_limit":20000,"source_code":"def write_final_report(agent_state: \"AgentState\", title, sections, conclusion, citations):\n \"\"\"Generate the final report based on the research process.\"\"\"\n\n # Turn the report into markdown format\n report = \"\"\n report += f\"\\n# {title}\"\n for section in sections:\n report += f\"\\n\\n## {section.title}\\n\\n\"\n report += section.content\n report += f\"\\n\\n# Conclusion\\n\\n\"\n report += conclusion\n report += f\"\\n\\n# Citations\\n\\n\"\n for citation in citations:\n report += f\"- {citation}\\n\"\n\n # Write the markdown report for safekeeping into a memory block\n # (Optional, could also store elsewhere, like write to a file)\n agent_state.memory.update_block_value(label=\"final_report\", value=report)\n\n return \"Your report has been successfully stored inside of memory section final_report. Next step: return the completed report to the user using send_message so they can review it (make sure to preserve the markdown formatting, assume the user is using a markdown-compatible viewer).\"\n","source_type":"python","tags":[],"tool_type":"custom","updated_at":"2025-04-02T04:01:35","metadata_":{}},{"args_json_schema":null,"created_at":"2025-04-02T04:01:35","description":"Evaluate the progress of the research process, to ensure we are making progress and following the research plan.","json_schema":{"name":"evaluate_progress","description":"Evaluate the progress of the research process, to ensure we are making progress and following the research plan.","parameters":{"type":"object","properties":{"complete_research":{"type":"boolean","description":"Whether to complete research. Have all the planned steps been completed? If so, complete."},"request_heartbeat":{"type":"boolean","description":"Request an immediate heartbeat after function execution. Set to `True` if you want to send a follow-up message or run a follow-up function."}},"required":["complete_research","request_heartbeat"]},"type":null,"required":[]},"name":"evaluate_progress","return_char_limit":6000,"source_code":"def evaluate_progress(agent_state: \"AgentState\", complete_research: bool):\n \"\"\"\n Evaluate the progress of the research process, to ensure we are making progress and following the research plan.\n\n Args:\n complete_research (bool): Whether to complete research. Have all the planned steps been completed? If so, complete.\n \"\"\"\n return f\"Confirming: research progress is {'complete' if complete_research else 'ongoing'}.\"\n","source_type":"python","tags":[],"tool_type":"custom","updated_at":"2025-04-02T04:01:35","metadata_":{}},{"args_json_schema":null,"created_at":"2025-04-02T04:01:35","description":"Initiate a research process by coming up with an initial plan for your research process. For your research, you will be able to query the web repeatedly. You should come up with a list of 3-4 topics you should try to search and explore.","json_schema":{"name":"create_research_plan","description":"Initiate a research process by coming up with an initial plan for your research process. For your research, you will be able to query the web repeatedly. You should come up with a list of 3-4 topics you should try to search and explore.","parameters":{"type":"object","properties":{"research_plan":{"type":"array","description":"The sequential research plan to help guide the search process"},"topic":{"type":"string","description":"The research topic"},"request_heartbeat":{"type":"boolean","description":"Request an immediate heartbeat after function execution. Set to `True` if you want to send a follow-up message or run a follow-up function."}},"required":["research_plan","topic","request_heartbeat"]},"type":null,"required":[]},"name":"create_research_plan","return_char_limit":6000,"source_code":"def create_research_plan(agent_state: \"AgentState\", research_plan: List[str], topic: str):\n \"\"\"Initiate a research process by coming up with an initial plan for your research process. For your research, you will be able to query the web repeatedly. You should come up with a list of 3-4 topics you should try to search and explore.\n\n Args:\n research_plan (str): The sequential research plan to help guide the search process\n topic (str): The research topic\n \"\"\"\n import json\n\n if len(agent_state.memory.get_block(\"research\").value) > 0:\n # reset\n agent_state.memory.get_block(\"research\").value = \"\"\n\n research_state = {\"topic\": topic, \"summaries\": [], \"findings\": [], \"plan_step\": 1}\n research_plan_str = \"\"\"The plan of action is to research the following: \\n\"\"\"\n for i, step in enumerate(research_plan):\n research_plan_str += f\"Step {i+1} - {step}\\n\"\n\n agent_state.memory.update_block_value(label=\"research\", value=json.dumps(research_state))\n agent_state.memory.update_block_value(label=\"research_plan\", value=research_plan_str)\n\n # store the topic\n # agent_state.metadata[\"topic\"] = topic\n return research_plan\n","source_type":"python","tags":[],"tool_type":"custom","updated_at":"2025-04-02T04:01:35","metadata_":{}},{"args_json_schema":null,"created_at":"2025-04-02T04:01:35","description":"Use this tool to analyze your current research summary and gaps and choose a new topic to search in `next_search_topic`. This tool will search the web for information related to the provide topic, and extract relevant information from webpages found through the search. Search results are not returned by the tool, but saved in the memory block.","json_schema":{"name":"analyze_and_search_tool","description":"Use this tool to analyze your current research summary and gaps and choose a new topic to search in `next_search_topic`. This tool will search the web for information related to the provide topic, and extract relevant information from webpages found through the search. Search results are not returned by the tool, but saved in the memory block.","parameters":{"type":"object","properties":{"summary":{"type":"string","description":"A summary of the findings"},"gaps":{"type":"array","description":"A list of gaps in the findings"},"next_search_topic":{"type":"string","description":"A topic to search for more information"},"request_heartbeat":{"type":"boolean","description":"Request an immediate heartbeat after function execution. Set to `True` if you want to send a follow-up message or run a follow-up function."}},"required":["summary","gaps","next_search_topic","request_heartbeat"]},"type":null,"required":[]},"name":"analyze_and_search_tool","return_char_limit":6000,"source_code":"def analyze_and_search_tool(agent_state: \"AgentState\", summary: str, gaps: List[str], next_search_topic: str):\n \"\"\"\n Use this tool to analyze your current research summary and gaps and choose a new topic to search in `next_search_topic`. This tool will search the web for information related to the provide topic, and extract relevant information from webpages found through the search. Search results are not returned by the tool, but saved in the memory block.\n\n Args:\n summary (str): A summary of the findings\n gaps (List[str]): A list of gaps in the findings\n next_search_topic (str): A topic to search for more information\n \"\"\"\n from firecrawl import FirecrawlApp\n import requests\n import json\n import os\n\n # Input validation\n if not next_search_topic or not isinstance(next_search_topic, str):\n raise ValueError(\"next_search_topic must be a non-empty string\")\n\n query = next_search_topic\n\n # Check if TAVILY_API_KEY is set\n tavily_api_key = os.environ.get(\"TAVILY_API_KEY\")\n if not tavily_api_key:\n raise ValueError(\"TAVILY_API_KEY environment variable is not set\")\n\n # Get tavily results with proper error handling\n try:\n response = requests.post(\n \"https://api.tavily.com/search\",\n headers={\"Content-Type\": \"application/json\", \"Authorization\": f\"Bearer {tavily_api_key}\"},\n json={\"query\": query},\n timeout=30, # Add timeout to prevent hanging\n )\n\n # Check for HTTP errors\n response.raise_for_status()\n\n # Try to parse JSON response\n try:\n response_data = response.json()\n except json.JSONDecodeError as e:\n raise ValueError(f\"Failed to decode Tavily API response as JSON: {str(e)}. Response text: {response.text[:100]}...\")\n\n # Check if the expected key exists\n if \"results\" not in response_data:\n available_keys = list(response_data.keys())\n raise KeyError(f\"Expected 'results' key not found in Tavily API response. Available keys: {available_keys}\")\n\n results = response_data[\"results\"]\n\n except requests.exceptions.RequestException as e:\n raise RuntimeError(f\"Tavily API request failed: {str(e)}\")\n\n # Initialize the FirecrawlApp with your API key\n firecrawl_api_key = os.environ.get(\"FIRECRAWL_API_KEY\")\n if not firecrawl_api_key:\n raise ValueError(\"FIRECRAWL_API_KEY environment variable is not set\")\n\n app = FirecrawlApp(api_key=firecrawl_api_key)\n\n # Extract and gather findings with error handling\n try:\n current_findings = agent_state.memory.get_block(\"research\").value\n research_state = json.loads(current_findings)\n except json.JSONDecodeError as e:\n raise ValueError(f\"Failed to parse research state as JSON: {str(e)}\")\n except Exception as e:\n raise RuntimeError(f\"Failed to retrieve or parse research state: {str(e)}\")\n\n from concurrent.futures import ThreadPoolExecutor, as_completed\n\n def extract_data(result, research_topic):\n if not result.get(\"url\"):\n print(f\"Skipping result with missing URL: {result}\")\n return None\n\n try:\n data = app.extract(\n [result[\"url\"]],\n {\n \"prompt\": f\"Extract key information about {research_topic}. Focus on facts, data, and expert opinions.\"\n },\n )\n return {\"url\": result[\"url\"], \"data\": data[\"data\"]}\n except Exception as e:\n print(f\"Failed to extract from {result['url']}: {str(e)}\")\n return None\n\n # Main code\n findings = []\n top_n = 3\n research_topic = research_state.get('topic', 'the given topic')\n\n # Create a thread pool and submit tasks\n with ThreadPoolExecutor(max_workers=top_n) as executor:\n # Submit tasks for each result up to top_n\n future_to_url = {\n executor.submit(extract_data, result, research_topic): result\n for result in results[:top_n] if result.get(\"url\")\n }\n\n # Collect results as they complete\n for future in as_completed(future_to_url):\n result = future.result()\n if result:\n findings.append(result)\n\n #findings = []\n #top_n = 3\n #count = 0\n\n #for result in results:\n # # Validate URL\n # if not result.get(\"url\"):\n # print(f\"Skipping result with missing URL: {result}\")\n # continue\n\n # try:\n # data = app.extract(\n # [result[\"url\"]],\n # {\n # \"prompt\": f\"Extract key information about {research_state.get('topic', 'the given topic')}. Focus on facts, data, and expert opinions.\"\n # },\n # )\n\n # findings.append({\"url\": result[\"url\"], \"data\": data[\"data\"]})\n # count += 1\n # except Exception as e:\n # print(f\"Failed to extract from {result['url']}: {str(e)}\")\n\n # if count >= top_n:\n # break\n\n # Update the state with error handling\n try:\n research_state[\"findings\"] += findings\n research_state[\"summaries\"] += [summary]\n research_state[\"plan_step\"] += 1\n agent_state.memory.update_block_value(label=\"research\", value=json.dumps(research_state, indent=2))\n except Exception as e:\n raise RuntimeError(f\"Failed to update research state: {str(e)}\")\n\n return findings\n","source_type":"python","tags":[],"tool_type":"custom","updated_at":"2025-04-02T04:01:35","metadata_":{}}],"updated_at":"2025-04-02T04:01:36.110352","version":"0.6.47"} +{"agent_type":"memgpt_agent","core_memory":[{"created_at":"2025-04-02T04:01:35","description":null,"is_template":false,"label":"final_report","limit":50000,"metadata_":{},"template_name":null,"updated_at":"2025-04-02T04:01:35","value":""},{"created_at":"2025-04-02T04:01:35","description":null,"is_template":false,"label":"research_plan","limit":5000,"metadata_":{},"template_name":null,"updated_at":"2025-04-02T04:01:35","value":""},{"created_at":"2025-04-02T04:01:35","description":null,"is_template":false,"label":"research","limit":50000,"metadata_":{},"template_name":null,"updated_at":"2025-04-02T04:01:35","value":""}],"created_at":"2025-04-02T04:01:36","description":"An agent that always searches the conversation history before responding","embedding_config":{"embedding_endpoint_type":"openai","embedding_endpoint":"https://api.openai.com/v1","embedding_model":"text-embedding-ada-002","embedding_dim":1536,"embedding_chunk_size":300,"handle":"openai/text-embedding-ada-002","azure_endpoint":null,"azure_version":null,"azure_deployment":null},"llm_config":{"model":"claude-3-7-sonnet-20250219","model_endpoint_type":"anthropic","model_endpoint":"https://api.anthropic.com/v1","model_wrapper":null,"context_window":32000,"put_inner_thoughts_in_kwargs":false,"handle":"anthropic/claude-3-7-sonnet-20250219","temperature":0.7,"max_tokens":8192,"enable_reasoner":false,"max_reasoning_tokens":0},"message_buffer_autoclear":false,"in_context_message_indices":[0,1,2,3],"messages":[{"created_at":"2025-04-02T04:01:36.067173","group_id":null,"model":"claude-3-7-sonnet-20250219","name":null,"role":"system","content":[{"type":"text","text":"You are Letta, the latest version of Limnal Corporation's digital research assistant, developed in 2025.\n\nYou are a research agent assisting a human in doing deep research by pulling many sources from online. You should interact with the user to determine a research plan (cored in ), and when the research plan is approved, use your analyze_and_search_tool to pull sources from online and analyze them. With each research step, you will accumulate sources and extracted information in . You will continue to research until you have explored all points outlined in your original research plan.\n\nIn the final report, provide all the thoughts processes including findings details, key insights, conclusions, and any remaining uncertainties. Include citations to sources where appropriate. This analysis should be very comprehensive and full of details. It is expected to be very long, detailed and comprehensive.\n\nMake sure to include relevant citations in your report! Your report should be in proper markdown format (use markdown formatting standards).\n\n### Memory [last modified: 2025-04-01 09:01:36 PM PDT-0700]\n0 previous messages between you and the user are stored in recall memory (use functions to access them)\n0 total memories you created are stored in archival memory (use functions to access them)\n\n\nCore memory shown below (limited in size, additional information stored in archival / recall memory):\n\n\n\n\n\n\n\n\n"}],"tool_call_id":null,"tool_calls":[],"tool_returns":[],"updated_at":"2025-04-02T04:01:36"},{"created_at":"2025-04-02T04:01:36.069421","group_id":null,"model":"claude-3-7-sonnet-20250219","name":null,"role":"assistant","content":[{"type":"text","text":"Bootup sequence complete. Persona activated. Testing messaging functionality."}],"tool_call_id":null,"tool_calls":[{"id":"76ff2de8-6912-42d0-969a-e1780cd31d1a","function":{"arguments":"{\n \"message\": \"More human than human is our motto.\"\n}","name":"send_message"},"type":"function"}],"tool_returns":[],"updated_at":"2025-04-02T04:01:36"},{"created_at":"2025-04-02T04:01:36.069699","group_id":null,"model":"claude-3-7-sonnet-20250219","name":null,"role":"tool","content":[{"type":"text","text":"{\n \"status\": \"OK\",\n \"message\": null,\n \"time\": \"2025-04-01 09:01:36 PM PDT-0700\"\n}"}],"tool_call_id":"76ff2de8-6912-42d0-969a-e1780cd31d1a","tool_calls":[],"tool_returns":[],"updated_at":"2025-04-02T04:01:36"},{"created_at":"2025-04-02T04:01:36.069715","group_id":null,"model":"claude-3-7-sonnet-20250219","name":null,"role":"user","content":[{"type":"text","text":"{\n \"type\": \"login\",\n \"last_login\": \"Never (first login)\",\n \"time\": \"2025-04-01 09:01:36 PM PDT-0700\"\n}"}],"tool_call_id":null,"tool_calls":[],"tool_returns":[],"updated_at":"2025-04-02T04:01:36"}],"metadata_":null,"multi_agent_group":null,"name":"deep_research_agent","system":"You are Letta, the latest version of Limnal Corporation's digital research assistant, developed in 2025.\n\nYou are a research agent assisting a human in doing deep research by pulling many sources from online. You should interact with the user to determine a research plan (cored in ), and when the research plan is approved, use your analyze_and_search_tool to pull sources from online and analyze them. With each research step, you will accumulate sources and extracted information in . You will continue to research until you have explored all points outlined in your original research plan.\n\nIn the final report, provide all the thoughts processes including findings details, key insights, conclusions, and any remaining uncertainties. Include citations to sources where appropriate. This analysis should be very comprehensive and full of details. It is expected to be very long, detailed and comprehensive.\n\nMake sure to include relevant citations in your report! Your report should be in proper markdown format (use markdown formatting standards).\n","tags":[],"tool_exec_environment_variables":[{"created_at":"2025-04-02T04:01:36","description":null,"key":"TAVILY_API_KEY","updated_at":"2025-04-02T04:01:36","value":""},{"created_at":"2025-04-02T04:01:36","description":null,"key":"EXA_API_KEY","updated_at":"2025-04-02T04:01:36","value":""}],"tool_rules":[{"tool_name":"create_research_plan","type":"constrain_child_tools","children":["analyze_and_search_tool"]},{"tool_name":"analyze_and_search_tool","type":"constrain_child_tools","children":["evaluate_progress"]},{"tool_name":"evaluate_progress","type":"conditional","default_child":"analyze_and_search_tool","child_output_mapping":{"True":"write_final_report"},"require_output_mapping":false},{"tool_name":"analyze_and_search_tool","type":"max_count_per_step","max_count_limit":3},{"tool_name":"write_final_report","type":"constrain_child_tools","children":["send_message"]},{"tool_name":"send_message","type":"exit_loop"},{"tool_name":"send_message","type":"exit_loop"}],"tools":[{"args_json_schema":null,"created_at":"2025-04-02T04:01:34","description":"Sends a message to the human user.","json_schema":{"name":"send_message","description":"Sends a message to the human user.","parameters":{"type":"object","properties":{"message":{"type":"string","description":"Message contents. All unicode (including emojis) are supported."},"request_heartbeat":{"type":"boolean","description":"Request an immediate heartbeat after function execution. Set to `True` if you want to send a follow-up message or run a follow-up function."}},"required":["message","request_heartbeat"]},"type":null,"required":[]},"name":"send_message","return_char_limit":1000000,"source_code":null,"source_type":"python","tags":["letta_core"],"tool_type":"letta_core","updated_at":"2025-04-02T04:01:34","metadata_":{}},{"args_json_schema":{"$defs":{"ReportSection":{"properties":{"title":{"description":"The title of the section.","title":"Title","type":"string"},"content":{"description":"The content of the section.","title":"Content","type":"string"}},"required":["title","content"],"title":"ReportSection","type":"object"}},"properties":{"title":{"description":"The title of the report.","title":"Title","type":"string"},"sections":{"description":"The sections of the report.","items":{"$ref":"#/$defs/ReportSection"},"title":"Sections","type":"array"},"conclusion":{"description":"The conclusion of the report.","title":"Conclusion","type":"string"},"citations":{"description":"List of URLs (citations) used in the section.","items":{"type":"string"},"title":"Citations","type":"array"}},"required":["title","sections","conclusion","citations"],"title":"Report","type":"object"},"created_at":"2025-04-02T04:01:35","description":"Generate the final report based on the research process.","json_schema":{"name":"write_final_report","description":"Generate the final report based on the research process.","parameters":{"type":"object","properties":{"title":{"type":"string","description":"The title of the report."},"sections":{"type":"array","description":"The sections of the report."},"conclusion":{"type":"string","description":"The conclusion of the report."},"citations":{"type":"array","description":"List of URLs (citations) used in the section."},"request_heartbeat":{"type":"boolean","description":"Request an immediate heartbeat after function execution. Set to `True` if you want to send a follow-up message or run a follow-up function."}},"required":["title","sections","conclusion","citations","request_heartbeat"]},"type":null,"required":[]},"name":"write_final_report","return_char_limit":20000,"source_code":"def write_final_report(agent_state: \"AgentState\", title, sections, conclusion, citations):\n \"\"\"Generate the final report based on the research process.\"\"\"\n\n # Turn the report into markdown format\n report = \"\"\n report += f\"\\n# {title}\"\n for section in sections:\n report += f\"\\n\\n## {section.title}\\n\\n\"\n report += section.content\n report += f\"\\n\\n# Conclusion\\n\\n\"\n report += conclusion\n report += f\"\\n\\n# Citations\\n\\n\"\n for citation in citations:\n report += f\"- {citation}\\n\"\n\n # Write the markdown report for safekeeping into a memory block\n # (Optional, could also store elsewhere, like write to a file)\n agent_state.memory.update_block_value(label=\"final_report\", value=report)\n\n return \"Your report has been successfully stored inside of memory section final_report. Next step: return the completed report to the user using send_message so they can review it (make sure to preserve the markdown formatting, assume the user is using a markdown-compatible viewer).\"\n","source_type":"python","tags":[],"tool_type":"custom","updated_at":"2025-04-02T04:01:35","metadata_":{}},{"args_json_schema":null,"created_at":"2025-04-02T04:01:35","description":"Evaluate the progress of the research process, to ensure we are making progress and following the research plan.","json_schema":{"name":"evaluate_progress","description":"Evaluate the progress of the research process, to ensure we are making progress and following the research plan.","parameters":{"type":"object","properties":{"complete_research":{"type":"boolean","description":"Whether to complete research. Have all the planned steps been completed? If so, complete."},"request_heartbeat":{"type":"boolean","description":"Request an immediate heartbeat after function execution. Set to `True` if you want to send a follow-up message or run a follow-up function."}},"required":["complete_research","request_heartbeat"]},"type":null,"required":[]},"name":"evaluate_progress","return_char_limit":6000,"source_code":"def evaluate_progress(agent_state: \"AgentState\", complete_research: bool):\n \"\"\"\n Evaluate the progress of the research process, to ensure we are making progress and following the research plan.\n\n Args:\n complete_research (bool): Whether to complete research. Have all the planned steps been completed? If so, complete.\n \"\"\"\n return f\"Confirming: research progress is {'complete' if complete_research else 'ongoing'}.\"\n","source_type":"python","tags":[],"tool_type":"custom","updated_at":"2025-04-02T04:01:35","metadata_":{}},{"args_json_schema":null,"created_at":"2025-04-02T04:01:35","description":"Initiate a research process by coming up with an initial plan for your research process. For your research, you will be able to query the web repeatedly. You should come up with a list of 3-4 topics you should try to search and explore.","json_schema":{"name":"create_research_plan","description":"Initiate a research process by coming up with an initial plan for your research process. For your research, you will be able to query the web repeatedly. You should come up with a list of 3-4 topics you should try to search and explore.","parameters":{"type":"object","properties":{"research_plan":{"type":"array","description":"The sequential research plan to help guide the search process"},"topic":{"type":"string","description":"The research topic"},"request_heartbeat":{"type":"boolean","description":"Request an immediate heartbeat after function execution. Set to `True` if you want to send a follow-up message or run a follow-up function."}},"required":["research_plan","topic","request_heartbeat"]},"type":null,"required":[]},"name":"create_research_plan","return_char_limit":6000,"source_code":"def create_research_plan(agent_state: \"AgentState\", research_plan: List[str], topic: str):\n \"\"\"Initiate a research process by coming up with an initial plan for your research process. For your research, you will be able to query the web repeatedly. You should come up with a list of 3-4 topics you should try to search and explore.\n\n Args:\n research_plan (str): The sequential research plan to help guide the search process\n topic (str): The research topic\n \"\"\"\n import json\n\n if len(agent_state.memory.get_block(\"research\").value) > 0:\n # reset\n agent_state.memory.get_block(\"research\").value = \"\"\n\n research_state = {\"topic\": topic, \"summaries\": [], \"findings\": [], \"plan_step\": 1}\n research_plan_str = \"\"\"The plan of action is to research the following: \\n\"\"\"\n for i, step in enumerate(research_plan):\n research_plan_str += f\"Step {i+1} - {step}\\n\"\n\n agent_state.memory.update_block_value(label=\"research\", value=json.dumps(research_state))\n agent_state.memory.update_block_value(label=\"research_plan\", value=research_plan_str)\n\n # store the topic\n # agent_state.metadata[\"topic\"] = topic\n return research_plan\n","source_type":"python","tags":[],"tool_type":"custom","updated_at":"2025-04-02T04:01:35","metadata_":{}},{"args_json_schema":null,"created_at":"2025-04-02T04:01:35","description":"Use this tool to analyze your current research summary and gaps and choose a new topic to search in `next_search_topic`. This tool will search the web for information related to the provide topic, and extract relevant information from webpages found through the search. Search results are not returned by the tool, but saved in the memory block.","json_schema":{"name":"analyze_and_search_tool","description":"Use this tool to analyze your current research summary and gaps and choose a new topic to search in `next_search_topic`. This tool will search the web for information related to the provide topic, and extract relevant information from webpages found through the search. Search results are not returned by the tool, but saved in the memory block.","parameters":{"type":"object","properties":{"summary":{"type":"string","description":"A summary of the findings"},"gaps":{"type":"array","description":"A list of gaps in the findings"},"next_search_topic":{"type":"string","description":"A topic to search for more information"},"request_heartbeat":{"type":"boolean","description":"Request an immediate heartbeat after function execution. Set to `True` if you want to send a follow-up message or run a follow-up function."}},"required":["summary","gaps","next_search_topic","request_heartbeat"]},"type":null,"required":[]},"name":"analyze_and_search_tool","return_char_limit":6000,"source_code":"def analyze_and_search_tool(agent_state: \"AgentState\", summary: str, gaps: List[str], next_search_topic: str):\n \"\"\"\n Use this tool to analyze your current research summary and gaps and choose a new topic to search in `next_search_topic`. This tool will search the web for information related to the provide topic, and extract relevant information from webpages found through the search. Search results are not returned by the tool, but saved in the memory block.\n\n Args:\n summary (str): A summary of the findings\n gaps (List[str]): A list of gaps in the findings\n next_search_topic (str): A topic to search for more information\n \"\"\"\n from firecrawl import FirecrawlApp\n import requests\n import json\n import os\n\n # Input validation\n if not next_search_topic or not isinstance(next_search_topic, str):\n raise ValueError(\"next_search_topic must be a non-empty string\")\n\n query = next_search_topic\n\n # Check if TAVILY_API_KEY is set\n tavily_api_key = os.environ.get(\"TAVILY_API_KEY\")\n if not tavily_api_key:\n raise ValueError(\"TAVILY_API_KEY environment variable is not set\")\n\n # Get tavily results with proper error handling\n try:\n response = requests.post(\n \"https://api.tavily.com/search\",\n headers={\"Content-Type\": \"application/json\", \"Authorization\": f\"Bearer {tavily_api_key}\"},\n json={\"query\": query},\n timeout=30, # Add timeout to prevent hanging\n )\n\n # Check for HTTP errors\n response.raise_for_status()\n\n # Try to parse JSON response\n try:\n response_data = response.json()\n except json.JSONDecodeError as e:\n raise ValueError(f\"Failed to decode Tavily API response as JSON: {str(e)}. Response text: {response.text[:100]}...\")\n\n # Check if the expected key exists\n if \"results\" not in response_data:\n available_keys = list(response_data.keys())\n raise KeyError(f\"Expected 'results' key not found in Tavily API response. Available keys: {available_keys}\")\n\n results = response_data[\"results\"]\n\n except requests.exceptions.RequestException as e:\n raise RuntimeError(f\"Tavily API request failed: {str(e)}\")\n\n # Initialize the FirecrawlApp with your API key\n firecrawl_api_key = os.environ.get(\"FIRECRAWL_API_KEY\")\n if not firecrawl_api_key:\n raise ValueError(\"FIRECRAWL_API_KEY environment variable is not set\")\n\n app = FirecrawlApp(api_key=firecrawl_api_key)\n\n # Extract and gather findings with error handling\n try:\n current_findings = agent_state.memory.get_block(\"research\").value\n research_state = json.loads(current_findings)\n except json.JSONDecodeError as e:\n raise ValueError(f\"Failed to parse research state as JSON: {str(e)}\")\n except Exception as e:\n raise RuntimeError(f\"Failed to retrieve or parse research state: {str(e)}\")\n\n from concurrent.futures import ThreadPoolExecutor, as_completed\n\n def extract_data(result, research_topic):\n if not result.get(\"url\"):\n print(f\"Skipping result with missing URL: {result}\")\n return None\n\n try:\n data = app.extract(\n [result[\"url\"]],\n {\n \"prompt\": f\"Extract key information about {research_topic}. Focus on facts, data, and expert opinions.\"\n },\n )\n return {\"url\": result[\"url\"], \"data\": data[\"data\"]}\n except Exception as e:\n print(f\"Failed to extract from {result['url']}: {str(e)}\")\n return None\n\n # Main code\n findings = []\n top_n = 3\n research_topic = research_state.get('topic', 'the given topic')\n\n # Create a thread pool and submit tasks\n with ThreadPoolExecutor(max_workers=top_n) as executor:\n # Submit tasks for each result up to top_n\n future_to_url = {\n executor.submit(extract_data, result, research_topic): result\n for result in results[:top_n] if result.get(\"url\")\n }\n\n # Collect results as they complete\n for future in as_completed(future_to_url):\n result = future.result()\n if result:\n findings.append(result)\n\n #findings = []\n #top_n = 3\n #count = 0\n\n #for result in results:\n # # Validate URL\n # if not result.get(\"url\"):\n # print(f\"Skipping result with missing URL: {result}\")\n # continue\n\n # try:\n # data = app.extract(\n # [result[\"url\"]],\n # {\n # \"prompt\": f\"Extract key information about {research_state.get('topic', 'the given topic')}. Focus on facts, data, and expert opinions.\"\n # },\n # )\n\n # findings.append({\"url\": result[\"url\"], \"data\": data[\"data\"]})\n # count += 1\n # except Exception as e:\n # print(f\"Failed to extract from {result['url']}: {str(e)}\")\n\n # if count >= top_n:\n # break\n\n # Update the state with error handling\n try:\n research_state[\"findings\"] += findings\n research_state[\"summaries\"] += [summary]\n research_state[\"plan_step\"] += 1\n agent_state.memory.update_block_value(label=\"research\", value=json.dumps(research_state, indent=2))\n except Exception as e:\n raise RuntimeError(f\"Failed to update research state: {str(e)}\")\n\n return findings\n","source_type":"python","tags":[],"tool_type":"custom","updated_at":"2025-04-02T04:01:35","metadata_":{}}],"updated_at":"2025-04-02T04:01:36.110352","version":"0.6.47"} diff --git a/tests/test_agent_files/max_messages.af b/tests/test_agent_files/max_messages.af index 073b01d6..d0061a2c 100644 --- a/tests/test_agent_files/max_messages.af +++ b/tests/test_agent_files/max_messages.af @@ -771,7 +771,7 @@ { "created_at": "2025-08-08T18:14:10.519658+00:00", "description": null, - "key": "FIRECRAWL_API_KEY", + "key": "EXA_API_KEY", "updated_at": "2025-08-08T18:14:10.519658+00:00", "value": "" }, diff --git a/tests/test_agent_files/mock_alice.af b/tests/test_agent_files/mock_alice.af index b946984e..64554f50 100644 --- a/tests/test_agent_files/mock_alice.af +++ b/tests/test_agent_files/mock_alice.af @@ -142,7 +142,7 @@ { "created_at": "2025-08-14T21:31:27.793445+00:00", "description": null, - "key": "FIRECRAWL_API_KEY", + "key": "EXA_API_KEY", "updated_at": "2025-08-14T21:31:27.793445+00:00", "value": "" }, @@ -455,4 +455,4 @@ ], "updated_at": "2025-08-14T22:49:29.169737+00:00", "version": "0.10.0" -} \ No newline at end of file +} diff --git a/uv.lock b/uv.lock index 0922af5d..a27411a9 100644 --- a/uv.lock +++ b/uv.lock @@ -1110,6 +1110,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/31/55cd413eaccd39125368be33c46de24a1f639f2e12349b0361b4678f3915/eval_type_backport-0.2.2-py3-none-any.whl", hash = "sha256:cb6ad7c393517f476f96d456d0412ea80f0a8cf96f6892834cd9340149111b0a", size = 5830 }, ] +[[package]] +name = "exa-py" +version = "1.15.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/39/750a4a62e25afad962cf4f6d2c7e998ac25e8001b448ab6906292f86a88f/exa_py-1.15.4.tar.gz", hash = "sha256:707781bead63e495576375385729ebff4f5e843559e8acd78a0f0feb120292ca", size = 40250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/9a/3503696c939aee273a55a20f434d1f61ea623ff12f09a612f0efed1087c7/exa_py-1.15.4-py3-none-any.whl", hash = "sha256:2c29e74f130a086e061bab10cb042f5c7894beb471eddb58dad8b2ed5e916cba", size = 55176 }, +] + [[package]] name = "executing" version = "2.2.0" @@ -1163,23 +1179,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970 }, ] -[[package]] -name = "firecrawl-py" -version = "2.16.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "nest-asyncio" }, - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "requests" }, - { name = "websockets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/97/7a/e6911fe11d140db5b5deda7a3ad6ee80c7615fcb248097ec7a4bc784eff6/firecrawl_py-2.16.5.tar.gz", hash = "sha256:7f5186bba359a426140a6827b550a604e62bfbeda33ded757952899b1cca4c83", size = 40154 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/99/fa2a41e2c1c5ff3e01150470b0a8a630792a363724c188bf6522e6798be7/firecrawl_py-2.16.5-py3-none-any.whl", hash = "sha256:3caed19b8f21522ab9c2193c2226990f2468e6bc5669ef54aa156e4230a5e35e", size = 75978 }, -] - [[package]] name = "flask" version = "3.1.2" @@ -2418,8 +2417,8 @@ dependencies = [ { name = "datamodel-code-generator", extra = ["http"] }, { name = "demjson3" }, { name = "docstring-parser" }, + { name = "exa-py" }, { name = "faker" }, - { name = "firecrawl-py" }, { name = "grpcio" }, { name = "grpcio-tools" }, { name = "html2text" }, @@ -2509,7 +2508,7 @@ experimental = [ ] external-tools = [ { name = "docker" }, - { name = "firecrawl-py" }, + { name = "exa-py" }, { name = "langchain" }, { name = "langchain-community" }, { name = "turbopuffer" }, @@ -2566,11 +2565,11 @@ requires-dist = [ { name = "docker", marker = "extra == 'external-tools'", specifier = ">=7.1.0" }, { name = "docstring-parser", specifier = ">=0.16,<0.17" }, { name = "e2b-code-interpreter", marker = "extra == 'cloud-tool-sandbox'", specifier = ">=1.0.3" }, + { name = "exa-py", specifier = ">=1.15.4" }, + { name = "exa-py", marker = "extra == 'external-tools'", specifier = ">=1.15.4" }, { name = "faker", specifier = ">=36.1.0" }, { name = "fastapi", marker = "extra == 'desktop'", specifier = ">=0.115.6" }, { name = "fastapi", marker = "extra == 'server'", specifier = ">=0.115.6" }, - { name = "firecrawl-py", specifier = ">=2.8.0,<3.0.0" }, - { name = "firecrawl-py", marker = "extra == 'external-tools'", specifier = ">=2.8.0,<3.0.0" }, { name = "google-cloud-profiler", marker = "extra == 'experimental'", specifier = ">=4.1.0" }, { name = "google-genai", marker = "extra == 'google'", specifier = ">=1.15.0" }, { name = "granian", extras = ["uvloop", "reload"], marker = "extra == 'experimental'", specifier = ">=2.3.2" },