Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ jobs:
exit 1
fi

- name: Test real web, Maps, News, and Shopping responses
- name: Test real web, Maps, News, Shopping, Hotels, Flights, and Travel Explore responses
run: uv run pytest -m live

package:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ jobs:
exit 1
fi

- name: Test real web, Maps, News, and Shopping responses
- name: Test real web, Maps, News, Shopping, Hotels, Flights, and Travel Explore responses
run: uv run pytest -m live

build:
Expand Down
27 changes: 18 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,13 @@
[![Python versions](https://img.shields.io/pypi/pyversions/serpapi-hermes-plugin.svg)](https://pypi.org/project/serpapi-hermes-plugin/)
[![License: MIT](https://img.shields.io/pypi/l/serpapi-hermes-plugin.svg)](https://github.com/serpapi/serpapi-hermes-plugin/blob/main/LICENSE)

Give [Hermes Agent](https://hermes-agent.nousresearch.com/) fast, fresh search
results from the web, Google Maps, Google News, and Google Shopping with
[SerpApi](https://serpapi.com/).
Give [Hermes Agent](https://hermes-agent.nousresearch.com/) fresh web, local, news, shopping, hotel, flight, and destination results with [SerpApi](https://serpapi.com/).

The plugin adds SerpApi to Hermes in two ways:

- Hermes's built-in `web_search` uses the fast Google Light engine.
- Dedicated Maps, News, and Shopping tools let Hermes choose the right SerpApi
engine for local places, current reporting, and product searches.
- Dedicated Maps, News, Shopping, Hotels, Flights, and Travel Explore tools let Hermes choose the right SerpApi engine for each request.
- Direct SerpApi tools return token-efficient Markdown by default, including tables, links, and YAML frontmatter designed for agents.

## Ask Hermes to install it

Expand Down Expand Up @@ -45,8 +43,8 @@ dashboard. Do not ask for the key until installation and enablement succeed.
After I provide it, save it as SERPAPI_API_KEY in ~/.hermes/.env without
printing, logging, or committing it. Configure SerpApi as the Hermes web search
backend, tell me whether Hermes must be restarted, and verify that web search,
Maps, News, and Shopping tools are available. Use this key for future SerpApi
searches and never expose it in output.
Maps, News, Shopping, Hotels, Flights, and Travel Explore tools are available.
Use this key for future SerpApi searches and never expose it in output.
```

## Install
Expand Down Expand Up @@ -142,22 +140,33 @@ The environment variable takes precedence over the value in `~/.hermes/.env`.

## Search capabilities

Hermes's built-in `web_search` contract requires structured web records, so the plugin requests JSON for that provider and converts it to Hermes's standard response. The six directly registered SerpApi tools request [`output=md`](https://serpapi.com/search-api#api-parameters-output) by default and return SerpApi's Markdown without reparsing it. This preserves result tables and links while using fewer tokens than full JSON.

Each direct tool also accepts `output: "json"` when the agent needs structured fields such as a Google Flights `departure_token`. Markdown remains the schema default.

| What you ask for | Hermes tool | SerpApi engine |
|---|---|---|
| General web research | `web_search` | `google_light` |
| Places and local businesses | `serpapi_maps_search` | `google_maps` |
| Current and recent news | `serpapi_news_search` | `google_news_light` |
| Products, prices, and merchants | `serpapi_shopping_search` | `google_shopping_light` |
| Hotels and vacation rentals | `serpapi_hotels_search` | [`google_hotels`](https://serpapi.com/google-hotels-api) |
| Fixed-route flight fares | `serpapi_flights_search` | [`google_flights`](https://serpapi.com/google-flights-api) |
| Flexible destinations and dates | `serpapi_travel_explore_search` | [`google_travel_explore`](https://serpapi.com/google-travel-explore-api) |

Hermes chooses a tool from your request. Each tool selects and validates its own
SerpApi engine, so you do not need to specify an engine name.
Hermes chooses a tool from your request. Each tool selects and validates its own SerpApi engine, so you do not need to specify an engine name. Flight and Travel Explore location fields accept individual uppercase airport codes such as `LHR`, `CDG`, or `AUS`, as well as `/m/` or `/g/` location KGMIDs. Use Travel Explore when the traveler has a city or region in mind but no exact airport.

Example prompts:

- "Search the web for the latest Python 3.14 release notes."
- "Find highly rated coffee shops near Times Square."
- "Show me recent news about reusable rockets."
- "Find well-reviewed laptops under $1,200 with free shipping."
- "Find four-star hotels in Kyoto for October 10 to October 15."
- "Compare nonstop business-class flights from JFK to LAX next month."
- "Where can I go from Bengaluru for a one-week beach trip in December?"

Google Flights returns outbound choices first for round trips. To inspect return-flight choices, call `serpapi_flights_search` with `output: "json"`, select a `departure_token`, then call the tool again with that token and the same route and dates.

## Contributing

Expand Down
19 changes: 16 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,27 @@ build-backend = "setuptools.build_meta"
[project]
name = "serpapi-hermes-plugin"
version = "0.1.1"
description = "SerpApi search plugin for Hermes Agent: web, Maps, News, and Shopping"
description = "SerpApi search plugin for Hermes Agent: web, Maps, News, Shopping, Hotels, Flights, and Travel Explore"
readme = "README.md"
requires-python = ">=3.11,<3.15"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "SerpApi" }]
keywords = ["hermes-agent", "plugin", "serpapi", "web-search", "maps", "news", "shopping"]
keywords = [
"hermes-agent",
"plugin",
"serpapi",
"web-search",
"maps",
"news",
"shopping",
"hotels",
"flights",
"travel",
"markdown",
]
classifiers = [
"Development Status :: 3 - Alpha",
"Development Status :: 4 - Beta",
"Environment :: Plugins",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
Expand All @@ -24,6 +36,7 @@ classifiers = [
]
dependencies = [
"httpx>=0.28.1,<1",
"markdown-it-py>=4.2,<5",
]

[project.entry-points."hermes_agent.plugins"]
Expand Down
23 changes: 20 additions & 3 deletions src/serpapi_hermes_plugin/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
"""SerpApi web, Maps, News, and Shopping search for Hermes Agent."""
"""SerpApi web, Maps, News, Shopping, Hotels, Flights, and Travel Explore for Hermes Agent."""

from __future__ import annotations

from .provider import SerpApiWebSearchProvider
from .schemas import MAPS_SEARCH_SCHEMA, NEWS_SEARCH_SCHEMA, SHOPPING_SEARCH_SCHEMA
from .tools import maps_search, news_search, shopping_search
from .schemas import (
FLIGHTS_SEARCH_SCHEMA,
HOTELS_SEARCH_SCHEMA,
MAPS_SEARCH_SCHEMA,
NEWS_SEARCH_SCHEMA,
SHOPPING_SEARCH_SCHEMA,
TRAVEL_EXPLORE_SEARCH_SCHEMA,
)
from .tools import (
flights_search,
hotels_search,
maps_search,
news_search,
shopping_search,
travel_explore_search,
)

__all__ = ["SerpApiWebSearchProvider", "register"]

Expand All @@ -18,6 +32,9 @@ def register(ctx) -> None:
(MAPS_SEARCH_SCHEMA, maps_search),
(NEWS_SEARCH_SCHEMA, news_search),
(SHOPPING_SEARCH_SCHEMA, shopping_search),
(HOTELS_SEARCH_SCHEMA, hotels_search),
(FLIGHTS_SEARCH_SCHEMA, flights_search),
(TRAVEL_EXPLORE_SEARCH_SCHEMA, travel_explore_search),
):
ctx.register_tool(
name=schema["name"],
Expand Down
61 changes: 47 additions & 14 deletions src/serpapi_hermes_plugin/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
logger = logging.getLogger(__name__)

API_KEY_ENV = "SERPAPI_API_KEY"
ENDPOINT = "https://serpapi.com/search.json"
ENDPOINT = "https://serpapi.com/search"
TIMEOUT_SECONDS = 15.0


Expand All @@ -37,8 +37,30 @@ def _safe_api_error(value: Any, api_key: str) -> str:
return message[:500]


def call_serpapi(engine: str, params: Mapping[str, Any]) -> dict[str, Any]:
"""Call one SerpApi engine and return its decoded JSON response."""
def _response_api_error(response: httpx.Response, api_key: str) -> str | None:
"""Read a safe error message from a SerpApi JSON response."""
try:
payload = response.json()
except ValueError:
return None
if not isinstance(payload, dict) or not payload.get("error"):
return None
return _safe_api_error(payload["error"], api_key)


def _redact_response(value: Any, api_key: str) -> Any:
"""Remove the credential from any response text before returning it to Hermes."""
if isinstance(value, str):
return value.replace(api_key, "[redacted]")
if isinstance(value, list):
return [_redact_response(item, api_key) for item in value]
if isinstance(value, dict):
return {key: _redact_response(item, api_key) for key, item in value.items()}
return value


def call_serpapi(engine: str, params: Mapping[str, Any]) -> dict[str, Any] | str:
"""Call one SerpApi engine and return Markdown by default or decoded JSON."""
api_key = get_api_key()
if not api_key:
raise SerpApiError(f"{API_KEY_ENV} is not set. Run `hermes tools` to configure SerpApi.")
Expand All @@ -48,20 +70,17 @@ def call_serpapi(engine: str, params: Mapping[str, Any]) -> dict[str, Any]:
for key, value in params.items()
if key not in {"api_key", "engine"} and value is not None and value != ""
}
request_params.update(
{
"engine": engine,
"api_key": api_key,
"output": "json",
}
)
output = str(request_params.get("output") or "md").strip().lower()
if output not in {"json", "md"}:
raise SerpApiError("output must be 'md' or 'json'")
request_params.update({"engine": engine, "api_key": api_key, "output": output})

try:
response = httpx.get(
ENDPOINT,
params=request_params,
headers={
"Accept": "application/json",
"Accept": "text/markdown" if output == "md" else "application/json",
"X-Client-Source": "hermes",
},
timeout=TIMEOUT_SECONDS,
Expand All @@ -76,23 +95,37 @@ def call_serpapi(engine: str, params: Mapping[str, Any]) -> dict[str, Any]:
message = "SerpApi quota exhausted; try again later"
elif status >= 500:
message = f"SerpApi upstream error (HTTP {status}); try again shortly"
elif 400 <= status < 500:
message = _response_api_error(exc.response, api_key) or (
f"SerpApi request failed (HTTP {status})"
)
else:
message = f"SerpApi request failed (HTTP {status})"
raise SerpApiError(message) from None
except httpx.RequestError as exc:
logger.warning("SerpApi request failed (%s)", type(exc).__name__)
raise SerpApiError("Could not reach SerpApi; try again shortly") from None

if output == "md" and "application/json" not in response.headers.get("content-type", ""):
markdown = response.text.replace(api_key, "[redacted]").strip()
if not markdown:
raise SerpApiError("SerpApi returned an empty Markdown response")
return markdown

try:
payload = response.json()
payload = _redact_response(response.json(), api_key)
except ValueError:
logger.warning("SerpApi returned malformed JSON")
raise SerpApiError("SerpApi returned malformed JSON") from None
response_name = "Markdown" if output == "md" else "JSON"
logger.warning("SerpApi returned malformed %s", response_name)
raise SerpApiError(f"SerpApi returned malformed {response_name}") from None

if not isinstance(payload, dict):
raise SerpApiError("SerpApi returned an unexpected response")

if payload.get("error"):
raise SerpApiError(_safe_api_error(payload["error"], api_key))

if output == "md":
raise SerpApiError("SerpApi returned an unexpected Markdown response")

return payload
68 changes: 68 additions & 0 deletions src/serpapi_hermes_plugin/markdown.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Structure-aware transformations for SerpApi Markdown responses."""

from __future__ import annotations

import re

from markdown_it import MarkdownIt

_PARSER = MarkdownIt("commonmark").enable("table")


def _normalized_heading(value: str) -> str:
return re.sub(r"[^a-z0-9]+", " ", value.casefold()).strip()


def _matches_heading(value: str, expected: str) -> bool:
normalized = _normalized_heading(value)
return normalized == expected or normalized.startswith(f"{expected} ")


def limit_result_table(markdown: str, *, heading: str, limit: int) -> str:
"""Limit table body rows under a named Markdown heading.

Source line mappings from markdown-it-py let us remove excess rows without
rendering the document again, so unrelated formatting remains unchanged.
"""
tokens = _PARSER.parse(markdown)
expected_heading = _normalized_heading(heading)
section_level: int | None = None
in_table_body = False
result_count = 0
removed_lines: set[int] = set()

for index, token in enumerate(tokens):
if token.type == "heading_open":
level = int(token.tag.removeprefix("h"))
if section_level is not None and level <= section_level:
break

next_token = tokens[index + 1] if index + 1 < len(tokens) else None
if (
section_level is None
and next_token is not None
and next_token.type == "inline"
and _matches_heading(next_token.content, expected_heading)
):
section_level = level
continue

if section_level is None:
continue
if token.type == "tbody_open":
in_table_body = True
elif token.type == "tbody_close":
in_table_body = False
elif token.type == "tr_open" and in_table_body and token.map is not None:
result_count += 1
if result_count > limit:
removed_lines.update(range(*token.map))

if not removed_lines:
return markdown

return "".join(
line
for line_number, line in enumerate(markdown.splitlines(keepends=True))
if line_number not in removed_lines
)
9 changes: 6 additions & 3 deletions src/serpapi_hermes_plugin/plugin.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: serpapi
version: 0.1.0
description: "SerpApi search for Hermes Agent: web, Maps, News, and Shopping."
version: 0.1.1
description: "SerpApi web, Maps, News, Shopping, Hotels, Flights, and Travel Explore for Hermes Agent."
author: SerpApi
kind: backend
provides_web_providers:
Expand All @@ -9,8 +9,11 @@ provides_tools:
- serpapi_maps_search
- serpapi_news_search
- serpapi_shopping_search
- serpapi_hotels_search
- serpapi_flights_search
- serpapi_travel_explore_search
requires_env:
- name: SERPAPI_API_KEY
description: "API key for SerpApi web search"
description: "API key for SerpApi search"
url: "https://serpapi.com/manage-api-key"
secret: true
1 change: 1 addition & 0 deletions src/serpapi_hermes_plugin/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def search(self, query: str, limit: int = 5) -> dict[str, Any]:
{
"q": query,
"num": result_limit,
"output": "json",
},
)
except SerpApiError as exc:
Expand Down
Loading