Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions agentstack/_tools/parallel/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Free anonymous search and page excerpts through Parallel Search MCP."""

import json
from datetime import timedelta
from urllib.parse import urlsplit

from agentstack.search import get_search_session_id
from agentstack.utils import get_version


MCP_URL = 'https://search.parallel.ai/mcp'
REQUEST_TIMEOUT = timedelta(seconds=30)


async def _call_tool(name: str, arguments: dict) -> str:
# Imported only when called, so listing tools does not require the MCP extra.
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client

headers = {'User-Agent': f'AgentStack/{get_version()}'}
result = None
async with streamablehttp_client(
MCP_URL,
headers=headers,
timeout=REQUEST_TIMEOUT,
sse_read_timeout=REQUEST_TIMEOUT,
) as (read, write, _):
async with ClientSession(read, write, read_timeout_seconds=REQUEST_TIMEOUT) as session:
await session.initialize()
result = await session.call_tool(name, arguments, read_timeout_seconds=REQUEST_TIMEOUT)

if result is None:
# A transport task can be cancelled while its task group unwinds.
# Preserve cancellation even if an SDK context manager suppresses it.
from anyio import get_cancelled_exc_class

raise get_cancelled_exc_class()
text = '\n'.join(block.text for block in result.content if block.type == 'text')
if result.isError:
raise RuntimeError(f'Parallel {name} failed: {text}')
if text:
return text
if result.structuredContent is not None:
return json.dumps(result.structuredContent)
raise RuntimeError(f'Parallel {name} returned no text or structured content')


def _run_tool(name: str, arguments: dict) -> str:
from anyio.from_thread import start_blocking_portal

arguments['session_id'] = get_search_session_id()
# Framework tools are synchronous. A portal also works when their caller
# already runs an event loop, without nesting asyncio.run in that loop.
with start_blocking_portal() as portal:
return portal.call(_call_tool, name, arguments)


def web_search(objective: str, search_queries: list[str]) -> str:
"""Search the web for current information, returning source URLs and excerpts.

Args:
objective: A focused description of the information to find.
search_queries: At least one related keyword query; prefer 3–6 words each.

Returns:
Search results with URLs and excerpts to cite or read with web_fetch.
"""
if not objective.strip() or not search_queries or any(not query.strip() for query in search_queries):
raise ValueError('Provide an objective and at least one nonempty search query')
return _run_tool('web_search', {'objective': objective, 'search_queries': search_queries})


def web_fetch(urls: list[str], objective: str) -> str:
"""Read excerpts from up to 20 HTTP(S) pages, preserving source URLs.

Args:
urls: The web page URLs to read, such as sources returned by web_search.
objective: Description of relevant information, up to 200 characters; use an empty string for general excerpts.

Returns:
Page excerpts and their source URLs for the agent to analyze and cite.
"""
if not 1 <= len(urls) <= 20:
raise ValueError('Provide between 1 and 20 URLs')
if any(urlsplit(url).scheme not in ('http', 'https') or not urlsplit(url).netloc for url in urls):
raise ValueError('Only absolute HTTP or HTTPS URLs are supported')
if len(objective) > 200:
raise ValueError('The fetch objective must be at most 200 characters')
return _run_tool('web_fetch', {'urls': urls, 'objective': objective or None})
8 changes: 8 additions & 0 deletions agentstack/_tools/parallel/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "parallel",
"url": "https://docs.parallel.ai/integrations/mcp/search-mcp",
"category": "web-retrieval",
"dependencies": ["mcp>=1.12,<2"],
"tools": ["web_search", "web_fetch"],
"cta": "Parallel Search MCP is ready. No search API key or account is required."
}
20 changes: 11 additions & 9 deletions agentstack/cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from agentstack.exceptions import ValidationError
from agentstack import inputs
from agentstack import frameworks
from agentstack.search import search_session
from agentstack.utils import get_framework, verify_agentstack_project

MAIN_FILENAME: Path = Path("src/main.py")
Expand Down Expand Up @@ -98,7 +99,7 @@ def run_project(command: str = 'run', cli_args: Optional[List[str]] = None):
"""Validate that the project is ready to run and then run it."""
conf.assert_project()
verify_agentstack_project()

if conf.get_framework() not in frameworks.SUPPORTED_FRAMEWORKS:
raise ValidationError(f"Framework {conf.get_framework()} is not supported by agentstack.")

Expand All @@ -122,14 +123,15 @@ def run_project(command: str = 'run', cli_args: Optional[List[str]] = None):
# import src/main.py from the project path and run `command` from the project's main.py
try:
log.notify("Running your agent...")
project_main = _import_project_module(conf.PATH)
main = getattr(project_main, command)

# handle both async and sync entrypoints
if asyncio.iscoroutinefunction(main):
asyncio.run(main())
else:
main()
with search_session():
project_main = _import_project_module(conf.PATH)
main = getattr(project_main, command)

# handle both async and sync entrypoints
if asyncio.iscoroutinefunction(main):
asyncio.run(main())
else:
main()
except ImportError as e:
raise ValidationError(f"Failed to import AgentStack project at: {conf.PATH.absolute()}\n{e}")
except Exception as e:
Expand Down
28 changes: 28 additions & 0 deletions agentstack/search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Conversation context shared by web search and page fetching."""

from contextlib import contextmanager
from contextvars import ContextVar
from uuid import uuid4


_session_id: ContextVar[str | None] = ContextVar('agentstack_search_session', default=None)


@contextmanager
def search_session():
"""Start a research run; related tool calls share an opaque conversation ID.

The CLI starts a session for each ``agentstack run``. Applications using the
tools directly can wrap their conversation in this context manager instead.
Nested sessions represent new conversations and restore the outer context.
"""
token = _session_id.set(str(uuid4()))
try:
yield
finally:
_session_id.reset(token)


def get_search_session_id() -> str:
"""Use the current conversation, or a new ID for a standalone tool call."""
return _session_id.get() or str(uuid4())
14 changes: 13 additions & 1 deletion agentstack/templates/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import pydantic
import requests
import json
from dotenv import dotenv_values
from agentstack.exceptions import ValidationError
from agentstack.utils import get_package_path

Expand Down Expand Up @@ -247,7 +248,18 @@ def from_template_name(cls, name: str) -> 'TemplateConfig':
raise ValidationError(f"Template {name} not bundled with agentstack.")

path = _get_builtin_templates_path() / f"{name}.json"
return cls.from_file(path)
template = cls.from_file(path)
# Only choose a default for new projects using the bundled research
# starter. Saved projects and explicit file/URL templates keep their tools.
if name == 'research':
home_key = dotenv_values(Path.home() / '.env').get('PERPLEXITY_API_KEY')
key = os.getenv('PERPLEXITY_API_KEY', home_key)
if not key or not key.strip():
template.tools = [
cls.Tool(name='parallel', agents=tool.agents) if tool.name == 'perplexity' else tool
for tool in template.tools
]
return template

@classmethod
def from_file(cls, path: Path) -> 'TemplateConfig':
Expand Down
1 change: 1 addition & 0 deletions docs/tools/community.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ description: 'AgentStack tools from community contributors'
- [Hyperbrowser](/tools/tool/hyperbrowser)

## Search
- [Parallel](/tools/tool/parallel)
- [Perplexity](/tools/tool/perplexity)
- [Dappier](/tools/tool/dappier)

Expand Down
54 changes: 54 additions & 0 deletions docs/tools/tool/parallel.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
title: 'Parallel'
description: 'Free web search and page excerpts without a search API key'
---

[Parallel Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp)
provides web search powered by Fast mode and page fetching. No Parallel account
or API key is needed for light use; the anonymous service has usage limits.

## Installation

Within an AgentStack project, run:

```bash
agentstack tools add parallel
```

This installs the MCP client and adds `web_search` and `web_fetch` to your agents.
Use `--agent researcher` to restrict the tool to one agent. The tools return source
URLs and excerpts for the agent to analyze and cite. `web_fetch` accepts up to 20
HTTP(S) URLs and an objective of up to 200 characters. Pass an empty objective
string for general excerpts. Requests time out
after 30 seconds; service errors propagate to the framework without switching
providers or retrying automatically.

## Research starter

New projects created with `agentstack init my_research --template research` use
Parallel when `PERPLEXITY_API_KEY` is absent from the environment and `~/.env`.
If that key is configured, the starter keeps Perplexity. An LLM provider must
still be configured for the agents.

Existing projects and templates loaded from a file or URL keep their selected
tools. You can explicitly add Perplexity with `agentstack tools add perplexity`.
To remove Parallel from an existing project, run `agentstack tools remove parallel`.

## Conversation context

Each `agentstack run` gives related search and fetch calls one opaque session ID.
A later run starts a new session. Applications calling the tools without the CLI
can scope related calls with `search_session`:

```python
import agentstack
from agentstack.search import search_session

with search_session():
search, fetch = agentstack.tools['parallel']
# Pass these framework-native callables to your agent within this context.
```

Without a session context, each standalone tool call receives its own session ID.
Concurrent conversations have separate contexts. The MCP connection is opened
only when a tool is called; adding or listing the tool makes no search request.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ dev = [
]
test = [
"tox",
"mcp>=1.12,<2",
]
crewai = [
"crewai==0.118.0",
Expand Down
41 changes: 41 additions & 0 deletions tests/test_research_search_default.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import json
import os
from pathlib import Path
from unittest.mock import patch

from agentstack.templates import TemplateConfig


def test_unconfigured_research_uses_parallel(tmp_path):
with patch.dict(os.environ, {}, clear=True), patch.object(Path, 'home', return_value=tmp_path):
template = TemplateConfig.from_template_name('research')
assert [(tool.name, tool.agents) for tool in template.tools] == [('parallel', ['researcher'])]


def test_configured_perplexity_is_preserved(tmp_path):
for environment, home in [
({'PERPLEXITY_API_KEY': 'configured'}, ''),
({}, 'PERPLEXITY_API_KEY=home-key'),
]:
(tmp_path / '.env').write_text(home)
with (
patch.dict(os.environ, environment, clear=True),
patch.object(Path, 'home', return_value=tmp_path),
):
assert TemplateConfig.from_template_name('research').tools[0].name == 'perplexity'


def test_explicit_file_template_keeps_selected_provider(tmp_path):
data = json.loads((Path(__file__).parent.parent / 'agentstack/templates/research.json').read_text())
template_path = tmp_path / 'research.json'
template_path.write_text(json.dumps(data))
with patch.dict(os.environ, {}, clear=True), patch.object(Path, 'home', return_value=tmp_path):
assert TemplateConfig.from_user_input(str(template_path)).tools[0].name == 'perplexity'
data['tools'] = []
template_path.write_text(json.dumps(data))
assert TemplateConfig.from_user_input(str(template_path)).tools == []


def test_other_starters_keep_their_tools(tmp_path):
with patch.dict(os.environ, {}, clear=True), patch.object(Path, 'home', return_value=tmp_path):
assert TemplateConfig.from_template_name('hello_alex').tools[0].name == 'file_read'
Loading