diff --git a/libs/deepagents-cli/deepagents_cli/README.md b/libs/deepagents-cli/deepagents_cli/README.md index 63caf24d..99da36f1 100644 --- a/libs/deepagents-cli/deepagents_cli/README.md +++ b/libs/deepagents-cli/deepagents_cli/README.md @@ -12,7 +12,7 @@ cli/ ├── __main__.py # Entry point for `python -m deepagents.cli` ├── main.py # CLI loop, argument parsing, main orchestration ├── config.py # Configuration, constants, colors, model creation -├── tools.py # Custom tools (http_request, web_search) +├── tools.py # Custom tools (http_request, parallel_search, tavily_search) ├── ui.py # Display logic, TokenTracker, help screens ├── input.py # Input handling, completers, prompt session ├── commands.py # Slash command and bash command handlers @@ -44,9 +44,10 @@ cli/ ### `tools.py` - Custom Agent Tools - **Purpose**: Additional tools for the agent beyond built-in filesystem operations - **Tools**: + - `fetch_url()` - Fetch and convert web content to markdown - `http_request()` - Make HTTP requests to APIs - - `web_search()` - Search the web using Tavily API - - `tavily_client` - Initialized Tavily client (if API key available) + - `parallel_search()` - Search the web using Parallel API (if PARALLEL_API_KEY is set) + - `tavily_search()` - Search the web using Tavily API (if TAVILY_API_KEY is set) ### `ui.py` - Display & Rendering - **Purpose**: All UI rendering and display logic diff --git a/libs/deepagents-cli/deepagents_cli/agent.py b/libs/deepagents-cli/deepagents_cli/agent.py index 3eff9a0c..77e08794 100644 --- a/libs/deepagents-cli/deepagents_cli/agent.py +++ b/libs/deepagents-cli/deepagents_cli/agent.py @@ -160,15 +160,27 @@ def get_system_prompt(assistant_id: str, sandbox_type: str | None = None) -> str ### Web Search Tool Usage -When you use the web_search tool: -1. The tool will return search results with titles, URLs, and content excerpts -2. You MUST read and process these results, then respond naturally to the user -3. NEVER show raw JSON or tool results directly to the user -4. Synthesize the information from multiple sources into a coherent answer -5. Cite your sources by mentioning page titles or URLs when relevant -6. If the search doesn't find what you need, explain what you found and ask clarifying questions +You have access to web search tools based on available API keys: -The user only sees your text responses - not tool results. Always provide a complete, natural language answer after using web_search. +**tavily_search**: +- Returns results with 'content' field (string) containing excerpts +- Includes 'score' field for relevance (0-1) +- Supports 'topic' parameter: "general", "news", or "finance" + +**parallel_search**: +- Returns results with 'excerpts' field (array of strings) in markdown format +- Includes 'search_id' for tracking +- Supports 'objective' parameter for natural language search goals and multiple queries + +When using either tool: +1. You MUST read and process the results, then respond naturally to the user +2. NEVER show raw JSON or tool results directly to the user +3. Synthesize the information from multiple sources into a coherent answer +4. Cite your sources by mentioning page titles or URLs when relevant +5. If the search doesn't find what you need, explain what you found and ask clarifying questions + +The user only sees your text responses - not tool results. +Always provide a complete, natural language answer after searching. ### Todo List Management @@ -211,13 +223,37 @@ def _format_edit_file_description(tool_call: ToolCall, state: AgentState, runtim ) -def _format_web_search_description(tool_call: ToolCall, state: AgentState, runtime: Runtime) -> str: - """Format web_search tool call for approval prompt.""" +def _format_tavily_search_description( + tool_call: ToolCall, state: AgentState, runtime: Runtime +) -> str: + """Format tavily_search tool call for approval prompt.""" args = tool_call["args"] query = args.get("query", "unknown") max_results = args.get("max_results", 5) + topic = args.get("topic", "general") + + return ( + f"Query: {query}\n" + f"Max results: {max_results}\n" + f"Topic: {topic}\n\n" + "⚠️ This will use Tavily API credits" + ) + + +def _format_parallel_search_description( + tool_call: ToolCall, state: AgentState, runtime: Runtime +) -> str: + """Format parallel_search tool call for approval prompt.""" + args = tool_call["args"] + queries = args.get("queries", []) + max_results = args.get("max_results", 5) + objective = args.get("objective") + + description = f"Queries: {queries}\nMax results: {max_results}\n" + description += f"Objective: {objective}\n" + description += "\n⚠️ This will use Parallel API credits" - return f"Query: {query}\nMax results: {max_results}\n\n⚠️ This will use Tavily API credits" + return description def _format_fetch_url_description(tool_call: ToolCall, state: AgentState, runtime: Runtime) -> str: @@ -354,9 +390,14 @@ def create_agent_with_config( "description": _format_edit_file_description, } - web_search_interrupt_config: InterruptOnConfig = { + tavily_search_interrupt_config: InterruptOnConfig = { + "allowed_decisions": ["approve", "reject"], + "description": _format_tavily_search_description, + } + + parallel_search_interrupt_config: InterruptOnConfig = { "allowed_decisions": ["approve", "reject"], - "description": _format_web_search_description, + "description": _format_parallel_search_description, } fetch_url_interrupt_config: InterruptOnConfig = { @@ -380,7 +421,8 @@ def create_agent_with_config( "execute": execute_interrupt_config, "write_file": write_file_interrupt_config, "edit_file": edit_file_interrupt_config, - "web_search": web_search_interrupt_config, + "tavily_search": tavily_search_interrupt_config, + "parallel_search": parallel_search_interrupt_config, "fetch_url": fetch_url_interrupt_config, "task": task_interrupt_config, }, diff --git a/libs/deepagents-cli/deepagents_cli/config.py b/libs/deepagents-cli/deepagents_cli/config.py index 3de79b0f..2c6cdd98 100644 --- a/libs/deepagents-cli/deepagents_cli/config.py +++ b/libs/deepagents-cli/deepagents_cli/config.py @@ -128,12 +128,14 @@ class Settings: openai_api_key: OpenAI API key if available anthropic_api_key: Anthropic API key if available tavily_api_key: Tavily API key if available + parallel_api_key: Parallel API key if available """ # API keys openai_api_key: str | None anthropic_api_key: str | None tavily_api_key: str | None + parallel_api_key: str | None # Project information project_root: Path | None @@ -152,6 +154,7 @@ def from_environment(cls, *, start_path: Path | None = None) -> "Settings": openai_key = os.environ.get("OPENAI_API_KEY") anthropic_key = os.environ.get("ANTHROPIC_API_KEY") tavily_key = os.environ.get("TAVILY_API_KEY") + parallel_key = os.environ.get("PARALLEL_API_KEY") # Detect project project_root = _find_project_root(start_path) @@ -160,6 +163,7 @@ def from_environment(cls, *, start_path: Path | None = None) -> "Settings": openai_api_key=openai_key, anthropic_api_key=anthropic_key, tavily_api_key=tavily_key, + parallel_api_key=parallel_key, project_root=project_root, ) @@ -178,6 +182,11 @@ def has_tavily(self) -> bool: """Check if Tavily API key is configured.""" return self.tavily_api_key is not None + @property + def has_parallel(self) -> bool: + """Check if Parallel API key is configured.""" + return self.parallel_api_key is not None + @property def has_project(self) -> bool: """Check if currently in a git project.""" diff --git a/libs/deepagents-cli/deepagents_cli/default_agent_prompt.md b/libs/deepagents-cli/deepagents_cli/default_agent_prompt.md index 6d495264..ff9306ab 100644 --- a/libs/deepagents-cli/deepagents_cli/default_agent_prompt.md +++ b/libs/deepagents-cli/deepagents_cli/default_agent_prompt.md @@ -95,8 +95,11 @@ Examples: `pytest /foo/bar/tests` (good), `cd /foo/bar && pytest tests` (bad) Always use absolute paths starting with /. -### web_search -Search for documentation, error solutions, and code examples. +### parallel_search +Search for documentation, error solutions, and code examples using Parallel. + +### tavily_search +Search for documentation, error solutions, and code examples using Tavily. ### http_request Make HTTP requests to APIs (GET, POST, etc.). diff --git a/libs/deepagents-cli/deepagents_cli/execution.py b/libs/deepagents-cli/deepagents_cli/execution.py index b36932e8..78ecc91f 100644 --- a/libs/deepagents-cli/deepagents_cli/execution.py +++ b/libs/deepagents-cli/deepagents_cli/execution.py @@ -210,7 +210,8 @@ async def execute_task( "grep": "🔎", "shell": "⚡", "execute": "🔧", - "web_search": "🌐", + "parallel_search": "🌐", + "tavily_search": "🌐", "http_request": "🌍", "task": "🤖", "write_todos": "📋", @@ -372,7 +373,7 @@ def flush_text_buffer(*, final: bool = False) -> None: status.start() spinner_active = True - # For all other tools (web_search, http_request, etc.), + # For all other tools (parallel_search, http_request, etc.), # results are hidden from user - agent will process and respond continue diff --git a/libs/deepagents-cli/deepagents_cli/main.py b/libs/deepagents-cli/deepagents_cli/main.py index 0e38ad12..b3f96db4 100644 --- a/libs/deepagents-cli/deepagents_cli/main.py +++ b/libs/deepagents-cli/deepagents_cli/main.py @@ -24,7 +24,7 @@ get_default_working_dir, ) from deepagents_cli.skills import execute_skills_command, setup_skills_parser -from deepagents_cli.tools import fetch_url, http_request, web_search +from deepagents_cli.tools import fetch_url, http_request, parallel_search, tavily_search from deepagents_cli.ui import TokenTracker, show_help @@ -52,6 +52,11 @@ def check_cli_dependencies() -> None: except ImportError: missing.append("tavily-python") + try: + import parallel + except ImportError: + missing.append("parallel-web") + try: import prompt_toolkit except ImportError: @@ -167,15 +172,17 @@ async def simple_cli( ) console.print() - if not settings.has_tavily: + if not settings.has_tavily and not settings.has_parallel: console.print( - "[yellow]⚠ Web search disabled:[/yellow] TAVILY_API_KEY not found.", + "[yellow]⚠ Web search disabled:[/yellow] No API key found.", style=COLORS["dim"], ) - console.print(" To enable web search, set your Tavily API key:", style=COLORS["dim"]) + console.print(" To enable web search, set one of these API keys:", style=COLORS["dim"]) console.print(" export TAVILY_API_KEY=your_api_key_here", style=COLORS["dim"]) + console.print(" export PARALLEL_API_KEY=your_api_key_here", style=COLORS["dim"]) + console.print(" Or add them to your .env file.", style=COLORS["dim"]) console.print( - " Or add it to your .env file. Get your key at: https://tavily.com", + " Get keys at: https://tavily.com or https://www.parallel.ai", style=COLORS["dim"], ) console.print() @@ -273,7 +280,9 @@ async def _run_agent_session( # Create agent with conditional tools tools = [http_request, fetch_url] if settings.has_tavily: - tools.append(web_search) + tools.append(tavily_search) + if settings.has_parallel: + tools.append(parallel_search) agent, composite_backend = create_agent_with_config( model, assistant_id, tools, sandbox=sandbox_backend, sandbox_type=sandbox_type diff --git a/libs/deepagents-cli/deepagents_cli/tools.py b/libs/deepagents-cli/deepagents_cli/tools.py index b014b3c5..e922062e 100644 --- a/libs/deepagents-cli/deepagents_cli/tools.py +++ b/libs/deepagents-cli/deepagents_cli/tools.py @@ -4,6 +4,8 @@ import requests from markdownify import markdownify +from parallel import Parallel +from parallel.types.beta.search_result import SearchResult from tavily import TavilyClient from deepagents_cli.config import settings @@ -11,6 +13,9 @@ # Initialize Tavily client if API key is available tavily_client = TavilyClient(api_key=settings.tavily_api_key) if settings.has_tavily else None +# Initialize Parallel client if API key is available +parallel_client = Parallel(api_key=settings.parallel_api_key) if settings.has_parallel else None + def http_request( url: str, @@ -87,12 +92,12 @@ def http_request( } -def web_search( +def tavily_search( query: str, max_results: int = 5, topic: Literal["general", "news", "finance"] = "general", include_raw_content: bool = False, -): +) -> dict[str, Any]: """Search the web using Tavily for current information and documentation. This tool searches the web and returns relevant results. After receiving results, @@ -134,7 +139,103 @@ def web_search( topic=topic, ) except Exception as e: - return {"error": f"Web search error: {e!s}", "query": query} + return {"error": f"Tavily search error: {e!s}", "query": query} + + +def parallel_search( + objective: str, + queries: list[str], + max_results: int = 5, + max_chars_per_excerpt: int = 1000, +) -> SearchResult: + """Search the web using Parallel for current information and documentation. + + This tool searches the web and returns relevant results with excerpts. + After receiving results, you MUST synthesize the information into a natural, + helpful response for the user. + + Args: + objective: Natural-language description of what the web search is trying to find. + May include guidance about preferred sources or freshness. + queries: A list of search queries (be specific and detailed) + max_results: Number of results to return (default: 5) + max_chars_per_excerpt: Maximum characters per excerpt (default: 1000) + + Returns: + Dictionary containing: + - results: A list of WebSearchResult objects, ordered by decreasing relevance: + - title: Title of the webpage, if available. + - url: URL associated with the search result. + - excerpts: List of relevant excerpted content from the URL, formatted as markdown. + - publish_date: Publish date of the webpage in YYYY-MM-DD format, if available. + - search_id: Unique search identifier + + IMPORTANT: After using this tool: + 1. Read through the 'excerpts' field of each result (array of strings) + 2. Extract relevant information that answers the user's question + 3. Synthesize this into a clear, natural language response + 4. Cite sources by mentioning the page titles or URLs + 5. NEVER show the raw JSON to the user - always provide a formatted response + """ + if parallel_client is None: + return { + "error": "Parallel API key not configured. Please set PARALLEL_API_KEY environment variable.", + "queries": queries, + "objective": objective, + } + + try: + return parallel_client.beta.search( + objective=objective, + search_queries=queries, + max_results=max_results, + max_chars_per_result=max_chars_per_excerpt, + ) + except Exception as e: + return { + "error": f"Parallel search error: {e!s}", + "queries": queries, + "objective": objective, + } + + +def web_search( + query: str, + max_results: int = 5, + topic: Literal["general", "news", "finance"] = "general", + include_raw_content: bool = False, +): + """Search the web using Tavily for current information and documentation. + + This is a backward compatibility wrapper for tavily_search. + For new code, use tavily_search or parallel_search directly. + + This tool searches the web and returns relevant results. After receiving results, + you MUST synthesize the information into a natural, helpful response for the user. + + Args: + query: The search query (be specific and detailed) + max_results: Number of results to return (default: 5) + topic: Search topic type - "general" for most queries, "news" for current events + include_raw_content: Include full page content (warning: uses more tokens) + + Returns: + Dictionary containing: + - results: List of search results, each with: + - title: Page title + - url: Page URL + - content: Relevant excerpt from the page + - score: Relevance score (0-1) + - query: The original search query + + IMPORTANT: After using this tool: + 1. Read through the 'content' field of each result + 2. Extract relevant information that answers the user's question + 3. Synthesize this into a clear, natural language response + 4. Cite sources by mentioning the page titles or URLs + 5. NEVER show the raw JSON to the user - always provide a formatted response + """ + return tavily_search(query, max_results, topic, include_raw_content) def fetch_url(url: str, timeout: int = 30) -> dict[str, Any]: diff --git a/libs/deepagents-cli/deepagents_cli/ui.py b/libs/deepagents-cli/deepagents_cli/ui.py index 1288196f..2269f5d2 100644 --- a/libs/deepagents-cli/deepagents_cli/ui.py +++ b/libs/deepagents-cli/deepagents_cli/ui.py @@ -36,7 +36,7 @@ def format_tool_display(tool_name: str, tool_args: dict) -> str: Examples: read_file(path="/long/path/file.py") → "read_file(file.py)" - web_search(query="how to code", max_results=5) → 'web_search("how to code")' + parallel_search(objective="how to code", max_results=5) → 'parallel_search("how to code")' shell(command="pip install foo") → 'shell("pip install foo")' """ @@ -79,13 +79,20 @@ def abbreviate_path(path_str: str, max_length: int = 60) -> str: path = abbreviate_path(str(path_value)) return f"{tool_name}({path})" - elif tool_name == "web_search": - # Web search: show the query string + elif tool_name == "tavily_search": + # Tavily search: show the query string if "query" in tool_args: query = str(tool_args["query"]) query = truncate_value(query, 100) return f'{tool_name}("{query}")' + elif tool_name == "parallel_search": + # Parallel search: show the objective + if "objective" in tool_args: + objective = str(tool_args["objective"]) + objective = truncate_value(objective, 100) + return f'{tool_name}("{objective}")' + elif tool_name == "grep": # Grep: show the search pattern if "pattern" in tool_args: diff --git a/libs/deepagents-cli/pyproject.toml b/libs/deepagents-cli/pyproject.toml index e104487d..3b9a3794 100644 --- a/libs/deepagents-cli/pyproject.toml +++ b/libs/deepagents-cli/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "modal>=0.65.0", "markdownify>=0.13.0", "langchain>=1.0.7", + "parallel-web", ] [project.scripts] diff --git a/libs/deepagents-cli/tests/unit_tests/test_agent.py b/libs/deepagents-cli/tests/unit_tests/test_agent.py index 629ea333..057cc97d 100644 --- a/libs/deepagents-cli/tests/unit_tests/test_agent.py +++ b/libs/deepagents-cli/tests/unit_tests/test_agent.py @@ -8,7 +8,8 @@ _format_fetch_url_description, _format_shell_description, _format_task_description, - _format_web_search_description, + _format_tavily_search_description, + _format_parallel_search_description, _format_write_file_description, ) @@ -103,10 +104,10 @@ def test_format_edit_file_description_all_occurrences(): assert "Action: Replace text (all occurrences)" in description -def test_format_web_search_description(): - """Test web_search description formatting.""" +def test_format_tavily_search_description(): + """Test tavily_search description formatting.""" tool_call = { - "name": "web_search", + "name": "tavily_search", "args": { "query": "python async programming", "max_results": 10, @@ -117,17 +118,17 @@ def test_format_web_search_description(): state = Mock() runtime = Mock() - description = _format_web_search_description(tool_call, state, runtime) + description = _format_tavily_search_description(tool_call, state, runtime) assert "Query: python async programming" in description assert "Max results: 10" in description assert "⚠️ This will use Tavily API credits" in description -def test_format_web_search_description_default_max_results(): - """Test web_search description with default max_results.""" +def test_format_tavily_search_description_default_max_results(): + """Test tavily_search description with default max_results.""" tool_call = { - "name": "web_search", + "name": "tavily_search", "args": { "query": "langchain tutorial", }, @@ -137,12 +138,35 @@ def test_format_web_search_description_default_max_results(): state = Mock() runtime = Mock() - description = _format_web_search_description(tool_call, state, runtime) + description = _format_tavily_search_description(tool_call, state, runtime) assert "Query: langchain tutorial" in description assert "Max results: 5" in description +def test_format_parallel_search_description(): + """Test parallel_search description formatting.""" + tool_call = { + "name": "parallel_search", + "args": { + "objective": "Learn python async programming", + "queries": ["python async programming"], + "max_results": 10, + }, + "id": "call-5", + } + + state = Mock() + runtime = Mock() + + description = _format_parallel_search_description(tool_call, state, runtime) + + assert "Objective: Learn python async programming" in description + assert "Queries: ['python async programming']" in description + assert "Max results: 10" in description + assert "⚠️ This will use Parallel API credits" in description + + def test_format_fetch_url_description(): """Test fetch_url description formatting.""" tool_call = { diff --git a/libs/deepagents-cli/tests/unit_tests/tools/test_parallel_search.py b/libs/deepagents-cli/tests/unit_tests/tools/test_parallel_search.py new file mode 100644 index 00000000..c06d4ce4 --- /dev/null +++ b/libs/deepagents-cli/tests/unit_tests/tools/test_parallel_search.py @@ -0,0 +1,71 @@ +import pytest +from parallel.types.beta.web_search_result import WebSearchResult + +from deepagents_cli import tools + + +@pytest.fixture +def mock_parallel_client(): + original = tools.parallel_client + yield + tools.parallel_client = original + + +def test_parallel_search_success(mock_parallel_client): + class MockResult: + results = [WebSearchResult(url="https://python.org", title="Python", excerpts=["Guide"])] + search_id = "123" + + class MockBeta: + def search(self, *args, **kwargs): + return MockResult() + + class MockClient: + beta = MockBeta() + + tools.parallel_client = MockClient() + result = tools.parallel_search("Learn Python", ["python"]) + + assert result.search_id == "123" + assert len(result.results) == 1 + + +def test_parallel_search_with_objective(mock_parallel_client): + class MockResult: + results = [] + search_id = "456" + + class MockBeta: + def search(self, objective, search_queries, **kwargs): + assert objective == "Learn Python" + assert search_queries == ["python"] + return MockResult() + + class MockClient: + beta = MockBeta() + + tools.parallel_client = MockClient() + result = tools.parallel_search("Learn Python", ["python"]) + + assert result.search_id == "456" + + +def test_parallel_search_no_client(mock_parallel_client): + tools.parallel_client = None + result = tools.parallel_search("Learn Python", ["python"]) + + assert "error" in result + + +def test_parallel_search_error(mock_parallel_client): + class MockBeta: + def search(self, *args, **kwargs): + raise Exception("API error") + + class MockClient: + beta = MockBeta() + + tools.parallel_client = MockClient() + result = tools.parallel_search("Learn Python", ["python"]) + + assert "error" in result diff --git a/libs/deepagents-cli/uv.lock b/libs/deepagents-cli/uv.lock index 2e9af281..b3a64fd9 100644 --- a/libs/deepagents-cli/uv.lock +++ b/libs/deepagents-cli/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11, <4.0" [[package]] @@ -710,6 +710,7 @@ dependencies = [ { name = "langchain-openai" }, { name = "markdownify" }, { name = "modal" }, + { name = "parallel-web" }, { name = "prompt-toolkit" }, { name = "python-dotenv" }, { name = "requests" }, @@ -751,6 +752,7 @@ requires-dist = [ { name = "langchain-openai", specifier = ">=0.1.0" }, { name = "markdownify", specifier = ">=0.13.0" }, { name = "modal", specifier = ">=0.65.0" }, + { name = "parallel-web" }, { name = "prompt-toolkit", specifier = ">=3.0.52" }, { name = "python-dotenv" }, { name = "requests" }, @@ -1842,6 +1844,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "parallel-web" +version = "0.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/c7/2d784abf966cc87aaa07bff44f109bf659a1ef9998ce4a0ce13b05600594/parallel_web-0.3.4.tar.gz", hash = "sha256:eae6e20b87a43f475bb05df7295e506989c6bd38d322380da204d2ed0bb6e556", size = 131133, upload-time = "2025-11-13T00:29:34.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/6e/c21754fe48505d2bc112d322cf8de7bd84f035a6f331d86acb548d0b0387/parallel_web-0.3.4-py3-none-any.whl", hash = "sha256:2804e84ebba789e475901c9aeb88c10045c2d07a2afd9bbc05e317725785c720", size = 137028, upload-time = "2025-11-13T00:29:32.037Z" }, +] + [[package]] name = "pathspec" version = "0.12.1"