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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ Each example is self-contained and demonstrates specific patterns and capabiliti
- `hackernews_search/` - Hacker News search via the free Algolia API
- `pdf_reader/` - PDF text + metadata extraction (local file or URL, page-range support)
- `searxng_search/` - Privacy-focused search integration
- `serply_search/` - Google web, news, and scholar search via the Serply API
- `tavily_search/` - Tavily API search tool
- `weather/` - Current conditions and daily/hourly forecast via Open-Meteo (no key)
- `webpage_scraper/` - Web scraping capabilities
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,7 @@ Atomic Forge is a collection of tools that can be used with Atomic Agents to ext
- Hacker News Search
- PDF Reader
- SearXNG Search
- Serply Search
- Tavily Search
- Webpage Scraper
- Weather
Expand Down
1 change: 1 addition & 0 deletions atomic-forge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ The Atomic Forge project includes the following tools:
- [Hacker News Search](/atomic-forge/tools/hackernews_search/README.md) — search HN stories, comments, Show HN, Ask HN via the free Algolia API.
- [PDF Reader](/atomic-forge/tools/pdf_reader/README.md) — extract text and metadata from a local or remote PDF, with page-range filtering.
- [SearXNG Search](/atomic-forge/tools/searxng_search/README.md)
- [Serply Search](/atomic-forge/tools/serply_search/README.md)
- [Tavily Search](/atomic-forge/tools/tavily_search/README.md)
- [Webpage Scraper](/atomic-forge/tools/webpage_scraper/README.md)
- [Weather](/atomic-forge/tools/weather/README.md) — current conditions and forecast via the free Open-Meteo API.
Expand Down
8 changes: 8 additions & 0 deletions atomic-forge/tools/serply_search/.coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[run]
source = tool
omit = */tests/*

[report]
exclude_lines =
if __name__ == "__main__":
show_missing = True
59 changes: 59 additions & 0 deletions atomic-forge/tools/serply_search/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Serply Search Tool

## Overview
Searches Google web results, Google News, or Google Scholar through the [Serply](https://serply.io) API and returns each result's title, URL, and snippet, plus the publish date and source for news and the authors and citation count for papers. Requires a Serply API key.

## Prerequisites and Dependencies
- Python 3.12 or later
- `atomic-agents`
- `pydantic`
- `aiohttp`
- A Serply API key from [serply.io](https://serply.io)

## Installation
1. Use the Atomic Assembler CLI: run `atomic` and pick `serply_search`.
2. Or copy the `tool/` folder directly into your project.

## Configuration
- `api_key` (str): Serply API key. Falls back to the `SERPLY_API_KEY` environment variable when empty.
- `base_url` (str): API base URL (default `https://api.serply.io/v1`).
- `hl` (str): interface language code, e.g. `en` (default `en`).
- `gl` (str): country code for the search, e.g. `us` (default `us`).
- `user_agent` (str): user agent sent with each request.
- `timeout` (float): HTTP timeout in seconds (default 30).

## Input & Output Structure

### Input Schema
- `queries` (list[str]): search queries to run.
- `search_type` (str): `search` (Google web), `news` (Google News), or `scholar` (Google Scholar). Default `search`.
- `max_results_per_query` (int): 1-100 (default 10).

### Output Schema
A list of `SerplySearchResultItem` items. Each has `query`, `title`, `url`, and optional `description`, `position` (web search), `published` and `source` (news), `authors` and `citations` (scholar).

## Usage

```python
from tool.serply_search import SerplySearchTool, SerplySearchToolConfig, SerplySearchToolInputSchema

tool = SerplySearchTool(config=SerplySearchToolConfig(api_key="your-serply-api-key"))

output = tool.run(SerplySearchToolInputSchema(
queries=["retrieval augmented generation"],
search_type="scholar",
max_results_per_query=3,
))

for item in output.results:
print(item.title, "-", item.url)
print(item.authors, item.citations)
```

The request and response formats for each endpoint are documented at [serply.io/docs](https://serply.io/docs).

## Contributing
PRs welcome. See the main repo `CONTRIBUTING.md`.

## License
Same as the main Atomic Agents project.
34 changes: 34 additions & 0 deletions atomic-forge/tools/serply_search/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["tool"]

[project]
name = "serply-search"
version = "1.0.0"
description = "Google web, news, and scholar search tool for Atomic Agents via the Serply API"
readme = "README.md"
authors = [
{ name = "Serply", email = "googio@serply.io" }
]
requires-python = ">=3.12"
dependencies = [
"atomic-agents",
"pydantic>=2.10.3,<3.0.0",
"aiohttp>=3.9.0,<4.0.0",
]

[dependency-groups]
dev = [
"coverage>=7.6.1,<8.0.0",
"pytest>=8.3.3,<9.0.0",
"pytest-asyncio>=0.23.5,<1.0.0",
"pytest-cov>=5.0.0,<6.0.0",
"python-dotenv>=1.0.0,<2.0.0",
"rich>=13.7.0,<14.0.0",
]

[tool.uv.sources]
atomic-agents = { workspace = true }
3 changes: 3 additions & 0 deletions atomic-forge/tools/serply_search/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
atomic-agents>=2.0.0,<3.0.0
pydantic>=2.10.3,<3.0.0
aiohttp>=3.9.0,<4.0.0
203 changes: 203 additions & 0 deletions atomic-forge/tools/serply_search/tests/test_serply_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import os
import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))

from tool.serply_search import ( # noqa: E402
SerplySearchResultItem,
SerplySearchTool,
SerplySearchToolConfig,
SerplySearchToolInputSchema,
SerplySearchToolOutputSchema,
)


SEARCH_HIT = {
"title": "Welcome to Python.org",
"description": "Python is a programming language that lets you work quickly.",
"position": 1,
"realPosition": 1,
"result_type": "organic",
"link": "https://www.python.org/",
}

NEWS_HIT = {
"title": "Python 3.14 released - example.com",
"link": "https://example.com/python-3-14",
"published": "Wed, 10 Jun 2026 07:00:00 GMT",
"summary": '<a href="https://example.com/python-3-14">Python 3.14 released</a>',
"source": {"href": "https://example.com", "title": "example.com"},
}

SCHOLAR_HIT = {
"title": "SciPy 1.0: fundamental algorithms for scientific computing in Python",
"link": "https://example.com/scipy-1-0",
"id": "W3003257820",
"author": {"names": "Pauli Virtanen, Ralf Gommers"},
"description": "SciPy is an open-source scientific computing library.",
"extras": {"citations": {"count": 12345}},
}


def _mock_session(status: int, payload: dict, reason: str = "OK") -> MagicMock:
session = MagicMock()
response = SimpleNamespace(status=status, reason=reason, json=AsyncMock(return_value=payload))
session.get.return_value.__aenter__.return_value = response
return session


@pytest.fixture
def tool():
return SerplySearchTool(config=SerplySearchToolConfig(api_key="test-key", hl="de", gl="ch"))


def test_to_item_search(tool):
item = SerplySearchTool._to_item(SEARCH_HIT, "python")
assert isinstance(item, SerplySearchResultItem)
assert item.query == "python"
assert item.title == "Welcome to Python.org"
assert item.url == "https://www.python.org/"
assert item.description.startswith("Python is a programming language")
assert item.position == 1
assert item.published is None
assert item.authors is None


def test_to_item_news(tool):
"""News entries carry a summary, a publish date, and a source dict."""
item = SerplySearchTool._to_item(NEWS_HIT, "python")
assert item.description.startswith("<a href=")
assert item.published == "Wed, 10 Jun 2026 07:00:00 GMT"
assert item.source == "example.com"
assert item.position is None


def test_to_item_scholar(tool):
"""Scholar articles carry authors and a citation count."""
item = SerplySearchTool._to_item(SCHOLAR_HIT, "scipy")
assert item.authors == "Pauli Virtanen, Ralf Gommers"
assert item.citations == 12345
assert item.description == "SciPy is an open-source scientific computing library."


@pytest.mark.asyncio
async def test_fetch_builds_request(tool):
session = _mock_session(200, {"results": [SEARCH_HIT]})

items = await tool._fetch(session, "python", "search", 5)

assert len(items) == 1
assert items[0].url == "https://www.python.org/"
call_args = session.get.call_args
assert call_args[0][0] == "https://api.serply.io/v1/search/"
assert call_args[1]["params"] == {"q": "python", "num": "5", "hl": "de", "gl": "ch"}


@pytest.mark.asyncio
async def test_fetch_news_trims_to_max_results(tool):
"""The news endpoint ignores num, so the tool cuts the entries client side."""
session = _mock_session(200, {"entries": [NEWS_HIT] * 5})

items = await tool._fetch(session, "python", "news", 2)

assert len(items) == 2
assert session.get.call_args[0][0] == "https://api.serply.io/v1/news/"


@pytest.mark.asyncio
async def test_fetch_skips_hits_without_title_or_link(tool):
hits = [SEARCH_HIT, {"title": "no link"}, {"link": "https://example.com/no-title"}]
session = _mock_session(200, {"results": hits})

items = await tool._fetch(session, "python", "search", 10)

assert [item.url for item in items] == ["https://www.python.org/"]


@pytest.mark.asyncio
async def test_fetch_raises_on_http_error(tool):
session = _mock_session(401, {}, reason="Unauthorized")

with pytest.raises(Exception, match="Serply search failed for 'python': 401 Unauthorized"):
await tool._fetch(session, "python", "search", 10)


@pytest.mark.asyncio
async def test_run_async_aggregates_results(tool):
async def fake_fetch(self, session, query, search_type, max_results):
return [SerplySearchTool._to_item(SEARCH_HIT, query), SerplySearchTool._to_item(SCHOLAR_HIT, query)]

with patch.object(SerplySearchTool, "_fetch", fake_fetch):
out = await tool.run_async(SerplySearchToolInputSchema(queries=["q1", "q2"]))

assert isinstance(out, SerplySearchToolOutputSchema)
assert len(out.results) == 4
assert {r.query for r in out.results} == {"q1", "q2"}


@pytest.mark.asyncio
async def test_run_async_skips_failing_query(tool):
async def fake_fetch(self, session, query, search_type, max_results):
if query == "bad":
raise Exception("rate limited")
return [SerplySearchTool._to_item(SEARCH_HIT, query)]

with patch.object(SerplySearchTool, "_fetch", fake_fetch):
out = await tool.run_async(SerplySearchToolInputSchema(queries=["bad", "good"]))

assert len(out.results) == 1
assert out.results[0].query == "good"


@pytest.mark.asyncio
async def test_run_async_sends_api_key_header(tool):
captured = {}

class FakeSession:
def __init__(self, headers=None, timeout=None):
captured["headers"] = headers

async def __aenter__(self):
return self

async def __aexit__(self, *args):
return False

async def fake_fetch(self, session, query, search_type, max_results):
return []

with patch("tool.serply_search.aiohttp.ClientSession", FakeSession), patch.object(SerplySearchTool, "_fetch", fake_fetch):
await tool.run_async(SerplySearchToolInputSchema(queries=["q"]))

assert captured["headers"]["X-Api-Key"] == "test-key"
assert "User-Agent" in captured["headers"]


@pytest.mark.asyncio
async def test_run_async_requires_api_key(monkeypatch):
monkeypatch.delenv("SERPLY_API_KEY", raising=False)
tool = SerplySearchTool(config=SerplySearchToolConfig())

with pytest.raises(ValueError, match="SERPLY_API_KEY"):
await tool.run_async(SerplySearchToolInputSchema(queries=["q"]))


def test_api_key_falls_back_to_environment(monkeypatch):
monkeypatch.setenv("SERPLY_API_KEY", "env-key")
tool = SerplySearchTool(config=SerplySearchToolConfig())
assert tool.api_key == "env-key"


def test_run_invokes_run_async(tool):
sentinel = SerplySearchToolOutputSchema(results=[])
with patch.object(SerplySearchTool, "run_async", AsyncMock(return_value=sentinel)):
out = tool.run(SerplySearchToolInputSchema(queries=["x"]))
assert out is sentinel


if __name__ == "__main__":
pytest.main([__file__, "-v"])
Loading
Loading